@malloy-publisher/server 0.0.382-dev → 0.2.0
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/README.docker.md +145 -20
- package/README.md +65 -12
- package/dist/app/api-doc.html +5 -0
- package/dist/app/api-doc.yaml +3903 -583
- package/dist/app/assets/EnvironmentPage-DiUgLuCH.js +1 -0
- package/dist/app/assets/HomePage-MhhjhnFj.js +1 -0
- package/dist/app/assets/LightMode-BEyvNUzT.js +1 -0
- package/dist/app/assets/MainPage-D4Cfee9o.js +2 -0
- package/dist/app/assets/MaterializationsPage-nGgGqwSN.js +1 -0
- package/dist/app/assets/ModelPage-DLAcEw-F.js +1 -0
- package/dist/app/assets/PackagePage-Df0ikH7w.js +1 -0
- package/dist/app/assets/RouteError-BFQwtroH.js +1 -0
- package/dist/app/assets/ThemeEditorPage-OoskoQYN.js +1 -0
- package/dist/app/assets/WorkbookPage-CpFgvUDC.js +1 -0
- package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-lb28vQkR.es-BoVSOc75.js} +1 -1
- package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
- package/dist/app/assets/index-5eLCcNmP.css +1 -0
- package/dist/app/assets/index-6ypuFH6g.js +1774 -0
- package/dist/app/assets/index-B8pnU_V_.js +615 -0
- package/dist/app/assets/index-BImcHmVP.js +1 -0
- package/dist/app/assets/index-DSoCUF-f.js +2725 -0
- package/dist/app/assets/index-DfqzSItA.js +2 -0
- package/dist/app/assets/index-w_0OQJgZ.js +23 -0
- package/dist/app/index.html +8 -4
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
- package/dist/package_load_worker.mjs +15219 -0
- package/dist/runtime/publisher.js +331 -0
- package/dist/{server.js → server.mjs} +115548 -49590
- package/dist/sshcrypto-vd2k5hq9.node +0 -0
- package/package.json +41 -28
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -38
- package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
- package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
- package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
- package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
- package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
- package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
- package/dist/app/assets/index-15BOvhp0.js +0 -456
- package/dist/app/assets/index-Bb2jqquW.js +0 -676
- package/dist/app/assets/index-CMlGQMcl.css +0 -1
- package/dist/app/assets/index-D68X76-7.js +0 -1276
- package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
- package/dxt/malloy_bridge.py +0 -354
- package/dxt/manifest.json +0 -22
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.json +0 -33
- package/src/config.spec.ts +0 -850
- package/src/config.ts +0 -305
- package/src/constants.ts +0 -10
- package/src/controller/compile.controller.ts +0 -35
- package/src/controller/connection.controller.ts +0 -515
- package/src/controller/database.controller.ts +0 -21
- package/src/controller/manifest.controller.ts +0 -29
- package/src/controller/materialization.controller.ts +0 -125
- package/src/controller/model.controller.ts +0 -107
- package/src/controller/package.controller.ts +0 -182
- package/src/controller/query.controller.ts +0 -60
- package/src/controller/watch-mode.controller.ts +0 -98
- package/src/data_styles.ts +0 -71
- package/src/dto/connection.dto.spec.ts +0 -131
- package/src/dto/connection.dto.ts +0 -174
- package/src/dto/index.ts +0 -2
- package/src/dto/package.dto.spec.ts +0 -42
- package/src/dto/package.dto.ts +0 -12
- package/src/dto/validate.spec.ts +0 -76
- package/src/dto/validate.ts +0 -31
- package/src/errors.ts +0 -116
- package/src/health.ts +0 -189
- package/src/instrumentation.ts +0 -148
- package/src/logger.ts +0 -154
- package/src/mcp/error_messages.ts +0 -199
- package/src/mcp/handler_utils.ts +0 -239
- package/src/mcp/mcp_constants.ts +0 -22
- package/src/mcp/prompts/handlers.ts +0 -84
- package/src/mcp/prompts/index.ts +0 -11
- package/src/mcp/prompts/prompt_definitions.ts +0 -160
- package/src/mcp/prompts/prompt_service.ts +0 -67
- package/src/mcp/prompts/utils.ts +0 -62
- package/src/mcp/resource_metadata.ts +0 -47
- package/src/mcp/resources/model_resource.ts +0 -153
- package/src/mcp/resources/notebook_resource.ts +0 -137
- package/src/mcp/resources/package_resource.ts +0 -370
- package/src/mcp/resources/project_resource.ts +0 -184
- package/src/mcp/resources/query_resource.ts +0 -117
- package/src/mcp/resources/source_resource.ts +0 -141
- package/src/mcp/resources/view_resource.ts +0 -136
- package/src/mcp/server.ts +0 -62
- package/src/mcp/tools/discovery_tools.ts +0 -258
- package/src/mcp/tools/execute_query_tool.ts +0 -265
- package/src/server.ts +0 -1195
- package/src/service/connection.spec.ts +0 -1331
- package/src/service/connection.ts +0 -1511
- package/src/service/connection_service.spec.ts +0 -641
- package/src/service/connection_service.ts +0 -181
- package/src/service/db_utils.spec.ts +0 -714
- package/src/service/db_utils.ts +0 -1227
- package/src/service/filter.spec.ts +0 -447
- package/src/service/filter.ts +0 -337
- package/src/service/filter_integration.spec.ts +0 -825
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/manifest_service.spec.ts +0 -201
- package/src/service/manifest_service.ts +0 -106
- package/src/service/materialization_service.spec.ts +0 -648
- package/src/service/materialization_service.ts +0 -929
- package/src/service/materialized_table_gc.spec.ts +0 -383
- package/src/service/materialized_table_gc.ts +0 -279
- package/src/service/model.spec.ts +0 -257
- package/src/service/model.ts +0 -1069
- package/src/service/package.spec.ts +0 -236
- package/src/service/package.ts +0 -463
- package/src/service/project.ts +0 -681
- package/src/service/project_compile.spec.ts +0 -197
- package/src/service/project_store.spec.ts +0 -859
- package/src/service/project_store.ts +0 -1531
- package/src/service/quoting.ts +0 -41
- package/src/service/resolve_project.ts +0 -13
- package/src/storage/BaseRepository.ts +0 -31
- package/src/storage/DatabaseInterface.ts +0 -180
- package/src/storage/StorageManager.mock.ts +0 -59
- package/src/storage/StorageManager.ts +0 -210
- package/src/storage/duckdb/ConnectionRepository.ts +0 -141
- package/src/storage/duckdb/DuckDBConnection.ts +0 -177
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/DuckDBRepository.ts +0 -224
- package/src/storage/duckdb/ManifestRepository.ts +0 -119
- package/src/storage/duckdb/MaterializationRepository.ts +0 -249
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/ProjectRepository.ts +0 -148
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/duckdb/schema.ts +0 -147
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
- package/src/utils.ts +0 -13
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/harness/e2e.ts +0 -96
- package/tests/harness/mcp_test_setup.ts +0 -344
- package/tests/harness/mocks.ts +0 -37
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/harness/uris.ts +0 -31
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
- package/tests/integration/mcp/setup.spec.ts +0 -5
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
- package/tests/unit/ducklake/ducklake.test.ts +0 -950
- package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
- package/tests/unit/mcp/prompt_happy.test.ts +0 -51
- 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
|
-
}
|