@malloy-publisher/server 0.0.87 → 0.0.89

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 (35) hide show
  1. package/build.ts +26 -0
  2. package/dist/app/api-doc.yaml +126 -5
  3. package/dist/app/assets/RenderedResult-BAZuT25g-QakVAbYy.js +2 -0
  4. package/dist/app/assets/{index-BbW5TZg_.js → index-Bq29VQqL.js} +2 -2
  5. package/dist/app/assets/{index-2xWCh-ya.css → index-CcIq0aEZ.css} +1 -1
  6. package/dist/app/assets/index-DZMePHJ5.js +251 -0
  7. package/dist/app/assets/{index-CIfV3yj1.js → index-TslDWlxH.js} +6 -6
  8. package/dist/app/assets/{index.umd-x-naS8R7.js → index.umd-BN4_E5KD.js} +259 -259
  9. package/dist/app/assets/mui-BEbinrI-.js +161 -0
  10. package/dist/app/assets/vendor-c5ypKtDW.js +17 -0
  11. package/dist/app/index.html +4 -2
  12. package/dist/instrumentation.js +67818 -35196
  13. package/dist/server.js +80404 -82231
  14. package/package.json +11 -5
  15. package/publisher.config.json +1 -1
  16. package/src/config.ts +20 -0
  17. package/src/constants.ts +14 -0
  18. package/src/controller/connection.controller.ts +21 -4
  19. package/src/controller/package.controller.ts +52 -2
  20. package/src/controller/schedule.controller.ts +3 -3
  21. package/src/controller/watch-mode.controller.ts +83 -0
  22. package/src/errors.ts +2 -1
  23. package/src/logger.ts +9 -0
  24. package/src/server.ts +33 -19
  25. package/src/service/connection.ts +159 -161
  26. package/src/service/model.ts +6 -6
  27. package/src/service/package.spec.ts +12 -10
  28. package/src/service/package.ts +15 -8
  29. package/src/service/project.ts +77 -36
  30. package/src/service/project_store.spec.ts +83 -56
  31. package/src/service/project_store.ts +330 -50
  32. package/src/utils.ts +0 -18
  33. package/tests/harness/mcp_test_setup.ts +5 -5
  34. package/dist/app/assets/RenderedResult-BAZuT25g-BMU632YI.js +0 -2
  35. package/dist/app/assets/index-C7whj6wK.js +0 -432
@@ -1,14 +1,15 @@
1
1
  import { BaseConnection } from "@malloydata/malloy/connection";
2
2
  import { Mutex } from "async-mutex";
3
- import * as fs from "fs/promises";
3
+ import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { components } from "../api";
6
- import { API_PREFIX, README_NAME } from "../constants";
6
+ import { API_PREFIX, PACKAGE_MANIFEST_NAME, README_NAME } from "../constants";
7
7
  import {
8
8
  ConnectionNotFoundError,
9
9
  PackageNotFoundError,
10
10
  ProjectNotFoundError,
11
11
  } from "../errors";
12
+ import { logger } from "../logger";
12
13
  import { createConnections, InternalConnection } from "./connection";
13
14
  import { ApiConnection } from "./model";
14
15
  import { Package } from "./package";
@@ -41,6 +42,7 @@ export class Project {
41
42
  this.metadata = {
42
43
  resource: `${API_PREFIX}/projects/${this.projectName}`,
43
44
  name: this.projectName,
45
+ location: this.projectPath,
44
46
  };
45
47
  void this.reloadProjectMetadata();
46
48
  }
@@ -58,7 +60,7 @@ export class Project {
58
60
  `${API_PREFIX}/projects/`,
59
61
  "",
60
62
  );
61
- if (!(await fs.exists(this.projectPath))) {
63
+ if (!(await fs.promises.exists(this.projectPath))) {
62
64
  throw new ProjectNotFoundError(
63
65
  `Project path "${this.projectPath}" not found`,
64
66
  );
@@ -72,14 +74,24 @@ export class Project {
72
74
  static async create(
73
75
  projectName: string,
74
76
  projectPath: string,
77
+ defaultConnections: ApiConnection[],
75
78
  ): Promise<Project> {
76
- if (!(await fs.stat(projectPath)).isDirectory()) {
79
+ if (!(await fs.promises.stat(projectPath)).isDirectory()) {
77
80
  throw new ProjectNotFoundError(
78
81
  `Project path ${projectPath} not found`,
79
82
  );
80
83
  }
81
- const { malloyConnections, apiConnections } =
82
- await createConnections(projectPath);
84
+ const { malloyConnections, apiConnections } = await createConnections(
85
+ projectPath,
86
+ defaultConnections,
87
+ );
88
+ logger.info(
89
+ `Loaded ${malloyConnections.size + apiConnections.length} connections for project ${projectName}`,
90
+ {
91
+ malloyConnections,
92
+ apiConnections,
93
+ },
94
+ );
83
95
  return new Project(
84
96
  projectName,
85
97
  projectPath,
@@ -103,7 +115,7 @@ export class Project {
103
115
  let readme = "";
104
116
  try {
105
117
  readme = (
106
- await fs.readFile(path.join(this.projectPath, README_NAME))
118
+ await fs.promises.readFile(path.join(this.projectPath, README_NAME))
107
119
  ).toString();
108
120
  } catch {
109
121
  // Readme not found, so we'll just return an empty string
@@ -157,27 +169,33 @@ export class Project {
157
169
  }
158
170
 
159
171
  public async listPackages(): Promise<ApiPackage[]> {
172
+ logger.info("Listing packages", { projectPath: this.projectPath });
160
173
  try {
161
- const files = await fs.readdir(this.projectPath, {
174
+ const files = await fs.promises.readdir(this.projectPath, {
162
175
  withFileTypes: true,
163
176
  });
177
+ const packageDirectories = files.filter(
178
+ (file) =>
179
+ file.isDirectory() &&
180
+ fs.existsSync(
181
+ path.join(this.projectPath, file.name, PACKAGE_MANIFEST_NAME),
182
+ ),
183
+ );
164
184
  const packageMetadata = await Promise.all(
165
- files
166
- .filter((file) => file.isDirectory())
167
- .map(async (directory) => {
168
- try {
169
- return (
170
- await this.getPackage(directory.name, false)
171
- ).getPackageMetadata();
172
- } catch (error) {
173
- console.log(
174
- `Failed to load package: ${directory.name} due to : ${error}`,
175
- );
176
- // Directory did not contain a valid package.json file -- therefore, it's not a package.
177
- // Or it timed out
178
- return undefined;
179
- }
180
- }),
185
+ packageDirectories.map(async (directory) => {
186
+ try {
187
+ return (
188
+ await this.getPackage(directory.name, false)
189
+ ).getPackageMetadata();
190
+ } catch (error) {
191
+ logger.error(
192
+ `Failed to load package: ${directory.name} due to : ${error}`,
193
+ );
194
+ // Directory did not contain a valid package.json file -- therefore, it's not a package.
195
+ // Or it timed out
196
+ return undefined;
197
+ }
198
+ }),
181
199
  );
182
200
  // Get rid of undefined entries (i.e, directories without publisher.json files).
183
201
  const filteredMetadata = packageMetadata.filter(
@@ -185,23 +203,27 @@ export class Project {
185
203
  ) as ApiPackage[];
186
204
  return filteredMetadata;
187
205
  } catch (error) {
188
- throw new Error("Error listing packages: " + error);
206
+ logger.error("Error listing packages", { error });
207
+ console.error(error);
208
+ throw error;
189
209
  }
190
210
  }
191
211
 
192
212
  public async getPackage(
193
213
  packageName: string,
194
- reload: boolean,
214
+ reload: boolean = false,
195
215
  ): Promise<Package> {
196
216
  // We need to acquire the mutex to prevent a thundering herd of requests from creating the
197
217
  // package multiple times.
198
218
  let packageMutex = this.packageMutexes.get(packageName);
199
- if (!packageMutex) {
200
- packageMutex = new Mutex();
201
- this.packageMutexes.set(packageName, packageMutex);
219
+ if (packageMutex?.isLocked()) {
220
+ await packageMutex.waitForUnlock();
221
+ return this.packages.get(packageName)!;
202
222
  }
223
+ packageMutex = new Mutex();
224
+ this.packageMutexes.set(packageName, packageMutex);
203
225
 
204
- return await packageMutex.runExclusive(async () => {
226
+ return packageMutex.runExclusive(async () => {
205
227
  const _package = this.packages.get(packageName);
206
228
  if (_package !== undefined && !reload) {
207
229
  return _package;
@@ -219,6 +241,9 @@ export class Project {
219
241
  } catch (error) {
220
242
  this.packages.delete(packageName);
221
243
  throw error;
244
+ } finally {
245
+ packageMutex.release();
246
+ this.packageMutexes.delete(packageName);
222
247
  }
223
248
  });
224
249
  }
@@ -226,11 +251,18 @@ export class Project {
226
251
  public async addPackage(packageName: string) {
227
252
  const packagePath = path.join(this.projectPath, packageName);
228
253
  if (
229
- !(await fs.exists(packagePath)) ||
230
- !(await fs.stat(packagePath)).isDirectory()
254
+ !(await fs.promises.exists(packagePath)) ||
255
+ !(await fs.promises.stat(packagePath)).isDirectory()
231
256
  ) {
232
257
  throw new PackageNotFoundError(`Package ${packageName} not found`);
233
258
  }
259
+ logger.info(
260
+ `Adding package ${packageName} to project ${this.projectName}`,
261
+ {
262
+ packagePath,
263
+ malloyConnections: this.malloyConnections,
264
+ },
265
+ );
234
266
  this.packages.set(
235
267
  packageName,
236
268
  await Package.create(
@@ -243,10 +275,7 @@ export class Project {
243
275
  return this.packages.get(packageName);
244
276
  }
245
277
 
246
- public async updatePackage(
247
- packageName: string,
248
- body: { resource?: string; name?: string; description?: string },
249
- ) {
278
+ public async updatePackage(packageName: string, body: ApiPackage) {
250
279
  const _package = this.packages.get(packageName);
251
280
  if (!_package) {
252
281
  throw new PackageNotFoundError(`Package ${packageName} not found`);
@@ -258,6 +287,7 @@ export class Project {
258
287
  name: body.name,
259
288
  description: body.description,
260
289
  resource: body.resource,
290
+ location: body.location,
261
291
  });
262
292
  return _package.getPackageMetadata();
263
293
  }
@@ -267,6 +297,17 @@ export class Project {
267
297
  if (!_package) {
268
298
  throw new PackageNotFoundError(`Package ${packageName} not found`);
269
299
  }
300
+ await fs.promises.rm(path.join(this.projectPath, packageName), {
301
+ recursive: true,
302
+ });
270
303
  this.packages.delete(packageName);
271
304
  }
305
+
306
+ public async serialize(): Promise<ApiProject> {
307
+ return {
308
+ ...this.metadata,
309
+ connections: this.listApiConnections(),
310
+ packages: await this.listPackages(),
311
+ };
312
+ }
272
313
  }
@@ -1,37 +1,52 @@
1
- import { describe, expect, it, mock, spyOn } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ describe,
5
+ expect,
6
+ it,
7
+ mock,
8
+ spyOn,
9
+ } from "bun:test";
10
+ import { rmSync } from "fs";
2
11
  import * as fs from "fs/promises";
3
12
  import path from "path";
13
+ import { isPublisherConfigFrozen } from "../config";
14
+ import { publisherPath } from "../constants";
4
15
  import { FrozenConfigError, ProjectNotFoundError } from "../errors";
5
- import { isPublisherConfigFrozen } from "../utils";
16
+ import { logger } from "../logger";
6
17
  import { ProjectStore } from "./project_store";
18
+ import sinon from "sinon";
7
19
 
8
20
  describe("ProjectStore", () => {
9
21
  const serverRoot = path.resolve(
10
22
  process.cwd(),
11
23
  process.env.SERVER_ROOT || ".",
12
24
  );
25
+ let loggerStub: sinon.SinonStub;
13
26
 
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
+ beforeAll(() => {
28
+ rmSync(path.resolve(publisherPath, "malloy-samples"), {
29
+ recursive: true,
30
+ force: true,
31
+ });
32
+ loggerStub = sinon.stub(logger, "info").returns(logger);
33
+ });
34
+ afterAll(() => {
35
+ loggerStub.restore();
36
+ });
37
+
38
+ it("should load all projects from publisher.config.json on initialization", async () => {
27
39
  mock(isPublisherConfigFrozen).mockReturnValue(true);
28
40
  const projectStore = new ProjectStore(serverRoot);
29
- await waitForInitialization(projectStore);
30
- expect(projectStore.listProjects()).toEqual([
41
+ mock(projectStore.downloadGitHubDirectory).mockResolvedValue(undefined);
42
+ await projectStore.finishedInitialization;
43
+ expect(await projectStore.listProjects()).toEqual([
31
44
  {
32
45
  name: "malloy-samples",
33
- readme: expect.stringContaining("# Malloy Analysis Examples"),
46
+ readme: expect.any(String),
34
47
  resource: "/api/v0/projects/malloy-samples",
48
+ packages: expect.any(Array),
49
+ connections: expect.any(Array),
35
50
  },
36
51
  ]);
37
52
  });
@@ -39,14 +54,16 @@ describe("ProjectStore", () => {
39
54
  it("should list projects from memory by default", async () => {
40
55
  mock(isPublisherConfigFrozen).mockReturnValue(true);
41
56
  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();
57
+ const projects = await projectStore.listProjects();
58
+ expect(projects).toEqual([
59
+ {
60
+ name: "malloy-samples",
61
+ readme: expect.any(String),
62
+ resource: "/api/v0/projects/malloy-samples",
63
+ packages: expect.any(Array),
64
+ connections: expect.any(Array),
65
+ },
66
+ ]);
50
67
  });
51
68
 
52
69
  it("should list projects from disk if reload is true", async () => {
@@ -63,24 +80,31 @@ describe("ProjectStore", () => {
63
80
  });
64
81
 
65
82
  it("should allow modifying the in-memory hashmap if config is not frozen", async () => {
66
- mock.module("../utils", () => ({
83
+ mock.module("../config", () => ({
67
84
  isPublisherConfigFrozen: () => false,
68
85
  }));
69
86
  const projectStore = new ProjectStore(serverRoot);
70
- await waitForInitialization(projectStore);
87
+ mock(projectStore.downloadGitHubDirectory).mockResolvedValue(undefined);
88
+ await projectStore.finishedInitialization;
71
89
  await projectStore.updateProject({
72
90
  name: "malloy-samples",
73
91
  readme: "Updated README",
74
92
  });
75
- expect(projectStore.listProjects()).toEqual([
76
- {
93
+ let projects = await projectStore.listProjects();
94
+ projects = await projectStore.listProjects();
95
+ let malloySamplesProject = projects.find(
96
+ (p) => p.name === "malloy-samples",
97
+ );
98
+ expect(malloySamplesProject).toBeDefined();
99
+ expect(malloySamplesProject).toMatchObject(
100
+ expect.objectContaining({
77
101
  name: "malloy-samples",
78
102
  readme: "Updated README",
79
103
  resource: "/api/v0/projects/malloy-samples",
80
- },
81
- ]);
104
+ }),
105
+ );
82
106
  await projectStore.deleteProject("malloy-samples");
83
- expect(projectStore.listProjects()).toEqual([]);
107
+ expect(await projectStore.listProjects()).toEqual([]);
84
108
  await projectStore.addProject({
85
109
  name: "malloy-samples",
86
110
  });
@@ -90,31 +114,32 @@ describe("ProjectStore", () => {
90
114
  ).toHaveProperty("metadata", {
91
115
  name: "malloy-samples",
92
116
  resource: "/api/v0/projects/malloy-samples",
117
+ location: expect.any(String),
93
118
  });
94
119
 
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
- ]);
120
+ projects = await projectStore.listProjects();
121
+ malloySamplesProject = projects.find((p) => p.name === "malloy-samples");
122
+ expect(malloySamplesProject).toBeDefined();
123
+ expect(malloySamplesProject).toMatchObject({
124
+ name: "malloy-samples",
125
+ resource: "/api/v0/projects/malloy-samples",
126
+ });
104
127
  });
105
128
 
106
129
  it("should not allow modifying the in-memory hashmap if config is frozen", async () => {
107
- mock.module("../utils", () => ({
130
+ mock.module("../config", () => ({
108
131
  isPublisherConfigFrozen: () => true,
109
132
  }));
110
133
  const projectStore = new ProjectStore(serverRoot);
111
134
  // Initialization should succeed
112
- await waitForInitialization(projectStore);
113
- expect(projectStore.listProjects()).toEqual([
135
+ await projectStore.finishedInitialization;
136
+ expect(await projectStore.listProjects()).toEqual([
114
137
  {
115
138
  name: "malloy-samples",
116
- readme: expect.stringContaining("# Malloy Analysis Examples"),
139
+ readme: expect.any(String),
117
140
  resource: "/api/v0/projects/malloy-samples",
141
+ packages: expect.any(Array),
142
+ connections: expect.any(Array),
118
143
  },
119
144
  ]);
120
145
  // Adding a project should fail
@@ -138,36 +163,38 @@ describe("ProjectStore", () => {
138
163
  );
139
164
 
140
165
  // Failed methods should not modify the in-memory hashmap
141
- expect(projectStore.listProjects()).toEqual([
166
+ expect(await projectStore.listProjects()).toEqual([
142
167
  {
143
168
  name: "malloy-samples",
144
- readme: expect.stringContaining("# Malloy Analysis Examples"),
169
+ readme: expect.any(String),
145
170
  resource: "/api/v0/projects/malloy-samples",
171
+ packages: expect.any(Array),
172
+ connections: expect.any(Array),
146
173
  },
147
174
  ]);
148
175
  });
149
176
 
150
177
  it("should always try to reload a project if it's not in the hashmap", async () => {
151
- mock.module("../utils", () => ({
178
+ mock.module("../config", () => ({
152
179
  isPublisherConfigFrozen: () => false,
153
180
  }));
154
181
  const projectStore = new ProjectStore(serverRoot);
155
- await waitForInitialization(projectStore);
182
+ await projectStore.finishedInitialization;
156
183
  await projectStore.deleteProject("malloy-samples");
157
- expect(projectStore.listProjects()).toEqual([]);
184
+ expect(await projectStore.listProjects()).toEqual([]);
158
185
  const readFileSpy = spyOn(fs, "readFile");
159
186
  await projectStore.getProject("malloy-samples", true);
160
187
  expect(readFileSpy).toHaveBeenCalled();
161
188
  });
162
189
 
163
190
  it("should throw a NotFound error when reloading a project that is not in disk", async () => {
164
- mock.module("../utils", () => ({
191
+ mock.module("../config", () => ({
165
192
  isPublisherConfigFrozen: () => false,
166
193
  }));
167
194
  const projectStore = new ProjectStore(serverRoot);
168
- await waitForInitialization(projectStore);
169
- expect(projectStore.getProject("this-one-does-not-exist", true)).rejects.toThrow(
170
- ProjectNotFoundError,
171
- );
195
+ await projectStore.finishedInitialization;
196
+ expect(
197
+ projectStore.getProject("this-one-does-not-exist", true),
198
+ ).rejects.toThrow(ProjectNotFoundError);
172
199
  });
173
200
  });