@malloy-publisher/server 0.0.85 → 0.0.86
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/dist/app/api-doc.yaml +187 -0
- package/dist/app/assets/{RenderedResult-BAZuT25g-9oy_tMLH.js → RenderedResult-BAZuT25g-_eLjVkSo.js} +2 -2
- package/dist/app/assets/{index-DQ2nRzzZ.js → index-BvRGZn2o.js} +1 -1
- package/dist/app/assets/{index-C3s9KcRM.js → index-C-lp8bCy.js} +1 -1
- package/dist/app/assets/{index-Da-HoCBr.js → index-EI2L5apg.js} +119 -119
- package/dist/app/assets/{index.umd-CeT9AycY.js → index.umd-DBkg1U_t.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +2 -1
- package/dist/server.js +321 -108
- package/package.json +1 -1
- package/publisher.config.json +1 -0
- package/src/controller/package.controller.ts +32 -0
- package/src/errors.ts +14 -0
- package/src/logger.ts +12 -5
- package/src/mcp/tools/discovery_tools.ts +2 -2
- package/src/server.ts +95 -19
- package/src/service/db_utils.ts +3 -6
- package/src/service/package.ts +12 -0
- package/src/service/project.ts +85 -3
- package/src/service/project_store.spec.ts +173 -0
- package/src/service/project_store.ts +97 -22
- package/src/utils.ts +14 -2
package/package.json
CHANGED
package/publisher.config.json
CHANGED
|
@@ -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:
|
|
7
|
-
winston.format.
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
}
|
package/src/service/db_utils.ts
CHANGED
|
@@ -93,12 +93,9 @@ export async function getSchemasForConnection(
|
|
|
93
93
|
}
|
|
94
94
|
try {
|
|
95
95
|
const bigquery = getBigqueryConnection(connection);
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
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) => {
|
package/src/service/package.ts
CHANGED
|
@@ -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
|
}
|
package/src/service/project.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|