@malloy-publisher/server 0.0.382-dev → 0.2.0

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 (175) hide show
  1. package/README.docker.md +145 -20
  2. package/README.md +65 -12
  3. package/dist/app/api-doc.html +5 -0
  4. package/dist/app/api-doc.yaml +3903 -583
  5. package/dist/app/assets/EnvironmentPage-DiUgLuCH.js +1 -0
  6. package/dist/app/assets/HomePage-MhhjhnFj.js +1 -0
  7. package/dist/app/assets/LightMode-BEyvNUzT.js +1 -0
  8. package/dist/app/assets/MainPage-D4Cfee9o.js +2 -0
  9. package/dist/app/assets/MaterializationsPage-nGgGqwSN.js +1 -0
  10. package/dist/app/assets/ModelPage-DLAcEw-F.js +1 -0
  11. package/dist/app/assets/PackagePage-Df0ikH7w.js +1 -0
  12. package/dist/app/assets/RouteError-BFQwtroH.js +1 -0
  13. package/dist/app/assets/ThemeEditorPage-OoskoQYN.js +1 -0
  14. package/dist/app/assets/WorkbookPage-CpFgvUDC.js +1 -0
  15. package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-lb28vQkR.es-BoVSOc75.js} +1 -1
  16. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  17. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  18. package/dist/app/assets/index-6ypuFH6g.js +1774 -0
  19. package/dist/app/assets/index-B8pnU_V_.js +615 -0
  20. package/dist/app/assets/index-BImcHmVP.js +1 -0
  21. package/dist/app/assets/index-DSoCUF-f.js +2725 -0
  22. package/dist/app/assets/index-DfqzSItA.js +2 -0
  23. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  24. package/dist/app/index.html +8 -4
  25. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  26. package/dist/default-publisher.config.json +23 -0
  27. package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
  28. package/dist/package_load_worker.mjs +15219 -0
  29. package/dist/runtime/publisher.js +331 -0
  30. package/dist/{server.js → server.mjs} +115548 -49590
  31. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  32. package/package.json +41 -28
  33. package/publisher.config.example.bigquery.json +33 -0
  34. package/publisher.config.example.duckdb.json +23 -0
  35. package/.eslintrc.json +0 -44
  36. package/.prettierignore +0 -1
  37. package/.prettierrc +0 -4
  38. package/build.ts +0 -38
  39. package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
  40. package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
  41. package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
  42. package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
  43. package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
  44. package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
  45. package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
  46. package/dist/app/assets/index-15BOvhp0.js +0 -456
  47. package/dist/app/assets/index-Bb2jqquW.js +0 -676
  48. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  49. package/dist/app/assets/index-D68X76-7.js +0 -1276
  50. package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
  51. package/dxt/malloy_bridge.py +0 -354
  52. package/dxt/manifest.json +0 -22
  53. package/eslint.config.mjs +0 -76
  54. package/k6-tests/README.md +0 -144
  55. package/k6-tests/bun.lock +0 -797
  56. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  57. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  58. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  59. package/k6-tests/load-test/load-test-crud.ts +0 -142
  60. package/k6-tests/load-test/load-test.ts +0 -784
  61. package/k6-tests/package.json +0 -27
  62. package/k6-tests/scripts/run-with-otel.sh +0 -50
  63. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  64. package/k6-tests/tsconfig.json +0 -29
  65. package/k6-tests/utils/client_factory.ts +0 -128
  66. package/k6-tests/utils/common.ts +0 -489
  67. package/k6-tests/utils/logger.ts +0 -62
  68. package/k6-tests/utils/types.d.ts +0 -35
  69. package/malloy_mcp.dxt +0 -0
  70. package/publisher.config.json +0 -33
  71. package/src/config.spec.ts +0 -850
  72. package/src/config.ts +0 -305
  73. package/src/constants.ts +0 -10
  74. package/src/controller/compile.controller.ts +0 -35
  75. package/src/controller/connection.controller.ts +0 -515
  76. package/src/controller/database.controller.ts +0 -21
  77. package/src/controller/manifest.controller.ts +0 -29
  78. package/src/controller/materialization.controller.ts +0 -125
  79. package/src/controller/model.controller.ts +0 -107
  80. package/src/controller/package.controller.ts +0 -182
  81. package/src/controller/query.controller.ts +0 -60
  82. package/src/controller/watch-mode.controller.ts +0 -98
  83. package/src/data_styles.ts +0 -71
  84. package/src/dto/connection.dto.spec.ts +0 -131
  85. package/src/dto/connection.dto.ts +0 -174
  86. package/src/dto/index.ts +0 -2
  87. package/src/dto/package.dto.spec.ts +0 -42
  88. package/src/dto/package.dto.ts +0 -12
  89. package/src/dto/validate.spec.ts +0 -76
  90. package/src/dto/validate.ts +0 -31
  91. package/src/errors.ts +0 -116
  92. package/src/health.ts +0 -189
  93. package/src/instrumentation.ts +0 -148
  94. package/src/logger.ts +0 -154
  95. package/src/mcp/error_messages.ts +0 -199
  96. package/src/mcp/handler_utils.ts +0 -239
  97. package/src/mcp/mcp_constants.ts +0 -22
  98. package/src/mcp/prompts/handlers.ts +0 -84
  99. package/src/mcp/prompts/index.ts +0 -11
  100. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  101. package/src/mcp/prompts/prompt_service.ts +0 -67
  102. package/src/mcp/prompts/utils.ts +0 -62
  103. package/src/mcp/resource_metadata.ts +0 -47
  104. package/src/mcp/resources/model_resource.ts +0 -153
  105. package/src/mcp/resources/notebook_resource.ts +0 -137
  106. package/src/mcp/resources/package_resource.ts +0 -370
  107. package/src/mcp/resources/project_resource.ts +0 -184
  108. package/src/mcp/resources/query_resource.ts +0 -117
  109. package/src/mcp/resources/source_resource.ts +0 -141
  110. package/src/mcp/resources/view_resource.ts +0 -136
  111. package/src/mcp/server.ts +0 -62
  112. package/src/mcp/tools/discovery_tools.ts +0 -258
  113. package/src/mcp/tools/execute_query_tool.ts +0 -265
  114. package/src/server.ts +0 -1195
  115. package/src/service/connection.spec.ts +0 -1331
  116. package/src/service/connection.ts +0 -1511
  117. package/src/service/connection_service.spec.ts +0 -641
  118. package/src/service/connection_service.ts +0 -181
  119. package/src/service/db_utils.spec.ts +0 -714
  120. package/src/service/db_utils.ts +0 -1227
  121. package/src/service/filter.spec.ts +0 -447
  122. package/src/service/filter.ts +0 -337
  123. package/src/service/filter_integration.spec.ts +0 -825
  124. package/src/service/gcs_s3_utils.ts +0 -427
  125. package/src/service/manifest_service.spec.ts +0 -201
  126. package/src/service/manifest_service.ts +0 -106
  127. package/src/service/materialization_service.spec.ts +0 -648
  128. package/src/service/materialization_service.ts +0 -929
  129. package/src/service/materialized_table_gc.spec.ts +0 -383
  130. package/src/service/materialized_table_gc.ts +0 -279
  131. package/src/service/model.spec.ts +0 -257
  132. package/src/service/model.ts +0 -1069
  133. package/src/service/package.spec.ts +0 -236
  134. package/src/service/package.ts +0 -463
  135. package/src/service/project.ts +0 -681
  136. package/src/service/project_compile.spec.ts +0 -197
  137. package/src/service/project_store.spec.ts +0 -859
  138. package/src/service/project_store.ts +0 -1531
  139. package/src/service/quoting.ts +0 -41
  140. package/src/service/resolve_project.ts +0 -13
  141. package/src/storage/BaseRepository.ts +0 -31
  142. package/src/storage/DatabaseInterface.ts +0 -180
  143. package/src/storage/StorageManager.mock.ts +0 -59
  144. package/src/storage/StorageManager.ts +0 -210
  145. package/src/storage/duckdb/ConnectionRepository.ts +0 -141
  146. package/src/storage/duckdb/DuckDBConnection.ts +0 -177
  147. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  148. package/src/storage/duckdb/DuckDBRepository.ts +0 -224
  149. package/src/storage/duckdb/ManifestRepository.ts +0 -119
  150. package/src/storage/duckdb/MaterializationRepository.ts +0 -249
  151. package/src/storage/duckdb/PackageRepository.ts +0 -135
  152. package/src/storage/duckdb/ProjectRepository.ts +0 -148
  153. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  154. package/src/storage/duckdb/schema.ts +0 -147
  155. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
  156. package/src/utils.ts +0 -13
  157. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  158. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  159. package/tests/fixtures/persist-test/publisher.json +0 -5
  160. package/tests/fixtures/publisher.config.json +0 -15
  161. package/tests/harness/e2e.ts +0 -96
  162. package/tests/harness/mcp_test_setup.ts +0 -344
  163. package/tests/harness/mocks.ts +0 -37
  164. package/tests/harness/rest_e2e.ts +0 -68
  165. package/tests/harness/uris.ts +0 -31
  166. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
  167. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
  168. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
  169. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
  170. package/tests/integration/mcp/setup.spec.ts +0 -5
  171. package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
  172. package/tests/unit/ducklake/ducklake.test.ts +0 -950
  173. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  174. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
  175. package/tsconfig.json +0 -32
@@ -1,1227 +0,0 @@
1
- import { ClientSecretCredential } from "@azure/identity";
2
- import { ContainerClient } from "@azure/storage-blob";
3
- import { BigQuery } from "@google-cloud/bigquery";
4
- import { Connection, TableSourceDef } from "@malloydata/malloy";
5
- import { components } from "../api";
6
- import { logger } from "../logger";
7
- import {
8
- CloudStorageCredentials,
9
- gcsConnectionToCredentials,
10
- getCloudTablesWithColumns,
11
- listCloudDirectorySchemas,
12
- listDataFilesInDirectory,
13
- parseCloudUri,
14
- s3ConnectionToCredentials,
15
- } from "./gcs_s3_utils";
16
- import { ApiConnection } from "./model";
17
-
18
- type ApiSchema = components["schemas"]["Schema"];
19
- type ApiTable = components["schemas"]["Table"];
20
- type ApiAzureConnection = components["schemas"]["AzureConnection"];
21
-
22
- /**
23
- * Build a SQL `AND column IN (...)` fragment for optional table-name filtering.
24
- * Returns an empty string when `values` is undefined or empty.
25
- */
26
- export function sqlInFilter(columnName: string, values?: string[]): string {
27
- if (!values || values.length === 0) return "";
28
- const escaped = values.map((v) => `'${v.replace(/'/g, "''")}'`);
29
- return `AND ${columnName} IN (${escaped.join(", ")})`;
30
- }
31
-
32
- /**
33
- * Group INFORMATION_SCHEMA.COLUMNS rows into ApiTable objects.
34
- * Handles both upper-case (Snowflake) and lower-case (Postgres/DuckDB) column names.
35
- */
36
- function groupColumnRowsIntoTables(
37
- rows: unknown[],
38
- buildResource: (tableName: string) => string,
39
- ): ApiTable[] {
40
- const tableMap = new Map<string, { name: string; type: string }[]>();
41
- for (const row of rows) {
42
- const r = row as Record<string, unknown>;
43
- const tableName = String(r.TABLE_NAME ?? r.table_name ?? "");
44
- const columnName = String(r.COLUMN_NAME ?? r.column_name ?? "");
45
- const dataType = String(r.DATA_TYPE ?? r.data_type ?? "").toLowerCase();
46
- if (!tableName) continue;
47
- if (!tableMap.has(tableName)) tableMap.set(tableName, []);
48
- tableMap.get(tableName)!.push({ name: columnName, type: dataType });
49
- }
50
- const tables: ApiTable[] = [];
51
- for (const [tableName, columns] of tableMap) {
52
- tables.push({ resource: buildResource(tableName), columns });
53
- }
54
- return tables;
55
- }
56
-
57
- function createBigQueryClient(connection: ApiConnection): BigQuery {
58
- if (!connection.bigqueryConnection) {
59
- throw new Error("BigQuery connection is required");
60
- }
61
-
62
- const config: {
63
- projectId: string;
64
- credentials?: object;
65
- keyFilename?: string;
66
- } = {
67
- projectId: connection.bigqueryConnection.defaultProjectId || "",
68
- };
69
-
70
- // Add service account key if provided
71
- if (connection.bigqueryConnection.serviceAccountKeyJson) {
72
- let credentials: Record<string, unknown>;
73
- try {
74
- credentials = JSON.parse(
75
- connection.bigqueryConnection.serviceAccountKeyJson,
76
- );
77
- } catch (parseError) {
78
- throw new Error(
79
- `Failed to parse BigQuery service account key JSON: ${(parseError as Error).message}`,
80
- );
81
- }
82
- config.credentials = credentials;
83
-
84
- if (!config.projectId && credentials.project_id) {
85
- config.projectId = credentials.project_id as string;
86
- }
87
-
88
- if (!config.projectId) {
89
- throw new Error(
90
- "BigQuery project ID is required. Either set the defaultProjectId in the connection configuration or the project_id in the service account key JSON.",
91
- );
92
- }
93
- } else if (
94
- Object.keys(connection.bigqueryConnection).length === 0 &&
95
- process.env.GOOGLE_APPLICATION_CREDENTIALS
96
- ) {
97
- // Note: The BigQuery client will infer the project ID from the ADC file.
98
- config.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS || "";
99
- } else {
100
- throw new Error(
101
- "BigQuery connection is required, either set the bigqueryConnection in the connection configuration or set the GOOGLE_APPLICATION_CREDENTIALS environment variable.",
102
- );
103
- }
104
-
105
- return new BigQuery(config);
106
- }
107
-
108
- function standardizeRunSQLResult(result: unknown): unknown[] {
109
- // Handle different result formats from malloyConnection.runSQL
110
- return Array.isArray(result)
111
- ? result
112
- : (result as { rows?: unknown[] }).rows || [];
113
- }
114
-
115
- function getCloudCredentialsFromAttachedDatabases(
116
- attachedDatabases: components["schemas"]["AttachedDatabase"][],
117
- storageType: "gcs" | "s3",
118
- ): CloudStorageCredentials | null {
119
- for (const attachedDb of attachedDatabases) {
120
- if (
121
- attachedDb.type === "gcs" &&
122
- storageType === "gcs" &&
123
- attachedDb.gcsConnection
124
- ) {
125
- return gcsConnectionToCredentials(attachedDb.gcsConnection);
126
- }
127
- if (
128
- attachedDb.type === "s3" &&
129
- storageType === "s3" &&
130
- attachedDb.s3Connection
131
- ) {
132
- return s3ConnectionToCredentials(attachedDb.s3Connection);
133
- }
134
- }
135
- return null;
136
- }
137
-
138
- async function getSchemasForBigQuery(
139
- connection: ApiConnection,
140
- ): Promise<ApiSchema[]> {
141
- if (!connection.bigqueryConnection) {
142
- throw new Error("BigQuery connection is required");
143
- }
144
- try {
145
- const bigquery = createBigQueryClient(connection);
146
- const [datasets] = await bigquery.getDatasets();
147
-
148
- return await Promise.all(
149
- datasets.map(async (dataset) => {
150
- const [metadata] = await dataset.getMetadata();
151
- return {
152
- name: dataset.id,
153
- isHidden: false,
154
- isDefault: false,
155
- description: (metadata as { description?: string })?.description,
156
- };
157
- }),
158
- );
159
- } catch (error) {
160
- logger.error(
161
- `Error getting schemas for BigQuery connection ${connection.name}`,
162
- { error },
163
- );
164
- throw new Error(
165
- `Failed to get schemas for BigQuery connection ${connection.name}: ${(error as Error).message}`,
166
- );
167
- }
168
- }
169
-
170
- async function getSchemasForPostgres(
171
- connection: ApiConnection,
172
- malloyConnection: Connection,
173
- ): Promise<ApiSchema[]> {
174
- if (!connection.postgresConnection) {
175
- throw new Error("Postgres connection is required");
176
- }
177
- try {
178
- // Wrap in row_to_json because the Malloy Postgres driver's runSQL
179
- // de-JSONs each row via row.row (matching Malloy-generated queries).
180
- const result = await malloyConnection.runSQL(
181
- "SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t",
182
- );
183
- const rows = standardizeRunSQLResult(result);
184
- return rows.map((row: unknown) => {
185
- const typedRow = row as Record<string, unknown>;
186
- const schemaName = String(
187
- typedRow.schema_name ?? typedRow.SCHEMA_NAME ?? "",
188
- );
189
- return {
190
- name: schemaName,
191
- isHidden: ["information_schema", "pg_catalog", "pg_toast"].includes(
192
- schemaName,
193
- ),
194
- isDefault: schemaName === "public",
195
- };
196
- });
197
- } catch (error) {
198
- logger.error(
199
- `Error getting schemas for Postgres connection ${connection.name}`,
200
- { error },
201
- );
202
- throw new Error(
203
- `Failed to get schemas for Postgres connection ${connection.name}: ${(error as Error).message}`,
204
- );
205
- }
206
- }
207
-
208
- async function getSchemasForMySQL(
209
- connection: ApiConnection,
210
- ): Promise<ApiSchema[]> {
211
- if (!connection.mysqlConnection) {
212
- throw new Error("Mysql connection is required");
213
- }
214
- return [
215
- {
216
- name: connection.mysqlConnection.database || "mysql",
217
- isHidden: false,
218
- isDefault: true,
219
- },
220
- ];
221
- }
222
-
223
- async function getSchemasForSnowflake(
224
- connection: ApiConnection,
225
- malloyConnection: Connection,
226
- ): Promise<ApiSchema[]> {
227
- if (!connection.snowflakeConnection) {
228
- throw new Error("Snowflake connection is required");
229
- }
230
- try {
231
- const database = connection.snowflakeConnection.database;
232
- const schema = connection.snowflakeConnection.schema;
233
-
234
- const filters: string[] = [];
235
- if (database) {
236
- filters.push(`CATALOG_NAME = '${database}'`);
237
- }
238
- if (schema) {
239
- filters.push(`SCHEMA_NAME = '${schema}'`);
240
- }
241
- const whereClause =
242
- filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
243
-
244
- const result = await malloyConnection.runSQL(
245
- `SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`,
246
- );
247
- const rows = standardizeRunSQLResult(result);
248
- return rows.map((row: unknown) => {
249
- const typedRow = row as Record<string, unknown>;
250
- const catalogName = String(
251
- typedRow.CATALOG_NAME ?? typedRow.catalog_name ?? "",
252
- );
253
- const schemaName = String(
254
- typedRow.SCHEMA_NAME ?? typedRow.schema_name ?? "",
255
- );
256
- const owner = String(
257
- typedRow.SCHEMA_OWNER ?? typedRow.schema_owner ?? "",
258
- );
259
- return {
260
- name: `${catalogName}.${schemaName}`,
261
- isHidden:
262
- ["SNOWFLAKE", ""].includes(owner) ||
263
- schemaName === "INFORMATION_SCHEMA",
264
- isDefault: schema ? schemaName === schema : false,
265
- };
266
- });
267
- } catch (error) {
268
- logger.error(
269
- `Error getting schemas for Snowflake connection ${connection.name}`,
270
- { error },
271
- );
272
- throw new Error(
273
- `Failed to get schemas for Snowflake connection ${connection.name}: ${(error as Error).message}`,
274
- );
275
- }
276
- }
277
-
278
- async function getSchemasForTrino(
279
- connection: ApiConnection,
280
- malloyConnection: Connection,
281
- ): Promise<ApiSchema[]> {
282
- if (!connection.trinoConnection) {
283
- throw new Error("Trino connection is required");
284
- }
285
- try {
286
- const configuredSchema = connection.trinoConnection.schema;
287
- let allRows: { catalog: string; schema: string }[] = [];
288
-
289
- if (connection.trinoConnection.catalog) {
290
- const catalog = connection.trinoConnection.catalog;
291
- const result = await malloyConnection.runSQL(
292
- `SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`,
293
- );
294
- const rows = standardizeRunSQLResult(result);
295
- allRows = rows.map((row: unknown) => {
296
- const r = row as Record<string, unknown>;
297
- return {
298
- catalog,
299
- schema: String(r.schema_name ?? r.Schema ?? ""),
300
- };
301
- });
302
- } else {
303
- const catalogsResult = await malloyConnection.runSQL(`SHOW CATALOGS`);
304
- const catalogNames = standardizeRunSQLResult(catalogsResult).map(
305
- (row: unknown) => {
306
- const r = row as Record<string, unknown>;
307
- return String(r.Catalog ?? r.catalog ?? "");
308
- },
309
- );
310
-
311
- for (const catalog of catalogNames) {
312
- try {
313
- const result = await malloyConnection.runSQL(
314
- `SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`,
315
- );
316
- const rows = standardizeRunSQLResult(result);
317
- for (const row of rows) {
318
- const r = row as Record<string, unknown>;
319
- allRows.push({
320
- catalog,
321
- schema: String(r.schema_name ?? r.Schema ?? ""),
322
- });
323
- }
324
- } catch (catalogError) {
325
- logger.warn(
326
- `Failed to list schemas for Trino catalog ${catalog}`,
327
- { error: catalogError },
328
- );
329
- }
330
- }
331
- }
332
-
333
- return allRows.map(({ catalog, schema }) => {
334
- const name = connection.trinoConnection?.catalog
335
- ? schema
336
- : `${catalog}.${schema}`;
337
- return {
338
- name,
339
- isHidden: ["information_schema", "performance_schema"].includes(
340
- schema,
341
- ),
342
- isDefault: configuredSchema ? schema === configuredSchema : false,
343
- };
344
- });
345
- } catch (error) {
346
- logger.error(
347
- `Error getting schemas for Trino connection ${connection.name}`,
348
- { error },
349
- );
350
- throw new Error(
351
- `Failed to get schemas for Trino connection ${connection.name}: ${(error as Error).message}`,
352
- );
353
- }
354
- }
355
-
356
- async function getSchemasForDuckDB(
357
- connection: ApiConnection,
358
- malloyConnection: Connection,
359
- ): Promise<ApiSchema[]> {
360
- if (!connection.duckdbConnection) {
361
- throw new Error("DuckDB connection is required");
362
- }
363
- try {
364
- const result = await malloyConnection.runSQL(
365
- "SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name",
366
- { rowLimit: 1000 },
367
- );
368
-
369
- const rows = standardizeRunSQLResult(result);
370
-
371
- const schemas: ApiSchema[] = rows.map((row: unknown) => {
372
- const typedRow = row as Record<string, unknown>;
373
- const schemaName = String(typedRow.schema_name ?? "");
374
- const catalogName = String(typedRow.catalog_name ?? "");
375
-
376
- return {
377
- name: `${catalogName}.${schemaName}`,
378
- isHidden:
379
- [
380
- "information_schema",
381
- "performance_schema",
382
- "pg_catalog",
383
- "pg_toast",
384
- "",
385
- ].includes(schemaName) ||
386
- ["md_information_schema", "system"].includes(catalogName),
387
- isDefault: catalogName === "main",
388
- };
389
- });
390
-
391
- const attachedDatabases =
392
- connection.duckdbConnection.attachedDatabases || [];
393
-
394
- const cloudDatabases = attachedDatabases.filter(
395
- (attachedDb) =>
396
- (attachedDb.type === "gcs" || attachedDb.type === "s3") &&
397
- (attachedDb.gcsConnection || attachedDb.s3Connection),
398
- );
399
-
400
- const cloudDbPromises = cloudDatabases.map(async (attachedDb) => {
401
- const dbType = attachedDb.type as "gcs" | "s3";
402
- const credentials =
403
- dbType === "gcs"
404
- ? gcsConnectionToCredentials(attachedDb.gcsConnection!)
405
- : s3ConnectionToCredentials(attachedDb.s3Connection!);
406
-
407
- try {
408
- return await listCloudDirectorySchemas(credentials);
409
- } catch (cloudError) {
410
- logger.warn(
411
- `Failed to list ${dbType.toUpperCase()} directory schemas for ${attachedDb.name}`,
412
- { error: cloudError },
413
- );
414
- return [];
415
- }
416
- });
417
-
418
- const cloudSchemaArrays = await Promise.all(cloudDbPromises);
419
- for (const cloudSchemas of cloudSchemaArrays) {
420
- schemas.push(...cloudSchemas);
421
- }
422
-
423
- const azureDatabases = attachedDatabases.filter(
424
- (attachedDb) =>
425
- attachedDb.type === "azure" && attachedDb.azureConnection,
426
- );
427
- for (const attachedDb of azureDatabases) {
428
- if (attachedDb.name) {
429
- schemas.push({
430
- name: attachedDb.name,
431
- isHidden: false,
432
- isDefault: false,
433
- });
434
- }
435
- }
436
-
437
- return schemas;
438
- } catch (error) {
439
- logger.error(
440
- `Error getting schemas for DuckDB connection ${connection.name}`,
441
- { error },
442
- );
443
- throw new Error(
444
- `Failed to get schemas for DuckDB connection ${connection.name}: ${(error as Error).message}`,
445
- );
446
- }
447
- }
448
-
449
- async function getSchemasForMotherDuck(
450
- connection: ApiConnection,
451
- malloyConnection: Connection,
452
- ): Promise<ApiSchema[]> {
453
- if (!connection.motherduckConnection) {
454
- throw new Error("MotherDuck connection is required");
455
- }
456
- try {
457
- const database = connection.motherduckConnection.database;
458
- const whereClause = database ? `WHERE catalog_name = '${database}'` : "";
459
- const result = await malloyConnection.runSQL(
460
- `SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`,
461
- );
462
- const rows = standardizeRunSQLResult(result);
463
- return rows.map((row: unknown) => {
464
- const typedRow = row as Record<string, unknown>;
465
- const schemaName = String(
466
- typedRow.schema_name ?? typedRow.SCHEMA_NAME ?? "",
467
- );
468
- return {
469
- name: schemaName,
470
- isHidden: ["information_schema", "performance_schema", ""].includes(
471
- schemaName,
472
- ),
473
- isDefault: schemaName === "main",
474
- };
475
- });
476
- } catch (error) {
477
- logger.error(
478
- `Error getting schemas for MotherDuck connection ${connection.name}`,
479
- { error },
480
- );
481
- throw new Error(
482
- `Failed to get schemas for MotherDuck connection ${connection.name}: ${(error as Error).message}`,
483
- );
484
- }
485
- }
486
-
487
- async function getSchemasForDuckLake(
488
- connection: ApiConnection,
489
- malloyConnection: Connection,
490
- ): Promise<ApiSchema[]> {
491
- try {
492
- // The catalog is attached with the connection name (see attachDuckLake in connection.ts)
493
- const catalogName = connection.name;
494
- const result = await malloyConnection.runSQL(
495
- `SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`,
496
- { rowLimit: 1000 },
497
- );
498
- const rows = standardizeRunSQLResult(result);
499
-
500
- return rows.map((row: unknown) => {
501
- const typedRow = row as Record<string, unknown>;
502
- const schemaName = typedRow.schema_name as string;
503
- const shouldShow = schemaName === "main" || schemaName === "public";
504
- return {
505
- name: schemaName,
506
- isHidden: !shouldShow,
507
- isDefault: false,
508
- };
509
- });
510
- } catch (error) {
511
- logger.error(
512
- `Error getting schemas for DuckLake connection ${connection.name}`,
513
- { error },
514
- );
515
- throw new Error(
516
- `Failed to get schemas for DuckLake connection ${connection.name}: ${(error as Error).message}`,
517
- );
518
- }
519
- }
520
-
521
- export async function getSchemasForConnection(
522
- connection: ApiConnection,
523
- malloyConnection: Connection,
524
- ): Promise<ApiSchema[]> {
525
- switch (connection.type) {
526
- case "bigquery":
527
- return getSchemasForBigQuery(connection);
528
- case "postgres":
529
- return getSchemasForPostgres(connection, malloyConnection);
530
- case "mysql":
531
- return getSchemasForMySQL(connection);
532
- case "snowflake":
533
- return getSchemasForSnowflake(connection, malloyConnection);
534
- case "trino":
535
- return getSchemasForTrino(connection, malloyConnection);
536
- case "duckdb":
537
- return getSchemasForDuckDB(connection, malloyConnection);
538
- case "motherduck":
539
- return getSchemasForMotherDuck(connection, malloyConnection);
540
- case "ducklake":
541
- return getSchemasForDuckLake(connection, malloyConnection);
542
- default:
543
- throw new Error(`Unsupported connection type: ${connection.type}`);
544
- }
545
- }
546
-
547
- function getFileType(key: string): string {
548
- const lowerKey = key.toLowerCase();
549
- if (lowerKey.endsWith(".csv")) return "csv";
550
- if (lowerKey.endsWith(".parquet")) return "parquet";
551
- if (lowerKey.endsWith(".json")) return "json";
552
- if (lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson"))
553
- return "jsonl";
554
- return "unknown";
555
- }
556
-
557
- /**
558
- * Lists blobs in an Azure container matching a glob-like prefix/extension filter.
559
- * Parses an HTTPS SAS URL like:
560
- * https://account.blob.core.windows.net/container/path/*.parquet?sasToken
561
- * Returns individual file URLs with the SAS token appended.
562
- */
563
- async function listAzureBlobs(
564
- fileUrl: string,
565
- azureConnection?: ApiAzureConnection,
566
- ): Promise<{ url: string; blobName: string }[]> {
567
- // Split URL and SAS token carefully to avoid encoding issues with signatures
568
- const queryStart = fileUrl.indexOf("?");
569
- const baseUrl = queryStart >= 0 ? fileUrl.substring(0, queryStart) : fileUrl;
570
- const sasToken = queryStart >= 0 ? fileUrl.substring(queryStart) : "";
571
-
572
- // Parse the URL to extract account, container, and blob path
573
- let accountUrl: string;
574
- let container: string;
575
- let blobPath: string;
576
-
577
- if (baseUrl.startsWith("abfss://")) {
578
- // abfss://container/path or abfss://account.dfs.core.windows.net/container/path
579
- const withoutScheme = baseUrl.substring("abfss://".length);
580
- const parts = withoutScheme.split("/").filter(Boolean);
581
- if (parts[0].includes(".")) {
582
- // Fully qualified: abfss://account.dfs.core.windows.net/container/path
583
- const accountName = parts[0].split(".")[0];
584
- accountUrl = `https://${accountName}.blob.core.windows.net`;
585
- container = parts[1];
586
- blobPath = parts.slice(2).join("/");
587
- } else {
588
- // Short form: abfss://container/path — need accountName from config
589
- if (!azureConnection?.accountName) {
590
- throw new Error(
591
- "accountName is required to list blobs with abfss:// URLs",
592
- );
593
- }
594
- accountUrl = `https://${azureConnection.accountName}.blob.core.windows.net`;
595
- container = parts[0];
596
- blobPath = parts.slice(1).join("/");
597
- }
598
- } else {
599
- // https://account.blob.core.windows.net/container/path
600
- const url = new URL(baseUrl);
601
- const pathParts = url.pathname.split("/").filter(Boolean);
602
- container = pathParts[0];
603
- blobPath = pathParts.slice(1).join("/");
604
- accountUrl = `${url.protocol}//${url.host}`;
605
- }
606
-
607
- // Three supported glob patterns:
608
- // path/file.ext → single file (handled upstream by isAzureSingleFileUrl)
609
- // path/*.ext → files directly in path/ with that extension (no subdirs)
610
- // path/** → all valid data files in path/ and nested dirs (recursive)
611
- let prefix: string;
612
- let extensionFilter = ""; // for *.ext pattern
613
- let recursive = true; // for ** pattern
614
-
615
- if (blobPath.endsWith("**")) {
616
- // Recursive listing: everything under this prefix
617
- prefix = blobPath.slice(0, -2);
618
- recursive = true;
619
- } else if (blobPath.includes("*")) {
620
- // Single-level glob: path/*.ext — files directly in that dir only
621
- const starIndex = blobPath.indexOf("*");
622
- prefix = blobPath.substring(0, starIndex);
623
- extensionFilter = blobPath.substring(starIndex + 1); // e.g. ".parquet"
624
- recursive = false;
625
- } else {
626
- // No glob — use blobPath as prefix (container-level listing)
627
- prefix = blobPath;
628
- recursive = true;
629
- }
630
-
631
- // Create ContainerClient with appropriate authentication
632
- let containerClient: ContainerClient;
633
- if (
634
- azureConnection?.authType === "service_principal" &&
635
- azureConnection.tenantId &&
636
- azureConnection.clientId &&
637
- azureConnection.clientSecret
638
- ) {
639
- const credential = new ClientSecretCredential(
640
- azureConnection.tenantId,
641
- azureConnection.clientId,
642
- azureConnection.clientSecret,
643
- );
644
- containerClient = new ContainerClient(
645
- `${accountUrl}/${container}`,
646
- credential,
647
- );
648
- } else {
649
- // SAS token auth — append token to container URL
650
- const containerUrl = `${accountUrl}/${container}${sasToken}`;
651
- containerClient = new ContainerClient(containerUrl);
652
- }
653
-
654
- const matchingFiles: { url: string; blobName: string }[] = [];
655
- for await (const blob of containerClient.listBlobsFlat({
656
- prefix: prefix || undefined,
657
- })) {
658
- if (extensionFilter && !blob.name.endsWith(extensionFilter)) continue;
659
- // For *.ext (non-recursive): only allow files directly in prefix dir
660
- if (!recursive) {
661
- const nameAfterPrefix = blob.name.substring(prefix.length);
662
- if (nameAfterPrefix.includes("/")) continue;
663
- }
664
- if (!isDataFile(blob.name)) continue;
665
- // For SPN: use abfss:// URLs that DuckDB's azure extension can read
666
- // For SAS: use https:// URLs with token appended
667
- let url: string;
668
- if (azureConnection?.authType === "service_principal") {
669
- const account =
670
- azureConnection.accountName ||
671
- accountUrl.split("//")[1]?.split(".")[0];
672
- url = `abfss://${account}.dfs.core.windows.net/${container}/${blob.name}`;
673
- } else {
674
- url = `${accountUrl}/${container}/${blob.name}${sasToken}`;
675
- }
676
- matchingFiles.push({ url, blobName: blob.name });
677
- }
678
-
679
- logger.info(
680
- `Listed ${matchingFiles.length} matching blobs in Azure container ${container} with prefix "${prefix}"`,
681
- );
682
- return matchingFiles;
683
- }
684
-
685
- function isDataFile(key: string): boolean {
686
- const lowerKey = key.toLowerCase();
687
- return (
688
- lowerKey.endsWith(".csv") ||
689
- lowerKey.endsWith(".parquet") ||
690
- lowerKey.endsWith(".json") ||
691
- lowerKey.endsWith(".jsonl") ||
692
- lowerKey.endsWith(".ndjson")
693
- );
694
- }
695
-
696
- async function describeRemoteFile(
697
- malloyConnection: Connection,
698
- fileUri: string,
699
- ): Promise<ApiTable> {
700
- const pathWithoutQuery = fileUri.split("?")[0];
701
- const fileType = getFileType(pathWithoutQuery);
702
-
703
- let describeQuery: string;
704
- switch (fileType) {
705
- case "csv":
706
- describeQuery = `DESCRIBE SELECT * FROM read_csv('${fileUri}', auto_detect=true) LIMIT 1`;
707
- break;
708
- case "parquet":
709
- describeQuery = `DESCRIBE SELECT * FROM read_parquet('${fileUri}') LIMIT 1`;
710
- break;
711
- case "json":
712
- describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', auto_detect=true) LIMIT 1`;
713
- break;
714
- case "jsonl":
715
- describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
716
- break;
717
- default:
718
- logger.warn(`Unsupported file type for file: ${fileUri}`);
719
- return { resource: fileUri, columns: [] };
720
- }
721
-
722
- const result = await malloyConnection.runSQL(describeQuery);
723
- const rows = standardizeRunSQLResult(result);
724
- const columns = rows.map((row: unknown) => {
725
- const typedRow = row as Record<string, unknown>;
726
- return {
727
- name: (typedRow.column_name || typedRow.name) as string,
728
- type: (typedRow.column_type || typedRow.type) as string,
729
- };
730
- });
731
-
732
- const fileName = pathWithoutQuery.split("/").pop() || fileUri;
733
- return { resource: fileName, columns };
734
- }
735
-
736
- function isAzureSingleFileUrl(fileUri: string): boolean {
737
- const pathWithoutQuery = fileUri.split("?")[0];
738
- // Has a glob — not a single file
739
- if (pathWithoutQuery.includes("*")) return false;
740
- // Ends with / — directory listing
741
- if (pathWithoutQuery.endsWith("/")) return false;
742
- // Check if the last path segment has a data file extension
743
- const lastSegment = pathWithoutQuery.split("/").pop() || "";
744
- return isDataFile(lastSegment);
745
- }
746
-
747
- async function describeAzureFile(
748
- malloyConnection: Connection,
749
- fileUri: string,
750
- azureConnection?: ApiAzureConnection,
751
- ): Promise<ApiTable[]> {
752
- try {
753
- if (isAzureSingleFileUrl(fileUri)) {
754
- // Single file — describe directly via DuckDB
755
- return [await describeRemoteFile(malloyConnection, fileUri)];
756
- }
757
-
758
- // Glob pattern or container/directory URL — list blobs via Azure SDK
759
- const blobs = await listAzureBlobs(fileUri, azureConnection);
760
- if (blobs.length === 0) {
761
- return [{ resource: fileUri, columns: [] }];
762
- }
763
-
764
- const results = await Promise.all(
765
- blobs.map(async ({ url, blobName }) => {
766
- try {
767
- const table = await describeRemoteFile(malloyConnection, url);
768
- return { ...table, resource: blobName };
769
- } catch (error) {
770
- logger.warn(`Failed to describe Azure blob: ${url}`, { error });
771
- return { resource: blobName, columns: [] } as ApiTable;
772
- }
773
- }),
774
- );
775
- return results;
776
- } catch (error) {
777
- logger.error(`Failed to describe Azure file: ${fileUri}`, { error });
778
- throw new Error(
779
- `Failed to describe Azure file: ${error instanceof Error ? error.message : String(error)}`,
780
- );
781
- }
782
- }
783
-
784
- export async function listTablesForSchema(
785
- connection: ApiConnection,
786
- schemaName: string,
787
- malloyConnection: Connection,
788
- tableNames?: string[],
789
- ): Promise<ApiTable[]> {
790
- switch (connection.type) {
791
- case "bigquery":
792
- return listTablesForBigQuery(
793
- connection,
794
- schemaName,
795
- malloyConnection,
796
- tableNames,
797
- );
798
- case "mysql":
799
- return listTablesForMySQL(
800
- connection,
801
- schemaName,
802
- malloyConnection,
803
- tableNames,
804
- );
805
- case "postgres":
806
- return listTablesForPostgres(
807
- connection,
808
- schemaName,
809
- malloyConnection,
810
- tableNames,
811
- );
812
- case "snowflake":
813
- return listTablesForSnowflake(
814
- connection,
815
- schemaName,
816
- malloyConnection,
817
- tableNames,
818
- );
819
- case "trino":
820
- return listTablesForTrino(
821
- connection,
822
- schemaName,
823
- malloyConnection,
824
- tableNames,
825
- );
826
- case "duckdb":
827
- return listTablesForDuckDB(
828
- connection,
829
- schemaName,
830
- malloyConnection,
831
- tableNames,
832
- );
833
- case "motherduck":
834
- return listTablesForMotherDuck(
835
- connection,
836
- schemaName,
837
- malloyConnection,
838
- tableNames,
839
- );
840
- case "ducklake":
841
- return listTablesForDuckLake(
842
- connection,
843
- schemaName,
844
- malloyConnection,
845
- tableNames,
846
- );
847
- default:
848
- throw new Error(`Unsupported connection type: ${connection.type}`);
849
- }
850
- }
851
-
852
- /**
853
- * BigQuery: list tables via API client, then fetch each table's schema
854
- * individually since BigQuery's INFORMATION_SCHEMA is region-scoped.
855
- */
856
- async function listTablesForBigQuery(
857
- connection: ApiConnection,
858
- schemaName: string,
859
- malloyConnection: Connection,
860
- tableNames?: string[],
861
- ): Promise<ApiTable[]> {
862
- try {
863
- const bigquery = createBigQueryClient(connection);
864
- const dataset = bigquery.dataset(schemaName);
865
- const [tables] = await dataset.getTables();
866
-
867
- let names = tables
868
- .map((table) => table.id)
869
- .filter((id): id is string => id !== undefined);
870
- if (tableNames) {
871
- const allowed = new Set(tableNames);
872
- names = names.filter((id) => allowed.has(id));
873
- }
874
-
875
- const results = await Promise.all(
876
- names.map(async (tableName) => {
877
- const tablePath = `${schemaName}.${tableName}`;
878
- try {
879
- const source = await (
880
- malloyConnection as Connection & {
881
- fetchTableSchema: (
882
- tableKey: string,
883
- tablePath: string,
884
- ) => Promise<TableSourceDef | undefined>;
885
- }
886
- ).fetchTableSchema(tableName, tablePath);
887
- const columns =
888
- source?.fields?.map((field) => ({
889
- name: field.name,
890
- type: field.type,
891
- })) || [];
892
- return { resource: tablePath, columns };
893
- } catch (error) {
894
- logger.warn(`Failed to get schema for table ${tableName}`, {
895
- error: extractErrorDataFromError(error),
896
- schemaName,
897
- tableName,
898
- });
899
- return { resource: tablePath, columns: [] };
900
- }
901
- }),
902
- );
903
- return results;
904
- } catch (error) {
905
- logger.error(
906
- `Error getting tables for BigQuery schema ${schemaName} in connection ${connection.name}`,
907
- { error },
908
- );
909
- throw new Error(
910
- `Failed to get tables for BigQuery schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
911
- );
912
- }
913
- }
914
-
915
- async function listTablesForMySQL(
916
- connection: ApiConnection,
917
- schemaName: string,
918
- malloyConnection: Connection,
919
- tableNames?: string[],
920
- ): Promise<ApiTable[]> {
921
- if (!connection.mysqlConnection) {
922
- throw new Error("Mysql connection is required");
923
- }
924
- try {
925
- const result = await malloyConnection.runSQL(
926
- `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`,
927
- );
928
- const rows = standardizeRunSQLResult(result);
929
- return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
930
- } catch (error) {
931
- logger.error(
932
- `Error getting tables for MySQL schema ${schemaName} in connection ${connection.name}`,
933
- { error },
934
- );
935
- throw new Error(
936
- `Failed to get tables for MySQL schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
937
- );
938
- }
939
- }
940
-
941
- async function listTablesForPostgres(
942
- connection: ApiConnection,
943
- schemaName: string,
944
- malloyConnection: Connection,
945
- tableNames?: string[],
946
- ): Promise<ApiTable[]> {
947
- if (!connection.postgresConnection) {
948
- throw new Error("Postgres connection is required");
949
- }
950
- try {
951
- // Wrap in row_to_json because the Malloy Postgres driver's runSQL
952
- // de-JSONs each row via row.row (matching Malloy-generated queries).
953
- const result = await malloyConnection.runSQL(
954
- `SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position) t`,
955
- );
956
- const rows = standardizeRunSQLResult(result);
957
- return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
958
- } catch (error) {
959
- logger.error(
960
- `Error getting tables for Postgres schema ${schemaName} in connection ${connection.name}`,
961
- { error },
962
- );
963
- throw new Error(
964
- `Failed to get tables for Postgres schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
965
- );
966
- }
967
- }
968
-
969
- async function listTablesForSnowflake(
970
- connection: ApiConnection,
971
- schemaName: string,
972
- malloyConnection: Connection,
973
- tableNames?: string[],
974
- ): Promise<ApiTable[]> {
975
- if (!connection.snowflakeConnection) {
976
- throw new Error("Snowflake connection is required");
977
- }
978
- try {
979
- const parts = schemaName.split(".");
980
- let databaseName: string;
981
- let schemaOnly: string;
982
-
983
- if (parts.length >= 2) {
984
- databaseName = parts[0];
985
- schemaOnly = parts[1];
986
- } else {
987
- databaseName = connection.snowflakeConnection.database ?? "";
988
- schemaOnly = parts[0];
989
- }
990
-
991
- if (!databaseName) {
992
- throw new Error(
993
- `Cannot resolve database for schema "${schemaName}": provide DATABASE.SCHEMA or configure a database on the connection`,
994
- );
995
- }
996
-
997
- const qualifiedSchema = `${databaseName}.${schemaOnly}`;
998
- const result = await malloyConnection.runSQL(
999
- `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${schemaOnly}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`,
1000
- );
1001
- const rows = standardizeRunSQLResult(result);
1002
- return groupColumnRowsIntoTables(rows, (t) => `${qualifiedSchema}.${t}`);
1003
- } catch (error) {
1004
- logger.error(
1005
- `Error getting tables for Snowflake schema ${schemaName} in connection ${connection.name}`,
1006
- { error },
1007
- );
1008
- throw new Error(
1009
- `Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
1010
- );
1011
- }
1012
- }
1013
-
1014
- async function listTablesForTrino(
1015
- connection: ApiConnection,
1016
- schemaName: string,
1017
- malloyConnection: Connection,
1018
- tableNames?: string[],
1019
- ): Promise<ApiTable[]> {
1020
- if (!connection.trinoConnection) {
1021
- throw new Error("Trino connection is required");
1022
- }
1023
- try {
1024
- let catalogPrefix: string;
1025
- let schemaOnly: string;
1026
- let resourcePrefix: string;
1027
-
1028
- if (connection.trinoConnection.catalog) {
1029
- catalogPrefix = `${connection.trinoConnection.catalog}.`;
1030
- schemaOnly = schemaName;
1031
- resourcePrefix = `${connection.trinoConnection.catalog}.${schemaName}`;
1032
- } else {
1033
- const dotIdx = schemaName.indexOf(".");
1034
- if (dotIdx > 0) {
1035
- catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
1036
- schemaOnly = schemaName.substring(dotIdx + 1);
1037
- } else {
1038
- catalogPrefix = "";
1039
- schemaOnly = schemaName;
1040
- }
1041
- resourcePrefix = schemaName;
1042
- }
1043
-
1044
- const result = await malloyConnection.runSQL(
1045
- `SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
1046
- );
1047
- const rows = standardizeRunSQLResult(result);
1048
- return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
1049
- } catch (error) {
1050
- logger.error(
1051
- `Error getting tables for Trino schema ${schemaName} in connection ${connection.name}`,
1052
- { error },
1053
- );
1054
- throw new Error(
1055
- `Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
1056
- );
1057
- }
1058
- }
1059
-
1060
- async function listTablesForDuckDB(
1061
- connection: ApiConnection,
1062
- schemaName: string,
1063
- malloyConnection: Connection,
1064
- tableNames?: string[],
1065
- ): Promise<ApiTable[]> {
1066
- if (!connection.duckdbConnection) {
1067
- throw new Error("DuckDB connection is required");
1068
- }
1069
-
1070
- const attachedDbs = connection.duckdbConnection.attachedDatabases || [];
1071
-
1072
- // Azure attached database matched by name
1073
- const azureDb = attachedDbs.find(
1074
- (db) =>
1075
- db.type === "azure" && db.name === schemaName && db.azureConnection,
1076
- );
1077
- if (azureDb) {
1078
- const azureConn = azureDb.azureConnection!;
1079
- const fileUrl =
1080
- azureConn.authType === "sas_token"
1081
- ? azureConn.sasUrl
1082
- : azureConn.fileUrl;
1083
- if (fileUrl) {
1084
- return describeAzureFile(malloyConnection, fileUrl, azureConn);
1085
- }
1086
- }
1087
-
1088
- // Azure ADLS file path (abfss://, https://, az://)
1089
- if (
1090
- schemaName.startsWith("abfss://") ||
1091
- schemaName.startsWith("https://") ||
1092
- schemaName.startsWith("az://")
1093
- ) {
1094
- return describeAzureFile(malloyConnection, schemaName);
1095
- }
1096
-
1097
- // Cloud storage (GCS/S3)
1098
- const parsedUri = parseCloudUri(schemaName);
1099
- if (parsedUri) {
1100
- const {
1101
- type: cloudType,
1102
- bucket: bucketName,
1103
- path: directoryPath,
1104
- } = parsedUri;
1105
- const credentials = getCloudCredentialsFromAttachedDatabases(
1106
- attachedDbs,
1107
- cloudType,
1108
- );
1109
- if (!credentials) {
1110
- throw new Error(
1111
- `${cloudType.toUpperCase()} credentials not found in attached databases`,
1112
- );
1113
- }
1114
- const fileKeys = await listDataFilesInDirectory(
1115
- credentials,
1116
- bucketName,
1117
- directoryPath,
1118
- );
1119
- return getCloudTablesWithColumns(
1120
- malloyConnection,
1121
- credentials,
1122
- bucketName,
1123
- fileKeys,
1124
- );
1125
- }
1126
-
1127
- // Regular DuckDB schema — query information_schema.columns
1128
- const dotIdx = schemaName.indexOf(".");
1129
- if (dotIdx < 0) {
1130
- throw new Error(
1131
- `DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}"`,
1132
- );
1133
- }
1134
- const catalogName = schemaName.substring(0, dotIdx);
1135
- const actualSchemaName = schemaName.substring(dotIdx + 1);
1136
-
1137
- try {
1138
- const result = await malloyConnection.runSQL(
1139
- `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
1140
- );
1141
- const rows = standardizeRunSQLResult(result);
1142
- return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
1143
- } catch (error) {
1144
- logger.error(
1145
- `Error getting tables for DuckDB schema ${schemaName} in connection ${connection.name}`,
1146
- { error },
1147
- );
1148
- throw new Error(
1149
- `Failed to get tables for DuckDB schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
1150
- );
1151
- }
1152
- }
1153
-
1154
- async function listTablesForMotherDuck(
1155
- connection: ApiConnection,
1156
- schemaName: string,
1157
- malloyConnection: Connection,
1158
- tableNames?: string[],
1159
- ): Promise<ApiTable[]> {
1160
- if (!connection.motherduckConnection) {
1161
- throw new Error("MotherDuck connection is required");
1162
- }
1163
- try {
1164
- const result = await malloyConnection.runSQL(
1165
- `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
1166
- );
1167
- const rows = standardizeRunSQLResult(result);
1168
- return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
1169
- } catch (error) {
1170
- logger.error(
1171
- `Error getting tables for MotherDuck schema ${schemaName} in connection ${connection.name}`,
1172
- { error },
1173
- );
1174
- throw new Error(
1175
- `Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
1176
- );
1177
- }
1178
- }
1179
-
1180
- async function listTablesForDuckLake(
1181
- connection: ApiConnection,
1182
- schemaName: string,
1183
- malloyConnection: Connection,
1184
- tableNames?: string[],
1185
- ): Promise<ApiTable[]> {
1186
- // Prefix bare schema names with the catalog (connection) name.
1187
- // Two-part names like "catalog.schema" are already qualified.
1188
- if (!schemaName.includes(".")) {
1189
- schemaName = `${connection.name}.${schemaName}`;
1190
- }
1191
-
1192
- const catalogName = schemaName.split(".")[0];
1193
- const actualSchemaName = schemaName.split(".")[1];
1194
- try {
1195
- const result = await malloyConnection.runSQL(
1196
- `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
1197
- );
1198
- const rows = standardizeRunSQLResult(result);
1199
- return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
1200
- } catch (error) {
1201
- logger.error(
1202
- `Error getting tables for DuckLake schema ${schemaName} in connection ${connection.name}`,
1203
- { error },
1204
- );
1205
- throw new Error(
1206
- `Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
1207
- );
1208
- }
1209
- }
1210
-
1211
- export function extractErrorDataFromError(error: unknown): {
1212
- error: string;
1213
- stack?: string;
1214
- task?: unknown;
1215
- } {
1216
- const errorMessage = error instanceof Error ? error.message : String(error);
1217
- const errorData: { error: string; stack?: string; task?: unknown } = {
1218
- error: errorMessage,
1219
- };
1220
- if (error instanceof Error && logger.level === "debug") {
1221
- errorData.stack = error.stack;
1222
- }
1223
- if (error && typeof error === "object" && "task" in error) {
1224
- errorData.task = (error as { task?: unknown }).task;
1225
- }
1226
- return errorData;
1227
- }