@malloy-publisher/server 0.0.165 → 0.0.168

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 (41) hide show
  1. package/.eslintrc.json +9 -1
  2. package/dist/app/api-doc.yaml +143 -1
  3. package/dist/app/assets/HomePage-D2tUw_9U.js +1 -0
  4. package/dist/app/assets/{MainPage-DAyUfYba.js → MainPage-DBQW76L7.js} +2 -2
  5. package/dist/app/assets/{ModelPage-CrMryV1s.js → ModelPage-BnfOKuhQ.js} +1 -1
  6. package/dist/app/assets/PackagePage-zPhE-rDg.js +1 -0
  7. package/dist/app/assets/ProjectPage-BpSTvuW6.js +1 -0
  8. package/dist/app/assets/RouteError-Cp9-yCK5.js +1 -0
  9. package/dist/app/assets/{WorkbookPage-DZEVYGW3.js → WorkbookPage-FD_gmxeE.js} +1 -1
  10. package/dist/app/assets/{index-BvVmB5sv.js → index-D5QBYuLK.js} +150 -150
  11. package/dist/app/assets/{index-CsC07BYd.js → index-DNCvL_5f.js} +1 -1
  12. package/dist/app/assets/{index-DWhjtyBB.js → index-x9S1fsYn.js} +1 -1
  13. package/dist/app/assets/{index.umd-DvM-lTQa.js → index.umd-CTYdFEHH.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/instrumentation.js +85955 -88560
  16. package/dist/server.js +197441 -106276
  17. package/package.json +2 -1
  18. package/src/controller/compile.controller.ts +35 -0
  19. package/src/controller/connection.controller.ts +22 -2
  20. package/src/controller/model.controller.ts +20 -9
  21. package/src/health.ts +8 -0
  22. package/src/instrumentation.ts +123 -34
  23. package/src/server.ts +49 -3
  24. package/src/service/connection.spec.ts +1331 -0
  25. package/src/service/connection.ts +407 -29
  26. package/src/service/db_utils.ts +104 -45
  27. package/src/service/gcs_s3_utils.ts +115 -40
  28. package/src/service/model.ts +5 -5
  29. package/src/service/project.ts +140 -4
  30. package/src/service/project_compile.spec.ts +197 -0
  31. package/src/service/project_store.ts +49 -21
  32. package/src/storage/StorageManager.ts +4 -3
  33. package/src/storage/duckdb/schema.ts +6 -5
  34. package/tests/harness/e2e.ts +4 -0
  35. package/tests/harness/mcp_test_setup.ts +172 -28
  36. package/tests/unit/duckdb/attached_databases.test.ts +61 -3
  37. package/tests/unit/ducklake/ducklake.test.ts +950 -0
  38. package/dist/app/assets/HomePage-QekMXs8r.js +0 -1
  39. package/dist/app/assets/PackagePage-DDaABD2A.js +0 -1
  40. package/dist/app/assets/ProjectPage-FAYUFGhL.js +0 -1
  41. package/dist/app/assets/RouteError-BKYctANX.js +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.165",
4
+ "version": "0.0.168",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -42,6 +42,7 @@
42
42
  "@modelcontextprotocol/sdk": "^1.13.2",
43
43
  "@opentelemetry/api": "^1.9.0",
44
44
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
45
+ "@opentelemetry/exporter-prometheus": "^0.212.0",
45
46
  "@opentelemetry/sdk-metrics": "^2.0.0",
46
47
  "@opentelemetry/sdk-node": "^0.200.0",
47
48
  "@opentelemetry/sdk-trace-node": "^2.0.0",
@@ -0,0 +1,35 @@
1
+ import type { LogMessage } from "@malloydata/malloy";
2
+ import { ProjectStore } from "../service/project_store";
3
+
4
+ export class CompileController {
5
+ private projectStore: ProjectStore;
6
+
7
+ constructor(projectStore: ProjectStore) {
8
+ this.projectStore = projectStore;
9
+ }
10
+
11
+ public async compile(
12
+ projectName: string,
13
+ packageName: string,
14
+ modelName: string,
15
+ source: string,
16
+ includeSql: boolean = false,
17
+ ): Promise<{ status: string; problems: LogMessage[]; sql?: string }> {
18
+ const project = await this.projectStore.getProject(projectName, false);
19
+ const { problems, sql } = await project.compileSource(
20
+ packageName,
21
+ modelName,
22
+ source,
23
+ includeSql,
24
+ );
25
+
26
+ // Determine overall status based on presence of errors
27
+ const hasErrors = problems.some((p) => p.severity === "error");
28
+
29
+ return {
30
+ status: hasErrors ? "error" : "success",
31
+ problems: problems,
32
+ ...(sql !== undefined && { sql }),
33
+ };
34
+ }
35
+ }
@@ -155,17 +155,37 @@ export class ConnectionController {
155
155
  public async getTable(
156
156
  projectName: string,
157
157
  connectionName: string,
158
- _schemaName: string,
158
+ schemaName: string,
159
159
  tablePath: string,
160
160
  ): Promise<ApiTable> {
161
161
  const malloyConnection = await this.getMalloyConnection(
162
162
  projectName,
163
163
  connectionName,
164
164
  );
165
+ const connection = await this.getConnection(projectName, connectionName);
166
+
167
+ if (connection.type === "ducklake") {
168
+ if (tablePath.split(".").length === 1) {
169
+ // tablePath is just the table name, construct full path
170
+ tablePath = `${connectionName}.${schemaName}.${tablePath}`;
171
+ } else if (
172
+ tablePath.split(".").length === 2 &&
173
+ !tablePath.startsWith(connectionName)
174
+ ) {
175
+ // tablePath is schemaName.tableName but missing connection prefix
176
+ tablePath = `${connectionName}.${tablePath}`;
177
+ }
178
+ // If tablePath already has 3+ parts or starts with connection name, use as-is
179
+ }
180
+
181
+ const tableKey = tablePath.split(".").pop();
182
+ if (!tableKey) {
183
+ throw new Error(`Invalid tablePath: ${tablePath}`);
184
+ }
165
185
 
166
186
  const tableSource = await getConnectionTableSource(
167
187
  malloyConnection,
168
- tablePath.split(".").pop()!, // tableKey is the table name
188
+ tableKey, // tableKey is the table name
169
189
  tablePath,
170
190
  );
171
191
 
@@ -38,16 +38,27 @@ export class ModelController {
38
38
  packageName: string,
39
39
  modelPath: string,
40
40
  ): Promise<ApiCompiledModel> {
41
- const project = await this.projectStore.getProject(projectName, false);
42
- const p = await project.getPackage(packageName, false);
43
- const model = p.getModel(modelPath);
44
- if (!model) {
45
- throw new ModelNotFoundError(`${modelPath} does not exist`);
46
- }
47
- if (model.getType() === "notebook") {
48
- throw new ModelNotFoundError(`${modelPath} is a notebook`);
41
+ try {
42
+ const project = await this.projectStore.getProject(projectName, false);
43
+ const p = await project.getPackage(packageName, false);
44
+ const model = p.getModel(modelPath);
45
+ if (!model) {
46
+ throw new ModelNotFoundError(`${modelPath} does not exist`);
47
+ }
48
+ if (model.getType() === "notebook") {
49
+ throw new ModelNotFoundError(`${modelPath} is a notebook`);
50
+ }
51
+ return await model.getModel();
52
+ } catch (error) {
53
+ // Re-throw ModelNotFoundError as-is
54
+ if (error instanceof ModelNotFoundError) {
55
+ throw error;
56
+ }
57
+ // Wrap other errors with more context
58
+ throw new Error(
59
+ `Failed to get model ${modelPath} from package ${packageName} in project ${projectName}: ${error}`,
60
+ );
49
61
  }
50
- return model.getModel();
51
62
  }
52
63
 
53
64
  public async getNotebook(
package/src/health.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { Express, NextFunction, Request, Response } from "express";
14
14
  import { Server } from "http";
15
15
  import { components } from "./api";
16
+ import { shutdownSDK } from "./instrumentation";
16
17
  import { logger } from "./logger";
17
18
  export type OperationalState =
18
19
  components["schemas"]["ServerStatus"]["operationalState"];
@@ -112,6 +113,13 @@ export function registerSignalHandlers(
112
113
  closeServer(mcpServer, "MCP server"),
113
114
  ]);
114
115
 
116
+ try {
117
+ await shutdownSDK();
118
+ logger.info("OpenTelemetry SDK shut down");
119
+ } catch (_error) {
120
+ /* do nothing */
121
+ }
122
+
115
123
  try {
116
124
  logger.close();
117
125
  } catch (_error) {
@@ -1,59 +1,148 @@
1
- import { NodeSDK } from "@opentelemetry/sdk-node";
2
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
3
- import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
4
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
1
+ import { metrics } from "@opentelemetry/api";
5
2
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
4
+ import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
5
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
6
+ import {
7
+ ExpressInstrumentation,
8
+ ExpressLayerType,
9
+ } from "@opentelemetry/instrumentation-express";
6
10
  import { resourceFromAttributes } from "@opentelemetry/resources";
11
+ import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
12
+ import { NodeSDK } from "@opentelemetry/sdk-node";
13
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
7
14
  import {
8
15
  ATTR_SERVICE_NAME,
9
16
  ATTR_SERVICE_VERSION,
10
17
  } from "@opentelemetry/semantic-conventions";
11
- import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
12
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
13
- import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
18
+ import type { NextFunction, Request, Response } from "express";
14
19
  import { logger } from "./logger";
15
20
 
21
+ let prometheusExporter: PrometheusExporter | null = null;
22
+ let sdk: NodeSDK | null = null;
23
+
24
+ export function getPrometheusMetricsHandler() {
25
+ if (!prometheusExporter) {
26
+ throw new Error("Prometheus exporter not initialized");
27
+ }
28
+ return (req: Request, res: Response) => {
29
+ prometheusExporter!.getMetricsRequestHandler(req, res);
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Shuts down the OpenTelemetry SDK gracefully.
35
+ * Should be called during application shutdown.
36
+ */
37
+ export async function shutdownSDK(): Promise<void> {
38
+ if (sdk) {
39
+ await sdk.shutdown();
40
+ sdk = null;
41
+ }
42
+ }
43
+
16
44
  function instrument() {
17
45
  const otelCollectorUrl = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
18
- if (!otelCollectorUrl) {
19
- logger.info("No OTLP collector URL found, skipping instrumentation");
20
- return;
21
- }
22
46
 
23
- logger.info(
24
- `Initializing OpenTelemetry SDK with OTLP collector at ${otelCollectorUrl}`,
25
- );
26
- const traceExporter = new OTLPTraceExporter({
27
- url: `${otelCollectorUrl}/v1/traces`,
28
- headers: {},
29
- });
30
- const metricExporter = new OTLPMetricExporter({
31
- url: `${otelCollectorUrl}/v1/metrics`,
32
- headers: {},
33
- });
34
- const logExporter = new OTLPLogExporter({
35
- url: `${otelCollectorUrl}/v1/logs`,
36
- headers: {},
47
+ prometheusExporter = new PrometheusExporter({
48
+ preventServerStart: true,
37
49
  });
38
50
 
39
- const sdk = new NodeSDK({
40
- serviceName: "publisher",
51
+ const instrumentations = [
52
+ getNodeAutoInstrumentations(),
53
+ new ExpressInstrumentation({
54
+ ignoreLayersType: [ExpressLayerType.MIDDLEWARE],
55
+ ignoreLayers: [/\/health/, /\/metrics/],
56
+ }),
57
+ ];
58
+
59
+ sdk = new NodeSDK({
41
60
  resource: resourceFromAttributes({
42
61
  [ATTR_SERVICE_NAME]: "publisher",
43
62
  [ATTR_SERVICE_VERSION]: "1.0.0",
44
63
  }),
45
64
  autoDetectResources: true,
46
- traceExporter: traceExporter,
47
- metricReader: new PeriodicExportingMetricReader({
48
- exporter: metricExporter,
65
+ metricReader: prometheusExporter,
66
+ instrumentations,
67
+ ...(otelCollectorUrl && {
68
+ spanProcessors: [
69
+ new BatchSpanProcessor(
70
+ new OTLPTraceExporter({
71
+ url: `${otelCollectorUrl}/v1/traces`,
72
+ }),
73
+ ),
74
+ ],
75
+ logRecordProcessors: [
76
+ new BatchLogRecordProcessor(
77
+ new OTLPLogExporter({
78
+ url: `${otelCollectorUrl}/v1/logs`,
79
+ }),
80
+ ),
81
+ ],
49
82
  }),
50
- instrumentations: [getNodeAutoInstrumentations()],
51
- spanProcessors: [new BatchSpanProcessor(traceExporter)],
52
- logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
53
83
  });
54
84
 
55
85
  sdk.start();
56
- logger.info("Initialized OpenTelemetry SDK");
86
+
87
+ if (otelCollectorUrl) {
88
+ logger.info(
89
+ `OpenTelemetry SDK initialized with OTLP collector at ${otelCollectorUrl}`,
90
+ );
91
+ } else {
92
+ logger.info("OpenTelemetry SDK initialized with Prometheus metrics only");
93
+ }
57
94
  }
58
95
 
59
96
  instrument();
97
+
98
+ // --- HTTP metrics middleware ---
99
+
100
+ const meter = metrics.getMeter("publisher");
101
+
102
+ const httpRequestDuration = meter.createHistogram(
103
+ "http_server_request_duration_ms",
104
+ {
105
+ description: "Duration of HTTP requests in milliseconds",
106
+ unit: "ms",
107
+ advice: {
108
+ explicitBucketBoundaries: [
109
+ 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 60000,
110
+ ],
111
+ },
112
+ },
113
+ );
114
+
115
+ const httpRequestCount = meter.createCounter("http_server_requests_total", {
116
+ description: "Total number of HTTP requests",
117
+ });
118
+
119
+ const IGNORED_PATHS = new Set([
120
+ "/health",
121
+ "/health/liveness",
122
+ "/health/readiness",
123
+ "/metrics",
124
+ ]);
125
+
126
+ export function httpMetricsMiddleware(
127
+ req: Request,
128
+ res: Response,
129
+ next: NextFunction,
130
+ ) {
131
+ const start = performance.now();
132
+
133
+ res.on("finish", () => {
134
+ if (IGNORED_PATHS.has(req.path)) return;
135
+
136
+ const duration = performance.now() - start;
137
+ const attrs = {
138
+ "http.method": req.method,
139
+ "http.route": req.route?.path ?? req.path,
140
+ "http.status_code": res.statusCode,
141
+ };
142
+
143
+ httpRequestDuration.record(duration, attrs);
144
+ httpRequestCount.add(1, attrs);
145
+ });
146
+
147
+ next();
148
+ }
package/src/server.ts CHANGED
@@ -1,3 +1,10 @@
1
+ // Pre-load the instrumentation module; the instrumentation module must be loaded before the other imports.
2
+ import "./instrumentation";
3
+ import {
4
+ getPrometheusMetricsHandler,
5
+ httpMetricsMiddleware,
6
+ } from "./instrumentation";
7
+
1
8
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
9
  import * as bodyParser from "body-parser";
3
10
  import cors from "cors";
@@ -6,6 +13,7 @@ import * as http from "http";
6
13
  import { createProxyMiddleware } from "http-proxy-middleware";
7
14
  import { AddressInfo } from "net";
8
15
  import * as path from "path";
16
+ import { CompileController } from "./controller/compile.controller";
9
17
  import { ConnectionController } from "./controller/connection.controller";
10
18
  import { DatabaseController } from "./controller/database.controller";
11
19
  import { ModelController } from "./controller/model.controller";
@@ -19,6 +27,7 @@ import {
19
27
  registerSignalHandlers,
20
28
  } from "./health";
21
29
  import { logger, loggerMiddleware } from "./logger";
30
+
22
31
  import { initializeMcpServer } from "./mcp/server";
23
32
  import { ProjectStore } from "./service/project_store";
24
33
  // Parse command line arguments
@@ -110,7 +119,7 @@ const isDevelopment = process.env["NODE_ENV"] === "development";
110
119
 
111
120
  const app = express();
112
121
  app.use(loggerMiddleware);
113
-
122
+ app.use(httpMetricsMiddleware);
114
123
  const projectStore = new ProjectStore(SERVER_ROOT);
115
124
  const watchModeController = new WatchModeController(projectStore);
116
125
  const connectionController = new ConnectionController(projectStore);
@@ -118,9 +127,13 @@ const modelController = new ModelController(projectStore);
118
127
  const packageController = new PackageController(projectStore);
119
128
  const databaseController = new DatabaseController(projectStore);
120
129
  const queryController = new QueryController(projectStore);
130
+ const compileController = new CompileController(projectStore);
121
131
 
122
132
  export const mcpApp = express();
123
133
 
134
+ // Register health endpoints on mcpApp (for E2E tests)
135
+ registerHealthEndpoints(mcpApp);
136
+
124
137
  mcpApp.use(MCP_ENDPOINT, express.json());
125
138
  mcpApp.use(MCP_ENDPOINT, cors());
126
139
 
@@ -220,7 +233,10 @@ if (!isDevelopment) {
220
233
  target: "http://localhost:5173",
221
234
  changeOrigin: true,
222
235
  ws: true,
223
- pathFilter: (path) => !path.startsWith("/api/"),
236
+ pathFilter: (path) =>
237
+ !path.startsWith("/api/") &&
238
+ !path.startsWith("/metrics") &&
239
+ !path.startsWith("/health"),
224
240
  }),
225
241
  );
226
242
  }
@@ -240,9 +256,19 @@ app.use(
240
256
  );
241
257
  app.use(bodyParser.json());
242
258
 
243
- // Register health check endpoints
259
+ // Register health check endpoints on main app:
260
+ // - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
244
261
  registerHealthEndpoints(app);
245
262
 
263
+ // Register Prometheus metrics endpoint
264
+ try {
265
+ const metricsHandler = getPrometheusMetricsHandler();
266
+ app.get("/metrics", metricsHandler);
267
+ logger.info("Prometheus metrics endpoint registered at /metrics");
268
+ } catch (error) {
269
+ logger.warn("Failed to register Prometheus metrics endpoint", { error });
270
+ }
271
+
246
272
  // Register draining guard middleware - must be after health endpoints but before other routes
247
273
  app.use(drainingGuard);
248
274
 
@@ -897,6 +923,26 @@ app.get(
897
923
  },
898
924
  );
899
925
 
926
+ app.post(
927
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/models/:modelName/compile`,
928
+ async (req, res) => {
929
+ try {
930
+ const result = await compileController.compile(
931
+ req.params.projectName,
932
+ req.params.packageName,
933
+ req.params.modelName,
934
+ req.body.source,
935
+ req.body.includeSql === true,
936
+ );
937
+ res.status(200).json(result);
938
+ } catch (error) {
939
+ logger.error("Compilation error", { error });
940
+ const { json, status } = internalErrorToHttpError(error as Error);
941
+ res.status(status).json(json);
942
+ }
943
+ },
944
+ );
945
+
900
946
  // Modify the catch-all route to only serve index.html in production
901
947
  if (!isDevelopment) {
902
948
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));