@malloy-publisher/server 0.0.86 → 0.0.88
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 +25 -0
- package/dist/app/api-doc.yaml +83 -0
- package/dist/app/assets/{RenderedResult-BAZuT25g-_eLjVkSo.js → RenderedResult-BAZuT25g-DLMDvQic.js} +2 -2
- package/dist/app/assets/{index-BvRGZn2o.js → index-CNRCklos.js} +211 -206
- package/dist/app/assets/index-CcIq0aEZ.css +1 -0
- package/dist/app/assets/index-CobAY3LE.js +427 -0
- package/dist/app/assets/index-lhN38COk.js +211 -0
- package/dist/app/assets/index.umd-CSPhcx55.js +2105 -0
- package/dist/app/index.html +2 -2
- package/dist/instrumentation.js +67969 -35371
- package/dist/server.js +49297 -40630
- package/package.json +7 -4
- package/publisher.config.json +1 -1
- package/src/config.ts +20 -0
- package/src/constants.ts +6 -0
- package/src/controller/package.controller.ts +41 -2
- package/src/controller/watch-mode.controller.ts +83 -0
- package/src/errors.ts +2 -1
- package/src/server.ts +22 -14
- package/src/service/connection.ts +7 -7
- package/src/service/model.ts +6 -6
- package/src/service/package.ts +4 -4
- package/src/service/project.ts +6 -4
- package/src/service/project_store.spec.ts +19 -14
- package/src/service/project_store.ts +262 -47
- package/src/utils.ts +0 -18
- package/dist/app/assets/index-C-lp8bCy.js +0 -210
- package/dist/app/assets/index-EI2L5apg.js +0 -432
- package/dist/app/assets/index-RMQQYOAi.css +0 -1
- package/dist/app/assets/index.umd-DBkg1U_t.js +0 -2078
|
@@ -1,24 +1,34 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
|
|
2
|
+
import { Storage } from "@google-cloud/storage";
|
|
3
|
+
import * as fs from "fs";
|
|
2
4
|
import * as path from "path";
|
|
5
|
+
import simpleGit from "simple-git";
|
|
6
|
+
import { Writable } from "stream";
|
|
3
7
|
import { components } from "../api";
|
|
4
|
-
import {
|
|
8
|
+
import { getPublisherConfig, isPublisherConfigFrozen } from "../config";
|
|
9
|
+
import { API_PREFIX, PUBLISHER_CONFIG_NAME } from "../constants";
|
|
5
10
|
import { FrozenConfigError, ProjectNotFoundError } from "../errors";
|
|
6
11
|
import { logger } from "../logger";
|
|
7
|
-
import { isPublisherConfigFrozen } from "../utils";
|
|
8
12
|
import { Project } from "./project";
|
|
9
13
|
type ApiProject = components["schemas"]["Project"];
|
|
10
14
|
|
|
11
15
|
export class ProjectStore {
|
|
12
|
-
|
|
16
|
+
public serverRootPath: string;
|
|
13
17
|
private projects: Map<string, Project> = new Map();
|
|
14
18
|
public publisherConfigIsFrozen: boolean;
|
|
19
|
+
public finishedInitialization: Promise<void>;
|
|
20
|
+
private s3Client = new S3({
|
|
21
|
+
followRegionRedirects: true,
|
|
22
|
+
});
|
|
23
|
+
private gcsClient = new Storage();
|
|
15
24
|
|
|
16
25
|
constructor(serverRootPath: string) {
|
|
17
26
|
this.serverRootPath = serverRootPath;
|
|
18
|
-
|
|
27
|
+
this.finishedInitialization = this.initialize();
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
private async initialize() {
|
|
31
|
+
const initialTime = performance.now();
|
|
22
32
|
try {
|
|
23
33
|
this.publisherConfigIsFrozen = isPublisherConfigFrozen(
|
|
24
34
|
this.serverRootPath,
|
|
@@ -26,26 +36,32 @@ export class ProjectStore {
|
|
|
26
36
|
const projectManifest = await ProjectStore.reloadProjectManifest(
|
|
27
37
|
this.serverRootPath,
|
|
28
38
|
);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
logger.info(`Initializing project store.`);
|
|
40
|
+
await Promise.all(
|
|
41
|
+
Object.keys(projectManifest.projects).map(async (projectName) => {
|
|
42
|
+
logger.info(`Adding project "${projectName}"`);
|
|
43
|
+
const project = await this.addProject(
|
|
44
|
+
{
|
|
45
|
+
name: projectName,
|
|
46
|
+
resource: `${API_PREFIX}/projects/${projectName}`,
|
|
47
|
+
location: projectManifest.projects[projectName],
|
|
48
|
+
},
|
|
49
|
+
true,
|
|
50
|
+
);
|
|
51
|
+
return project.listPackages();
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
logger.info(
|
|
55
|
+
`Project store successfully initialized in ${performance.now() - initialTime}ms`,
|
|
56
|
+
);
|
|
42
57
|
} catch (error) {
|
|
43
58
|
logger.error("Error initializing project store", { error });
|
|
44
59
|
process.exit(1);
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
|
|
48
|
-
public listProjects() {
|
|
63
|
+
public async listProjects() {
|
|
64
|
+
await this.finishedInitialization;
|
|
49
65
|
return Array.from(this.projects.values()).map(
|
|
50
66
|
(project) => project.metadata,
|
|
51
67
|
);
|
|
@@ -55,17 +71,17 @@ export class ProjectStore {
|
|
|
55
71
|
projectName: string,
|
|
56
72
|
reload: boolean,
|
|
57
73
|
): Promise<Project> {
|
|
74
|
+
await this.finishedInitialization;
|
|
58
75
|
let project = this.projects.get(projectName);
|
|
59
76
|
if (project === undefined || reload) {
|
|
60
77
|
const projectManifest = await ProjectStore.reloadProjectManifest(
|
|
61
78
|
this.serverRootPath,
|
|
62
79
|
);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
) {
|
|
80
|
+
const projectPath =
|
|
81
|
+
project?.metadata.location || projectManifest.projects[projectName];
|
|
82
|
+
if (!projectPath) {
|
|
67
83
|
throw new ProjectNotFoundError(
|
|
68
|
-
`Project "${projectName}" not
|
|
84
|
+
`Project "${projectName}" could not be resolved to a path.`,
|
|
69
85
|
);
|
|
70
86
|
}
|
|
71
87
|
project = await this.addProject({
|
|
@@ -76,7 +92,13 @@ export class ProjectStore {
|
|
|
76
92
|
return project;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
public async addProject(
|
|
95
|
+
public async addProject(
|
|
96
|
+
project: ApiProject,
|
|
97
|
+
skipInitialization: boolean = false,
|
|
98
|
+
) {
|
|
99
|
+
if (!skipInitialization) {
|
|
100
|
+
await this.finishedInitialization;
|
|
101
|
+
}
|
|
80
102
|
if (this.publisherConfigIsFrozen) {
|
|
81
103
|
throw new FrozenConfigError();
|
|
82
104
|
}
|
|
@@ -87,24 +109,19 @@ export class ProjectStore {
|
|
|
87
109
|
const projectManifest = await ProjectStore.reloadProjectManifest(
|
|
88
110
|
this.serverRootPath,
|
|
89
111
|
);
|
|
90
|
-
const projectPath =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const absoluteProjectPath = path.join(this.serverRootPath, projectPath);
|
|
97
|
-
if (!(await fs.stat(absoluteProjectPath)).isDirectory()) {
|
|
98
|
-
throw new ProjectNotFoundError(
|
|
99
|
-
`Project ${projectName} not found in ${absoluteProjectPath}`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
112
|
+
const projectPath =
|
|
113
|
+
project.location || projectManifest.projects[projectName];
|
|
114
|
+
const absoluteProjectPath = await this.loadProjectIntoDisk(
|
|
115
|
+
projectName,
|
|
116
|
+
projectPath,
|
|
117
|
+
);
|
|
102
118
|
const newProject = await Project.create(projectName, absoluteProjectPath);
|
|
103
119
|
this.projects.set(projectName, newProject);
|
|
104
120
|
return newProject;
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
public async updateProject(project: ApiProject) {
|
|
124
|
+
await this.finishedInitialization;
|
|
108
125
|
if (this.publisherConfigIsFrozen) {
|
|
109
126
|
throw new FrozenConfigError();
|
|
110
127
|
}
|
|
@@ -122,6 +139,7 @@ export class ProjectStore {
|
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
public async deleteProject(projectName: string) {
|
|
142
|
+
await this.finishedInitialization;
|
|
125
143
|
if (this.publisherConfigIsFrozen) {
|
|
126
144
|
throw new FrozenConfigError();
|
|
127
145
|
}
|
|
@@ -133,26 +151,20 @@ export class ProjectStore {
|
|
|
133
151
|
return project;
|
|
134
152
|
}
|
|
135
153
|
|
|
136
|
-
|
|
137
|
-
serverRootPath: string,
|
|
138
|
-
): Promise<{ projects: { [key: string]: string } }> {
|
|
154
|
+
public static async reloadProjectManifest(serverRootPath: string) {
|
|
139
155
|
try {
|
|
140
|
-
|
|
141
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
142
|
-
"utf8",
|
|
143
|
-
);
|
|
144
|
-
return JSON.parse(projectManifestContent);
|
|
156
|
+
return getPublisherConfig(serverRootPath);
|
|
145
157
|
} catch (error) {
|
|
146
158
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
147
159
|
logger.error(
|
|
148
|
-
`Error reading
|
|
160
|
+
`Error reading ${PUBLISHER_CONFIG_NAME}. Generating from directory`,
|
|
149
161
|
{ error },
|
|
150
162
|
);
|
|
151
163
|
return { projects: {} };
|
|
152
164
|
} else {
|
|
153
165
|
// If publisher.config.json is missing, generate the manifest from directories
|
|
154
166
|
try {
|
|
155
|
-
const entries = await fs.readdir(serverRootPath, {
|
|
167
|
+
const entries = await fs.promises.readdir(serverRootPath, {
|
|
156
168
|
withFileTypes: true,
|
|
157
169
|
});
|
|
158
170
|
const projects: { [key: string]: string } = {};
|
|
@@ -171,4 +183,207 @@ export class ProjectStore {
|
|
|
171
183
|
}
|
|
172
184
|
}
|
|
173
185
|
}
|
|
186
|
+
|
|
187
|
+
private async loadProjectIntoDisk(projectName: string, projectPath: string) {
|
|
188
|
+
const absoluteTargetPath = `/etc/publisher/${projectName}`;
|
|
189
|
+
// Handle absolute paths
|
|
190
|
+
if (projectPath.startsWith("/")) {
|
|
191
|
+
try {
|
|
192
|
+
logger.info(`Mounting local directory at "${projectPath}"`);
|
|
193
|
+
await this.mountLocalDirectory(
|
|
194
|
+
projectPath,
|
|
195
|
+
absoluteTargetPath,
|
|
196
|
+
projectName,
|
|
197
|
+
);
|
|
198
|
+
return absoluteTargetPath;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
logger.error(`Failed to mount local directory "${projectPath}"`, {
|
|
201
|
+
error,
|
|
202
|
+
});
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Handle GCS URIs
|
|
208
|
+
if (projectPath.startsWith("gs://")) {
|
|
209
|
+
// Download from GCS
|
|
210
|
+
try {
|
|
211
|
+
logger.info(
|
|
212
|
+
`Downloading GCS path "${projectPath}" to "${absoluteTargetPath}"`,
|
|
213
|
+
);
|
|
214
|
+
await this.downloadGcsDirectory(
|
|
215
|
+
projectPath,
|
|
216
|
+
projectName,
|
|
217
|
+
absoluteTargetPath,
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
logger.error(`Failed to download GCS path "${projectPath}"`, {
|
|
221
|
+
error,
|
|
222
|
+
});
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return absoluteTargetPath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Handle S3 URIs
|
|
229
|
+
if (projectPath.startsWith("s3://")) {
|
|
230
|
+
try {
|
|
231
|
+
logger.info(`Mounting S3 path "${projectPath}"`);
|
|
232
|
+
await this.downloadS3Directory(
|
|
233
|
+
projectPath,
|
|
234
|
+
projectName,
|
|
235
|
+
absoluteTargetPath,
|
|
236
|
+
);
|
|
237
|
+
return absoluteTargetPath;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logger.error(`Failed to mount S3 path "${projectPath}"`, { error });
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Handle GitHub URIs
|
|
245
|
+
if (
|
|
246
|
+
projectPath.startsWith("https://github.com/") ||
|
|
247
|
+
projectPath.startsWith("git@")
|
|
248
|
+
) {
|
|
249
|
+
try {
|
|
250
|
+
logger.info(`Mounting GitHub path "${projectPath}"`);
|
|
251
|
+
await this.downloadGitHubDirectory(projectPath, absoluteTargetPath);
|
|
252
|
+
return absoluteTargetPath;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.error(`Failed to mount GitHub path "${projectPath}"`, {
|
|
255
|
+
error,
|
|
256
|
+
});
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const errorMsg = `Invalid project path: "${projectPath}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
|
|
262
|
+
logger.error(errorMsg, { projectName, projectPath });
|
|
263
|
+
throw new ProjectNotFoundError(errorMsg);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async mountLocalDirectory(
|
|
267
|
+
projectPath: string,
|
|
268
|
+
absoluteTargetPath: string,
|
|
269
|
+
projectName: string,
|
|
270
|
+
) {
|
|
271
|
+
const projectDirExists = (
|
|
272
|
+
await fs.promises.stat(projectPath)
|
|
273
|
+
).isDirectory();
|
|
274
|
+
if (projectDirExists) {
|
|
275
|
+
await fs.promises.rm(absoluteTargetPath, {
|
|
276
|
+
recursive: true,
|
|
277
|
+
force: true,
|
|
278
|
+
});
|
|
279
|
+
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
280
|
+
await fs.promises.cp(projectPath, absoluteTargetPath, {
|
|
281
|
+
recursive: true,
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
throw new ProjectNotFoundError(
|
|
285
|
+
`Project ${projectName} not found in "${projectPath}"`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async downloadGcsDirectory(
|
|
291
|
+
gcsPath: string,
|
|
292
|
+
projectName: string,
|
|
293
|
+
absoluteDirPath: string,
|
|
294
|
+
) {
|
|
295
|
+
const trimmedPath = gcsPath.slice(5);
|
|
296
|
+
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
297
|
+
const prefix = prefixParts.join("/");
|
|
298
|
+
const [files] = await this.gcsClient.bucket(bucketName).getFiles({
|
|
299
|
+
prefix,
|
|
300
|
+
});
|
|
301
|
+
if (files.length === 0) {
|
|
302
|
+
throw new ProjectNotFoundError(
|
|
303
|
+
`Project ${projectName} not found in ${gcsPath}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
307
|
+
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
308
|
+
await Promise.all(
|
|
309
|
+
files.map(async (file) => {
|
|
310
|
+
const relativeFilePath = file.name.replace(prefix, "");
|
|
311
|
+
const absoluteFilePath = path.join(
|
|
312
|
+
absoluteDirPath,
|
|
313
|
+
relativeFilePath,
|
|
314
|
+
);
|
|
315
|
+
if (file.name.endsWith("/")) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
await fs.promises.mkdir(path.dirname(absoluteFilePath), {
|
|
319
|
+
recursive: true,
|
|
320
|
+
});
|
|
321
|
+
return fs.promises.writeFile(
|
|
322
|
+
absoluteFilePath,
|
|
323
|
+
await file.download(),
|
|
324
|
+
);
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async downloadS3Directory(
|
|
330
|
+
s3Path: string,
|
|
331
|
+
projectName: string,
|
|
332
|
+
absoluteDirPath: string,
|
|
333
|
+
) {
|
|
334
|
+
const trimmedPath = s3Path.slice(5);
|
|
335
|
+
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
336
|
+
const prefix = prefixParts.join("/");
|
|
337
|
+
const objects = await this.s3Client.listObjectsV2({
|
|
338
|
+
Bucket: bucketName,
|
|
339
|
+
Prefix: prefix,
|
|
340
|
+
});
|
|
341
|
+
await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
342
|
+
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
343
|
+
|
|
344
|
+
if (!objects.Contents || objects.Contents.length === 0) {
|
|
345
|
+
throw new ProjectNotFoundError(
|
|
346
|
+
`Project ${projectName} not found in ${s3Path}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
await Promise.all(
|
|
350
|
+
objects.Contents?.map(async (object) => {
|
|
351
|
+
const key = object.Key;
|
|
352
|
+
if (!key) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const relativeFilePath = key.replace(prefix, "");
|
|
356
|
+
if (!relativeFilePath || relativeFilePath.endsWith("/")) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const absoluteFilePath = path.join(
|
|
360
|
+
absoluteDirPath,
|
|
361
|
+
relativeFilePath,
|
|
362
|
+
);
|
|
363
|
+
await fs.promises.mkdir(path.dirname(absoluteFilePath), {
|
|
364
|
+
recursive: true,
|
|
365
|
+
});
|
|
366
|
+
const command = new GetObjectCommand({
|
|
367
|
+
Bucket: bucketName,
|
|
368
|
+
Key: key,
|
|
369
|
+
});
|
|
370
|
+
const item = await this.s3Client.send(command);
|
|
371
|
+
if (!item.Body) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const file = fs.createWriteStream(absoluteFilePath);
|
|
375
|
+
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
376
|
+
await new Promise<void>((resolve, reject) => {
|
|
377
|
+
file.on("error", reject);
|
|
378
|
+
file.on("finish", resolve);
|
|
379
|
+
});
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
|
|
385
|
+
await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
386
|
+
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
387
|
+
await simpleGit().clone(githubUrl, absoluteDirPath);
|
|
388
|
+
}
|
|
174
389
|
}
|
package/src/utils.ts
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { URLReader } from "@malloydata/malloy";
|
|
2
2
|
import * as fs from "fs";
|
|
3
|
-
import path from "path";
|
|
4
3
|
import { fileURLToPath } from "url";
|
|
5
4
|
|
|
6
|
-
export const PACKAGE_MANIFEST_NAME = "publisher.json";
|
|
7
|
-
export const CONNECTIONS_MANIFEST_NAME = "publisher.connections.json";
|
|
8
|
-
export const MODEL_FILE_SUFFIX = ".malloy";
|
|
9
|
-
export const NOTEBOOK_FILE_SUFFIX = ".malloynb";
|
|
10
|
-
// TODO: Move this to server config.
|
|
11
|
-
export const ROW_LIMIT = 1000;
|
|
12
|
-
|
|
13
5
|
export const URL_READER: URLReader = {
|
|
14
6
|
readURL: (url: URL) => {
|
|
15
7
|
let path = url.toString();
|
|
@@ -20,13 +12,3 @@ export const URL_READER: URLReader = {
|
|
|
20
12
|
},
|
|
21
13
|
};
|
|
22
14
|
|
|
23
|
-
export const isPublisherConfigFrozen = (serverRoot: string) => {
|
|
24
|
-
const publisherConfigPath = path.join(serverRoot, "publisher.config.json");
|
|
25
|
-
if (!fs.existsSync(publisherConfigPath)) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
const publisherConfig = JSON.parse(
|
|
29
|
-
fs.readFileSync(publisherConfigPath, "utf8"),
|
|
30
|
-
);
|
|
31
|
-
return Boolean(publisherConfig.frozenConfig);
|
|
32
|
-
};
|