@malloy-publisher/server 0.0.85 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.85",
4
+ "version": "0.0.87",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "frozenConfig": false,
2
3
  "projects": {
3
4
  "malloy-samples": "./malloy-samples"
4
5
  }
@@ -1,4 +1,5 @@
1
1
  import { components } from "../api";
2
+ import { BadRequestError, FrozenConfigError } from "../errors";
2
3
  import { ProjectStore } from "../service/project_store";
3
4
 
4
5
  type ApiPackage = components["schemas"]["Package"];
@@ -24,4 +25,35 @@ export class PackageController {
24
25
  const p = await project.getPackage(packageName, reload);
25
26
  return p.getPackageMetadata();
26
27
  }
28
+
29
+ async addPackage(projectName: string, body: ApiPackage) {
30
+ if (this.projectStore.publisherConfigIsFrozen) {
31
+ throw new FrozenConfigError();
32
+ }
33
+ if (!body.name) {
34
+ throw new BadRequestError("Package name is required");
35
+ }
36
+ const project = await this.projectStore.getProject(projectName, false);
37
+ return project.addPackage(body.name);
38
+ }
39
+
40
+ public async deletePackage(projectName: string, packageName: string) {
41
+ if (this.projectStore.publisherConfigIsFrozen) {
42
+ throw new FrozenConfigError();
43
+ }
44
+ const project = await this.projectStore.getProject(projectName, false);
45
+ return project.deletePackage(packageName);
46
+ }
47
+
48
+ public async updatePackage(
49
+ projectName: string,
50
+ packageName: string,
51
+ body: ApiPackage,
52
+ ) {
53
+ if (this.projectStore.publisherConfigIsFrozen) {
54
+ throw new FrozenConfigError();
55
+ }
56
+ const project = await this.projectStore.getProject(projectName, false);
57
+ return project.updatePackage(packageName, body);
58
+ }
27
59
  }
package/src/errors.ts CHANGED
@@ -3,10 +3,16 @@ import { MalloyError } from "@malloydata/malloy";
3
3
  export function internalErrorToHttpError(error: Error) {
4
4
  if (error instanceof BadRequestError) {
5
5
  return httpError(400, error.message);
6
+ } else if (error instanceof FrozenConfigError) {
7
+ return httpError(403, error.message);
8
+ } else if (error instanceof ProjectNotFoundError) {
9
+ return httpError(404, error.message);
6
10
  } else if (error instanceof PackageNotFoundError) {
7
11
  return httpError(404, error.message);
8
12
  } else if (error instanceof ModelNotFoundError) {
9
13
  return httpError(404, error.message);
14
+ } else if (error instanceof MalloyError) {
15
+ return httpError(400, error.message);
10
16
  } else if (error instanceof ConnectionNotFoundError) {
11
17
  return httpError(404, error.message);
12
18
  } else if (error instanceof ModelCompilationError) {
@@ -75,3 +81,11 @@ export class ModelCompilationError extends Error {
75
81
  super(error.message);
76
82
  }
77
83
  }
84
+
85
+ export class FrozenConfigError extends Error {
86
+ constructor(
87
+ message = "Publisher config can't be updated when publisher.config.json has `frozenConfig: true`",
88
+ ) {
89
+ super(message);
90
+ }
91
+ }
package/src/logger.ts CHANGED
@@ -2,12 +2,19 @@ import { AxiosError } from "axios";
2
2
  import { RequestHandler } from "express";
3
3
  import winston from "winston";
4
4
 
5
+ const isTelemetryEnabled = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
6
+
5
7
  export const logger = winston.createLogger({
6
- format: winston.format.combine(
7
- winston.format.uncolorize(),
8
- winston.format.timestamp(),
9
- winston.format.json(),
10
- ),
8
+ format: isTelemetryEnabled
9
+ ? winston.format.combine(
10
+ winston.format.uncolorize(),
11
+ winston.format.timestamp(),
12
+ winston.format.json(),
13
+ )
14
+ : winston.format.combine(
15
+ winston.format.colorize(),
16
+ winston.format.simple(),
17
+ ),
11
18
  transports: [new winston.transports.Console()],
12
19
  });
13
20
 
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
2
3
  import { ProjectStore } from "../../service/project_store";
3
4
  import { buildMalloyUri } from "../handler_utils";
4
- import { z } from "zod";
5
5
 
6
6
  const listPackagesShape = {
7
7
  // projectName is required; other fields mirror SDK expectations
@@ -78,7 +78,7 @@ export function registerTools(
78
78
  allProjects.map(async (project) => {
79
79
  const name = project.name;
80
80
  const projectInstance = await project.project;
81
- const metadata = await projectInstance.getProjectMetadata();
81
+ const metadata = await projectInstance.reloadProjectMetadata();
82
82
  const readme = metadata.readme;
83
83
  return {
84
84
  name,
package/src/server.ts CHANGED
@@ -92,7 +92,6 @@ const scheduleController = new ScheduleController(projectStore);
92
92
 
93
93
  const mcpApp = express();
94
94
 
95
- initProjects();
96
95
  mcpApp.use(MCP_ENDPOINT, express.json());
97
96
  mcpApp.use(MCP_ENDPOINT, cors());
98
97
 
@@ -192,7 +191,7 @@ if (!isDevelopment) {
192
191
  target: "http://localhost:5173",
193
192
  changeOrigin: true,
194
193
  ws: true,
195
- pathFilter: (path) => !path.startsWith("/api"),
194
+ pathFilter: (path) => !path.startsWith("/api/"),
196
195
  }),
197
196
  );
198
197
  }
@@ -217,13 +216,45 @@ app.get(`${API_PREFIX}/projects`, async (_req, res) => {
217
216
  }
218
217
  });
219
218
 
219
+ app.post(`${API_PREFIX}/projects`, async (req, res) => {
220
+ try {
221
+ res.status(200).json(await projectStore.addProject(req.body));
222
+ } catch (error) {
223
+ logger.error(error);
224
+ const { json, status } = internalErrorToHttpError(error as Error);
225
+ res.status(status).json(json);
226
+ }
227
+ });
228
+
220
229
  app.get(`${API_PREFIX}/projects/:projectName`, async (req, res) => {
221
230
  try {
222
231
  const project = await projectStore.getProject(
223
232
  req.params.projectName,
224
233
  req.query.reload === "true",
225
234
  );
226
- res.status(200).json(await project.getProjectMetadata());
235
+ res.status(200).json(project.metadata);
236
+ } catch (error) {
237
+ logger.error(error);
238
+ const { json, status } = internalErrorToHttpError(error as Error);
239
+ res.status(status).json(json);
240
+ }
241
+ });
242
+
243
+ app.patch(`${API_PREFIX}/projects/:projectName`, async (req, res) => {
244
+ try {
245
+ res.status(200).json(await projectStore.updateProject(req.body));
246
+ } catch (error) {
247
+ logger.error(error);
248
+ const { json, status } = internalErrorToHttpError(error as Error);
249
+ res.status(status).json(json);
250
+ }
251
+ });
252
+
253
+ app.delete(`${API_PREFIX}/projects/:projectName`, async (req, res) => {
254
+ try {
255
+ res.status(200).json(
256
+ await projectStore.deleteProject(req.params.projectName),
257
+ );
227
258
  } catch (error) {
228
259
  logger.error(error);
229
260
  const { json, status } = internalErrorToHttpError(error as Error);
@@ -412,6 +443,18 @@ app.get(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
412
443
  }
413
444
  });
414
445
 
446
+ app.post(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
447
+ try {
448
+ res.status(200).json(
449
+ await packageController.addPackage(req.params.projectName, req.body),
450
+ );
451
+ } catch (error) {
452
+ logger.error(error);
453
+ const { json, status } = internalErrorToHttpError(error as Error);
454
+ res.status(status).json(json);
455
+ }
456
+ });
457
+
415
458
  app.get(
416
459
  `${API_PREFIX}/projects/:projectName/packages/:packageName`,
417
460
  async (req, res) => {
@@ -436,6 +479,43 @@ app.get(
436
479
  },
437
480
  );
438
481
 
482
+ app.patch(
483
+ `${API_PREFIX}/projects/:projectName/packages/:packageName`,
484
+ async (req, res) => {
485
+ try {
486
+ res.status(200).json(
487
+ await packageController.updatePackage(
488
+ req.params.projectName,
489
+ req.params.packageName,
490
+ req.body,
491
+ ),
492
+ );
493
+ } catch (error) {
494
+ logger.error(error);
495
+ const { json, status } = internalErrorToHttpError(error as Error);
496
+ res.status(status).json(json);
497
+ }
498
+ },
499
+ );
500
+
501
+ app.delete(
502
+ `${API_PREFIX}/projects/:projectName/packages/:packageName`,
503
+ async (req, res) => {
504
+ try {
505
+ res.status(200).json(
506
+ await packageController.deletePackage(
507
+ req.params.projectName,
508
+ req.params.packageName,
509
+ ),
510
+ );
511
+ } catch (error) {
512
+ logger.error(error);
513
+ const { json, status } = internalErrorToHttpError(error as Error);
514
+ res.status(status).json(json);
515
+ }
516
+ },
517
+ );
518
+
439
519
  app.get(
440
520
  `${API_PREFIX}/projects/:projectName/packages/:packageName/models`,
441
521
  async (req, res) => {
@@ -611,11 +691,18 @@ if (!isDevelopment) {
611
691
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));
612
692
  }
613
693
 
614
- app.use((err: Error, _req: express.Request, res: express.Response) => {
615
- logger.error("Unhandled error:", err);
616
- const { json, status } = internalErrorToHttpError(err);
617
- res.status(status).json(json);
618
- });
694
+ app.use(
695
+ (
696
+ err: Error,
697
+ _req: express.Request,
698
+ res: express.Response,
699
+ _next: express.NextFunction,
700
+ ) => {
701
+ logger.error("Unhandled error:", err);
702
+ const { json, status } = internalErrorToHttpError(err);
703
+ res.status(status).json(json);
704
+ },
705
+ );
619
706
 
620
707
  const mainServer = http.createServer(app);
621
708
  mainServer.listen(PUBLISHER_PORT, PUBLISHER_HOST, () => {
@@ -635,14 +722,3 @@ const mcpHttpServer = mcpApp.listen(MCP_PORT, PUBLISHER_HOST, () => {
635
722
  });
636
723
 
637
724
  export { app, mainServer as httpServer, mcpApp, mcpHttpServer };
638
-
639
- // Warm up the packages
640
- function initProjects() {
641
- projectStore.listProjects().then((projects) => {
642
- projects.forEach((project) => {
643
- projectStore.getProject(project.name!, false).then((project) => {
644
- project.listPackages();
645
- });
646
- });
647
- });
648
- }
@@ -93,12 +93,9 @@ export async function getSchemasForConnection(
93
93
  }
94
94
  try {
95
95
  const bigquery = getBigqueryConnection(connection);
96
- // Set the projectId if it's provided in the bigqueryConnection
97
- const [datasets] = await bigquery.getDatasets({
98
- ...(connection.bigqueryConnection.defaultProjectId
99
- ? { projectId: connection.bigqueryConnection.defaultProjectId }
100
- : {}),
101
- });
96
+ const projectId = connection.bigqueryConnection.defaultProjectId;
97
+ const options = projectId ? { projectId } : {};
98
+ const [datasets] = await bigquery.getDatasets(options);
102
99
  return datasets
103
100
  .filter((dataset) => dataset.id)
104
101
  .map((dataset) => {
@@ -373,4 +373,16 @@ export class Package {
373
373
  const rowCount = result.data.value[0].row_count?.valueOf() as number;
374
374
  return { name: databasePath, rowCount, columns: schema };
375
375
  }
376
+
377
+ public setName(name: string) {
378
+ this.packageName = name;
379
+ }
380
+
381
+ public setProjectName(projectName: string) {
382
+ this.projectName = projectName;
383
+ }
384
+
385
+ public setPackageMetadata(packageMetadata: ApiPackage) {
386
+ this.packageMetadata = packageMetadata;
387
+ }
376
388
  }
@@ -4,7 +4,11 @@ import * as fs from "fs/promises";
4
4
  import * as path from "path";
5
5
  import { components } from "../api";
6
6
  import { API_PREFIX, README_NAME } from "../constants";
7
- import { ConnectionNotFoundError, ProjectNotFoundError } from "../errors";
7
+ import {
8
+ ConnectionNotFoundError,
9
+ PackageNotFoundError,
10
+ ProjectNotFoundError,
11
+ } from "../errors";
8
12
  import { createConnections, InternalConnection } from "./connection";
9
13
  import { ApiConnection } from "./model";
10
14
  import { Package } from "./package";
@@ -19,6 +23,7 @@ export class Project {
19
23
  private internalConnections: InternalConnection[];
20
24
  private projectPath: string;
21
25
  private projectName: string;
26
+ public metadata: ApiProject;
22
27
 
23
28
  constructor(
24
29
  projectName: string,
@@ -33,6 +38,35 @@ export class Project {
33
38
  // InternalConnections have full connection details for doing schema inspection
34
39
  this.internalConnections = internalConnections;
35
40
  this.apiConnections = apiConnections;
41
+ this.metadata = {
42
+ resource: `${API_PREFIX}/projects/${this.projectName}`,
43
+ name: this.projectName,
44
+ };
45
+ void this.reloadProjectMetadata();
46
+ }
47
+
48
+ public async update(payload: ApiProject) {
49
+ if (payload.name) {
50
+ this.projectName = payload.name;
51
+ this.packages.forEach((_package) => {
52
+ _package.setProjectName(this.projectName);
53
+ });
54
+ this.metadata.name = this.projectName;
55
+ }
56
+ if (payload.resource) {
57
+ this.projectPath = payload.resource.replace(
58
+ `${API_PREFIX}/projects/`,
59
+ "",
60
+ );
61
+ if (!(await fs.exists(this.projectPath))) {
62
+ throw new ProjectNotFoundError(
63
+ `Project path "${this.projectPath}" not found`,
64
+ );
65
+ }
66
+ this.metadata.resource = payload.resource;
67
+ }
68
+ this.metadata.readme = payload.readme;
69
+ return this;
36
70
  }
37
71
 
38
72
  static async create(
@@ -65,7 +99,7 @@ export class Project {
65
99
  );
66
100
  }
67
101
 
68
- public async getProjectMetadata(): Promise<ApiProject> {
102
+ public async reloadProjectMetadata(): Promise<ApiProject> {
69
103
  let readme = "";
70
104
  try {
71
105
  readme = (
@@ -74,11 +108,12 @@ export class Project {
74
108
  } catch {
75
109
  // Readme not found, so we'll just return an empty string
76
110
  }
77
- return {
111
+ this.metadata = {
78
112
  resource: `${API_PREFIX}/projects/${this.projectName}`,
79
113
  name: this.projectName,
80
114
  readme: readme,
81
115
  };
116
+ return this.metadata;
82
117
  }
83
118
 
84
119
  public listApiConnections(): ApiConnection[] {
@@ -187,4 +222,51 @@ export class Project {
187
222
  }
188
223
  });
189
224
  }
225
+
226
+ public async addPackage(packageName: string) {
227
+ const packagePath = path.join(this.projectPath, packageName);
228
+ if (
229
+ !(await fs.exists(packagePath)) ||
230
+ !(await fs.stat(packagePath)).isDirectory()
231
+ ) {
232
+ throw new PackageNotFoundError(`Package ${packageName} not found`);
233
+ }
234
+ this.packages.set(
235
+ packageName,
236
+ await Package.create(
237
+ this.projectName,
238
+ packageName,
239
+ packagePath,
240
+ this.malloyConnections,
241
+ ),
242
+ );
243
+ return this.packages.get(packageName);
244
+ }
245
+
246
+ public async updatePackage(
247
+ packageName: string,
248
+ body: { resource?: string; name?: string; description?: string },
249
+ ) {
250
+ const _package = this.packages.get(packageName);
251
+ if (!_package) {
252
+ throw new PackageNotFoundError(`Package ${packageName} not found`);
253
+ }
254
+ if (body.name) {
255
+ _package.setName(body.name);
256
+ }
257
+ _package.setPackageMetadata({
258
+ name: body.name,
259
+ description: body.description,
260
+ resource: body.resource,
261
+ });
262
+ return _package.getPackageMetadata();
263
+ }
264
+
265
+ public async deletePackage(packageName: string) {
266
+ const _package = this.packages.get(packageName);
267
+ if (!_package) {
268
+ throw new PackageNotFoundError(`Package ${packageName} not found`);
269
+ }
270
+ this.packages.delete(packageName);
271
+ }
190
272
  }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it, mock, spyOn } from "bun:test";
2
+ import * as fs from "fs/promises";
3
+ import path from "path";
4
+ import { FrozenConfigError, ProjectNotFoundError } from "../errors";
5
+ import { isPublisherConfigFrozen } from "../utils";
6
+ import { ProjectStore } from "./project_store";
7
+
8
+ describe("ProjectStore", () => {
9
+ const serverRoot = path.resolve(
10
+ process.cwd(),
11
+ process.env.SERVER_ROOT || ".",
12
+ );
13
+
14
+ async function waitForInitialization(projectStore: ProjectStore) {
15
+ const maxRetries = 100;
16
+ let retries = 0;
17
+ do {
18
+ await new Promise((resolve) => setTimeout(resolve, 10));
19
+ retries++;
20
+ } while (projectStore.listProjects().length === 0 && retries < maxRetries);
21
+ if (projectStore.listProjects().length === 0) {
22
+ throw new Error("Timed out initializing ProjectStore");
23
+ }
24
+ }
25
+
26
+ it("should load all projects from publisher.config.json within a second on initialization", async () => {
27
+ mock(isPublisherConfigFrozen).mockReturnValue(true);
28
+ const projectStore = new ProjectStore(serverRoot);
29
+ await waitForInitialization(projectStore);
30
+ expect(projectStore.listProjects()).toEqual([
31
+ {
32
+ name: "malloy-samples",
33
+ readme: expect.stringContaining("# Malloy Analysis Examples"),
34
+ resource: "/api/v0/projects/malloy-samples",
35
+ },
36
+ ]);
37
+ });
38
+
39
+ it("should list projects from memory by default", async () => {
40
+ mock(isPublisherConfigFrozen).mockReturnValue(true);
41
+ const projectStore = new ProjectStore(serverRoot);
42
+ // Mock fs.readFile & fs.readdir to track calls
43
+ const readFileSpy = spyOn(fs, "readFile");
44
+ const readdirSpy = spyOn(fs, "readdir");
45
+ // Call listProjects, which should use memory and not call fs.readFile
46
+ projectStore.listProjects();
47
+
48
+ expect(readFileSpy).not.toHaveBeenCalled();
49
+ expect(readdirSpy).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("should list projects from disk if reload is true", async () => {
53
+ // Mock fs.readFile to track calls
54
+ const fs = await import("fs/promises");
55
+ const readFileSpy = spyOn(fs, "readFile");
56
+ mock(isPublisherConfigFrozen).mockReturnValue(true);
57
+ const projectStore = new ProjectStore(serverRoot);
58
+
59
+ // Call getProject with reload=true
60
+ await projectStore.getProject("malloy-samples", true);
61
+
62
+ expect(readFileSpy).toHaveBeenCalled();
63
+ });
64
+
65
+ it("should allow modifying the in-memory hashmap if config is not frozen", async () => {
66
+ mock.module("../utils", () => ({
67
+ isPublisherConfigFrozen: () => false,
68
+ }));
69
+ const projectStore = new ProjectStore(serverRoot);
70
+ await waitForInitialization(projectStore);
71
+ await projectStore.updateProject({
72
+ name: "malloy-samples",
73
+ readme: "Updated README",
74
+ });
75
+ expect(projectStore.listProjects()).toEqual([
76
+ {
77
+ name: "malloy-samples",
78
+ readme: "Updated README",
79
+ resource: "/api/v0/projects/malloy-samples",
80
+ },
81
+ ]);
82
+ await projectStore.deleteProject("malloy-samples");
83
+ expect(projectStore.listProjects()).toEqual([]);
84
+ await projectStore.addProject({
85
+ name: "malloy-samples",
86
+ });
87
+
88
+ expect(
89
+ await projectStore.getProject("malloy-samples", false),
90
+ ).toHaveProperty("metadata", {
91
+ name: "malloy-samples",
92
+ resource: "/api/v0/projects/malloy-samples",
93
+ });
94
+
95
+ // After a while, it'll resolve the async promise where the readme gets loaded in memory
96
+ await waitForInitialization(projectStore);
97
+ expect(projectStore.listProjects()).toEqual([
98
+ {
99
+ name: "malloy-samples",
100
+ readme: expect.stringContaining("# Malloy Analysis Examples"),
101
+ resource: "/api/v0/projects/malloy-samples",
102
+ },
103
+ ]);
104
+ });
105
+
106
+ it("should not allow modifying the in-memory hashmap if config is frozen", async () => {
107
+ mock.module("../utils", () => ({
108
+ isPublisherConfigFrozen: () => true,
109
+ }));
110
+ const projectStore = new ProjectStore(serverRoot);
111
+ // Initialization should succeed
112
+ await waitForInitialization(projectStore);
113
+ expect(projectStore.listProjects()).toEqual([
114
+ {
115
+ name: "malloy-samples",
116
+ readme: expect.stringContaining("# Malloy Analysis Examples"),
117
+ resource: "/api/v0/projects/malloy-samples",
118
+ },
119
+ ]);
120
+ // Adding a project should fail
121
+ expect(
122
+ projectStore.addProject({
123
+ name: "malloy-samples",
124
+ }),
125
+ ).rejects.toThrow(FrozenConfigError);
126
+
127
+ // Updating a project should fail
128
+ expect(
129
+ projectStore.updateProject({
130
+ name: "malloy-samples",
131
+ readme: "Updated README",
132
+ }),
133
+ ).rejects.toThrow(FrozenConfigError);
134
+
135
+ // Deleting a project should fail
136
+ expect(projectStore.deleteProject("malloy-samples")).rejects.toThrow(
137
+ FrozenConfigError,
138
+ );
139
+
140
+ // Failed methods should not modify the in-memory hashmap
141
+ expect(projectStore.listProjects()).toEqual([
142
+ {
143
+ name: "malloy-samples",
144
+ readme: expect.stringContaining("# Malloy Analysis Examples"),
145
+ resource: "/api/v0/projects/malloy-samples",
146
+ },
147
+ ]);
148
+ });
149
+
150
+ it("should always try to reload a project if it's not in the hashmap", async () => {
151
+ mock.module("../utils", () => ({
152
+ isPublisherConfigFrozen: () => false,
153
+ }));
154
+ const projectStore = new ProjectStore(serverRoot);
155
+ await waitForInitialization(projectStore);
156
+ await projectStore.deleteProject("malloy-samples");
157
+ expect(projectStore.listProjects()).toEqual([]);
158
+ const readFileSpy = spyOn(fs, "readFile");
159
+ await projectStore.getProject("malloy-samples", true);
160
+ expect(readFileSpy).toHaveBeenCalled();
161
+ });
162
+
163
+ it("should throw a NotFound error when reloading a project that is not in disk", async () => {
164
+ mock.module("../utils", () => ({
165
+ isPublisherConfigFrozen: () => false,
166
+ }));
167
+ const projectStore = new ProjectStore(serverRoot);
168
+ await waitForInitialization(projectStore);
169
+ expect(projectStore.getProject("this-one-does-not-exist", true)).rejects.toThrow(
170
+ ProjectNotFoundError,
171
+ );
172
+ });
173
+ });