@malloy-publisher/server 0.0.87 → 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.
@@ -1,24 +1,34 @@
1
- import * as fs from "fs/promises";
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 { API_PREFIX } from "../constants";
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
- private serverRootPath: string;
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
- void this.initialize();
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
- for (const projectName of Object.keys(projectManifest.projects)) {
30
- const projectPath = projectManifest.projects[projectName];
31
- const absoluteProjectPath = path.join(
32
- this.serverRootPath,
33
- projectPath,
34
- );
35
- const project = await Project.create(
36
- projectName,
37
- absoluteProjectPath,
38
- );
39
- this.projects.set(projectName, project);
40
- }
41
- logger.info("Project store successfully initialized");
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
- if (
64
- !projectManifest.projects ||
65
- !projectManifest.projects[projectName]
66
- ) {
80
+ const projectPath =
81
+ project?.metadata.location || projectManifest.projects[projectName];
82
+ if (!projectPath) {
67
83
  throw new ProjectNotFoundError(
68
- `Project "${projectName}" not found in publisher`,
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(project: ApiProject) {
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 = projectManifest.projects[projectName];
91
- if (!projectPath) {
92
- throw new ProjectNotFoundError(
93
- `Project "${projectName}" not found in publisher.config.json`,
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
- private static async reloadProjectManifest(
137
- serverRootPath: string,
138
- ): Promise<{ projects: { [key: string]: string } }> {
154
+ public static async reloadProjectManifest(serverRootPath: string) {
139
155
  try {
140
- const projectManifestContent = await fs.readFile(
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 publisher.config.json. Generating from directory`,
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
- };