@malloy-publisher/server 0.0.88 → 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.
- package/build.ts +1 -0
- package/dist/app/api-doc.yaml +43 -5
- package/dist/app/assets/RenderedResult-BAZuT25g-QakVAbYy.js +2 -0
- package/dist/app/assets/{index-CNRCklos.js → index-Bq29VQqL.js} +1 -1
- package/dist/app/assets/index-DZMePHJ5.js +251 -0
- package/dist/app/assets/{index-lhN38COk.js → index-TslDWlxH.js} +5 -5
- package/dist/app/assets/{index.umd-CSPhcx55.js → index.umd-BN4_E5KD.js} +1 -1
- package/dist/app/assets/mui-BEbinrI-.js +161 -0
- package/dist/app/assets/vendor-c5ypKtDW.js +17 -0
- package/dist/app/index.html +3 -1
- package/dist/instrumentation.js +28 -4
- package/dist/server.js +38461 -48955
- package/package.json +5 -2
- package/src/constants.ts +9 -1
- package/src/controller/connection.controller.ts +21 -4
- package/src/controller/package.controller.ts +12 -1
- package/src/controller/schedule.controller.ts +3 -3
- package/src/logger.ts +9 -0
- package/src/server.ts +36 -30
- package/src/service/connection.ts +152 -154
- package/src/service/package.spec.ts +12 -10
- package/src/service/package.ts +11 -4
- package/src/service/project.ts +72 -33
- package/src/service/project_store.spec.ts +73 -51
- package/src/service/project_store.ts +76 -11
- package/tests/harness/mcp_test_setup.ts +5 -5
- package/dist/app/assets/RenderedResult-BAZuT25g-DLMDvQic.js +0 -2
- package/dist/app/assets/index-CobAY3LE.js +0 -427
|
@@ -4,13 +4,18 @@ import { join } from "path";
|
|
|
4
4
|
import sinon from "sinon";
|
|
5
5
|
import { PackageNotFoundError } from "../errors";
|
|
6
6
|
import { readConnectionConfig } from "./connection";
|
|
7
|
-
import { Model } from "./model";
|
|
7
|
+
import { ApiConnection, Model } from "./model";
|
|
8
8
|
import { Package } from "./package";
|
|
9
9
|
import { Scheduler } from "./scheduler";
|
|
10
10
|
|
|
11
11
|
// Minimal partial types for mocking
|
|
12
12
|
type PartialScheduler = Pick<Scheduler, "list">;
|
|
13
13
|
|
|
14
|
+
const connectionMocks: ApiConnection[] = [
|
|
15
|
+
{ name: "conn1", type: "postgres", postgresConnection: {} },
|
|
16
|
+
{ name: "conn2", type: "bigquery", bigqueryConnection: {} },
|
|
17
|
+
];
|
|
18
|
+
|
|
14
19
|
describe("service/package", () => {
|
|
15
20
|
const testPackageDirectory = "testPackage";
|
|
16
21
|
|
|
@@ -24,10 +29,7 @@ describe("service/package", () => {
|
|
|
24
29
|
join(testPackageDirectory, "database.csv"),
|
|
25
30
|
parquetBuffer,
|
|
26
31
|
);
|
|
27
|
-
const content = JSON.stringify(
|
|
28
|
-
{ name: "conn1", type: "database" },
|
|
29
|
-
{ name: "conn2", type: "api" },
|
|
30
|
-
]);
|
|
32
|
+
const content = JSON.stringify(connectionMocks);
|
|
31
33
|
await fs.writeFile(
|
|
32
34
|
join(testPackageDirectory, "publisher.connections.json"),
|
|
33
35
|
content,
|
|
@@ -82,7 +84,7 @@ describe("service/package", () => {
|
|
|
82
84
|
});
|
|
83
85
|
it("should return a Package object if the package exists", async () => {
|
|
84
86
|
sinon.stub(fs, "stat").resolves();
|
|
85
|
-
sinon
|
|
87
|
+
const readFileStub = sinon
|
|
86
88
|
.stub(fs, "readFile")
|
|
87
89
|
.resolves(
|
|
88
90
|
Buffer.from(JSON.stringify({ description: "Test package" })),
|
|
@@ -98,6 +100,9 @@ describe("service/package", () => {
|
|
|
98
100
|
list: () => [],
|
|
99
101
|
} as PartialScheduler);
|
|
100
102
|
|
|
103
|
+
readFileStub.restore();
|
|
104
|
+
readFileStub.resolves(Buffer.from(JSON.stringify([])));
|
|
105
|
+
|
|
101
106
|
const packageInstance = await Package.create(
|
|
102
107
|
"testProject",
|
|
103
108
|
"testPackage",
|
|
@@ -221,10 +226,7 @@ describe("service/package", () => {
|
|
|
221
226
|
sinon.stub(fs, "stat").resolves();
|
|
222
227
|
const config = await readConnectionConfig(testPackageDirectory);
|
|
223
228
|
|
|
224
|
-
expect(config).toEqual(
|
|
225
|
-
{ name: "conn1", type: "database" },
|
|
226
|
-
{ name: "conn2", type: "api" },
|
|
227
|
-
]);
|
|
229
|
+
expect(config).toEqual(connectionMocks);
|
|
228
230
|
});
|
|
229
231
|
});
|
|
230
232
|
});
|
package/src/service/package.ts
CHANGED
|
@@ -101,7 +101,10 @@ export class Package {
|
|
|
101
101
|
unit: "ms",
|
|
102
102
|
});
|
|
103
103
|
const connections = new Map<string, Connection>(projectConnections);
|
|
104
|
-
|
|
104
|
+
logger.info(`Project connections: ${connections.size}`, {
|
|
105
|
+
connections,
|
|
106
|
+
projectConnections,
|
|
107
|
+
});
|
|
105
108
|
// Package connections override project connections.
|
|
106
109
|
const { malloyConnections: packageConnections } =
|
|
107
110
|
await createConnections(packagePath);
|
|
@@ -147,6 +150,11 @@ export class Package {
|
|
|
147
150
|
malloy_package_name: packageName,
|
|
148
151
|
status: "success",
|
|
149
152
|
});
|
|
153
|
+
logger.info(`Successfully loaded package ${packageName}`, {
|
|
154
|
+
packageName,
|
|
155
|
+
duration: executionTime,
|
|
156
|
+
unit: "ms",
|
|
157
|
+
});
|
|
150
158
|
return new Package(
|
|
151
159
|
projectName,
|
|
152
160
|
packageName,
|
|
@@ -158,15 +166,14 @@ export class Package {
|
|
|
158
166
|
);
|
|
159
167
|
} catch (error) {
|
|
160
168
|
logger.error(`Error loading package ${packageName}`, { error });
|
|
169
|
+
console.error(error);
|
|
161
170
|
const endTime = performance.now();
|
|
162
171
|
const executionTime = endTime - startTime;
|
|
163
172
|
this.packageLoadHistogram.record(executionTime, {
|
|
164
173
|
malloy_package_name: packageName,
|
|
165
174
|
status: "error",
|
|
166
175
|
});
|
|
167
|
-
throw
|
|
168
|
-
cause: error,
|
|
169
|
-
});
|
|
176
|
+
throw error;
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
179
|
|
package/src/service/project.ts
CHANGED
|
@@ -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
|
|
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";
|
|
@@ -59,7 +60,7 @@ export class Project {
|
|
|
59
60
|
`${API_PREFIX}/projects/`,
|
|
60
61
|
"",
|
|
61
62
|
);
|
|
62
|
-
if (!(await fs.exists(this.projectPath))) {
|
|
63
|
+
if (!(await fs.promises.exists(this.projectPath))) {
|
|
63
64
|
throw new ProjectNotFoundError(
|
|
64
65
|
`Project path "${this.projectPath}" not found`,
|
|
65
66
|
);
|
|
@@ -73,14 +74,24 @@ export class Project {
|
|
|
73
74
|
static async create(
|
|
74
75
|
projectName: string,
|
|
75
76
|
projectPath: string,
|
|
77
|
+
defaultConnections: ApiConnection[],
|
|
76
78
|
): Promise<Project> {
|
|
77
|
-
if (!(await fs.stat(projectPath)).isDirectory()) {
|
|
79
|
+
if (!(await fs.promises.stat(projectPath)).isDirectory()) {
|
|
78
80
|
throw new ProjectNotFoundError(
|
|
79
81
|
`Project path ${projectPath} not found`,
|
|
80
82
|
);
|
|
81
83
|
}
|
|
82
|
-
const { malloyConnections, apiConnections } =
|
|
83
|
-
|
|
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
|
+
);
|
|
84
95
|
return new Project(
|
|
85
96
|
projectName,
|
|
86
97
|
projectPath,
|
|
@@ -104,7 +115,7 @@ export class Project {
|
|
|
104
115
|
let readme = "";
|
|
105
116
|
try {
|
|
106
117
|
readme = (
|
|
107
|
-
await fs.readFile(path.join(this.projectPath, README_NAME))
|
|
118
|
+
await fs.promises.readFile(path.join(this.projectPath, README_NAME))
|
|
108
119
|
).toString();
|
|
109
120
|
} catch {
|
|
110
121
|
// Readme not found, so we'll just return an empty string
|
|
@@ -158,27 +169,33 @@ export class Project {
|
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
public async listPackages(): Promise<ApiPackage[]> {
|
|
172
|
+
logger.info("Listing packages", { projectPath: this.projectPath });
|
|
161
173
|
try {
|
|
162
|
-
const files = await fs.readdir(this.projectPath, {
|
|
174
|
+
const files = await fs.promises.readdir(this.projectPath, {
|
|
163
175
|
withFileTypes: true,
|
|
164
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
|
+
);
|
|
165
184
|
const packageMetadata = await Promise.all(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
}),
|
|
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
|
+
}),
|
|
182
199
|
);
|
|
183
200
|
// Get rid of undefined entries (i.e, directories without publisher.json files).
|
|
184
201
|
const filteredMetadata = packageMetadata.filter(
|
|
@@ -186,23 +203,27 @@ export class Project {
|
|
|
186
203
|
) as ApiPackage[];
|
|
187
204
|
return filteredMetadata;
|
|
188
205
|
} catch (error) {
|
|
189
|
-
|
|
206
|
+
logger.error("Error listing packages", { error });
|
|
207
|
+
console.error(error);
|
|
208
|
+
throw error;
|
|
190
209
|
}
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
public async getPackage(
|
|
194
213
|
packageName: string,
|
|
195
|
-
reload: boolean,
|
|
214
|
+
reload: boolean = false,
|
|
196
215
|
): Promise<Package> {
|
|
197
216
|
// We need to acquire the mutex to prevent a thundering herd of requests from creating the
|
|
198
217
|
// package multiple times.
|
|
199
218
|
let packageMutex = this.packageMutexes.get(packageName);
|
|
200
|
-
if (
|
|
201
|
-
packageMutex
|
|
202
|
-
this.
|
|
219
|
+
if (packageMutex?.isLocked()) {
|
|
220
|
+
await packageMutex.waitForUnlock();
|
|
221
|
+
return this.packages.get(packageName)!;
|
|
203
222
|
}
|
|
223
|
+
packageMutex = new Mutex();
|
|
224
|
+
this.packageMutexes.set(packageName, packageMutex);
|
|
204
225
|
|
|
205
|
-
return
|
|
226
|
+
return packageMutex.runExclusive(async () => {
|
|
206
227
|
const _package = this.packages.get(packageName);
|
|
207
228
|
if (_package !== undefined && !reload) {
|
|
208
229
|
return _package;
|
|
@@ -220,6 +241,9 @@ export class Project {
|
|
|
220
241
|
} catch (error) {
|
|
221
242
|
this.packages.delete(packageName);
|
|
222
243
|
throw error;
|
|
244
|
+
} finally {
|
|
245
|
+
packageMutex.release();
|
|
246
|
+
this.packageMutexes.delete(packageName);
|
|
223
247
|
}
|
|
224
248
|
});
|
|
225
249
|
}
|
|
@@ -227,11 +251,18 @@ export class Project {
|
|
|
227
251
|
public async addPackage(packageName: string) {
|
|
228
252
|
const packagePath = path.join(this.projectPath, packageName);
|
|
229
253
|
if (
|
|
230
|
-
!(await fs.exists(packagePath)) ||
|
|
231
|
-
!(await fs.stat(packagePath)).isDirectory()
|
|
254
|
+
!(await fs.promises.exists(packagePath)) ||
|
|
255
|
+
!(await fs.promises.stat(packagePath)).isDirectory()
|
|
232
256
|
) {
|
|
233
257
|
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
234
258
|
}
|
|
259
|
+
logger.info(
|
|
260
|
+
`Adding package ${packageName} to project ${this.projectName}`,
|
|
261
|
+
{
|
|
262
|
+
packagePath,
|
|
263
|
+
malloyConnections: this.malloyConnections,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
235
266
|
this.packages.set(
|
|
236
267
|
packageName,
|
|
237
268
|
await Package.create(
|
|
@@ -266,9 +297,17 @@ export class Project {
|
|
|
266
297
|
if (!_package) {
|
|
267
298
|
throw new PackageNotFoundError(`Package ${packageName} not found`);
|
|
268
299
|
}
|
|
269
|
-
await fs.rm(path.join(this.projectPath, packageName), {
|
|
300
|
+
await fs.promises.rm(path.join(this.projectPath, packageName), {
|
|
270
301
|
recursive: true,
|
|
271
302
|
});
|
|
272
303
|
this.packages.delete(packageName);
|
|
273
304
|
}
|
|
305
|
+
|
|
306
|
+
public async serialize(): Promise<ApiProject> {
|
|
307
|
+
return {
|
|
308
|
+
...this.metadata,
|
|
309
|
+
connections: this.listApiConnections(),
|
|
310
|
+
packages: await this.listPackages(),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
274
313
|
}
|
|
@@ -1,40 +1,52 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
4
13
|
import { isPublisherConfigFrozen } from "../config";
|
|
14
|
+
import { publisherPath } from "../constants";
|
|
5
15
|
import { FrozenConfigError, ProjectNotFoundError } from "../errors";
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if ((await projectStore.listProjects()).length === 0) {
|
|
25
|
-
throw new Error("Timed out initializing ProjectStore");
|
|
26
|
-
}
|
|
27
|
-
}
|
|
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
|
+
});
|
|
28
37
|
|
|
29
|
-
it("should load all projects from publisher.config.json
|
|
38
|
+
it("should load all projects from publisher.config.json on initialization", async () => {
|
|
30
39
|
mock(isPublisherConfigFrozen).mockReturnValue(true);
|
|
31
40
|
const projectStore = new ProjectStore(serverRoot);
|
|
32
|
-
|
|
41
|
+
mock(projectStore.downloadGitHubDirectory).mockResolvedValue(undefined);
|
|
42
|
+
await projectStore.finishedInitialization;
|
|
33
43
|
expect(await projectStore.listProjects()).toEqual([
|
|
34
44
|
{
|
|
35
45
|
name: "malloy-samples",
|
|
36
|
-
readme: expect.
|
|
46
|
+
readme: expect.any(String),
|
|
37
47
|
resource: "/api/v0/projects/malloy-samples",
|
|
48
|
+
packages: expect.any(Array),
|
|
49
|
+
connections: expect.any(Array),
|
|
38
50
|
},
|
|
39
51
|
]);
|
|
40
52
|
});
|
|
@@ -42,16 +54,16 @@ describe("ProjectStore", () => {
|
|
|
42
54
|
it("should list projects from memory by default", async () => {
|
|
43
55
|
mock(isPublisherConfigFrozen).mockReturnValue(true);
|
|
44
56
|
const projectStore = new ProjectStore(serverRoot);
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
]);
|
|
55
67
|
});
|
|
56
68
|
|
|
57
69
|
it("should list projects from disk if reload is true", async () => {
|
|
@@ -68,22 +80,29 @@ describe("ProjectStore", () => {
|
|
|
68
80
|
});
|
|
69
81
|
|
|
70
82
|
it("should allow modifying the in-memory hashmap if config is not frozen", async () => {
|
|
71
|
-
mock.module("../
|
|
83
|
+
mock.module("../config", () => ({
|
|
72
84
|
isPublisherConfigFrozen: () => false,
|
|
73
85
|
}));
|
|
74
86
|
const projectStore = new ProjectStore(serverRoot);
|
|
75
|
-
|
|
87
|
+
mock(projectStore.downloadGitHubDirectory).mockResolvedValue(undefined);
|
|
88
|
+
await projectStore.finishedInitialization;
|
|
76
89
|
await projectStore.updateProject({
|
|
77
90
|
name: "malloy-samples",
|
|
78
91
|
readme: "Updated README",
|
|
79
92
|
});
|
|
80
|
-
|
|
81
|
-
|
|
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({
|
|
82
101
|
name: "malloy-samples",
|
|
83
102
|
readme: "Updated README",
|
|
84
103
|
resource: "/api/v0/projects/malloy-samples",
|
|
85
|
-
},
|
|
86
|
-
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
87
106
|
await projectStore.deleteProject("malloy-samples");
|
|
88
107
|
expect(await projectStore.listProjects()).toEqual([]);
|
|
89
108
|
await projectStore.addProject({
|
|
@@ -95,31 +114,32 @@ describe("ProjectStore", () => {
|
|
|
95
114
|
).toHaveProperty("metadata", {
|
|
96
115
|
name: "malloy-samples",
|
|
97
116
|
resource: "/api/v0/projects/malloy-samples",
|
|
117
|
+
location: expect.any(String),
|
|
98
118
|
});
|
|
99
119
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
108
|
-
]);
|
|
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
|
+
});
|
|
109
127
|
});
|
|
110
128
|
|
|
111
129
|
it("should not allow modifying the in-memory hashmap if config is frozen", async () => {
|
|
112
|
-
mock.module("../
|
|
130
|
+
mock.module("../config", () => ({
|
|
113
131
|
isPublisherConfigFrozen: () => true,
|
|
114
132
|
}));
|
|
115
133
|
const projectStore = new ProjectStore(serverRoot);
|
|
116
134
|
// Initialization should succeed
|
|
117
|
-
await
|
|
135
|
+
await projectStore.finishedInitialization;
|
|
118
136
|
expect(await projectStore.listProjects()).toEqual([
|
|
119
137
|
{
|
|
120
138
|
name: "malloy-samples",
|
|
121
|
-
readme: expect.
|
|
139
|
+
readme: expect.any(String),
|
|
122
140
|
resource: "/api/v0/projects/malloy-samples",
|
|
141
|
+
packages: expect.any(Array),
|
|
142
|
+
connections: expect.any(Array),
|
|
123
143
|
},
|
|
124
144
|
]);
|
|
125
145
|
// Adding a project should fail
|
|
@@ -146,18 +166,20 @@ describe("ProjectStore", () => {
|
|
|
146
166
|
expect(await projectStore.listProjects()).toEqual([
|
|
147
167
|
{
|
|
148
168
|
name: "malloy-samples",
|
|
149
|
-
readme: expect.
|
|
169
|
+
readme: expect.any(String),
|
|
150
170
|
resource: "/api/v0/projects/malloy-samples",
|
|
171
|
+
packages: expect.any(Array),
|
|
172
|
+
connections: expect.any(Array),
|
|
151
173
|
},
|
|
152
174
|
]);
|
|
153
175
|
});
|
|
154
176
|
|
|
155
177
|
it("should always try to reload a project if it's not in the hashmap", async () => {
|
|
156
|
-
mock.module("../
|
|
178
|
+
mock.module("../config", () => ({
|
|
157
179
|
isPublisherConfigFrozen: () => false,
|
|
158
180
|
}));
|
|
159
181
|
const projectStore = new ProjectStore(serverRoot);
|
|
160
|
-
await
|
|
182
|
+
await projectStore.finishedInitialization;
|
|
161
183
|
await projectStore.deleteProject("malloy-samples");
|
|
162
184
|
expect(await projectStore.listProjects()).toEqual([]);
|
|
163
185
|
const readFileSpy = spyOn(fs, "readFile");
|
|
@@ -166,11 +188,11 @@ describe("ProjectStore", () => {
|
|
|
166
188
|
});
|
|
167
189
|
|
|
168
190
|
it("should throw a NotFound error when reloading a project that is not in disk", async () => {
|
|
169
|
-
mock.module("../
|
|
191
|
+
mock.module("../config", () => ({
|
|
170
192
|
isPublisherConfigFrozen: () => false,
|
|
171
193
|
}));
|
|
172
194
|
const projectStore = new ProjectStore(serverRoot);
|
|
173
|
-
await
|
|
195
|
+
await projectStore.finishedInitialization;
|
|
174
196
|
expect(
|
|
175
197
|
projectStore.getProject("this-one-does-not-exist", true),
|
|
176
198
|
).rejects.toThrow(ProjectNotFoundError);
|
|
@@ -2,11 +2,12 @@ import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
|
|
|
2
2
|
import { Storage } from "@google-cloud/storage";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
|
+
import AdmZip from "adm-zip";
|
|
5
6
|
import simpleGit from "simple-git";
|
|
6
7
|
import { Writable } from "stream";
|
|
7
8
|
import { components } from "../api";
|
|
8
9
|
import { getPublisherConfig, isPublisherConfigFrozen } from "../config";
|
|
9
|
-
import { API_PREFIX, PUBLISHER_CONFIG_NAME } from "../constants";
|
|
10
|
+
import { API_PREFIX, PUBLISHER_CONFIG_NAME, publisherPath } from "../constants";
|
|
10
11
|
import { FrozenConfigError, ProjectNotFoundError } from "../errors";
|
|
11
12
|
import { logger } from "../logger";
|
|
12
13
|
import { Project } from "./project";
|
|
@@ -56,20 +57,23 @@ export class ProjectStore {
|
|
|
56
57
|
);
|
|
57
58
|
} catch (error) {
|
|
58
59
|
logger.error("Error initializing project store", { error });
|
|
60
|
+
console.error(error);
|
|
59
61
|
process.exit(1);
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
public async listProjects() {
|
|
64
66
|
await this.finishedInitialization;
|
|
65
|
-
return
|
|
66
|
-
(project) =>
|
|
67
|
+
return Promise.all(
|
|
68
|
+
Array.from(this.projects.values()).map((project) =>
|
|
69
|
+
project.serialize(),
|
|
70
|
+
),
|
|
67
71
|
);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
public async getProject(
|
|
71
75
|
projectName: string,
|
|
72
|
-
reload: boolean,
|
|
76
|
+
reload: boolean = false,
|
|
73
77
|
): Promise<Project> {
|
|
74
78
|
await this.finishedInitialization;
|
|
75
79
|
let project = this.projects.get(projectName);
|
|
@@ -99,9 +103,10 @@ export class ProjectStore {
|
|
|
99
103
|
if (!skipInitialization) {
|
|
100
104
|
await this.finishedInitialization;
|
|
101
105
|
}
|
|
102
|
-
if (this.publisherConfigIsFrozen) {
|
|
106
|
+
if (!skipInitialization && this.publisherConfigIsFrozen) {
|
|
103
107
|
throw new FrozenConfigError();
|
|
104
108
|
}
|
|
109
|
+
|
|
105
110
|
const projectName = project.name;
|
|
106
111
|
if (!projectName) {
|
|
107
112
|
throw new Error("Project name is required");
|
|
@@ -111,15 +116,44 @@ export class ProjectStore {
|
|
|
111
116
|
);
|
|
112
117
|
const projectPath =
|
|
113
118
|
project.location || projectManifest.projects[projectName];
|
|
114
|
-
|
|
119
|
+
let absoluteProjectPath: string;
|
|
120
|
+
if (projectPath) {
|
|
121
|
+
absoluteProjectPath = await this.loadProjectIntoDisk(
|
|
122
|
+
projectName,
|
|
123
|
+
projectPath,
|
|
124
|
+
);
|
|
125
|
+
if (absoluteProjectPath.endsWith(".zip")) {
|
|
126
|
+
absoluteProjectPath = await this.unzipProject(absoluteProjectPath);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
absoluteProjectPath = await this.scaffoldProject(project);
|
|
130
|
+
}
|
|
131
|
+
const newProject = await Project.create(
|
|
115
132
|
projectName,
|
|
116
|
-
|
|
133
|
+
absoluteProjectPath,
|
|
134
|
+
project.connections || [],
|
|
117
135
|
);
|
|
118
|
-
const newProject = await Project.create(projectName, absoluteProjectPath);
|
|
119
136
|
this.projects.set(projectName, newProject);
|
|
120
137
|
return newProject;
|
|
121
138
|
}
|
|
122
139
|
|
|
140
|
+
public async unzipProject(absoluteProjectPath: string) {
|
|
141
|
+
logger.info(
|
|
142
|
+
`Detected zip file at "${absoluteProjectPath}". Unzipping...`,
|
|
143
|
+
);
|
|
144
|
+
const unzippedProjectPath = absoluteProjectPath.replace(".zip", "");
|
|
145
|
+
await fs.promises.rm(unzippedProjectPath, {
|
|
146
|
+
recursive: true,
|
|
147
|
+
force: true,
|
|
148
|
+
});
|
|
149
|
+
await fs.promises.mkdir(unzippedProjectPath, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const zip = new AdmZip(absoluteProjectPath);
|
|
152
|
+
zip.extractAllTo(unzippedProjectPath, true);
|
|
153
|
+
|
|
154
|
+
return unzippedProjectPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
123
157
|
public async updateProject(project: ApiProject) {
|
|
124
158
|
await this.finishedInitialization;
|
|
125
159
|
if (this.publisherConfigIsFrozen) {
|
|
@@ -184,8 +218,24 @@ export class ProjectStore {
|
|
|
184
218
|
}
|
|
185
219
|
}
|
|
186
220
|
|
|
221
|
+
private async scaffoldProject(project: ApiProject) {
|
|
222
|
+
const projectName = project.name;
|
|
223
|
+
if (!projectName) {
|
|
224
|
+
throw new Error("Project name is required");
|
|
225
|
+
}
|
|
226
|
+
const absoluteProjectPath = `${publisherPath}/${projectName}`;
|
|
227
|
+
await fs.promises.mkdir(absoluteProjectPath, { recursive: true });
|
|
228
|
+
if (project.readme) {
|
|
229
|
+
await fs.promises.writeFile(
|
|
230
|
+
path.join(absoluteProjectPath, "README.md"),
|
|
231
|
+
project.readme,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return absoluteProjectPath;
|
|
235
|
+
}
|
|
236
|
+
|
|
187
237
|
private async loadProjectIntoDisk(projectName: string, projectPath: string) {
|
|
188
|
-
const absoluteTargetPath =
|
|
238
|
+
const absoluteTargetPath = `${publisherPath}/${projectName}`;
|
|
189
239
|
// Handle absolute paths
|
|
190
240
|
if (projectPath.startsWith("/")) {
|
|
191
241
|
try {
|
|
@@ -263,7 +313,7 @@ export class ProjectStore {
|
|
|
263
313
|
throw new ProjectNotFoundError(errorMsg);
|
|
264
314
|
}
|
|
265
315
|
|
|
266
|
-
|
|
316
|
+
public async mountLocalDirectory(
|
|
267
317
|
projectPath: string,
|
|
268
318
|
absoluteTargetPath: string,
|
|
269
319
|
projectName: string,
|
|
@@ -384,6 +434,21 @@ export class ProjectStore {
|
|
|
384
434
|
async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
|
|
385
435
|
await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
386
436
|
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
387
|
-
|
|
437
|
+
|
|
438
|
+
await new Promise<void>((resolve, reject) => {
|
|
439
|
+
simpleGit().clone(githubUrl, absoluteDirPath, {}, (err) => {
|
|
440
|
+
if (err) {
|
|
441
|
+
console.error(err);
|
|
442
|
+
logger.error(
|
|
443
|
+
`Failed to clone GitHub repository "${githubUrl}"`,
|
|
444
|
+
{
|
|
445
|
+
error: err,
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
reject(err);
|
|
449
|
+
}
|
|
450
|
+
resolve();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
388
453
|
}
|
|
389
454
|
}
|