@malloy-publisher/server 0.0.382-dev → 0.2.1

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 (175) hide show
  1. package/README.docker.md +145 -20
  2. package/README.md +65 -12
  3. package/dist/app/api-doc.html +5 -0
  4. package/dist/app/api-doc.yaml +3929 -534
  5. package/dist/app/assets/EnvironmentPage-CHq5BF4l.js +1 -0
  6. package/dist/app/assets/HomePage-CIp-pJCa.js +1 -0
  7. package/dist/app/assets/LightMode-mHHPjEeA.js +1 -0
  8. package/dist/app/assets/MainPage-CylxO5Yu.js +2 -0
  9. package/dist/app/assets/MaterializationsPage-a0vjqxib.js +1 -0
  10. package/dist/app/assets/ModelPage-Cz-MrSZQ.js +1 -0
  11. package/dist/app/assets/PackagePage-Cdg5D7AN.js +1 -0
  12. package/dist/app/assets/RouteError-B5SIyn6G.js +1 -0
  13. package/dist/app/assets/ThemeEditorPage-B62rRc-y.js +1 -0
  14. package/dist/app/assets/WorkbookPage-45cG1Gx6.js +1 -0
  15. package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-DyU47reh.es-DY9b3WjS.js} +1 -1
  16. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  17. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  18. package/dist/app/assets/index-C5ClsnS7.js +1774 -0
  19. package/dist/app/assets/index-DF9tVqwN.js +1 -0
  20. package/dist/app/assets/index-DSoCUF-f.js +2725 -0
  21. package/dist/app/assets/index-DgVRz2wm.js +2 -0
  22. package/dist/app/assets/index-P-pF8nU5.js +632 -0
  23. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  24. package/dist/app/index.html +8 -4
  25. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  26. package/dist/default-publisher.config.json +23 -0
  27. package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
  28. package/dist/package_load_worker.mjs +15219 -0
  29. package/dist/runtime/publisher.js +331 -0
  30. package/dist/{server.js → server.mjs} +141276 -74705
  31. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  32. package/package.json +41 -28
  33. package/publisher.config.example.bigquery.json +33 -0
  34. package/publisher.config.example.duckdb.json +23 -0
  35. package/.eslintrc.json +0 -44
  36. package/.prettierignore +0 -1
  37. package/.prettierrc +0 -4
  38. package/build.ts +0 -38
  39. package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
  40. package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
  41. package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
  42. package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
  43. package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
  44. package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
  45. package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
  46. package/dist/app/assets/index-15BOvhp0.js +0 -456
  47. package/dist/app/assets/index-Bb2jqquW.js +0 -676
  48. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  49. package/dist/app/assets/index-D68X76-7.js +0 -1276
  50. package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
  51. package/dxt/malloy_bridge.py +0 -354
  52. package/dxt/manifest.json +0 -22
  53. package/eslint.config.mjs +0 -76
  54. package/k6-tests/README.md +0 -144
  55. package/k6-tests/bun.lock +0 -797
  56. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  57. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  58. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  59. package/k6-tests/load-test/load-test-crud.ts +0 -142
  60. package/k6-tests/load-test/load-test.ts +0 -784
  61. package/k6-tests/package.json +0 -27
  62. package/k6-tests/scripts/run-with-otel.sh +0 -50
  63. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  64. package/k6-tests/tsconfig.json +0 -29
  65. package/k6-tests/utils/client_factory.ts +0 -128
  66. package/k6-tests/utils/common.ts +0 -489
  67. package/k6-tests/utils/logger.ts +0 -62
  68. package/k6-tests/utils/types.d.ts +0 -35
  69. package/malloy_mcp.dxt +0 -0
  70. package/publisher.config.json +0 -33
  71. package/src/config.spec.ts +0 -850
  72. package/src/config.ts +0 -305
  73. package/src/constants.ts +0 -10
  74. package/src/controller/compile.controller.ts +0 -35
  75. package/src/controller/connection.controller.ts +0 -515
  76. package/src/controller/database.controller.ts +0 -21
  77. package/src/controller/manifest.controller.ts +0 -29
  78. package/src/controller/materialization.controller.ts +0 -125
  79. package/src/controller/model.controller.ts +0 -107
  80. package/src/controller/package.controller.ts +0 -182
  81. package/src/controller/query.controller.ts +0 -60
  82. package/src/controller/watch-mode.controller.ts +0 -98
  83. package/src/data_styles.ts +0 -71
  84. package/src/dto/connection.dto.spec.ts +0 -131
  85. package/src/dto/connection.dto.ts +0 -174
  86. package/src/dto/index.ts +0 -2
  87. package/src/dto/package.dto.spec.ts +0 -42
  88. package/src/dto/package.dto.ts +0 -12
  89. package/src/dto/validate.spec.ts +0 -76
  90. package/src/dto/validate.ts +0 -31
  91. package/src/errors.ts +0 -116
  92. package/src/health.ts +0 -189
  93. package/src/instrumentation.ts +0 -148
  94. package/src/logger.ts +0 -154
  95. package/src/mcp/error_messages.ts +0 -199
  96. package/src/mcp/handler_utils.ts +0 -239
  97. package/src/mcp/mcp_constants.ts +0 -22
  98. package/src/mcp/prompts/handlers.ts +0 -84
  99. package/src/mcp/prompts/index.ts +0 -11
  100. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  101. package/src/mcp/prompts/prompt_service.ts +0 -67
  102. package/src/mcp/prompts/utils.ts +0 -62
  103. package/src/mcp/resource_metadata.ts +0 -47
  104. package/src/mcp/resources/model_resource.ts +0 -153
  105. package/src/mcp/resources/notebook_resource.ts +0 -137
  106. package/src/mcp/resources/package_resource.ts +0 -370
  107. package/src/mcp/resources/project_resource.ts +0 -184
  108. package/src/mcp/resources/query_resource.ts +0 -117
  109. package/src/mcp/resources/source_resource.ts +0 -141
  110. package/src/mcp/resources/view_resource.ts +0 -136
  111. package/src/mcp/server.ts +0 -62
  112. package/src/mcp/tools/discovery_tools.ts +0 -258
  113. package/src/mcp/tools/execute_query_tool.ts +0 -265
  114. package/src/server.ts +0 -1195
  115. package/src/service/connection.spec.ts +0 -1331
  116. package/src/service/connection.ts +0 -1511
  117. package/src/service/connection_service.spec.ts +0 -641
  118. package/src/service/connection_service.ts +0 -181
  119. package/src/service/db_utils.spec.ts +0 -714
  120. package/src/service/db_utils.ts +0 -1227
  121. package/src/service/filter.spec.ts +0 -447
  122. package/src/service/filter.ts +0 -337
  123. package/src/service/filter_integration.spec.ts +0 -825
  124. package/src/service/gcs_s3_utils.ts +0 -427
  125. package/src/service/manifest_service.spec.ts +0 -201
  126. package/src/service/manifest_service.ts +0 -106
  127. package/src/service/materialization_service.spec.ts +0 -648
  128. package/src/service/materialization_service.ts +0 -929
  129. package/src/service/materialized_table_gc.spec.ts +0 -383
  130. package/src/service/materialized_table_gc.ts +0 -279
  131. package/src/service/model.spec.ts +0 -257
  132. package/src/service/model.ts +0 -1069
  133. package/src/service/package.spec.ts +0 -236
  134. package/src/service/package.ts +0 -463
  135. package/src/service/project.ts +0 -681
  136. package/src/service/project_compile.spec.ts +0 -197
  137. package/src/service/project_store.spec.ts +0 -859
  138. package/src/service/project_store.ts +0 -1531
  139. package/src/service/quoting.ts +0 -41
  140. package/src/service/resolve_project.ts +0 -13
  141. package/src/storage/BaseRepository.ts +0 -31
  142. package/src/storage/DatabaseInterface.ts +0 -180
  143. package/src/storage/StorageManager.mock.ts +0 -59
  144. package/src/storage/StorageManager.ts +0 -210
  145. package/src/storage/duckdb/ConnectionRepository.ts +0 -141
  146. package/src/storage/duckdb/DuckDBConnection.ts +0 -177
  147. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  148. package/src/storage/duckdb/DuckDBRepository.ts +0 -224
  149. package/src/storage/duckdb/ManifestRepository.ts +0 -119
  150. package/src/storage/duckdb/MaterializationRepository.ts +0 -249
  151. package/src/storage/duckdb/PackageRepository.ts +0 -135
  152. package/src/storage/duckdb/ProjectRepository.ts +0 -148
  153. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  154. package/src/storage/duckdb/schema.ts +0 -147
  155. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
  156. package/src/utils.ts +0 -13
  157. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  158. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  159. package/tests/fixtures/persist-test/publisher.json +0 -5
  160. package/tests/fixtures/publisher.config.json +0 -15
  161. package/tests/harness/e2e.ts +0 -96
  162. package/tests/harness/mcp_test_setup.ts +0 -344
  163. package/tests/harness/mocks.ts +0 -37
  164. package/tests/harness/rest_e2e.ts +0 -68
  165. package/tests/harness/uris.ts +0 -31
  166. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
  167. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
  168. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
  169. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
  170. package/tests/integration/mcp/setup.spec.ts +0 -5
  171. package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
  172. package/tests/unit/ducklake/ducklake.test.ts +0 -950
  173. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  174. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
  175. package/tsconfig.json +0 -32
@@ -1,1531 +0,0 @@
1
- import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
2
- import { Storage } from "@google-cloud/storage";
3
- import AdmZip from "adm-zip";
4
- import { Mutex } from "async-mutex";
5
- import crypto from "crypto";
6
- import * as fs from "fs";
7
- import * as path from "path";
8
- import simpleGit from "simple-git";
9
- import { Writable } from "stream";
10
- import { components } from "../api";
11
- import {
12
- getProcessedPublisherConfig,
13
- isPublisherConfigFrozen,
14
- ProcessedProject,
15
- ProcessedPublisherConfig,
16
- } from "../config";
17
- import {
18
- API_PREFIX,
19
- PUBLISHER_CONFIG_NAME,
20
- PUBLISHER_DATA_DIR,
21
- } from "../constants";
22
- import {
23
- BadRequestError,
24
- FrozenConfigError,
25
- PackageNotFoundError,
26
- ProjectNotFoundError,
27
- } from "../errors";
28
- import { getOperationalState, markNotReady, markReady } from "../health";
29
- import { formatDuration, logger } from "../logger";
30
- import { Connection } from "../storage/DatabaseInterface";
31
- import { StorageConfig, StorageManager } from "../storage/StorageManager";
32
- import { PackageStatus, Project } from "./project";
33
- type ApiProject = components["schemas"]["Project"];
34
-
35
- const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
36
- const AZURE_DATA_EXTENSIONS = [
37
- ".parquet",
38
- ".csv",
39
- ".json",
40
- ".jsonl",
41
- ".ndjson",
42
- ];
43
-
44
- function validateAzureUrl(url: string, fieldName: string): void {
45
- if (!AZURE_SUPPORTED_SCHEMES.some((s) => url.startsWith(s))) {
46
- throw new BadRequestError(
47
- `Azure ${fieldName} must use one of: ${AZURE_SUPPORTED_SCHEMES.join(", ")}`,
48
- );
49
- }
50
- const pathWithoutQuery = url.split("?")[0];
51
- const stars = (pathWithoutQuery.match(/\*/g) || []).length;
52
-
53
- if (stars === 0) {
54
- const lower = pathWithoutQuery.toLowerCase();
55
- if (!AZURE_DATA_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
56
- throw new BadRequestError(
57
- `Azure ${fieldName}: a single-file URL must end with a data file extension (${AZURE_DATA_EXTENSIONS.join(", ")})`,
58
- );
59
- }
60
- } else if (pathWithoutQuery.endsWith("**")) {
61
- // recursive — valid
62
- // includes all data files in the container and all subdirectories
63
- } else {
64
- const lastSegment = pathWithoutQuery.split("/").pop() || "";
65
- if (stars !== 1 || !lastSegment.startsWith("*")) {
66
- throw new BadRequestError(
67
- `Azure ${fieldName}: only three URL patterns are supported:\n` +
68
- ` • Single file: path/file.parquet\n` +
69
- ` • Directory glob: path/*.ext (direct children only)\n` +
70
- ` • Recursive: path/** (includes all data files in the container and all subdirectories)\n` +
71
- `Multi-level globs such as "sub_dir/*/*.parquet" are not supported.`,
72
- );
73
- }
74
- }
75
- }
76
-
77
- function validateProjectAzureUrls(project: ApiProject): void {
78
- for (const conn of project.connections || []) {
79
- if (conn.type !== "duckdb") continue;
80
- for (const db of conn.duckdbConnection?.attachedDatabases || []) {
81
- if (db.type !== "azure" || !db.azureConnection) continue;
82
- const { authType, sasUrl, fileUrl } = db.azureConnection;
83
- if (authType === "sas_token" && sasUrl) {
84
- validateAzureUrl(sasUrl, `"${db.name}" sasUrl`);
85
- } else if (authType === "service_principal" && fileUrl) {
86
- validateAzureUrl(fileUrl, `"${db.name}" fileUrl`);
87
- }
88
- }
89
- }
90
- }
91
-
92
- export class ProjectStore {
93
- public serverRootPath: string;
94
- private projects: Map<string, Project> = new Map();
95
- private projectMutexes = new Map<string, Mutex>();
96
- public publisherConfigIsFrozen: boolean;
97
- public finishedInitialization: Promise<void>;
98
- private isInitialized: boolean = false;
99
- public storageManager: StorageManager;
100
- private s3Client = new S3({
101
- followRegionRedirects: true,
102
- });
103
- private gcsClient: Storage;
104
-
105
- constructor(serverRootPath: string) {
106
- this.serverRootPath = serverRootPath;
107
- this.gcsClient = new Storage();
108
-
109
- const storageConfig: StorageConfig = {
110
- type: "duckdb",
111
- duckdb: {
112
- path: path.join(serverRootPath, "publisher.db"),
113
- },
114
- };
115
- this.storageManager = new StorageManager(storageConfig);
116
-
117
- this.finishedInitialization = this.initialize();
118
- }
119
-
120
- private async initialize() {
121
- const reInit = process.env.INITIALIZE_STORAGE === "true";
122
- const initialTime = performance.now();
123
-
124
- try {
125
- await this.storageManager.initialize(reInit);
126
-
127
- this.publisherConfigIsFrozen = isPublisherConfigFrozen(
128
- this.serverRootPath,
129
- );
130
-
131
- const projectManifest = await ProjectStore.reloadProjectManifest(
132
- this.serverRootPath,
133
- );
134
-
135
- await this.cleanupAndCreatePublisherPath();
136
-
137
- const repository = this.storageManager.getRepository();
138
-
139
- if (reInit) {
140
- // Load projects from config file
141
- await Promise.all(
142
- projectManifest.projects.map(async (project) => {
143
- await this.addProject(
144
- {
145
- name: project.name,
146
- resource: `${API_PREFIX}/projects/${project.name}`,
147
- connections: project.connections,
148
- packages: project.packages,
149
- },
150
- true,
151
- );
152
- }),
153
- );
154
- } else {
155
- // Load existing projects from database
156
- const existingProjects = await repository.listProjects();
157
-
158
- if (existingProjects.length > 0) {
159
- // Load projects from database
160
- await Promise.all(
161
- existingProjects.map(async (dbProject) => {
162
- // Check if project files exist on disk
163
- const projectExists = await fs.promises
164
- .access(dbProject.path)
165
- .then(() => true)
166
- .catch(() => false);
167
-
168
- if (!projectExists) {
169
- // Try to find in config and reload
170
- const projectConfig = projectManifest.projects.find(
171
- (p) => p.name === dbProject.name,
172
- );
173
-
174
- if (projectConfig) {
175
- const projectInstance = await this.addProject(
176
- {
177
- name: projectConfig.name,
178
- resource: `${API_PREFIX}/projects/${projectConfig.name}`,
179
- connections: projectConfig.connections,
180
- packages: projectConfig.packages,
181
- },
182
- true,
183
- );
184
-
185
- // Update database with new path
186
- await repository.updateProject(dbProject.id, {
187
- path: projectInstance.metadata.location,
188
- });
189
-
190
- return projectInstance.listPackages();
191
- } else {
192
- logger.error(
193
- `Project "${dbProject.name}" not found in config and files missing`,
194
- );
195
- return;
196
- }
197
- }
198
-
199
- // Get connections from database
200
- const connections = await repository.listConnections(
201
- dbProject.id,
202
- );
203
-
204
- const projectInstance = await Project.create(
205
- dbProject.name,
206
- dbProject.path,
207
- connections.map((conn) => ({
208
- name: conn.name,
209
- type: conn.type,
210
- resource: `${API_PREFIX}/connections/${conn.name}`,
211
- ...conn.config,
212
- })),
213
- );
214
-
215
- this.projects.set(dbProject.name, projectInstance);
216
-
217
- // Get packages from database
218
- const packages = await repository.listPackages(
219
- dbProject.id,
220
- );
221
- packages.forEach((pkg) => {
222
- projectInstance.setPackageStatus(
223
- pkg.name,
224
- PackageStatus.SERVING,
225
- );
226
- });
227
-
228
- return projectInstance.listPackages();
229
- }),
230
- );
231
- } else {
232
- // Fallback to config file if database is empty
233
- await Promise.all(
234
- projectManifest.projects.map(async (project) => {
235
- await this.addProject(
236
- {
237
- name: project.name,
238
- resource: `${API_PREFIX}/projects/${project.name}`,
239
- connections: project.connections,
240
- packages: project.packages,
241
- },
242
- true,
243
- );
244
- }),
245
- );
246
- }
247
- }
248
-
249
- this.isInitialized = true;
250
- markReady();
251
- const initializationDuration = performance.now() - initialTime;
252
- logger.info(
253
- `Project store successfully initialized in ${formatDuration(initializationDuration)}`,
254
- );
255
- } catch (error) {
256
- markNotReady();
257
- const errorData = this.extractErrorDataFromError(error);
258
- logger.error("Error initializing project store", errorData);
259
- process.exit(1);
260
- }
261
- }
262
-
263
- public async addProjectToDatabase(project: Project): Promise<void> {
264
- if (!project) {
265
- logger.error("Cannot sync: project is null or undefined");
266
- return;
267
- }
268
-
269
- const projectName = project.metadata?.name;
270
- if (!projectName) {
271
- throw new Error("Project name is required but not found");
272
- }
273
-
274
- const repository = this.storageManager.getRepository();
275
-
276
- // Sync project metadata
277
- const dbProject = await this.addProjectMetadata(project, repository);
278
-
279
- // Sync connections
280
- await this.addConnections(project, dbProject.id, repository);
281
-
282
- // Sync packages
283
- await this.addPackages(project, dbProject.id, repository);
284
-
285
- logger.info(`Synced project "${projectName}" to database`);
286
- }
287
-
288
- public async deleteProjectFromDatabase(projectName: string): Promise<void> {
289
- const repository = this.storageManager.getRepository();
290
-
291
- // Get the project from database
292
- const dbProject = await repository.getProjectByName(projectName);
293
-
294
- if (!dbProject) {
295
- logger.error(`Project "${projectName}" not found in database`);
296
- return;
297
- }
298
-
299
- // Delete the project (this will cascade delete connections and packages)
300
- await repository.deleteProject(dbProject.id);
301
- logger.info(`Deleted project "${projectName}" from database`);
302
- }
303
-
304
- private async addProjectMetadata(
305
- project: Project,
306
- repository: ReturnType<typeof this.storageManager.getRepository>,
307
- ): Promise<{ id: string; name: string }> {
308
- const projectName = project.metadata?.name;
309
- if (!projectName) {
310
- throw new Error("Project name is required but not found");
311
- }
312
- const projectPath = project.metadata?.location || "";
313
- const projectDescription = project.metadata?.readme;
314
-
315
- const projectData = {
316
- name: projectName,
317
- path: projectPath,
318
- description: projectDescription,
319
- metadata: project.metadata || {},
320
- };
321
- const existingProject = await repository.getProjectByName(projectName);
322
-
323
- let dbProject: { id: string; name: string };
324
- if (existingProject) {
325
- const updateData = {
326
- description: projectDescription,
327
- metadata: project.metadata || {},
328
- };
329
-
330
- await repository.updateProject(existingProject.id, updateData);
331
- dbProject = { id: existingProject.id, name: projectName };
332
- } else {
333
- dbProject = await repository.createProject(projectData);
334
- }
335
-
336
- // Initialize DuckLake manifest storage if configured on the project.
337
- const materializationStorage = project.metadata
338
- ?.materializationStorage as
339
- | { catalogUrl?: string; dataPath?: string }
340
- | undefined;
341
- if (
342
- materializationStorage?.catalogUrl &&
343
- materializationStorage?.dataPath
344
- ) {
345
- await this.storageManager.initializeDuckLakeForProject(dbProject.id, {
346
- catalogUrl: materializationStorage.catalogUrl,
347
- dataPath: materializationStorage.dataPath,
348
- });
349
- }
350
-
351
- return dbProject;
352
- }
353
-
354
- private async addPackages(
355
- project: Project,
356
- projectId: string,
357
- repository: ReturnType<typeof this.storageManager.getRepository>,
358
- ): Promise<void> {
359
- const packages = await project.listPackages();
360
-
361
- // Sync each package
362
- for (const pkg of packages) {
363
- if (!pkg.name) {
364
- logger.warn("Skipping package with undefined name");
365
- continue;
366
- }
367
-
368
- await this.addPackage(pkg, projectId, repository);
369
- }
370
- }
371
-
372
- private async addPackage(
373
- pkg: components["schemas"]["Package"],
374
- projectId: string,
375
- repository: ReturnType<typeof this.storageManager.getRepository>,
376
- ): Promise<void> {
377
- const pkgs = pkg as {
378
- name: string;
379
- description?: string;
380
- manifestPath?: string;
381
- metadata?: Record<string, unknown>;
382
- };
383
-
384
- const packageData = {
385
- projectId,
386
- name: pkgs.name,
387
- description: pkgs.description ?? undefined,
388
- manifestPath: pkgs.manifestPath ?? "",
389
- metadata: pkgs.metadata ?? {},
390
- };
391
-
392
- try {
393
- await repository.createPackage(packageData);
394
- logger.info(`Synced package: ${pkg.name}`);
395
- } catch (err: unknown) {
396
- const error = err as Error;
397
- if (
398
- error.message?.includes("UNIQUE") ||
399
- error.message?.includes("Constraint")
400
- ) {
401
- await this.updatePackage(
402
- pkgs.name,
403
- projectId,
404
- packageData,
405
- repository,
406
- );
407
- } else {
408
- logger.warn(`Failed to sync package ${pkg.name}:`, error.message);
409
- }
410
- }
411
- }
412
-
413
- private async updatePackage(
414
- packageName: string,
415
- projectId: string,
416
- packageData: {
417
- description: string | undefined;
418
- manifestPath: string;
419
- metadata: Record<string, unknown>;
420
- },
421
- repository: ReturnType<typeof this.storageManager.getRepository>,
422
- ): Promise<void> {
423
- const existingPackage = await repository.getPackageByName(
424
- projectId,
425
- packageName,
426
- );
427
-
428
- if (existingPackage) {
429
- await repository.updatePackage(existingPackage.id, packageData);
430
- logger.info(`Updated existing package: ${packageName}`);
431
- }
432
- }
433
-
434
- private async addConnections(
435
- project: Project,
436
- projectId: string,
437
- repository: ReturnType<typeof this.storageManager.getRepository>,
438
- ): Promise<void> {
439
- try {
440
- const connections = project.listApiConnections();
441
- // Add/update connections
442
- for (const conn of connections) {
443
- if (!conn.name) {
444
- logger.warn("Skipping connection with undefined name");
445
- continue;
446
- }
447
-
448
- // Check if connection exists
449
- const existingConn = await repository.getConnectionByName(
450
- projectId,
451
- conn.name,
452
- );
453
-
454
- if (existingConn) {
455
- await this.updateConnection(conn, projectId, repository);
456
- } else {
457
- await this.addConnection(conn, projectId, repository);
458
- }
459
- }
460
- } catch (err: unknown) {
461
- const error = err as Error;
462
- const projectName = project.metadata?.name;
463
- logger.error(`Error syncing connections for "${projectName}":`, error);
464
- }
465
- }
466
-
467
- public async addConnection(
468
- conn: ReturnType<Project["listApiConnections"]>[number],
469
- projectId: string,
470
- repository: ReturnType<typeof this.storageManager.getRepository>,
471
- ): Promise<void> {
472
- if (!conn.name) {
473
- logger.warn("Skipping connection with undefined name");
474
- return;
475
- }
476
-
477
- const connectionData = {
478
- projectId,
479
- name: conn.name,
480
- type: conn.type as Connection["type"],
481
- config: conn,
482
- };
483
-
484
- try {
485
- await repository.createConnection(connectionData);
486
- logger.info(`Created connection: ${conn.name}`);
487
- } catch (err: unknown) {
488
- const error = err as Error;
489
- logger.error(`Failed to create connection ${conn.name}:`, error);
490
- throw error;
491
- }
492
- }
493
-
494
- public async updateConnection(
495
- conn: ReturnType<Project["listApiConnections"]>[number],
496
- projectId: string,
497
- repository: ReturnType<typeof this.storageManager.getRepository>,
498
- ): Promise<void> {
499
- if (!conn.name) {
500
- throw new Error("Connection name is required for update");
501
- }
502
-
503
- const existingConn = await repository.getConnectionByName(
504
- projectId,
505
- conn.name,
506
- );
507
-
508
- if (!existingConn) {
509
- logger.error(`Connection "${conn.name}" not found in project`);
510
- }
511
-
512
- const connectionData = {
513
- type: conn.type as Connection["type"],
514
- config: conn,
515
- };
516
-
517
- try {
518
- if (existingConn) {
519
- await repository.updateConnection(existingConn.id, connectionData);
520
- }
521
- logger.info(`Updated connection: ${conn.name}`);
522
- } catch (err: unknown) {
523
- const error = err as Error;
524
- logger.error(`Failed to update connection ${conn.name}:`, error);
525
- throw error;
526
- }
527
- }
528
-
529
- public async addPackageToDatabase(
530
- projectName: string,
531
- packageName: string,
532
- ): Promise<void> {
533
- const project = await this.getProject(projectName, false);
534
- const repository = this.storageManager.getRepository();
535
-
536
- // Get the project ID from database
537
- const dbProject = await repository.getProjectByName(projectName);
538
-
539
- if (!dbProject) {
540
- logger.error(`Project "${projectName}" not found in database`);
541
- throw new Error(`Project "${projectName}" not found in database`);
542
- }
543
-
544
- // Get the package from the project
545
- const packages = await project.listPackages();
546
- const pkg = packages.find((p) => p.name === packageName);
547
-
548
- if (!pkg) {
549
- logger.warn(`Package "${packageName}" not found in project`);
550
- return;
551
- }
552
-
553
- // Sync the specific package
554
- await this.addPackage(pkg, dbProject.id, repository);
555
- logger.info(`Synced package "${packageName}" to database`);
556
- }
557
-
558
- /**
559
- * Delete a package from the database
560
- */
561
- public async deletePackageFromDatabase(
562
- projectName: string,
563
- packageName: string,
564
- ): Promise<void> {
565
- const repository = this.storageManager.getRepository();
566
-
567
- // Get the project ID from database
568
- const dbProject = await repository.getProjectByName(projectName);
569
-
570
- if (!dbProject) {
571
- logger.error(`Project "${projectName}" not found in database`);
572
- return;
573
- }
574
-
575
- // Find and delete the package
576
- const existingPackage = await repository.getPackageByName(
577
- dbProject.id,
578
- packageName,
579
- );
580
-
581
- if (existingPackage) {
582
- await repository.deletePackage(existingPackage.id);
583
- logger.info(`Deleted package "${packageName}" from database`);
584
- }
585
- }
586
-
587
- private async cleanupAndCreatePublisherPath() {
588
- const reInit = process.env.INITIALIZE_STORAGE === "true";
589
-
590
- // Ensure serverRootPath exists as a directory (mkdir recursive is a
591
- // no-op when the directory already exists and avoids a Bun-on-Windows
592
- // bug where fs.promises.stat resolves to undefined instead of throwing)
593
- await fs.promises.mkdir(this.serverRootPath, { recursive: true });
594
-
595
- if (reInit) {
596
- const uploadDocsPath = path.join(
597
- this.serverRootPath,
598
- PUBLISHER_DATA_DIR,
599
- );
600
- logger.info(
601
- `Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath}`,
602
- );
603
- try {
604
- await fs.promises.rm(uploadDocsPath, {
605
- recursive: true,
606
- force: true,
607
- });
608
- } catch (error) {
609
- if ((error as NodeJS.ErrnoException).code === "EACCES") {
610
- logger.warn(
611
- `Permission denied, skipping cleanup of upload documents path ${uploadDocsPath}`,
612
- );
613
- } else if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
614
- // Ignore if directory doesn't exist
615
- throw error;
616
- }
617
- }
618
- } else {
619
- logger.info(`Using existing publisher path`);
620
- }
621
-
622
- const uploadDocsPath = path.join(this.serverRootPath, PUBLISHER_DATA_DIR);
623
- await fs.promises.mkdir(uploadDocsPath, { recursive: true });
624
- }
625
-
626
- public async listProjects(skipInitializationCheck: boolean = false) {
627
- if (!skipInitializationCheck) {
628
- await this.finishedInitialization;
629
- }
630
- return Promise.all(
631
- Array.from(this.projects.values()).map((project) =>
632
- project.serialize(),
633
- ),
634
- );
635
- }
636
-
637
- public async getStatus() {
638
- const status = {
639
- timestamp: Date.now(),
640
- projects: [] as Array<components["schemas"]["Project"]>,
641
- initialized: this.isInitialized,
642
- frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
643
- operationalState:
644
- getOperationalState() as components["schemas"]["ServerStatus"]["operationalState"],
645
- };
646
-
647
- const projects = await this.listProjects(true);
648
-
649
- await Promise.all(
650
- projects.map(async (project) => {
651
- try {
652
- const packages = project.packages;
653
- const connections = project.connections;
654
-
655
- logger.debug(`Project ${project.name} status:`, {
656
- connectionsCount: project.connections?.length || 0,
657
- packagesCount: packages?.length || 0,
658
- });
659
-
660
- const _connections = connections?.map((connection) => {
661
- return {
662
- ...connection,
663
- attributes: undefined,
664
- };
665
- });
666
-
667
- const _project = {
668
- ...project,
669
- connections: _connections,
670
- };
671
- project.connections = _connections;
672
- status.projects.push(_project);
673
- } catch (error) {
674
- logger.error("Error listing packages and connections", {
675
- error,
676
- });
677
- throw new Error(
678
- "Error listing packages and connections: " + error,
679
- );
680
- }
681
- }),
682
- );
683
- return status;
684
- }
685
-
686
- public async getProject(
687
- projectName: string,
688
- reload: boolean = false,
689
- ): Promise<Project> {
690
- await this.finishedInitialization;
691
-
692
- // Check if project is already loaded first
693
- const project = this.projects.get(projectName);
694
- if (project !== undefined && !reload) {
695
- return project;
696
- }
697
-
698
- // We need to acquire the mutex to prevent concurrent requests from creating the
699
- // project multiple times.
700
- let projectMutex = this.projectMutexes.get(projectName);
701
- if (projectMutex?.isLocked()) {
702
- await projectMutex.waitForUnlock();
703
- const existingProject = this.projects.get(projectName);
704
- if (existingProject && !reload) {
705
- return existingProject;
706
- }
707
- }
708
- projectMutex = new Mutex();
709
- this.projectMutexes.set(projectName, projectMutex);
710
-
711
- return projectMutex.runExclusive(async () => {
712
- // Double-check after acquiring mutex
713
- const existingProject = this.projects.get(projectName);
714
- if (existingProject !== undefined && !reload) {
715
- return existingProject;
716
- }
717
-
718
- const projectManifest = await ProjectStore.reloadProjectManifest(
719
- this.serverRootPath,
720
- );
721
- const projectConfig = projectManifest.projects.find(
722
- (p) => p.name === projectName,
723
- );
724
- const projectPath =
725
- existingProject?.metadata.location ||
726
- projectConfig?.packages[0]?.location;
727
- if (!projectPath) {
728
- throw new ProjectNotFoundError(
729
- `Project "${projectName}" could not be resolved to a path.`,
730
- );
731
- }
732
- return await this.addProject({
733
- name: projectName,
734
- resource: `${API_PREFIX}/projects/${projectName}`,
735
- connections: projectConfig?.connections || [],
736
- });
737
- });
738
- }
739
-
740
- public async addProject(
741
- project: ApiProject,
742
- skipInitialization: boolean = false,
743
- ) {
744
- if (!skipInitialization) {
745
- await this.finishedInitialization;
746
- }
747
- if (!skipInitialization && this.publisherConfigIsFrozen) {
748
- throw new FrozenConfigError();
749
- }
750
- const projectName = project.name;
751
- if (!projectName) {
752
- throw new Error("Project name is required");
753
- }
754
- // Check if project already exists and update it instead of creating a new one
755
- const existingProject = this.projects.get(projectName);
756
- if (existingProject) {
757
- const updatedProject = await existingProject.update(project);
758
- this.projects.set(projectName, updatedProject);
759
- await this.addProjectToDatabase(updatedProject);
760
- return updatedProject;
761
- }
762
- const projectManifest = await ProjectStore.reloadProjectManifest(
763
- this.serverRootPath,
764
- );
765
- const projectConfig = projectManifest.projects.find(
766
- (p) => p.name === projectName,
767
- );
768
- const hasPackages =
769
- (project?.packages && project.packages.length > 0) ||
770
- (projectConfig?.packages && projectConfig.packages.length > 0);
771
- let absoluteProjectPath: string;
772
- if (hasPackages) {
773
- const packagesToProcess =
774
- project?.packages || projectConfig?.packages || [];
775
- absoluteProjectPath = await this.loadProjectIntoDisk(
776
- projectName,
777
- packagesToProcess,
778
- );
779
- if (absoluteProjectPath.endsWith(".zip")) {
780
- absoluteProjectPath = await this.unzipProject(absoluteProjectPath);
781
- }
782
- } else {
783
- absoluteProjectPath = await this.scaffoldProject(project);
784
- }
785
- const newProject = await Project.create(
786
- projectName,
787
- absoluteProjectPath,
788
- project.connections || [],
789
- );
790
-
791
- if (!newProject.metadata) newProject.metadata = {};
792
- newProject.metadata.location = absoluteProjectPath;
793
-
794
- this.projects.set(projectName, newProject);
795
-
796
- project?.packages?.forEach((_package) => {
797
- if (_package.name) {
798
- newProject.setPackageStatus(_package.name, PackageStatus.SERVING);
799
- }
800
- });
801
-
802
- await this.addProjectToDatabase(newProject);
803
-
804
- return newProject;
805
- }
806
-
807
- public async unzipProject(absoluteProjectPath: string) {
808
- logger.info(
809
- `Detected zip file at "${absoluteProjectPath}". Unzipping...`,
810
- );
811
- const unzippedProjectPath = absoluteProjectPath.replace(".zip", "");
812
- await fs.promises.rm(unzippedProjectPath, {
813
- recursive: true,
814
- force: true,
815
- });
816
- await fs.promises.mkdir(unzippedProjectPath, { recursive: true });
817
-
818
- const zip = new AdmZip(absoluteProjectPath);
819
- zip.extractAllTo(unzippedProjectPath, true);
820
-
821
- return unzippedProjectPath;
822
- }
823
-
824
- public async updateProject(project: ApiProject) {
825
- await this.finishedInitialization;
826
- if (this.publisherConfigIsFrozen) {
827
- throw new FrozenConfigError();
828
- }
829
- validateProjectAzureUrls(project);
830
- const projectName = project.name;
831
- if (!projectName) {
832
- throw new Error("Project name is required");
833
- }
834
- const existingProject = this.projects.get(projectName);
835
- if (!existingProject) {
836
- throw new ProjectNotFoundError(`Project ${projectName} not found`);
837
- }
838
- const updatedProject = await existingProject.update(project);
839
- this.projects.set(projectName, updatedProject);
840
- await this.addProjectToDatabase(updatedProject);
841
- return updatedProject;
842
- }
843
-
844
- public async deleteProject(
845
- projectName: string,
846
- ): Promise<Project | undefined> {
847
- await this.finishedInitialization;
848
- if (this.publisherConfigIsFrozen) {
849
- throw new FrozenConfigError();
850
- }
851
- const project = this.projects.get(projectName);
852
- if (!project) {
853
- return;
854
- }
855
-
856
- const projectPath = project.metadata?.location;
857
-
858
- // Close all connections before removing the project
859
- project.closeAllConnections();
860
-
861
- this.projects.delete(projectName);
862
- await this.deleteProjectFromDatabase(projectName);
863
- if (projectPath) {
864
- try {
865
- await fs.promises.rm(projectPath, { recursive: true, force: true });
866
- logger.info(`Deleted project directory: ${projectPath}`);
867
- } catch (err) {
868
- logger.error("Error removing project directory", { error: err });
869
- }
870
- }
871
-
872
- return project;
873
- }
874
-
875
- public static async reloadProjectManifest(
876
- serverRootPath: string,
877
- ): Promise<ProcessedPublisherConfig> {
878
- try {
879
- return getProcessedPublisherConfig(serverRootPath);
880
- } catch (error) {
881
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
882
- logger.error(
883
- `Error reading ${PUBLISHER_CONFIG_NAME}. Generating from directory`,
884
- { error },
885
- );
886
- return { frozenConfig: false, projects: [] };
887
- } else {
888
- // If publisher.config.json is missing, generate the manifest from directories
889
- try {
890
- const entries = await fs.promises.readdir(serverRootPath, {
891
- withFileTypes: true,
892
- });
893
- const projects: ProcessedProject[] = [];
894
- for (const entry of entries) {
895
- if (entry.isDirectory()) {
896
- projects.push({
897
- name: entry.name,
898
- packages: [
899
- {
900
- name: entry.name,
901
- location: `./${entry.name}` as const,
902
- },
903
- ],
904
- connections: [],
905
- });
906
- }
907
- }
908
- return { frozenConfig: false, projects };
909
- } catch (lsError) {
910
- logger.error(`Error listing directories in ${serverRootPath}`, {
911
- error: lsError,
912
- });
913
- return { frozenConfig: false, projects: [] };
914
- }
915
- }
916
- }
917
- }
918
-
919
- private async scaffoldProject(project: ApiProject) {
920
- const projectName = project.name;
921
- if (!projectName) {
922
- throw new Error("Project name is required");
923
- }
924
- const absoluteProjectPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${projectName}`;
925
- await fs.promises.mkdir(absoluteProjectPath, { recursive: true });
926
- if (project.readme) {
927
- await fs.promises.writeFile(
928
- path.join(absoluteProjectPath, "README.md"),
929
- project.readme,
930
- );
931
- }
932
- return absoluteProjectPath;
933
- }
934
-
935
- private isLocalPath(location: string) {
936
- return (
937
- location.startsWith("./") ||
938
- location.startsWith("../") ||
939
- location.startsWith("~/") ||
940
- location.startsWith("/") ||
941
- path.isAbsolute(location)
942
- );
943
- }
944
-
945
- private isGitHubURL(location: string) {
946
- return (
947
- location.startsWith("https://github.com/") ||
948
- location.startsWith("git@github.com:")
949
- );
950
- }
951
-
952
- private isGCSURL(location: string) {
953
- return location.startsWith("gs://");
954
- }
955
-
956
- private isS3URL(location: string) {
957
- return location.startsWith("s3://");
958
- }
959
-
960
- private async loadProjectIntoDisk(
961
- projectName: string,
962
- packages: ApiProject["packages"],
963
- ) {
964
- const absoluteTargetPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${projectName}`;
965
-
966
- await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
967
-
968
- if (!packages || packages.length === 0) {
969
- throw new PackageNotFoundError(
970
- `No packages found for project ${projectName}`,
971
- );
972
- }
973
-
974
- // Group packages by location to optimize downloads
975
- const locationGroups = new Map<
976
- string,
977
- Array<{ name: string; location: string }>
978
- >();
979
-
980
- for (const _package of packages) {
981
- if (!_package.name) {
982
- throw new PackageNotFoundError(`Package has no name specified`);
983
- }
984
-
985
- if (!_package.location) {
986
- throw new PackageNotFoundError(
987
- `Package ${_package.name} has no location specified`,
988
- );
989
- }
990
-
991
- // For GitHub URLs, group by base repository URL to optimize downloads
992
- let locationKey = _package.location;
993
- if (this.isGitHubURL(_package.location)) {
994
- const githubInfo = this.parseGitHubUrl(_package.location);
995
- if (githubInfo) {
996
- // Always use HTTPS format for grouping to ensure consistency
997
- locationKey = `https://github.com/${githubInfo.owner}/${githubInfo.repoName}`;
998
- }
999
- }
1000
-
1001
- if (!locationGroups.has(locationKey)) {
1002
- locationGroups.set(locationKey, []);
1003
- }
1004
- locationGroups.get(locationKey)!.push({
1005
- name: _package.name,
1006
- location: _package.location,
1007
- });
1008
- }
1009
-
1010
- // Processing by each unique location
1011
- for (const [groupedLocation, packagesForLocation] of locationGroups) {
1012
- // Use a hash instead of base64 to keep paths short (Windows MAX_PATH limit)
1013
- // This works for both local and remote paths
1014
- const locationHash = crypto
1015
- .createHash("sha256")
1016
- .update(groupedLocation)
1017
- .digest("hex")
1018
- .substring(0, 16); // Use first 16 chars for shorter paths
1019
- const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
1020
- await fs.promises.mkdir(tempDownloadPath, { recursive: true });
1021
- logger.info(`Created temporary directory: ${tempDownloadPath}`);
1022
- try {
1023
- // Use the existing download method for all locations
1024
- await this.downloadOrMountLocation(
1025
- groupedLocation,
1026
- tempDownloadPath,
1027
- projectName,
1028
- "shared",
1029
- );
1030
- // Extract each package from the downloaded content
1031
- for (const _package of packagesForLocation) {
1032
- const packageDir = _package.name;
1033
- const absolutePackagePath = `${absoluteTargetPath}/${packageDir}`;
1034
- // For GitHub URLs, extract the subdirectory path from the original location
1035
- let sourcePath: string;
1036
- if (this.isGitHubURL(_package.location)) {
1037
- const githubInfo = this.parseGitHubUrl(_package.location);
1038
- if (githubInfo && githubInfo.packagePath) {
1039
- // Extract subdirectory from the original GitHub URL
1040
- // Handle both /tree/main/subdir and /tree/branch/subdir cases
1041
- const subPathMatch =
1042
- _package.location.match(/\/tree\/[^/]+\/(.+)$/);
1043
- if (subPathMatch) {
1044
- sourcePath = path.join(
1045
- tempDownloadPath,
1046
- subPathMatch[1],
1047
- );
1048
- } else {
1049
- // If no subdirectory after /tree/branch, the repo itself is the package
1050
- sourcePath = tempDownloadPath;
1051
- }
1052
- } else {
1053
- // No packagePath means the repo itself is the package
1054
- sourcePath = tempDownloadPath;
1055
- }
1056
- } else {
1057
- // For non-GitHub locations, use package name
1058
- if (this.isLocalPath(_package.location)) {
1059
- sourcePath = _package.location;
1060
- } else {
1061
- sourcePath = path.join(tempDownloadPath, groupedLocation);
1062
- }
1063
- }
1064
-
1065
- const sourceExists = await fs.promises
1066
- .access(sourcePath)
1067
- .then(() => true)
1068
- .catch(() => false);
1069
-
1070
- if (sourceExists) {
1071
- // Copy the specific directory
1072
- await fs.promises.mkdir(absolutePackagePath, {
1073
- recursive: true,
1074
- });
1075
- await fs.promises.cp(sourcePath, absolutePackagePath, {
1076
- recursive: true,
1077
- });
1078
- logger.info(
1079
- `Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"}`,
1080
- );
1081
- } else {
1082
- // If source doesn't exist, copy the entire download as the package
1083
- await fs.promises.mkdir(absolutePackagePath, {
1084
- recursive: true,
1085
- });
1086
- await fs.promises.cp(tempDownloadPath, absolutePackagePath, {
1087
- recursive: true,
1088
- });
1089
- logger.info(
1090
- `Copied entire download as package "${packageDir}"`,
1091
- );
1092
- }
1093
- }
1094
- } catch (error) {
1095
- const errorData = this.extractErrorDataFromError(error);
1096
- logger.error(
1097
- `Failed to download or mount location "${groupedLocation}"`,
1098
- errorData,
1099
- );
1100
- throw new PackageNotFoundError(
1101
- `Failed to download or mount location: ${groupedLocation}`,
1102
- );
1103
- }
1104
- try {
1105
- // Clean up temporary download directory
1106
- await fs.promises.rm(tempDownloadPath, {
1107
- recursive: true,
1108
- force: true,
1109
- });
1110
- } catch (error) {
1111
- logger.warn(
1112
- `Failed to clean up temporary download directory "${tempDownloadPath}"`,
1113
- {
1114
- error,
1115
- },
1116
- );
1117
- }
1118
- }
1119
-
1120
- return absoluteTargetPath;
1121
- }
1122
-
1123
- private async downloadOrMountLocation(
1124
- location: string,
1125
- targetPath: string,
1126
- projectName: string,
1127
- packageName: string,
1128
- ) {
1129
- const isCompressedFile = location.endsWith(".zip");
1130
- // Handle GCS paths
1131
- if (this.isGCSURL(location)) {
1132
- try {
1133
- logger.info(
1134
- `Downloading GCS directory from "${location}" to "${targetPath}"`,
1135
- );
1136
- await this.downloadGcsDirectory(
1137
- location,
1138
- projectName,
1139
- targetPath,
1140
- isCompressedFile,
1141
- );
1142
- return;
1143
- } catch (error) {
1144
- const errorData = this.extractErrorDataFromError(error);
1145
- logger.error(
1146
- `Failed to download GCS directory "${location}"`,
1147
- errorData,
1148
- );
1149
- throw new PackageNotFoundError(
1150
- `Failed to download GCS directory: ${location}`,
1151
- );
1152
- }
1153
- }
1154
-
1155
- // Handle GitHub URLs
1156
- if (this.isGitHubURL(location)) {
1157
- try {
1158
- logger.info(
1159
- `Cloning GitHub repository from "${location}" to "${targetPath}"`,
1160
- );
1161
- await this.downloadGitHubDirectory(location, targetPath);
1162
- return;
1163
- } catch (error) {
1164
- const errorData = this.extractErrorDataFromError(error);
1165
- logger.error(
1166
- `Failed to clone GitHub repository "${location}"`,
1167
- errorData,
1168
- );
1169
- throw new PackageNotFoundError(
1170
- `Failed to clone GitHub repository: ${location}`,
1171
- );
1172
- }
1173
- }
1174
-
1175
- // Handle S3 paths
1176
- if (this.isS3URL(location)) {
1177
- try {
1178
- logger.info(
1179
- `Downloading S3 directory from "${location}" to "${targetPath}"`,
1180
- );
1181
- await this.downloadS3Directory(
1182
- location,
1183
- projectName,
1184
- targetPath,
1185
- isCompressedFile,
1186
- );
1187
- return;
1188
- } catch (error) {
1189
- const errorData = this.extractErrorDataFromError(error);
1190
- logger.error(
1191
- `Failed to download S3 directory "${location}"`,
1192
- errorData,
1193
- );
1194
- throw new PackageNotFoundError(
1195
- `Failed to download S3 directory: ${location}`,
1196
- );
1197
- }
1198
- }
1199
-
1200
- // Handle absolute and relative paths
1201
- if (this.isLocalPath(location)) {
1202
- const packagePath: string = path.isAbsolute(location)
1203
- ? location
1204
- : path.join(this.serverRootPath, location);
1205
- try {
1206
- logger.info(
1207
- `Mounting local directory at "${packagePath}" to "${targetPath}"`,
1208
- );
1209
- await this.mountLocalDirectory(
1210
- packagePath,
1211
- targetPath,
1212
- projectName,
1213
- packageName,
1214
- );
1215
- return;
1216
- } catch (error) {
1217
- const errorData = this.extractErrorDataFromError(error);
1218
- logger.error(
1219
- `Failed to mount local directory "${packagePath}"`,
1220
- errorData,
1221
- );
1222
- throw new PackageNotFoundError(
1223
- `Failed to mount local directory: ${packagePath}`,
1224
- );
1225
- }
1226
- }
1227
-
1228
- // If we get here, the path format is not supported
1229
- const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
1230
- logger.error(errorMsg, { projectName, location });
1231
- throw new PackageNotFoundError(errorMsg);
1232
- }
1233
-
1234
- public async mountLocalDirectory(
1235
- projectPath: string,
1236
- absoluteTargetPath: string,
1237
- projectName: string,
1238
- packageName: string,
1239
- ) {
1240
- if (projectPath.endsWith(".zip")) {
1241
- projectPath = await this.unzipProject(projectPath);
1242
- }
1243
- const projectDirExists =
1244
- (await fs.promises.stat(projectPath))?.isDirectory() ?? false;
1245
- if (projectDirExists) {
1246
- await fs.promises.rm(absoluteTargetPath, {
1247
- recursive: true,
1248
- force: true,
1249
- });
1250
- await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
1251
- await fs.promises.cp(projectPath, absoluteTargetPath, {
1252
- recursive: true,
1253
- });
1254
- } else {
1255
- throw new PackageNotFoundError(
1256
- `Package ${packageName} for project ${projectName} not found in "${projectPath}"`,
1257
- );
1258
- }
1259
- }
1260
-
1261
- async downloadGcsDirectory(
1262
- gcsPath: string,
1263
- projectName: string,
1264
- absoluteDirPath: string,
1265
- isCompressedFile: boolean,
1266
- ) {
1267
- const trimmedPath = gcsPath.slice(5);
1268
- const [bucketName, ...prefixParts] = trimmedPath.split("/");
1269
- const prefix = prefixParts.join("/");
1270
- const [files] = await this.gcsClient.bucket(bucketName).getFiles({
1271
- prefix,
1272
- });
1273
- if (files.length === 0) {
1274
- throw new ProjectNotFoundError(
1275
- `Project ${projectName} not found in ${gcsPath}`,
1276
- );
1277
- }
1278
- if (!isCompressedFile) {
1279
- await fs.promises.rm(absoluteDirPath, {
1280
- recursive: true,
1281
- force: true,
1282
- });
1283
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
1284
- } else {
1285
- absoluteDirPath = `${absoluteDirPath}.zip`;
1286
- }
1287
- await Promise.all(
1288
- files.map(async (file) => {
1289
- const relativeFilePath = file.name.replace(prefix, "");
1290
- const absoluteFilePath = isCompressedFile
1291
- ? absoluteDirPath
1292
- : path.join(absoluteDirPath, relativeFilePath);
1293
- if (file.name.endsWith("/")) {
1294
- return;
1295
- }
1296
- await fs.promises.mkdir(path.dirname(absoluteFilePath), {
1297
- recursive: true,
1298
- });
1299
- return fs.promises.writeFile(
1300
- absoluteFilePath,
1301
- await file.download(),
1302
- );
1303
- }),
1304
- );
1305
- if (isCompressedFile) {
1306
- await this.unzipProject(absoluteDirPath);
1307
- }
1308
- logger.info(`Downloaded GCS directory ${gcsPath} to ${absoluteDirPath}`);
1309
- }
1310
-
1311
- async downloadS3Directory(
1312
- s3Path: string,
1313
- projectName: string,
1314
- absoluteDirPath: string,
1315
- isCompressedFile: boolean = false,
1316
- ) {
1317
- const trimmedPath = s3Path.slice(5);
1318
- const [bucketName, ...prefixParts] = trimmedPath.split("/");
1319
- const prefix = prefixParts.join("/");
1320
-
1321
- if (isCompressedFile) {
1322
- // Download the single zip file
1323
- const zipFilePath = `${absoluteDirPath}.zip`;
1324
- await fs.promises.mkdir(path.dirname(zipFilePath), {
1325
- recursive: true,
1326
- });
1327
-
1328
- const command = new GetObjectCommand({
1329
- Bucket: bucketName,
1330
- Key: prefix,
1331
- });
1332
- const item = await this.s3Client.send(command);
1333
- if (!item.Body) {
1334
- throw new ProjectNotFoundError(
1335
- `Project ${projectName} not found in ${s3Path}`,
1336
- );
1337
- }
1338
- const file = fs.createWriteStream(zipFilePath);
1339
- item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
1340
- await new Promise<void>((resolve, reject) => {
1341
- file.on("error", reject);
1342
- file.on("finish", resolve);
1343
- });
1344
-
1345
- // Extract the zip file
1346
- await this.unzipProject(zipFilePath);
1347
- logger.info(`Downloaded S3 zip file ${s3Path} to ${absoluteDirPath}`);
1348
- return;
1349
- }
1350
-
1351
- // Original behavior: download directory contents
1352
- const objects = await this.s3Client.listObjectsV2({
1353
- Bucket: bucketName,
1354
- Prefix: prefix,
1355
- });
1356
- await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
1357
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
1358
-
1359
- if (!objects.Contents || objects.Contents.length === 0) {
1360
- throw new ProjectNotFoundError(
1361
- `Project ${projectName} not found in ${s3Path}`,
1362
- );
1363
- }
1364
- await Promise.all(
1365
- objects.Contents?.map(async (object) => {
1366
- const key = object.Key;
1367
- if (!key) {
1368
- return;
1369
- }
1370
- const relativeFilePath = key.replace(prefix, "");
1371
- if (!relativeFilePath || relativeFilePath.endsWith("/")) {
1372
- return;
1373
- }
1374
- const absoluteFilePath = path.join(
1375
- absoluteDirPath,
1376
- relativeFilePath,
1377
- );
1378
- await fs.promises.mkdir(path.dirname(absoluteFilePath), {
1379
- recursive: true,
1380
- });
1381
- const command = new GetObjectCommand({
1382
- Bucket: bucketName,
1383
- Key: key,
1384
- });
1385
- const item = await this.s3Client.send(command);
1386
- if (!item.Body) {
1387
- return;
1388
- }
1389
- const file = fs.createWriteStream(absoluteFilePath);
1390
- item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
1391
- await new Promise<void>((resolve, reject) => {
1392
- file.on("error", reject);
1393
- file.on("finish", resolve);
1394
- });
1395
- }),
1396
- );
1397
- logger.info(`Downloaded S3 directory ${s3Path} to ${absoluteDirPath}`);
1398
- }
1399
-
1400
- private parseGitHubUrl(
1401
- githubUrl: string,
1402
- ): { owner: string; repoName: string; packagePath?: string } | null {
1403
- // Handle HTTPS format: https://github.com/owner/repo/tree/branch/subdir
1404
- const httpsRegex =
1405
- /github\.com\/(?<owner>[^/]+)\/(?<repoName>[^/]+)(?<packagePath>\/[^/]+)*/;
1406
- const httpsMatch = githubUrl.match(httpsRegex);
1407
- if (httpsMatch) {
1408
- const { owner, repoName, packagePath } = httpsMatch.groups!;
1409
- return { owner, repoName, packagePath };
1410
- }
1411
-
1412
- // Handle SSH format: git@github.com:owner/repo.git or git@github.com:owner/repo
1413
- const sshRegex =
1414
- /git@github\.com:(?<owner>[^/]+)\/(?<repoName>[^/\s]+?)(?:\.git)?(?<packagePath>\/[^/]+)*$/;
1415
- const sshMatch = githubUrl.match(sshRegex);
1416
- if (sshMatch) {
1417
- const { owner, repoName, packagePath } = sshMatch.groups!;
1418
- return { owner, repoName, packagePath };
1419
- }
1420
-
1421
- return null;
1422
- }
1423
-
1424
- async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
1425
- // First we'll clone the repo without the additional path
1426
- // E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
1427
- const githubInfo = this.parseGitHubUrl(githubUrl);
1428
- if (!githubInfo) {
1429
- throw new Error(`Invalid GitHub URL: ${githubUrl}`);
1430
- }
1431
- const { owner, repoName, packagePath } = githubInfo;
1432
- const cleanPackagePath = packagePath?.replace("/tree/main", "") || "";
1433
-
1434
- // We'll make sure whatever was in absoluteDirPath is removed,
1435
- // so we have a nice a clean directory where we can clone the repo
1436
- await fs.promises.rm(absoluteDirPath, {
1437
- recursive: true,
1438
- force: true,
1439
- });
1440
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
1441
- const repoUrl = `https://github.com/${owner}/${repoName}`;
1442
-
1443
- // We'll clone the repo into absoluteDirPath
1444
- await new Promise<void>((resolve, reject) => {
1445
- simpleGit().clone(repoUrl, absoluteDirPath, {}, (err) => {
1446
- if (err) {
1447
- const errorData = this.extractErrorDataFromError(err);
1448
- logger.error(
1449
- `Failed to clone GitHub repository "${repoUrl}"`,
1450
- errorData,
1451
- );
1452
- reject(err);
1453
- }
1454
- resolve();
1455
- });
1456
- });
1457
-
1458
- // If there's no specific package path, we're done (for grouped downloads)
1459
- if (!cleanPackagePath) {
1460
- logger.info(
1461
- `Successfully cloned entire repository to: ${absoluteDirPath}`,
1462
- );
1463
- return;
1464
- }
1465
-
1466
- // For single package downloads, extract the specific subdirectory
1467
- // After cloning, we'll replace all contents of absoluteDirPath with the contents of absoluteDirPath/cleanPackagePath
1468
- // E.g. we're moving /var/publisher/asd123/imdb/publisher.json into /var/publisher/asd123/publisher.json
1469
-
1470
- // Remove all contents of absoluteDirPath (/var/publisher/asd123)
1471
- // except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
1472
- const packageFullPath = path.join(absoluteDirPath, cleanPackagePath);
1473
-
1474
- // Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
1475
- const packageExists = await fs.promises
1476
- .access(packageFullPath)
1477
- .then(() => true)
1478
- .catch(() => false);
1479
-
1480
- if (!packageExists) {
1481
- throw new Error(
1482
- `Package path "${cleanPackagePath}" does not exist in the cloned repository.`,
1483
- );
1484
- }
1485
-
1486
- // Remove everything in absoluteDirPath (/var/publisher/asd123)
1487
- const dirContents = await fs.promises.readdir(absoluteDirPath);
1488
- for (const entry of dirContents) {
1489
- // Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
1490
- if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
1491
- await fs.promises.rm(path.join(absoluteDirPath, entry), {
1492
- recursive: true,
1493
- force: true,
1494
- });
1495
- }
1496
- }
1497
-
1498
- // Now, move the contents of packageFullPath (/var/publisher/asd123/imdb) up to absoluteDirPath (/var/publisher/asd123)
1499
- const packageContents = await fs.promises.readdir(packageFullPath);
1500
- for (const entry of packageContents) {
1501
- await fs.promises.rename(
1502
- path.join(packageFullPath, entry),
1503
- path.join(absoluteDirPath, entry),
1504
- );
1505
- }
1506
-
1507
- // Remove the now-empty cleanPackagePath directory (/var/publisher/asd123/imdb)
1508
- await fs.promises.rm(packageFullPath, { recursive: true, force: true });
1509
-
1510
- // https://github.com/credibledata/malloy-samples/imdb/publisher.json -> ${absoluteDirPath}/publisher.json
1511
- }
1512
-
1513
- private extractErrorDataFromError(error: unknown): {
1514
- error: string;
1515
- stack?: string;
1516
- task?: unknown;
1517
- } {
1518
- const errorMessage =
1519
- error instanceof Error ? error.message : String(error);
1520
- const errorData: { error: string; stack?: string; task?: unknown } = {
1521
- error: errorMessage,
1522
- };
1523
- if (error instanceof Error && logger.level === "debug") {
1524
- errorData.stack = error.stack;
1525
- }
1526
- if (error && typeof error === "object" && "task" in error) {
1527
- errorData.task = (error as { task?: unknown }).task;
1528
- }
1529
- return errorData;
1530
- }
1531
- }