@malloy-publisher/server 0.0.235 → 0.0.236

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 (301) hide show
  1. package/dist/app/api-doc.yaml +47 -17
  2. package/dist/package_load_worker.mjs +8 -1
  3. package/dist/server.mjs +471 -54
  4. package/package.json +5 -2
  5. package/.eslintrc.json +0 -44
  6. package/.prettierignore +0 -1
  7. package/.prettierrc +0 -4
  8. package/build.ts +0 -97
  9. package/eslint.config.mjs +0 -76
  10. package/k6-tests/README.md +0 -144
  11. package/k6-tests/bun.lock +0 -797
  12. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  13. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  14. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  15. package/k6-tests/load-test/load-test-crud.ts +0 -142
  16. package/k6-tests/load-test/load-test.ts +0 -784
  17. package/k6-tests/package.json +0 -27
  18. package/k6-tests/scripts/run-with-otel.sh +0 -50
  19. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  20. package/k6-tests/tsconfig.json +0 -29
  21. package/k6-tests/utils/client_factory.ts +0 -128
  22. package/k6-tests/utils/common.ts +0 -489
  23. package/k6-tests/utils/logger.ts +0 -62
  24. package/k6-tests/utils/types.d.ts +0 -35
  25. package/malloy_mcp.dxt +0 -0
  26. package/publisher.config.example.bigquery.json +0 -33
  27. package/publisher.config.example.duckdb.json +0 -23
  28. package/publisher.config.json +0 -23
  29. package/scripts/bake-duckdb-extensions.js +0 -110
  30. package/src/config.spec.ts +0 -1535
  31. package/src/config.theme.spec.ts +0 -178
  32. package/src/config.ts +0 -1142
  33. package/src/constants.ts +0 -105
  34. package/src/controller/compile.controller.ts +0 -40
  35. package/src/controller/connection.controller.spec.ts +0 -849
  36. package/src/controller/connection.controller.ts +0 -901
  37. package/src/controller/database.controller.ts +0 -24
  38. package/src/controller/materialization.controller.spec.ts +0 -293
  39. package/src/controller/materialization.controller.ts +0 -319
  40. package/src/controller/model.controller.ts +0 -164
  41. package/src/controller/package.controller.spec.ts +0 -273
  42. package/src/controller/package.controller.ts +0 -306
  43. package/src/controller/query.controller.ts +0 -152
  44. package/src/controller/theme.controller.ts +0 -83
  45. package/src/controller/watch-mode.controller.ts +0 -243
  46. package/src/data_styles.ts +0 -71
  47. package/src/default-publisher.config.json +0 -23
  48. package/src/ducklake_version.spec.ts +0 -163
  49. package/src/ducklake_version.ts +0 -153
  50. package/src/errors.spec.ts +0 -119
  51. package/src/errors.ts +0 -237
  52. package/src/filter_deprecation.spec.ts +0 -64
  53. package/src/filter_deprecation.ts +0 -42
  54. package/src/health.spec.ts +0 -123
  55. package/src/health.ts +0 -233
  56. package/src/heap_check.spec.ts +0 -144
  57. package/src/heap_check.ts +0 -144
  58. package/src/instrumentation.ts +0 -214
  59. package/src/json_utils.spec.ts +0 -51
  60. package/src/json_utils.ts +0 -33
  61. package/src/logger.spec.ts +0 -210
  62. package/src/logger.ts +0 -251
  63. package/src/malloy_pin_prereqs.spec.ts +0 -25
  64. package/src/materialization_metrics.spec.ts +0 -169
  65. package/src/materialization_metrics.ts +0 -346
  66. package/src/mcp/error_messages.spec.ts +0 -35
  67. package/src/mcp/error_messages.ts +0 -183
  68. package/src/mcp/handler_utils.spec.ts +0 -108
  69. package/src/mcp/handler_utils.ts +0 -251
  70. package/src/mcp/mcp_constants.ts +0 -6
  71. package/src/mcp/query_envelope.spec.ts +0 -229
  72. package/src/mcp/query_envelope.ts +0 -240
  73. package/src/mcp/server.protocol.spec.ts +0 -250
  74. package/src/mcp/server.ts +0 -87
  75. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  76. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  77. package/src/mcp/skills/skills_bundle.json +0 -1
  78. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  79. package/src/mcp/tool_response.spec.ts +0 -108
  80. package/src/mcp/tool_response.ts +0 -138
  81. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  82. package/src/mcp/tools/compile_tool.ts +0 -208
  83. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  84. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  85. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  86. package/src/mcp/tools/docs_search_tool.ts +0 -128
  87. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  88. package/src/mcp/tools/embedding_index.ts +0 -808
  89. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  90. package/src/mcp/tools/execute_query_tool.ts +0 -316
  91. package/src/mcp/tools/get_context_eval.ts +0 -275
  92. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  93. package/src/mcp/tools/get_context_tool.ts +0 -585
  94. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  95. package/src/mcp/tools/reload_package_tool.ts +0 -132
  96. package/src/mcp_config.spec.ts +0 -919
  97. package/src/mcp_config.ts +0 -425
  98. package/src/oom_guards.integration.spec.ts +0 -269
  99. package/src/package_load/package_load_pool.spec.ts +0 -252
  100. package/src/package_load/package_load_pool.ts +0 -931
  101. package/src/package_load/package_load_worker.ts +0 -1034
  102. package/src/package_load/protocol.ts +0 -364
  103. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  104. package/src/package_load/rpc_wait_accountant.ts +0 -76
  105. package/src/package_load_metrics.spec.ts +0 -114
  106. package/src/package_load_metrics.ts +0 -127
  107. package/src/path_safety.spec.ts +0 -158
  108. package/src/path_safety.ts +0 -146
  109. package/src/pg_helpers.spec.ts +0 -223
  110. package/src/pg_helpers.ts +0 -52
  111. package/src/query_cap_metrics.spec.ts +0 -89
  112. package/src/query_cap_metrics.ts +0 -116
  113. package/src/query_concurrency.spec.ts +0 -247
  114. package/src/query_concurrency.ts +0 -237
  115. package/src/query_metadata_metrics.ts +0 -49
  116. package/src/query_param_utils.ts +0 -29
  117. package/src/query_timeout.spec.ts +0 -224
  118. package/src/query_timeout.ts +0 -179
  119. package/src/runtime/publisher.js +0 -328
  120. package/src/server-old.ts +0 -988
  121. package/src/server.ts +0 -2059
  122. package/src/service/annotations.spec.ts +0 -118
  123. package/src/service/annotations.ts +0 -91
  124. package/src/service/authorize.spec.ts +0 -154
  125. package/src/service/authorize.ts +0 -498
  126. package/src/service/authorize_integration.spec.ts +0 -2041
  127. package/src/service/build_plan.spec.ts +0 -685
  128. package/src/service/build_plan.ts +0 -797
  129. package/src/service/compile_authorize.spec.ts +0 -85
  130. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  131. package/src/service/connection.spec.ts +0 -2360
  132. package/src/service/connection.ts +0 -2187
  133. package/src/service/connection_config.spec.ts +0 -734
  134. package/src/service/connection_config.ts +0 -899
  135. package/src/service/connection_federation.spec.ts +0 -184
  136. package/src/service/connection_fingerprint.spec.ts +0 -102
  137. package/src/service/connection_service.spec.ts +0 -711
  138. package/src/service/connection_service.ts +0 -291
  139. package/src/service/cron_evaluator.spec.ts +0 -79
  140. package/src/service/cron_evaluator.ts +0 -105
  141. package/src/service/db_utils.spec.ts +0 -1085
  142. package/src/service/db_utils.ts +0 -1502
  143. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  144. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  145. package/src/service/embedding_provider.spec.ts +0 -329
  146. package/src/service/embedding_provider.ts +0 -236
  147. package/src/service/environment.ts +0 -2191
  148. package/src/service/environment_admission.spec.ts +0 -344
  149. package/src/service/environment_compile.spec.ts +0 -197
  150. package/src/service/environment_store.spec.ts +0 -2274
  151. package/src/service/environment_store.ts +0 -2434
  152. package/src/service/environment_store_anchoring.spec.ts +0 -107
  153. package/src/service/environment_store_clone.spec.ts +0 -350
  154. package/src/service/explore_visibility.spec.ts +0 -434
  155. package/src/service/exports_probe.spec.ts +0 -107
  156. package/src/service/extension_fetch_policy.spec.ts +0 -256
  157. package/src/service/filter.spec.ts +0 -458
  158. package/src/service/filter.ts +0 -341
  159. package/src/service/filter_bypass.spec.ts +0 -418
  160. package/src/service/filter_integration.spec.ts +0 -1004
  161. package/src/service/freshness.spec.ts +0 -183
  162. package/src/service/freshness.ts +0 -112
  163. package/src/service/gcs_s3_utils.ts +0 -427
  164. package/src/service/given.ts +0 -105
  165. package/src/service/givens_integration.spec.ts +0 -219
  166. package/src/service/manifest_loader.spec.ts +0 -189
  167. package/src/service/manifest_loader.ts +0 -156
  168. package/src/service/materialization_build_session.spec.ts +0 -435
  169. package/src/service/materialization_build_session.ts +0 -681
  170. package/src/service/materialization_config_validation.spec.ts +0 -99
  171. package/src/service/materialization_config_validation.ts +0 -120
  172. package/src/service/materialization_eligibility.spec.ts +0 -158
  173. package/src/service/materialization_eligibility.ts +0 -305
  174. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  175. package/src/service/materialization_scheduler.spec.ts +0 -289
  176. package/src/service/materialization_scheduler.ts +0 -304
  177. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  178. package/src/service/materialization_serve_transform.ts +0 -779
  179. package/src/service/materialization_service.spec.ts +0 -2373
  180. package/src/service/materialization_service.ts +0 -2342
  181. package/src/service/materialization_test_fixtures.ts +0 -249
  182. package/src/service/model.spec.ts +0 -1157
  183. package/src/service/model.ts +0 -3370
  184. package/src/service/model_limits.spec.ts +0 -209
  185. package/src/service/model_limits.ts +0 -131
  186. package/src/service/model_storage_serve.spec.ts +0 -193
  187. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  188. package/src/service/package.spec.ts +0 -612
  189. package/src/service/package.ts +0 -1799
  190. package/src/service/package_manifest.spec.ts +0 -230
  191. package/src/service/package_manifest.ts +0 -241
  192. package/src/service/package_memory_governor.spec.ts +0 -173
  193. package/src/service/package_memory_governor.ts +0 -233
  194. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  195. package/src/service/package_race.spec.ts +0 -208
  196. package/src/service/package_reload_safety.spec.ts +0 -193
  197. package/src/service/package_rollback.spec.ts +0 -190
  198. package/src/service/package_worker_path.spec.ts +0 -645
  199. package/src/service/path_injection.spec.ts +0 -39
  200. package/src/service/persist_annotation_validation.spec.ts +0 -89
  201. package/src/service/persist_annotation_validation.ts +0 -59
  202. package/src/service/persistence_policy.spec.ts +0 -374
  203. package/src/service/proxy.spec.ts +0 -414
  204. package/src/service/proxy.ts +0 -248
  205. package/src/service/query_boundary.spec.ts +0 -470
  206. package/src/service/query_metadata.spec.ts +0 -408
  207. package/src/service/query_metadata.ts +0 -492
  208. package/src/service/query_metadata_identity.spec.ts +0 -149
  209. package/src/service/query_text.spec.ts +0 -79
  210. package/src/service/query_text.ts +0 -43
  211. package/src/service/quoting.spec.ts +0 -135
  212. package/src/service/quoting.ts +0 -78
  213. package/src/service/resolve_environment.ts +0 -15
  214. package/src/service/restricted_mode.spec.ts +0 -299
  215. package/src/service/source_extraction.ts +0 -225
  216. package/src/service/theme_store.ts +0 -199
  217. package/src/storage/DatabaseInterface.ts +0 -198
  218. package/src/storage/StorageManager.ts +0 -113
  219. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  220. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  221. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  222. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  223. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  224. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  225. package/src/storage/duckdb/PackageRepository.ts +0 -135
  226. package/src/storage/duckdb/schema.ts +0 -229
  227. package/src/stream_helpers.spec.ts +0 -280
  228. package/src/stream_helpers.ts +0 -162
  229. package/src/telemetry.ts +0 -20
  230. package/src/test_helpers/metrics_harness.ts +0 -166
  231. package/src/theme_key_parity.spec.ts +0 -57
  232. package/src/utils.ts +0 -29
  233. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  234. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  235. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  236. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  237. package/tests/fixtures/html-pages-test/data.csv +0 -3
  238. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  239. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  240. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  241. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  242. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  243. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  244. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  245. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  246. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  247. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  248. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  249. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  250. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  251. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  252. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  253. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  254. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  255. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  256. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  257. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  258. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  259. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  260. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  261. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  262. package/tests/fixtures/persist-test/publisher.json +0 -5
  263. package/tests/fixtures/publisher.config.json +0 -15
  264. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  265. package/tests/fixtures/query-givens/model.malloy +0 -34
  266. package/tests/fixtures/query-givens/publisher.json +0 -5
  267. package/tests/fixtures/xlsx/database.xlsx +0 -0
  268. package/tests/harness/mcp_test_setup.ts +0 -332
  269. package/tests/harness/rest_e2e.ts +0 -68
  270. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  271. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  272. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  273. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  274. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  275. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  276. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  277. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  278. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  279. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  280. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  281. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  282. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  283. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  284. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  285. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  286. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  287. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  288. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  289. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  290. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  291. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  292. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  293. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  294. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  295. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  296. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  297. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  298. package/tests/unit/duckdb/repositories.test.ts +0 -208
  299. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  300. package/tests/unit/service/theme_store.spec.ts +0 -139
  301. package/tsconfig.json +0 -32
@@ -1,2434 +0,0 @@
1
- import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
2
- import { Storage } from "@google-cloud/storage";
3
- import { Mutex } from "async-mutex";
4
- import crypto from "crypto";
5
- import extract from "extract-zip";
6
- import * as fs from "fs";
7
- import * as os from "os";
8
- import * as path from "path";
9
- import simpleGit, { type SimpleGitProgressEvent } from "simple-git";
10
- import { Writable } from "stream";
11
- import { components } from "../api";
12
- import {
13
- getProcessedPublisherConfig,
14
- getPublisherConfigDir,
15
- isPublisherConfigFrozen,
16
- ProcessedEnvironment,
17
- ProcessedPublisherConfig,
18
- } from "../config";
19
- import {
20
- API_PREFIX,
21
- PUBLISHER_CONFIG_NAME,
22
- PUBLISHER_DATA_DIR,
23
- } from "../constants";
24
- import {
25
- BadRequestError,
26
- EnvironmentNotFoundError,
27
- FrozenConfigError,
28
- PackageNotFoundError,
29
- } from "../errors";
30
- import { getOperationalState, markNotReady, markReady } from "../health";
31
- import { formatDuration, logger } from "../logger";
32
- import {
33
- deleteEnvironmentEmbeddings,
34
- deletePackageEmbeddings,
35
- } from "../mcp/tools/embedding_index";
36
- import { redactPgSecrets } from "../pg_helpers";
37
- import {
38
- assertSafeEnvironmentPath,
39
- assertSafePackageName,
40
- safeJoinUnderRoot,
41
- } from "../path_safety";
42
- import { Connection } from "../storage/DatabaseInterface";
43
- import { StorageConfig, StorageManager } from "../storage/StorageManager";
44
- import { Environment, PackageStatus } from "./environment";
45
- import type { PackageMemoryGovernor } from "./package_memory_governor";
46
- type ApiEnvironment = components["schemas"]["Environment"];
47
- type LoadError = NonNullable<
48
- components["schemas"]["ServerStatus"]["loadErrors"]
49
- >[number];
50
-
51
- const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
52
- const AZURE_DATA_EXTENSIONS = [
53
- ".parquet",
54
- ".csv",
55
- ".json",
56
- ".jsonl",
57
- ".ndjson",
58
- ];
59
-
60
- function validateAzureUrl(url: string, fieldName: string): void {
61
- if (!AZURE_SUPPORTED_SCHEMES.some((s) => url.startsWith(s))) {
62
- throw new BadRequestError(
63
- `Azure ${fieldName} must use one of: ${AZURE_SUPPORTED_SCHEMES.join(", ")}`,
64
- );
65
- }
66
- const pathWithoutQuery = url.split("?")[0];
67
- const stars = (pathWithoutQuery.match(/\*/g) || []).length;
68
-
69
- if (stars === 0) {
70
- const lower = pathWithoutQuery.toLowerCase();
71
- if (!AZURE_DATA_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
72
- throw new BadRequestError(
73
- `Azure ${fieldName}: a single-file URL must end with a data file extension (${AZURE_DATA_EXTENSIONS.join(", ")})`,
74
- );
75
- }
76
- } else if (pathWithoutQuery.endsWith("**")) {
77
- // recursive — valid
78
- // includes all data files in the container and all subdirectories
79
- } else {
80
- const lastSegment = pathWithoutQuery.split("/").pop() || "";
81
- if (stars !== 1 || !lastSegment.startsWith("*")) {
82
- throw new BadRequestError(
83
- `Azure ${fieldName}: only three URL patterns are supported:\n` +
84
- ` • Single file: path/file.parquet\n` +
85
- ` • Directory glob: path/*.ext (direct children only)\n` +
86
- ` • Recursive: path/** (includes all data files in the container and all subdirectories)\n` +
87
- `Multi-level globs such as "sub_dir/*/*.parquet" are not supported.`,
88
- );
89
- }
90
- }
91
- }
92
-
93
- function validateEnvironmentAzureUrls(environment: ApiEnvironment): void {
94
- for (const conn of environment.connections || []) {
95
- if (conn.type !== "duckdb") continue;
96
- for (const db of conn.duckdbConnection?.attachedDatabases || []) {
97
- if (db.type !== "azure" || !db.azureConnection) continue;
98
- const { authType, sasUrl, fileUrl } = db.azureConnection;
99
- if (authType === "sas_token" && sasUrl) {
100
- validateAzureUrl(sasUrl, `"${db.name}" sasUrl`);
101
- } else if (authType === "service_principal" && fileUrl) {
102
- validateAzureUrl(fileUrl, `"${db.name}" fileUrl`);
103
- }
104
- }
105
- }
106
- }
107
-
108
- // Safely clear a prior mount target before (re)mounting at `targetPath`.
109
- // Distinguishes symlinks from real dirs: fs.rm's recursive mode could otherwise
110
- // traverse into a symlinked source and damage it, so unlink symlinks and rm real
111
- // dirs. Both the in-place (symlink) and copy mount paths call this, so toggling
112
- // watch mode across runs against the same publisher_data can't leave a stale
113
- // entry that breaks the next mount (mkdir is a no-op on a symlink and fs.cp then
114
- // throws ERR_FS_CP_DIR_TO_NON_DIR).
115
- async function clearMountTarget(targetPath: string): Promise<void> {
116
- try {
117
- const stats = await fs.promises.lstat(targetPath);
118
- if (stats.isDirectory() && !stats.isSymbolicLink()) {
119
- await fs.promises.rm(targetPath, { recursive: true, force: true });
120
- } else {
121
- await fs.promises.unlink(targetPath);
122
- }
123
- } catch {
124
- // Nothing there, fine.
125
- }
126
- }
127
-
128
- /**
129
- * Absolute on-disk path for a local package `location`.
130
- *
131
- * `~/` (POSIX form only) expands to the home directory. Anything still
132
- * relative resolves against `anchorDir`, the directory holding the active
133
- * config, so a config and the packages it points at can be moved or
134
- * committed together. Absolute locations are returned untouched.
135
- */
136
- export function resolvePackageLocation(
137
- location: string,
138
- anchorDir: string,
139
- homeDir?: string,
140
- ): string {
141
- let expanded = location;
142
- if (location.startsWith("~/")) {
143
- // Resolved lazily, inside the tilde branch only: os.homedir() can throw
144
- // where HOME is unset and there is no passwd entry for the uid (e.g. a
145
- // distroless container with runAsUser), and a `./sales` or absolute
146
- // location must never fail on that.
147
- const home = homeDir ?? os.homedir();
148
- if (!home) {
149
- throw new Error(
150
- `Cannot expand "~" in location "${location}": home directory is not set`,
151
- );
152
- }
153
- expanded = path.join(home, location.slice(2));
154
- }
155
- return path.isAbsolute(expanded) ? expanded : path.join(anchorDir, expanded);
156
- }
157
-
158
- /**
159
- * Options for every package git clone. Packages are served from the default
160
- * branch's working tree only (no ref is ever checked out afterwards, and no
161
- * later git operation touches the clone: a reload deletes and re-clones), so
162
- * history is pure download cost. Exported for tests.
163
- */
164
- export const GIT_CLONE_OPTIONS: Record<string, string | number | null> = {
165
- "--depth": 1,
166
- "--single-branch": null,
167
- };
168
-
169
- /**
170
- * With a progress handler configured, simple-git forces `--progress`, and a
171
- * failed clone's error message then carries every progress line git wrote to
172
- * stderr. Those messages surface in API error responses on the add/update
173
- * package path and in the failure logs (the boot path's `/status` loadErrors
174
- * carries a location-level summary instead), so drop the noise and keep the
175
- * lines that say what went wrong. Returns the input when everything matched,
176
- * rather than an empty error.
177
- */
178
- export function stripGitProgressNoise(message: string): string {
179
- const kept = message
180
- .split(/\r\n|\r|\n/)
181
- .filter(
182
- (line) =>
183
- !/\d+% \(\d+\/\d+\)/.test(line) &&
184
- // Also matches the "Error: Cloning into ..." shape V8 seeds
185
- // into err.stack from the message's first line.
186
- !/^(\w+: )?Cloning into /.test(line.trim()),
187
- )
188
- .join("\n")
189
- .trim();
190
- return kept.length > 0 ? kept : message.trim();
191
- }
192
-
193
- /** e.g. `[examples] cloning malloydata/publisher (storefront, +2 more)`. */
194
- export function cloneProgressLabel(
195
- repo: string,
196
- context?: { environmentName?: string; packageNames?: string[] },
197
- ): string {
198
- const env = context?.environmentName ? `[${context.environmentName}] ` : "";
199
- const names = context?.packageNames ?? [];
200
- const shown = names.length > 4 ? names.slice(0, 3) : names;
201
- const more = names.length - shown.length;
202
- const pkgs =
203
- shown.length > 0
204
- ? ` (${shown.join(", ")}${more > 0 ? `, +${more} more` : ""})`
205
- : "";
206
- return `${env}cloning ${repo}${pkgs}`;
207
- }
208
-
209
- /**
210
- * Streams git clone progress to stderr: off winston (whose format changes
211
- * under OTEL and whose level can hide info lines) and off stdout (which an
212
- * MCP stdio transport would own), and it is where git itself reports
213
- * progress. A TTY gets one line rewritten in place; anything else (CI logs)
214
- * gets a line per stage and per 25-point step, so a slow clone shows life
215
- * without flooding. Exported for tests.
216
- */
217
- export class CloneProgressReporter {
218
- private lastStage: string | undefined;
219
- private lastMilestone = -1;
220
- private wroteInPlace = false;
221
- private finished = false;
222
- /**
223
- * One reporter at a time owns a TTY's in-place line: environments load
224
- * concurrently at boot, and two reporters `\r`-rewriting the same line
225
- * would overwrite each other. Later reporters fall back to milestone
226
- * lines until the owner finishes. Keyed by stream so tests with fake
227
- * streams cannot collide.
228
- */
229
- private static ttyOwners = new WeakMap<object, CloneProgressReporter>();
230
-
231
- constructor(
232
- private readonly label: string,
233
- private readonly out: NodeJS.WriteStream = process.stderr,
234
- ) {}
235
-
236
- onProgress(event: SimpleGitProgressEvent): void {
237
- if (this.finished) {
238
- // A late event after done() must not re-claim the TTY line the
239
- // finishing newline just released.
240
- return;
241
- }
242
- const { stage, progress, processed, total } = event;
243
- const counts = total > 0 ? ` (${processed}/${total})` : "";
244
- const text = `${this.label}: ${stage} ${progress}%${counts}`;
245
- if (this.out.isTTY === true && this.claimTtyLine()) {
246
- // Clamped to the terminal width: an auto-wrapped line breaks the
247
- // \r rewrite (git clamps its own progress output for the same
248
- // reason). The moving part sits at the end, so shorten the label
249
- // and keep the stage/percentage visible; only when even the
250
- // suffix cannot fit does a plain head slice apply. \x1b[K clears
251
- // the tail of a longer previous line.
252
- const budget = Math.max(1, (this.out.columns || 80) - 1);
253
- const suffix = `: ${stage} ${progress}%${counts}`;
254
- let line: string;
255
- if (text.length <= budget) {
256
- line = text;
257
- } else if (suffix.length + 4 <= budget) {
258
- const labelBudget = budget - suffix.length;
259
- line = `${this.label.slice(0, labelBudget - 3)}...${suffix}`;
260
- } else {
261
- line = text.slice(0, budget);
262
- }
263
- this.out.write(`\r${line}\x1b[K`);
264
- this.wroteInPlace = true;
265
- return;
266
- }
267
- const milestone = Math.floor(progress / 25);
268
- if (stage !== this.lastStage || milestone < this.lastMilestone) {
269
- // A new stage, or a percentage restart inside one reported stage:
270
- // git's server-side counting and compressing phases both arrive as
271
- // stage "remote:", so a drop in progress marks the next sub-phase.
272
- this.lastStage = stage;
273
- this.lastMilestone = milestone;
274
- } else if (milestone > this.lastMilestone) {
275
- this.lastMilestone = milestone;
276
- } else {
277
- return;
278
- }
279
- if (this.out.isTTY === true) {
280
- // A non-owner on a TTY: the cursor may sit mid-line on the owner's
281
- // in-place output, so take over the row cleanly and scroll it.
282
- this.out.write(`\r${text}\x1b[K\n`);
283
- return;
284
- }
285
- this.out.write(`${text}\n`);
286
- }
287
-
288
- done(): void {
289
- this.finished = true;
290
- if (this.wroteInPlace) {
291
- this.out.write("\n");
292
- }
293
- if (CloneProgressReporter.ttyOwners.get(this.out) === this) {
294
- CloneProgressReporter.ttyOwners.delete(this.out);
295
- }
296
- }
297
-
298
- private claimTtyLine(): boolean {
299
- const owner = CloneProgressReporter.ttyOwners.get(this.out);
300
- if (owner === undefined) {
301
- CloneProgressReporter.ttyOwners.set(this.out, this);
302
- return true;
303
- }
304
- return owner === this;
305
- }
306
- }
307
-
308
- /**
309
- * The last `/segment` of a run of non-empty path segments at the start of
310
- * `afterRepo`, or undefined. Reproduces the capture of the old
311
- * `(\/[^/]+)*` group, which retained only its final iteration and stopped at
312
- * the first empty segment.
313
- */
314
- function lastGitHubPathSegment(afterRepo: string): string | undefined {
315
- if (!afterRepo.startsWith("/")) return undefined;
316
- const run: string[] = [];
317
- for (const seg of afterRepo.slice(1).split("/")) {
318
- if (seg === "") break;
319
- run.push(seg);
320
- }
321
- return run.length > 0 ? `/${run[run.length - 1]}` : undefined;
322
- }
323
-
324
- /**
325
- * Parse a GitHub package location into owner, repo, and (optionally) the
326
- * subdirectory path segment.
327
- *
328
- * Uses a repetition-free regex for owner/repo plus plain string ops for the
329
- * path, NOT a single `(\/[^/]+)*` regex: those shapes were flagged as
330
- * polynomial-backtracking (ReDoS) on adversarial input, and the location is
331
- * user/config supplied. Behavior is preserved exactly, including
332
- * `packagePath` being only the LAST path segment after the repo (a quirk the
333
- * callers rely on); environment_store.spec.ts fuzzes the old regexes as an
334
- * oracle to prove equivalence. Exported for that test.
335
- */
336
- export function parseGitHubUrl(
337
- githubUrl: string,
338
- ): { owner: string; repoName: string; packagePath?: string } | null {
339
- // HTTPS: https://github.com/owner/repo/tree/branch/subdir. The host may
340
- // appear anywhere (the old regex was unanchored); the owner/repo pattern
341
- // has no repetition group, so it cannot backtrack polynomially.
342
- const httpsMatch = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl);
343
- if (httpsMatch) {
344
- const afterRepo = githubUrl.slice(
345
- httpsMatch.index + httpsMatch[0].length,
346
- );
347
- return {
348
- owner: httpsMatch[1],
349
- repoName: httpsMatch[2],
350
- packagePath: lastGitHubPathSegment(afterRepo),
351
- };
352
- }
353
-
354
- // SSH: git@github.com:owner/repo(.git)?(/subdir)*. The old regex was
355
- // $-anchored, so the whole tail after the repo had to be a run of
356
- // non-empty `/segment`s; a trailing slash or empty segment matched nothing
357
- // and failed the parse.
358
- // `git@github.com:` may appear anywhere (the old regex was not anchored at
359
- // the start), though in practice isGitHubURL only routes locations that
360
- // begin with it here.
361
- const SSH_PREFIX = "git@github.com:";
362
- const sshAt = githubUrl.indexOf(SSH_PREFIX);
363
- if (sshAt !== -1) {
364
- const rest = githubUrl.slice(sshAt + SSH_PREFIX.length);
365
- const firstSlash = rest.indexOf("/");
366
- if (firstSlash > 0) {
367
- const owner = rest.slice(0, firstSlash);
368
- const afterOwner = rest.slice(firstSlash + 1);
369
- const secondSlash = afterOwner.indexOf("/");
370
- const repoSeg =
371
- secondSlash === -1 ? afterOwner : afterOwner.slice(0, secondSlash);
372
- const afterRepo =
373
- secondSlash === -1 ? "" : afterOwner.slice(secondSlash);
374
- // repo was `[^/\s]+` (no whitespace); anything else falls through to
375
- // "not a GitHub URL", as the old regex did.
376
- if (repoSeg.length > 0 && !/\s/.test(repoSeg)) {
377
- const repoName =
378
- repoSeg.length > 4 && repoSeg.endsWith(".git")
379
- ? repoSeg.slice(0, -4)
380
- : repoSeg;
381
- if (afterRepo === "") {
382
- return { owner, repoName };
383
- }
384
- const segments = afterRepo.slice(1).split("/");
385
- // $-anchored: every trailing segment had to be non-empty.
386
- if (!segments.includes("")) {
387
- return {
388
- owner,
389
- repoName,
390
- packagePath: `/${segments[segments.length - 1]}`,
391
- };
392
- }
393
- }
394
- }
395
- }
396
-
397
- return null;
398
- }
399
-
400
- /** `0.0.0.0`/`::` bind every interface; a script needs a host it can dial. */
401
- function displayHost(host: string): string {
402
- if (host === "0.0.0.0" || host === "::") {
403
- return "localhost";
404
- }
405
- // An IPv6 literal needs brackets to be dialable inside a URL.
406
- return host.includes(":") ? `[${host}]` : host;
407
- }
408
-
409
- /**
410
- * The one-line machine-readable boot signal. Scripts grep for the
411
- * `PUBLISHER_READY` prefix instead of polling `/api/v0/status`, and the
412
- * counts carry what `operationalState: "serving"` alone does not say: how
413
- * much of the configured world actually loaded. Host and ports mirror
414
- * server.ts's use-site defaults; parseArgs writes the flag values into
415
- * process.env before the store is constructed. Exported for tests.
416
- */
417
- export function formatReadinessLine(counts: {
418
- environments: number;
419
- packages: number;
420
- loadErrors: number;
421
- }): string {
422
- const host = displayHost(process.env.PUBLISHER_HOST || "0.0.0.0");
423
- const port = Number(process.env.PUBLISHER_PORT || 4000);
424
- const mcpPort = Number(process.env.MCP_PORT || 4040);
425
- return (
426
- `PUBLISHER_READY url=http://${host}:${port} ` +
427
- `mcp=http://${host}:${mcpPort} ` +
428
- `environments=${counts.environments} packages=${counts.packages} ` +
429
- `load_errors=${counts.loadErrors}`
430
- );
431
- }
432
-
433
- export class EnvironmentStore {
434
- public serverRootPath: string;
435
- private environments: Map<string, Environment> = new Map();
436
- /**
437
- * Environments that were configured but did not load, keyed by name.
438
- *
439
- * A load failure is not fatal: the environment is skipped and the server
440
- * still reports `serving`. Nothing else remembers it, because the throw
441
- * happens before the environment reaches `this.environments` or the
442
- * database, so this is the only record that a configured environment is
443
- * missing rather than never asked for. Surfaced on `getStatus`.
444
- */
445
- private failedEnvironments = new Map<string, string>();
446
- private environmentMutexes = new Map<string, Mutex>();
447
- public publisherConfigIsFrozen: boolean;
448
- public finishedInitialization: Promise<void>;
449
- private isInitialized: boolean = false;
450
- public storageManager: StorageManager;
451
- private s3Client = new S3({
452
- followRegionRedirects: true,
453
- });
454
- private gcsClient: Storage;
455
- // Shared by every Environment so the back-pressure decision is
456
- // process-wide. Set once at server start via setMemoryGovernor;
457
- // new Environments pick it up at construction.
458
- private memoryGovernor: PackageMemoryGovernor | null = null;
459
-
460
- /**
461
- * Set of environment names that should be loaded "in place" — i.e. the
462
- * package directories under `publisher_data/<envName>/` are symlinks to
463
- * the original local source directories instead of recursive copies.
464
- *
465
- * In-place mode powers the `npm run dev`-style live-reload story: edits
466
- * to your source repo are visible to Publisher and trigger watch events
467
- * that fan out via SSE to embedded HTML pages. Production mode (the
468
- * default) keeps the safe copy semantics so the source repo and the
469
- * served package are decoupled.
470
- *
471
- * Populated from the `PUBLISHER_WATCH` env var (comma-separated env
472
- * names) at construction; can also be augmented via `markInPlace()` if
473
- * a future feature wants to flip an environment to dev mode at runtime.
474
- *
475
- * Only LOCAL-DIR package locations are eligible for symlinking; remote
476
- * sources (GitHub, GCS, S3) always copy regardless.
477
- */
478
- private inPlaceEnvs = new Set<string>();
479
-
480
- constructor(serverRootPath: string) {
481
- this.serverRootPath = serverRootPath;
482
- this.gcsClient = new Storage();
483
-
484
- const watchEnvList = (process.env.PUBLISHER_WATCH || "")
485
- .split(",")
486
- .map((s) => s.trim())
487
- .filter((s) => s.length > 0);
488
- for (const envName of watchEnvList) this.inPlaceEnvs.add(envName);
489
-
490
- const storageConfig: StorageConfig = {
491
- type: "duckdb",
492
- duckdb: {
493
- path: path.join(serverRootPath, "publisher.db"),
494
- },
495
- };
496
- this.storageManager = new StorageManager(storageConfig);
497
-
498
- this.finishedInitialization = this.initialize();
499
- }
500
-
501
- /** True if this env should mount package dirs in place (symlinks). */
502
- public isInPlace(environmentName: string): boolean {
503
- return this.inPlaceEnvs.has(environmentName);
504
- }
505
-
506
- /** Add an env to in-place mode at runtime. Caller is responsible for
507
- * triggering a re-mount if the env was already loaded with copies. */
508
- public markInPlace(environmentName: string): void {
509
- this.inPlaceEnvs.add(environmentName);
510
- }
511
-
512
- /**
513
- * Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
514
- * Propagated to every Environment so the back-pressure decision is
515
- * process-wide, and remembered so any Environment created *after*
516
- * this call also picks it up at construction.
517
- */
518
- public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
519
- this.memoryGovernor = governor;
520
- for (const env of this.environments.values()) {
521
- env.setMemoryGovernor(governor);
522
- }
523
- }
524
-
525
- /**
526
- * Snapshot of the environments currently held in memory. Used by the
527
- * standalone materialization scheduler to sweep loaded packages without
528
- * touching the database or triggering loads.
529
- */
530
- public getLoadedEnvironments(): Environment[] {
531
- return [...this.environments.values()];
532
- }
533
-
534
- private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
535
- try {
536
- await this.addEnvironment(
537
- {
538
- name: environment.name,
539
- resource: `${API_PREFIX}/environments/${environment.name}`,
540
- connections: environment.connections,
541
- packages: environment.packages,
542
- },
543
- true,
544
- );
545
- } catch (error) {
546
- this.recordEnvironmentInitializationFailure(environment.name, error);
547
- }
548
- }
549
-
550
- /**
551
- * Log a skipped environment AND remember why, for getStatus to report.
552
- *
553
- * Both boot paths funnel through here: the config path
554
- * ({@link addConfiguredEnvironment}) and the database-restore path taken by
555
- * every restart against an existing publisher.db. Keeping the record here
556
- * rather than in the callers is deliberate: recording it in only one of them
557
- * would leave the other silently dropping failures, which is the bug this is
558
- * meant to fix.
559
- */
560
- private recordEnvironmentInitializationFailure(
561
- environmentName: string | undefined,
562
- error: unknown,
563
- ) {
564
- const label = environmentName ? ` "${environmentName}"` : "";
565
- logger.error(
566
- `Error initializing environment${label}; skipping environment`,
567
- this.extractErrorDataFromError(error),
568
- );
569
- // Only record an environment that is genuinely not there. An environment
570
- // reaches `this.environments` before the rest of its setup (the database
571
- // sync, its package listing) is done, and a throw from that tail lands
572
- // here while the environment is live and serving. Reporting it would
573
- // contradict `environments`, which still lists it, and a loadErrors entry
574
- // with no `package` means the whole environment was skipped. It also could
575
- // never be cleared: the clear below only runs when an environment is
576
- // created, so a live one would carry the entry until restart.
577
- if (environmentName && !this.environments.has(environmentName)) {
578
- // Redacted because this is bound for an HTTP response body, not just a
579
- // log line; see the same call in environment.ts.
580
- this.failedEnvironments.set(
581
- environmentName,
582
- redactPgSecrets(
583
- error instanceof Error ? error.message : String(error),
584
- ),
585
- );
586
- }
587
- }
588
-
589
- private async initialize() {
590
- const reInit = process.env.INITIALIZE_STORAGE === "true";
591
- const initialTime = performance.now();
592
-
593
- try {
594
- await this.storageManager.initialize(reInit);
595
-
596
- this.publisherConfigIsFrozen = isPublisherConfigFrozen(
597
- this.serverRootPath,
598
- );
599
-
600
- const environmentManifest =
601
- await EnvironmentStore.reloadEnvironmentManifest(
602
- this.serverRootPath,
603
- );
604
-
605
- await this.cleanupAndCreatePublisherPath();
606
-
607
- const repository = this.storageManager.getRepository();
608
-
609
- if (reInit) {
610
- // Load environments from config file
611
- await Promise.all(
612
- environmentManifest.environments.map(async (environment) => {
613
- await this.addConfiguredEnvironment(environment);
614
- }),
615
- );
616
- } else {
617
- // Load existing environments from database
618
- const existingEnvironments = await repository.listEnvironments();
619
-
620
- if (existingEnvironments.length > 0) {
621
- // Load environments from database
622
- await Promise.all(
623
- existingEnvironments.map(async (dbEnvironment) => {
624
- try {
625
- // Check if environment files exist on disk
626
- const environmentExists = await fs.promises
627
- .access(dbEnvironment.path)
628
- .then(() => true)
629
- .catch(() => false);
630
-
631
- if (!environmentExists) {
632
- // Try to find in config and reload
633
- const environmentConfig =
634
- environmentManifest.environments.find(
635
- (p) => p.name === dbEnvironment.name,
636
- );
637
-
638
- if (environmentConfig) {
639
- const environmentInstance =
640
- await this.addEnvironment(
641
- {
642
- name: environmentConfig.name,
643
- resource: `${API_PREFIX}/environments/${environmentConfig.name}`,
644
- connections:
645
- environmentConfig.connections,
646
- packages: environmentConfig.packages,
647
- },
648
- true,
649
- );
650
-
651
- // Update database with new path
652
- await repository.updateEnvironment(
653
- dbEnvironment.id,
654
- {
655
- path: environmentInstance.metadata.location,
656
- },
657
- );
658
-
659
- return environmentInstance.listPackages();
660
- } else {
661
- logger.error(
662
- `Environment "${dbEnvironment.name}" not found in config and files missing`,
663
- );
664
- return;
665
- }
666
- }
667
-
668
- // Get connections from database
669
- const connections = await repository.listConnections(
670
- dbEnvironment.id,
671
- );
672
-
673
- const environmentInstance = await Environment.create(
674
- dbEnvironment.name,
675
- dbEnvironment.path,
676
- connections.map((conn) => ({
677
- name: conn.name,
678
- type: conn.type,
679
- resource: `${API_PREFIX}/connections/${conn.name}`,
680
- ...conn.config,
681
- })),
682
- );
683
- environmentInstance.setMemoryGovernor(
684
- this.memoryGovernor,
685
- );
686
- // Re-establish serve routing when a package loads, from
687
- // its latest successful materialization — so serving
688
- // survives a restart, not only a fresh build. The full
689
- // entry map is returned; the environment splits it by
690
- // tier (colocated + storage=) in
691
- // rebindServeBindingsFromLocalStore.
692
- const envId = dbEnvironment.id;
693
- environmentInstance.setStorageBindingResolver(
694
- async (packageName) => {
695
- const runs =
696
- await repository.listMaterializations(
697
- envId,
698
- packageName,
699
- );
700
- const latest = runs.find(
701
- (m) =>
702
- m.status === "MANIFEST_FILE_READY" &&
703
- m.manifest?.entries,
704
- );
705
- return latest?.manifest?.entries ?? {};
706
- },
707
- );
708
-
709
- // Get packages from database
710
- const packages = await repository.listPackages(
711
- dbEnvironment.id,
712
- );
713
- packages.forEach((pkg) => {
714
- environmentInstance.setPackageStatus(
715
- pkg.name,
716
- PackageStatus.SERVING,
717
- );
718
- });
719
-
720
- this.environments.set(
721
- dbEnvironment.name,
722
- environmentInstance,
723
- );
724
-
725
- return environmentInstance.listPackages();
726
- } catch (error) {
727
- this.recordEnvironmentInitializationFailure(
728
- dbEnvironment.name,
729
- error,
730
- );
731
- }
732
- }),
733
- );
734
- } else {
735
- // Fallback to config file if database is empty
736
- await Promise.all(
737
- environmentManifest.environments.map(async (environment) => {
738
- await this.addConfiguredEnvironment(environment);
739
- }),
740
- );
741
- }
742
- }
743
-
744
- this.isInitialized = true;
745
- markReady();
746
- const initializationDuration = performance.now() - initialTime;
747
- logger.info(
748
- `Environment store successfully initialized in ${formatDuration(initializationDuration)}`,
749
- );
750
- this.emitReadinessLine();
751
- } catch (error) {
752
- markNotReady();
753
- const errorData = this.extractErrorDataFromError(error);
754
- logger.error("Error initializing environment store", errorData);
755
- try {
756
- // The failure counterpart of PUBLISHER_READY, so a script
757
- // waiting on that token fails fast instead of hanging on a
758
- // broken config. Redacted because initialization errors can
759
- // carry connection strings.
760
- process.stderr.write(
761
- `PUBLISHER_INIT_FAILED error=${JSON.stringify(
762
- redactPgSecrets(
763
- error instanceof Error ? error.message : String(error),
764
- ),
765
- )}\n`,
766
- );
767
- } catch {
768
- // A failed stderr write must not mask the logged error above.
769
- }
770
- }
771
- }
772
-
773
- /**
774
- * Printed exactly once, from initialize()'s success tail: markReady() has
775
- * just flipped /api/v0/status to "serving", and both HTTP listeners bound
776
- * long before the downloads finished, so this is the same moment every
777
- * existing readiness consumer (CI status polls, MCP /health/readiness)
778
- * fires on. The catch above swallows a failed boot after markNotReady(),
779
- * so the line never prints for a server that is not actually serving.
780
- */
781
- private emitReadinessLine(): void {
782
- let packages = 0;
783
- let loadErrors = this.failedEnvironments.size;
784
- for (const environment of this.environments.values()) {
785
- packages += environment.getServingPackageCount();
786
- loadErrors += environment.getFailedPackages().size;
787
- }
788
- try {
789
- process.stderr.write(
790
- formatReadinessLine({
791
- environments: this.environments.size,
792
- packages,
793
- loadErrors,
794
- }) + "\n",
795
- );
796
- } catch {
797
- // A failed stderr write must not fail a boot that is already
798
- // serving.
799
- }
800
- }
801
-
802
- public async addEnvironmentToDatabase(
803
- environment: Environment,
804
- ): Promise<void> {
805
- if (!environment) {
806
- logger.error("Cannot sync: environment is null or undefined");
807
- return;
808
- }
809
-
810
- const environmentName = environment.metadata?.name;
811
- if (!environmentName) {
812
- throw new Error("Environment name is required but not found");
813
- }
814
-
815
- const repository = this.storageManager.getRepository();
816
-
817
- // Sync environment metadata
818
- const dbEnvironment = await this.addEnvironmentMetadata(
819
- environment,
820
- repository,
821
- );
822
-
823
- // Sync connections
824
- await this.addConnections(environment, dbEnvironment.id, repository);
825
-
826
- // Sync packages
827
- await this.addPackages(environment, dbEnvironment.id, repository);
828
-
829
- logger.info(`Synced environment "${environmentName}" to database`);
830
- }
831
-
832
- public async deleteEnvironmentFromDatabase(
833
- environmentName: string,
834
- ): Promise<void> {
835
- // Before the metadata lookup, for the same reason as the package
836
- // variant: the cleanup needs only the name, never the row.
837
- this.cleanupEnvironmentEmbeddings(environmentName);
838
-
839
- const repository = this.storageManager.getRepository();
840
-
841
- // Get the environment from database
842
- const dbEnvironment =
843
- await repository.getEnvironmentByName(environmentName);
844
-
845
- if (!dbEnvironment) {
846
- logger.error(`Environment "${environmentName}" not found in database`);
847
- return;
848
- }
849
-
850
- // Delete the environment (this will cascade delete connections and packages)
851
- await repository.deleteEnvironment(dbEnvironment.id);
852
- logger.info(`Deleted environment "${environmentName}" from database`);
853
- }
854
-
855
- private async addEnvironmentMetadata(
856
- environment: Environment,
857
- repository: ReturnType<typeof this.storageManager.getRepository>,
858
- ): Promise<{ id: string; name: string }> {
859
- const environmentName = environment.metadata?.name;
860
- if (!environmentName) {
861
- throw new Error("Environment name is required but not found");
862
- }
863
- const environmentPath = environment.metadata?.location || "";
864
- const environmentDescription = environment.metadata?.readme;
865
-
866
- const environmentData = {
867
- name: environmentName,
868
- path: environmentPath,
869
- description: environmentDescription,
870
- metadata: environment.metadata || {},
871
- };
872
- const existingEnvironment =
873
- await repository.getEnvironmentByName(environmentName);
874
-
875
- let dbEnvironment: { id: string; name: string };
876
- if (existingEnvironment) {
877
- const updateData = {
878
- description: environmentDescription,
879
- metadata: environment.metadata || {},
880
- };
881
-
882
- await repository.updateEnvironment(existingEnvironment.id, updateData);
883
- dbEnvironment = { id: existingEnvironment.id, name: environmentName };
884
- } else {
885
- dbEnvironment = await repository.createEnvironment(environmentData);
886
- }
887
-
888
- return dbEnvironment;
889
- }
890
-
891
- private async addPackages(
892
- environment: Environment,
893
- environmentId: string,
894
- repository: ReturnType<typeof this.storageManager.getRepository>,
895
- ): Promise<void> {
896
- const packages = await environment.listPackages();
897
-
898
- // Sync each package
899
- for (const pkg of packages) {
900
- if (!pkg.name) {
901
- logger.warn("Skipping package with undefined name");
902
- continue;
903
- }
904
-
905
- await this.addPackage(pkg, environmentId, repository);
906
- }
907
- }
908
-
909
- private async addPackage(
910
- pkg: components["schemas"]["Package"],
911
- environmentId: string,
912
- repository: ReturnType<typeof this.storageManager.getRepository>,
913
- ): Promise<void> {
914
- const pkgs = pkg as {
915
- name: string;
916
- description?: string;
917
- manifestPath?: string;
918
- metadata?: Record<string, unknown>;
919
- };
920
-
921
- const packageData = {
922
- environmentId,
923
- name: pkgs.name,
924
- description: pkgs.description ?? undefined,
925
- manifestPath: pkgs.manifestPath ?? "",
926
- metadata: pkgs.metadata ?? {},
927
- };
928
-
929
- try {
930
- await repository.createPackage(packageData);
931
- logger.info(`Synced package: ${pkg.name}`);
932
- } catch (err: unknown) {
933
- const error = err as Error;
934
- if (
935
- error.message?.includes("UNIQUE") ||
936
- error.message?.includes("Constraint")
937
- ) {
938
- await this.updatePackage(
939
- pkgs.name,
940
- environmentId,
941
- packageData,
942
- repository,
943
- );
944
- } else {
945
- logger.warn(`Failed to sync package ${pkg.name}:`, error.message);
946
- }
947
- }
948
- }
949
-
950
- private async updatePackage(
951
- packageName: string,
952
- environmentId: string,
953
- packageData: {
954
- description: string | undefined;
955
- manifestPath: string;
956
- metadata: Record<string, unknown>;
957
- },
958
- repository: ReturnType<typeof this.storageManager.getRepository>,
959
- ): Promise<void> {
960
- const existingPackage = await repository.getPackageByName(
961
- environmentId,
962
- packageName,
963
- );
964
-
965
- if (existingPackage) {
966
- await repository.updatePackage(existingPackage.id, packageData);
967
- logger.info(`Updated existing package: ${packageName}`);
968
- }
969
- }
970
-
971
- private async addConnections(
972
- environment: Environment,
973
- environmentId: string,
974
- repository: ReturnType<typeof this.storageManager.getRepository>,
975
- ): Promise<void> {
976
- try {
977
- const connections = environment.listApiConnections();
978
- // Add/update connections
979
- for (const conn of connections) {
980
- if (!conn.name) {
981
- logger.warn("Skipping connection with undefined name");
982
- continue;
983
- }
984
-
985
- // Check if connection exists
986
- const existingConn = await repository.getConnectionByName(
987
- environmentId,
988
- conn.name,
989
- );
990
-
991
- if (existingConn) {
992
- await this.updateConnection(conn, environmentId, repository);
993
- } else {
994
- await this.addConnection(conn, environmentId, repository);
995
- }
996
- }
997
- } catch (err: unknown) {
998
- const error = err as Error;
999
- const environmentName = environment.metadata?.name;
1000
- logger.error(
1001
- `Error syncing connections for "${environmentName}":`,
1002
- error,
1003
- );
1004
- }
1005
- }
1006
-
1007
- public async addConnection(
1008
- conn: ReturnType<Environment["listApiConnections"]>[number],
1009
- environmentId: string,
1010
- repository: ReturnType<typeof this.storageManager.getRepository>,
1011
- ): Promise<void> {
1012
- if (!conn.name) {
1013
- logger.warn("Skipping connection with undefined name");
1014
- return;
1015
- }
1016
-
1017
- const connectionData = {
1018
- environmentId,
1019
- name: conn.name,
1020
- type: conn.type as Connection["type"],
1021
- config: conn,
1022
- };
1023
-
1024
- try {
1025
- await repository.createConnection(connectionData);
1026
- logger.info(`Created connection: ${conn.name}`);
1027
- } catch (err: unknown) {
1028
- const error = err as Error;
1029
- logger.error(`Failed to create connection ${conn.name}:`, error);
1030
- throw error;
1031
- }
1032
- }
1033
-
1034
- public async updateConnection(
1035
- conn: ReturnType<Environment["listApiConnections"]>[number],
1036
- environmentId: string,
1037
- repository: ReturnType<typeof this.storageManager.getRepository>,
1038
- ): Promise<void> {
1039
- if (!conn.name) {
1040
- throw new Error("Connection name is required for update");
1041
- }
1042
-
1043
- const existingConn = await repository.getConnectionByName(
1044
- environmentId,
1045
- conn.name,
1046
- );
1047
-
1048
- if (!existingConn) {
1049
- logger.error(`Connection "${conn.name}" not found in environment`);
1050
- }
1051
-
1052
- const connectionData = {
1053
- type: conn.type as Connection["type"],
1054
- config: conn,
1055
- };
1056
-
1057
- try {
1058
- if (existingConn) {
1059
- await repository.updateConnection(existingConn.id, connectionData);
1060
- }
1061
- logger.info(`Updated connection: ${conn.name}`);
1062
- } catch (err: unknown) {
1063
- const error = err as Error;
1064
- logger.error(`Failed to update connection ${conn.name}:`, error);
1065
- throw error;
1066
- }
1067
- }
1068
-
1069
- public async addPackageToDatabase(
1070
- environmentName: string,
1071
- packageName: string,
1072
- ): Promise<void> {
1073
- const environment = await this.getEnvironment(environmentName, false);
1074
- const repository = this.storageManager.getRepository();
1075
-
1076
- // Get the environment ID from database
1077
- const dbEnvironment =
1078
- await repository.getEnvironmentByName(environmentName);
1079
-
1080
- if (!dbEnvironment) {
1081
- logger.error(`Environment "${environmentName}" not found in database`);
1082
- throw new Error(
1083
- `Environment "${environmentName}" not found in database`,
1084
- );
1085
- }
1086
-
1087
- // Get the package from the environment
1088
- const packages = await environment.listPackages();
1089
- const pkg = packages.find((p) => p.name === packageName);
1090
-
1091
- if (!pkg) {
1092
- logger.warn(`Package "${packageName}" not found in environment`);
1093
- return;
1094
- }
1095
-
1096
- // Sync the specific package
1097
- await this.addPackage(pkg, dbEnvironment.id, repository);
1098
- logger.info(`Synced package "${packageName}" to database`);
1099
- }
1100
-
1101
- /**
1102
- * Delete a package from the database
1103
- */
1104
- public async deletePackageFromDatabase(
1105
- environmentName: string,
1106
- packageName: string,
1107
- ): Promise<void> {
1108
- // Before the metadata lookups: embeddings are written off the
1109
- // in-memory environment, which can outlive a lost metadata row,
1110
- // so the cleanup must not sit behind the missing-row early return.
1111
- this.cleanupPackageEmbeddings(environmentName, packageName);
1112
-
1113
- const repository = this.storageManager.getRepository();
1114
-
1115
- // Get the environment ID from database
1116
- const dbEnvironment =
1117
- await repository.getEnvironmentByName(environmentName);
1118
-
1119
- if (!dbEnvironment) {
1120
- logger.error(`Environment "${environmentName}" not found in database`);
1121
- return;
1122
- }
1123
-
1124
- // Find and delete the package
1125
- const existingPackage = await repository.getPackageByName(
1126
- dbEnvironment.id,
1127
- packageName,
1128
- );
1129
-
1130
- if (existingPackage) {
1131
- await repository.deletePackage(existingPackage.id);
1132
- logger.info(`Deleted package "${packageName}" from database`);
1133
- }
1134
- }
1135
-
1136
- /**
1137
- * Best-effort, non-blocking: drop a deleted package's cached entity
1138
- * embeddings so package churn does not grow publisher.db forever.
1139
- * Not awaited by callers: the cleanup queues on the package's sync
1140
- * mutex, which an in-flight bulk embed can hold for minutes, and a
1141
- * DELETE response must not stall behind it. Needs only the names, so
1142
- * it runs regardless of whether the metadata row still exists. An
1143
- * orphaned row is inert (reads are scoped by environment + package),
1144
- * so failure is logged, never propagated.
1145
- */
1146
- private cleanupPackageEmbeddings(
1147
- environmentName: string,
1148
- packageName: string,
1149
- ): void {
1150
- try {
1151
- deletePackageEmbeddings(
1152
- this.storageManager.getDuckDbConnection(),
1153
- environmentName,
1154
- packageName,
1155
- ).catch((error: unknown) => {
1156
- logger.warn("Failed to clean up entity embeddings for package", {
1157
- environmentName,
1158
- packageName,
1159
- error: error instanceof Error ? error.message : String(error),
1160
- });
1161
- });
1162
- } catch (error) {
1163
- logger.warn("Failed to clean up entity embeddings for package", {
1164
- environmentName,
1165
- packageName,
1166
- error: error instanceof Error ? error.message : String(error),
1167
- });
1168
- }
1169
- }
1170
-
1171
- /** Environment-wide variant of {@link cleanupPackageEmbeddings}. */
1172
- private cleanupEnvironmentEmbeddings(environmentName: string): void {
1173
- try {
1174
- deleteEnvironmentEmbeddings(
1175
- this.storageManager.getDuckDbConnection(),
1176
- environmentName,
1177
- ).catch((error: unknown) => {
1178
- logger.warn(
1179
- "Failed to clean up entity embeddings for environment",
1180
- {
1181
- environmentName,
1182
- error: error instanceof Error ? error.message : String(error),
1183
- },
1184
- );
1185
- });
1186
- } catch (error) {
1187
- logger.warn("Failed to clean up entity embeddings for environment", {
1188
- environmentName,
1189
- error: error instanceof Error ? error.message : String(error),
1190
- });
1191
- }
1192
- }
1193
-
1194
- private async cleanupAndCreatePublisherPath() {
1195
- const reInit = process.env.INITIALIZE_STORAGE === "true";
1196
-
1197
- // Ensure serverRootPath exists as a directory (mkdir recursive is a
1198
- // no-op when the directory already exists and avoids a Bun-on-Windows
1199
- // bug where fs.promises.stat resolves to undefined instead of throwing)
1200
- await fs.promises.mkdir(this.serverRootPath, { recursive: true });
1201
-
1202
- if (reInit) {
1203
- const uploadDocsPath = path.join(
1204
- this.serverRootPath,
1205
- PUBLISHER_DATA_DIR,
1206
- );
1207
- logger.info(
1208
- `Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath}`,
1209
- );
1210
- try {
1211
- await fs.promises.rm(uploadDocsPath, {
1212
- recursive: true,
1213
- force: true,
1214
- });
1215
- } catch (error) {
1216
- if ((error as NodeJS.ErrnoException).code === "EACCES") {
1217
- logger.warn(
1218
- `Permission denied, skipping cleanup of upload documents path ${uploadDocsPath}`,
1219
- );
1220
- } else if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1221
- // Ignore if directory doesn't exist
1222
- throw error;
1223
- }
1224
- }
1225
- } else {
1226
- logger.info(`Using existing publisher path`);
1227
- }
1228
-
1229
- const uploadDocsPath = path.join(this.serverRootPath, PUBLISHER_DATA_DIR);
1230
- await fs.promises.mkdir(uploadDocsPath, { recursive: true });
1231
- }
1232
-
1233
- public async listEnvironments(skipInitializationCheck: boolean = false) {
1234
- if (!skipInitializationCheck) {
1235
- await this.finishedInitialization;
1236
- }
1237
- return Promise.all(
1238
- Array.from(this.environments.values()).map((environment) =>
1239
- environment.serialize(),
1240
- ),
1241
- );
1242
- }
1243
-
1244
- public async getStatus() {
1245
- // Surface the memory governor's back-pressure as a "throttled"
1246
- // operational state so the control plane can stop routing new package
1247
- // loads/queries to a throttled worker. Draining takes precedence: a
1248
- // shutting-down server should keep reporting "draining". When the
1249
- // governor is disabled (no PUBLISHER_MAX_MEMORY_BYTES) this never fires.
1250
- const baseState = getOperationalState();
1251
- const operationalState = (
1252
- baseState !== "draining" &&
1253
- (this.memoryGovernor?.isBackpressured() ?? false)
1254
- ? "throttled"
1255
- : baseState
1256
- ) as components["schemas"]["ServerStatus"]["operationalState"];
1257
-
1258
- const status: {
1259
- timestamp: number;
1260
- environments: Array<components["schemas"]["Environment"]>;
1261
- initialized: boolean;
1262
- frozenConfig: boolean;
1263
- operationalState: components["schemas"]["ServerStatus"]["operationalState"];
1264
- loadErrors?: LoadError[];
1265
- } = {
1266
- timestamp: Date.now(),
1267
- environments: [],
1268
- initialized: this.isInitialized,
1269
- frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
1270
- operationalState,
1271
- };
1272
-
1273
- const environments = await this.listEnvironments(true);
1274
-
1275
- await Promise.all(
1276
- environments.map(async (environment) => {
1277
- try {
1278
- const packages = environment.packages;
1279
- const connections = environment.connections;
1280
-
1281
- logger.debug(`Environment ${environment.name} status:`, {
1282
- connectionsCount: environment.connections?.length || 0,
1283
- packagesCount: packages?.length || 0,
1284
- });
1285
-
1286
- const _connections = connections?.map((connection) => {
1287
- return {
1288
- ...connection,
1289
- attributes: undefined,
1290
- };
1291
- });
1292
-
1293
- const _environment = {
1294
- ...environment,
1295
- connections: _connections,
1296
- };
1297
- environment.connections = _connections;
1298
- status.environments.push(_environment);
1299
- } catch (error) {
1300
- logger.error("Error listing packages and connections", {
1301
- error,
1302
- });
1303
- throw new Error(
1304
- "Error listing packages and connections: " + error,
1305
- );
1306
- }
1307
- }),
1308
- );
1309
-
1310
- // Collected AFTER listEnvironments, not before: serialize() awaits
1311
- // listPackages(), which is what loads an environment's packages and so
1312
- // records their failures. Reading the maps at the top of this method risks
1313
- // reading them before the failures they report have been recorded.
1314
- //
1315
- // A read-time overlay over live state, like "throttled" above, so there is
1316
- // no fourth stored status to keep in sync.
1317
- const loadErrors: LoadError[] = [];
1318
- for (const [environmentName, message] of this.failedEnvironments) {
1319
- loadErrors.push({ environment: environmentName, message });
1320
- }
1321
- for (const [environmentName, environment] of this.environments) {
1322
- for (const [packageName, message] of environment.getFailedPackages()) {
1323
- loadErrors.push({
1324
- environment: environmentName,
1325
- package: packageName,
1326
- message,
1327
- });
1328
- }
1329
- }
1330
- // Left unset when nothing failed, so the key is absent from the response
1331
- // and a healthy server's status body is byte-for-byte what it was before
1332
- // this field existed.
1333
- if (loadErrors.length > 0) {
1334
- status.loadErrors = loadErrors;
1335
- }
1336
-
1337
- return status;
1338
- }
1339
-
1340
- public async getEnvironment(
1341
- environmentName: string,
1342
- reload: boolean = false,
1343
- ): Promise<Environment> {
1344
- await this.finishedInitialization;
1345
- assertSafePackageName(environmentName);
1346
-
1347
- // Check if environment is already loaded first
1348
- const environment = this.environments.get(environmentName);
1349
- if (environment !== undefined && !reload) {
1350
- return environment;
1351
- }
1352
-
1353
- // We need to acquire the mutex to prevent concurrent requests from creating the
1354
- // environment multiple times.
1355
- let environmentMutex = this.environmentMutexes.get(environmentName);
1356
- if (environmentMutex?.isLocked()) {
1357
- await environmentMutex.waitForUnlock();
1358
- const existingEnvironment = this.environments.get(environmentName);
1359
- if (existingEnvironment && !reload) {
1360
- return existingEnvironment;
1361
- }
1362
- }
1363
- environmentMutex = new Mutex();
1364
- this.environmentMutexes.set(environmentName, environmentMutex);
1365
-
1366
- return environmentMutex.runExclusive(async () => {
1367
- // Double-check after acquiring mutex
1368
- const existingEnvironment = this.environments.get(environmentName);
1369
- if (existingEnvironment !== undefined && !reload) {
1370
- return existingEnvironment;
1371
- }
1372
-
1373
- const environmentManifest =
1374
- await EnvironmentStore.reloadEnvironmentManifest(
1375
- this.serverRootPath,
1376
- );
1377
- const environmentConfig = environmentManifest.environments.find(
1378
- (e) => e.name === environmentName,
1379
- );
1380
- const environmentPath =
1381
- existingEnvironment?.metadata.location ||
1382
- environmentConfig?.packages[0]?.location;
1383
- if (!environmentPath) {
1384
- throw new EnvironmentNotFoundError(
1385
- `Environment "${environmentName}" could not be resolved to a path.`,
1386
- );
1387
- }
1388
- return await this.addEnvironment({
1389
- name: environmentName,
1390
- resource: `${API_PREFIX}/environments/${environmentName}`,
1391
- connections: environmentConfig?.connections || [],
1392
- });
1393
- });
1394
- }
1395
-
1396
- public async addEnvironment(
1397
- environment: ApiEnvironment,
1398
- skipInitialization: boolean = false,
1399
- ) {
1400
- if (!skipInitialization) {
1401
- await this.finishedInitialization;
1402
- }
1403
- if (!skipInitialization && this.publisherConfigIsFrozen) {
1404
- throw new FrozenConfigError();
1405
- }
1406
- assertSafePackageName(environment.name);
1407
- const environmentName = environment.name;
1408
- for (const _package of environment.packages || []) {
1409
- assertSafePackageName(_package.name);
1410
- }
1411
- // Check if environment already exists and update it instead of creating a new one
1412
- const existingEnvironment = this.environments.get(environmentName);
1413
- if (existingEnvironment) {
1414
- const updatedEnvironment =
1415
- await existingEnvironment.update(environment);
1416
- this.environments.set(environmentName, updatedEnvironment);
1417
- await this.addEnvironmentToDatabase(updatedEnvironment);
1418
- return updatedEnvironment;
1419
- }
1420
- const environmentManifest =
1421
- await EnvironmentStore.reloadEnvironmentManifest(this.serverRootPath);
1422
- const environmentConfig = environmentManifest.environments.find(
1423
- (e) => e.name === environmentName,
1424
- );
1425
- const hasPackages =
1426
- (environment?.packages && environment.packages.length > 0) ||
1427
- (environmentConfig?.packages && environmentConfig.packages.length > 0);
1428
- let absoluteEnvironmentPath: string;
1429
- let mountErrors: ReadonlyMap<string, string> = new Map();
1430
- if (hasPackages) {
1431
- const packagesToProcess =
1432
- environment?.packages || environmentConfig?.packages || [];
1433
- const loaded = await this.loadEnvironmentIntoDisk(
1434
- environmentName,
1435
- packagesToProcess,
1436
- );
1437
- absoluteEnvironmentPath = loaded.path;
1438
- mountErrors = loaded.mountErrors;
1439
- if (absoluteEnvironmentPath.endsWith(".zip")) {
1440
- absoluteEnvironmentPath = await this.unzipEnvironment(
1441
- absoluteEnvironmentPath,
1442
- );
1443
- }
1444
- } else {
1445
- absoluteEnvironmentPath = await this.scaffoldEnvironment(environment);
1446
- }
1447
- const newEnvironment = await Environment.create(
1448
- environmentName,
1449
- absoluteEnvironmentPath,
1450
- environment.connections || [],
1451
- );
1452
- newEnvironment.setMemoryGovernor(this.memoryGovernor);
1453
-
1454
- if (!newEnvironment.metadata) newEnvironment.metadata = {};
1455
- newEnvironment.metadata.location = absoluteEnvironmentPath;
1456
-
1457
- // Attach before the SERVING seeding below: those packages are configured
1458
- // but un-mounted, and this is the only record of why.
1459
- for (const [packageName, message] of mountErrors) {
1460
- newEnvironment.setPackageMountError(packageName, message);
1461
- }
1462
-
1463
- this.environments.set(environmentName, newEnvironment);
1464
- // It loaded, so any earlier failure for this name is stale. A boot
1465
- // failure can be followed by a successful POST or lazy load of the same
1466
- // environment, and a status that keeps reporting the old error would lie
1467
- // in the other direction.
1468
- this.failedEnvironments.delete(environmentName);
1469
-
1470
- environment?.packages?.forEach((_package) => {
1471
- if (_package.name) {
1472
- newEnvironment.setPackageStatus(
1473
- _package.name,
1474
- PackageStatus.SERVING,
1475
- );
1476
- }
1477
- });
1478
-
1479
- await this.addEnvironmentToDatabase(newEnvironment);
1480
-
1481
- return newEnvironment;
1482
- }
1483
-
1484
- public async unzipEnvironment(absoluteEnvironmentPath: string) {
1485
- assertSafeEnvironmentPath(absoluteEnvironmentPath);
1486
- const startedAt = Date.now();
1487
- logger.info(
1488
- `Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
1489
- );
1490
- const unzippedEnvironmentPath = absoluteEnvironmentPath.replace(
1491
- ".zip",
1492
- "",
1493
- );
1494
- await fs.promises.rm(unzippedEnvironmentPath, {
1495
- recursive: true,
1496
- force: true,
1497
- });
1498
- await fs.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
1499
-
1500
- // Stream-extract via yauzl (wrapped by extract-zip). Each entry's
1501
- // inflate and write are dispatched to the libuv thread pool, so the
1502
- // main event loop stays responsive even for very large archives.
1503
- // The previous adm-zip path used fs.readFileSync + zlib.inflateRawSync
1504
- // on the main thread, which parked the loop long enough on multi-
1505
- // hundred-MB packages to fail Kubernetes liveness probes mid-extract.
1506
- let entryCount = 0;
1507
- let totalUncompressedBytes = 0;
1508
- await extract(absoluteEnvironmentPath, {
1509
- dir: path.resolve(unzippedEnvironmentPath),
1510
- onEntry: (entry) => {
1511
- entryCount += 1;
1512
- totalUncompressedBytes += entry.uncompressedSize ?? 0;
1513
- },
1514
- });
1515
-
1516
- const mib = (totalUncompressedBytes / (1024 * 1024)).toFixed(1);
1517
- logger.info(
1518
- `Unzipped "${absoluteEnvironmentPath}" -> "${unzippedEnvironmentPath}" ` +
1519
- `(${entryCount} entries, ${mib} MiB uncompressed) in ` +
1520
- `${formatDuration(Date.now() - startedAt)}`,
1521
- );
1522
-
1523
- return unzippedEnvironmentPath;
1524
- }
1525
-
1526
- public async updateEnvironment(environment: ApiEnvironment) {
1527
- await this.finishedInitialization;
1528
- if (this.publisherConfigIsFrozen) {
1529
- throw new FrozenConfigError();
1530
- }
1531
- validateEnvironmentAzureUrls(environment);
1532
- assertSafePackageName(environment.name);
1533
- const environmentName = environment.name;
1534
- for (const _package of environment.packages || []) {
1535
- assertSafePackageName(_package.name);
1536
- }
1537
- const existingEnvironment = this.environments.get(environmentName);
1538
- if (!existingEnvironment) {
1539
- throw new EnvironmentNotFoundError(
1540
- `Environment ${environmentName} not found`,
1541
- );
1542
- }
1543
- const updatedEnvironment = await existingEnvironment.update(environment);
1544
- this.environments.set(environmentName, updatedEnvironment);
1545
- await this.addEnvironmentToDatabase(updatedEnvironment);
1546
- return updatedEnvironment;
1547
- }
1548
-
1549
- public async deleteEnvironment(
1550
- environmentName: string,
1551
- ): Promise<Environment | undefined> {
1552
- await this.finishedInitialization;
1553
- if (this.publisherConfigIsFrozen) {
1554
- throw new FrozenConfigError();
1555
- }
1556
- assertSafePackageName(environmentName);
1557
- const environment = this.environments.get(environmentName);
1558
- if (!environment) {
1559
- return;
1560
- }
1561
-
1562
- const environmentPath = environment.metadata?.location;
1563
-
1564
- // Close all connections before removing the environment
1565
- await environment.closeAllConnections();
1566
-
1567
- this.environments.delete(environmentName);
1568
- await this.deleteEnvironmentFromDatabase(environmentName);
1569
- if (environmentPath) {
1570
- try {
1571
- await fs.promises.rm(environmentPath, {
1572
- recursive: true,
1573
- force: true,
1574
- });
1575
- logger.info(`Deleted environment directory: ${environmentPath}`);
1576
- } catch (err) {
1577
- logger.error("Error removing environment directory", {
1578
- error: err,
1579
- });
1580
- }
1581
- }
1582
-
1583
- return environment;
1584
- }
1585
-
1586
- public static async reloadEnvironmentManifest(
1587
- serverRootPath: string,
1588
- ): Promise<ProcessedPublisherConfig> {
1589
- try {
1590
- return getProcessedPublisherConfig(serverRootPath);
1591
- } catch (error) {
1592
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1593
- logger.error(
1594
- `Error reading ${PUBLISHER_CONFIG_NAME}. Generating from directory`,
1595
- { error },
1596
- );
1597
- return { frozenConfig: false, environments: [] };
1598
- } else {
1599
- // If publisher.config.json is missing, generate the manifest from directories
1600
- try {
1601
- const entries = await fs.promises.readdir(serverRootPath, {
1602
- withFileTypes: true,
1603
- });
1604
- const environments: ProcessedEnvironment[] = [];
1605
- for (const entry of entries) {
1606
- if (entry.isDirectory()) {
1607
- environments.push({
1608
- name: entry.name,
1609
- packages: [
1610
- {
1611
- name: entry.name,
1612
- location: `./${entry.name}` as const,
1613
- },
1614
- ],
1615
- connections: [],
1616
- });
1617
- }
1618
- }
1619
- return { frozenConfig: false, environments };
1620
- } catch (lsError) {
1621
- logger.error(`Error listing directories in ${serverRootPath}`, {
1622
- error: lsError,
1623
- });
1624
- return { frozenConfig: false, environments: [] };
1625
- }
1626
- }
1627
- }
1628
- }
1629
-
1630
- private async scaffoldEnvironment(environment: ApiEnvironment) {
1631
- assertSafePackageName(environment.name);
1632
- const environmentName = environment.name;
1633
- const absoluteEnvironmentPath = safeJoinUnderRoot(
1634
- this.serverRootPath,
1635
- PUBLISHER_DATA_DIR,
1636
- environmentName,
1637
- );
1638
- await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
1639
- if (environment.readme) {
1640
- await fs.promises.writeFile(
1641
- safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
1642
- environment.readme,
1643
- );
1644
- }
1645
- return absoluteEnvironmentPath;
1646
- }
1647
-
1648
- private isLocalPath(location: string) {
1649
- return (
1650
- location.startsWith("./") ||
1651
- location.startsWith("../") ||
1652
- location.startsWith("~/") ||
1653
- location.startsWith("/") ||
1654
- path.isAbsolute(location)
1655
- );
1656
- }
1657
-
1658
- /**
1659
- * Absolute on-disk path for a local package `location`, anchored at the
1660
- * directory holding the active config. That covers locations POSTed at
1661
- * runtime too, which no config declares. When there is no config worth
1662
- * anchoring to, `getPublisherConfigDir` returns null (it owns the cases) and
1663
- * the server root is the only meaningful base, which is also what this did
1664
- * before the anchor moved.
1665
- */
1666
- private resolveLocalPath(location: string): string {
1667
- return resolvePackageLocation(
1668
- location,
1669
- getPublisherConfigDir(this.serverRootPath) ?? this.serverRootPath,
1670
- );
1671
- }
1672
-
1673
- private isGitHubURL(location: string) {
1674
- return (
1675
- location.startsWith("https://github.com/") ||
1676
- location.startsWith("git@github.com:")
1677
- );
1678
- }
1679
-
1680
- private isGCSURL(location: string) {
1681
- return location.startsWith("gs://");
1682
- }
1683
-
1684
- private isS3URL(location: string) {
1685
- return location.startsWith("s3://");
1686
- }
1687
-
1688
- /**
1689
- * Isolate a location failure to the packages it actually affects instead of
1690
- * aborting the whole environment. The packages that mounted still serve;
1691
- * each one named here is left un-mounted, so its configured status entry
1692
- * (set in addEnvironment) resolves to a per-package load failure that
1693
- * getFailedPackages() -> /status loadErrors reports. A bad location now
1694
- * behaves like a bad manifest: those packages dropped, siblings unaffected.
1695
- *
1696
- * The message is kept rather than letting the lazy load speak for it. An
1697
- * un-mounted package fails later on the manifest that was never copied, and
1698
- * "Package manifest ... does not exist." points at publisher_data/ instead
1699
- * of the location that is actually wrong. Redacted because it reaches an
1700
- * HTTP response body.
1701
- */
1702
- private recordMountFailure(
1703
- error: unknown,
1704
- location: string,
1705
- packageNames: string[],
1706
- mountErrors: Map<string, string>,
1707
- ): void {
1708
- logger.error(
1709
- `Failed to download or mount location "${location}"`,
1710
- this.extractErrorDataFromError(error),
1711
- );
1712
- const message = redactPgSecrets(
1713
- error instanceof Error ? error.message : String(error),
1714
- );
1715
- for (const packageName of packageNames) {
1716
- mountErrors.set(packageName, message);
1717
- }
1718
- }
1719
-
1720
- private async loadEnvironmentIntoDisk(
1721
- environmentName: string,
1722
- packages: ApiEnvironment["packages"],
1723
- ) {
1724
- assertSafePackageName(environmentName);
1725
- const absoluteTargetPath = safeJoinUnderRoot(
1726
- this.serverRootPath,
1727
- PUBLISHER_DATA_DIR,
1728
- environmentName,
1729
- );
1730
- // Why each package's location failed, carried out to the Environment so
1731
- // /status can report the real cause rather than the missing manifest it
1732
- // leaves behind.
1733
- const mountErrors = new Map<string, string>();
1734
-
1735
- await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
1736
-
1737
- if (!packages || packages.length === 0) {
1738
- throw new PackageNotFoundError(
1739
- `No packages found for environment ${environmentName}`,
1740
- );
1741
- }
1742
-
1743
- // Group packages by location to optimize downloads
1744
- const locationGroups = new Map<
1745
- string,
1746
- Array<{ name: string; location: string }>
1747
- >();
1748
-
1749
- for (const _package of packages) {
1750
- if (!_package.name) {
1751
- throw new PackageNotFoundError(`Package has no name specified`);
1752
- }
1753
-
1754
- if (!_package.location) {
1755
- throw new PackageNotFoundError(
1756
- `Package ${_package.name} has no location specified`,
1757
- );
1758
- }
1759
-
1760
- // For GitHub URLs, group by base repository URL to optimize downloads
1761
- let locationKey = _package.location;
1762
- if (this.isGitHubURL(_package.location)) {
1763
- const githubInfo = parseGitHubUrl(_package.location);
1764
- if (githubInfo) {
1765
- // Always use HTTPS format for grouping to ensure consistency
1766
- locationKey = `https://github.com/${githubInfo.owner}/${githubInfo.repoName}`;
1767
- }
1768
- }
1769
-
1770
- if (!locationGroups.has(locationKey)) {
1771
- locationGroups.set(locationKey, []);
1772
- }
1773
- locationGroups.get(locationKey)!.push({
1774
- name: _package.name,
1775
- location: _package.location,
1776
- });
1777
- }
1778
-
1779
- // Processing by each unique location
1780
- const totalPackages = packages.length;
1781
- let mountedCount = 0;
1782
- for (const [groupedLocation, packagesForLocation] of locationGroups) {
1783
- // Use a hash instead of base64 to keep paths short (Windows MAX_PATH limit)
1784
- // This works for both local and remote paths
1785
- const locationHash = crypto
1786
- .createHash("sha256")
1787
- .update(groupedLocation)
1788
- .digest("hex")
1789
- .substring(0, 16); // Use first 16 chars for shorter paths
1790
- const tempDownloadPath = safeJoinUnderRoot(
1791
- absoluteTargetPath,
1792
- `.temp_${locationHash}`,
1793
- );
1794
- await fs.promises.mkdir(tempDownloadPath, { recursive: true });
1795
- logger.info(`Created temporary directory: ${tempDownloadPath}`);
1796
- // Two failure scopes, deliberately separate. The download is shared by
1797
- // every package at this location, so losing it loses all of them. An
1798
- // extract is per package, so one bad sibling must not strand the ones
1799
- // after it in the loop.
1800
- let downloaded = true;
1801
- try {
1802
- // Use the existing download method for all locations
1803
- await this.downloadOrMountLocation(
1804
- groupedLocation,
1805
- tempDownloadPath,
1806
- environmentName,
1807
- "shared",
1808
- packagesForLocation.map((p) => p.name),
1809
- );
1810
- } catch (error) {
1811
- downloaded = false;
1812
- this.recordMountFailure(
1813
- error,
1814
- groupedLocation,
1815
- packagesForLocation.map((p) => p.name),
1816
- mountErrors,
1817
- );
1818
- }
1819
- // Extract each package from the downloaded content
1820
- for (const _package of downloaded ? packagesForLocation : []) {
1821
- // Declared out here so the catch can clean up the exact path this
1822
- // iteration created. Never re-derive it from the package name
1823
- // there: the name may be why we are in the catch at all, and
1824
- // safeJoinUnderRoot permits a name that resolves to the root, so a
1825
- // package called "." would hand the cleanup the whole environment
1826
- // directory. Undefined means nothing was created yet.
1827
- let absolutePackagePath: string | undefined;
1828
- try {
1829
- const packageDir = _package.name;
1830
- assertSafePackageName(packageDir);
1831
- absolutePackagePath = safeJoinUnderRoot(
1832
- absoluteTargetPath,
1833
- packageDir,
1834
- );
1835
- // For GitHub URLs, extract the subdirectory path from the original location
1836
- let sourcePath: string;
1837
- if (this.isGitHubURL(_package.location)) {
1838
- const githubInfo = parseGitHubUrl(_package.location);
1839
- if (githubInfo && githubInfo.packagePath) {
1840
- // Extract subdirectory from the original GitHub URL
1841
- // Handle both /tree/main/subdir and /tree/branch/subdir cases
1842
- const subPathMatch =
1843
- _package.location.match(/\/tree\/[^/]+\/(.+)$/);
1844
- if (subPathMatch) {
1845
- sourcePath = safeJoinUnderRoot(
1846
- tempDownloadPath,
1847
- subPathMatch[1],
1848
- );
1849
- } else {
1850
- // If no subdirectory after /tree/branch, the repo itself is the package
1851
- sourcePath = tempDownloadPath;
1852
- }
1853
- } else {
1854
- // No packagePath means the repo itself is the package
1855
- sourcePath = tempDownloadPath;
1856
- }
1857
- } else {
1858
- // For non-GitHub locations, use package name
1859
- if (this.isLocalPath(_package.location)) {
1860
- // Same resolution rule as `downloadOrMountLocation`.
1861
- // Without this step the existing-source check below
1862
- // falls through for any relative location, and the
1863
- // in-place mount branch is unreachable.
1864
- sourcePath = this.resolveLocalPath(_package.location);
1865
- } else {
1866
- sourcePath = safeJoinUnderRoot(
1867
- tempDownloadPath,
1868
- groupedLocation,
1869
- );
1870
- }
1871
- }
1872
-
1873
- const sourceExists = await fs.promises
1874
- .access(sourcePath)
1875
- .then(() => true)
1876
- .catch(() => false);
1877
-
1878
- if (sourceExists) {
1879
- // In-place mount path (watch-mode-only, local-dir packages
1880
- // only). Replace the recursive copy with a symlink so that
1881
- // edits to the source repo are visible to Publisher and
1882
- // can trigger live-reload events.
1883
- //
1884
- // Carefully: we MUST distinguish symlinks from real dirs
1885
- // when removing the prior content, because `fs.rm`'s
1886
- // recursive mode could in principle traverse into a
1887
- // symlinked source dir and damage it. We lstat first and
1888
- // call `unlink` for symlinks, `rm` for real directories.
1889
- const isInPlace =
1890
- this.inPlaceEnvs.has(environmentName) &&
1891
- this.isLocalPath(_package.location);
1892
- if (isInPlace) {
1893
- await clearMountTarget(absolutePackagePath);
1894
- const absoluteSourcePath = path.resolve(sourcePath);
1895
- // On Windows a "dir" symlink needs elevation
1896
- // (SeCreateSymbolicLinkPrivilege — admin or Developer
1897
- // Mode); a junction does not and works for absolute
1898
- // directory targets, so prefer it there. chokidar watches
1899
- // through both.
1900
- const linkType =
1901
- process.platform === "win32" ? "junction" : "dir";
1902
- try {
1903
- await fs.promises.symlink(
1904
- absoluteSourcePath,
1905
- absolutePackagePath,
1906
- linkType,
1907
- );
1908
- logger.info(
1909
- `In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}" (${++mountedCount}/${totalPackages})`,
1910
- );
1911
- } catch (linkError) {
1912
- // Degrade gracefully instead of failing the env load:
1913
- // copy the package so it still serves. Live reload
1914
- // won't work for it (source edits aren't seen), but the
1915
- // environment comes up. Hit e.g. on locked-down Windows
1916
- // where even junction creation is denied.
1917
- const code =
1918
- (linkError as NodeJS.ErrnoException)?.code ??
1919
- String(linkError);
1920
- logger.warn(
1921
- `In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`,
1922
- );
1923
- await clearMountTarget(absolutePackagePath);
1924
- await fs.promises.mkdir(absolutePackagePath, {
1925
- recursive: true,
1926
- });
1927
- await fs.promises.cp(sourcePath, absolutePackagePath, {
1928
- recursive: true,
1929
- });
1930
- logger.info(
1931
- `Copied package "${packageDir}" (${++mountedCount}/${totalPackages})`,
1932
- );
1933
- }
1934
- } else {
1935
- if (
1936
- this.inPlaceEnvs.has(environmentName) &&
1937
- !this.isLocalPath(_package.location)
1938
- ) {
1939
- logger.warn(
1940
- `Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`,
1941
- );
1942
- }
1943
- // Copy the specific directory. Clear any stale mount target
1944
- // first (e.g. a symlink left by a prior --watch-env run), or
1945
- // fs.cp would throw ERR_FS_CP_DIR_TO_NON_DIR onto it.
1946
- await clearMountTarget(absolutePackagePath);
1947
- await fs.promises.mkdir(absolutePackagePath, {
1948
- recursive: true,
1949
- });
1950
- await fs.promises.cp(sourcePath, absolutePackagePath, {
1951
- recursive: true,
1952
- });
1953
- logger.info(
1954
- `Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"} (${++mountedCount}/${totalPackages})`,
1955
- );
1956
- }
1957
- } else {
1958
- // If source doesn't exist, copy the entire download as the package
1959
- await clearMountTarget(absolutePackagePath);
1960
- await fs.promises.mkdir(absolutePackagePath, {
1961
- recursive: true,
1962
- });
1963
- await fs.promises.cp(tempDownloadPath, absolutePackagePath, {
1964
- recursive: true,
1965
- });
1966
- logger.info(
1967
- `Copied entire download as package "${packageDir}" (${++mountedCount}/${totalPackages})`,
1968
- );
1969
- }
1970
- } catch (error) {
1971
- this.recordMountFailure(
1972
- error,
1973
- groupedLocation,
1974
- [_package.name],
1975
- mountErrors,
1976
- );
1977
- // Leave nothing behind. A copy that threw part way through can
1978
- // leave a tree holding the manifest and models but not the data,
1979
- // which loads cleanly and therefore clears the very error
1980
- // recorded above, so the package serves incomplete content and
1981
- // /status says nothing. Continuing past the failure is what
1982
- // makes this reachable for more than one package per location.
1983
- if (absolutePackagePath) {
1984
- await clearMountTarget(absolutePackagePath);
1985
- }
1986
- }
1987
- }
1988
- try {
1989
- // Clean up temporary download directory
1990
- await fs.promises.rm(tempDownloadPath, {
1991
- recursive: true,
1992
- force: true,
1993
- });
1994
- } catch (error) {
1995
- logger.warn(
1996
- `Failed to clean up temporary download directory "${tempDownloadPath}"`,
1997
- {
1998
- error,
1999
- },
2000
- );
2001
- }
2002
- }
2003
-
2004
- return { path: absoluteTargetPath, mountErrors };
2005
- }
2006
-
2007
- private async downloadOrMountLocation(
2008
- location: string,
2009
- targetPath: string,
2010
- environmentName: string,
2011
- packageName: string,
2012
- packageNames?: string[],
2013
- ) {
2014
- const isCompressedFile = location.endsWith(".zip");
2015
- // Handle GCS paths
2016
- if (this.isGCSURL(location)) {
2017
- try {
2018
- logger.info(
2019
- `Downloading GCS directory from "${location}" to "${targetPath}"`,
2020
- );
2021
- await this.downloadGcsDirectory(
2022
- location,
2023
- environmentName,
2024
- targetPath,
2025
- isCompressedFile,
2026
- );
2027
- return;
2028
- } catch (error) {
2029
- const errorData = this.extractErrorDataFromError(error);
2030
- logger.error(
2031
- `Failed to download GCS directory "${location}"`,
2032
- errorData,
2033
- );
2034
- throw new PackageNotFoundError(
2035
- `Failed to download GCS directory: ${location}`,
2036
- );
2037
- }
2038
- }
2039
-
2040
- // Handle GitHub URLs
2041
- if (this.isGitHubURL(location)) {
2042
- try {
2043
- logger.info(
2044
- `Cloning GitHub repository from "${location}" to "${targetPath}"`,
2045
- );
2046
- await this.downloadGitHubDirectory(location, targetPath, {
2047
- environmentName,
2048
- packageNames,
2049
- });
2050
- return;
2051
- } catch (error) {
2052
- const errorData = this.extractErrorDataFromError(error);
2053
- logger.error(
2054
- `Failed to clone GitHub repository "${location}"`,
2055
- errorData,
2056
- );
2057
- throw new PackageNotFoundError(
2058
- `Failed to clone GitHub repository: ${location}`,
2059
- );
2060
- }
2061
- }
2062
-
2063
- // Handle S3 paths
2064
- if (this.isS3URL(location)) {
2065
- try {
2066
- logger.info(
2067
- `Downloading S3 directory from "${location}" to "${targetPath}"`,
2068
- );
2069
- await this.downloadS3Directory(
2070
- location,
2071
- environmentName,
2072
- targetPath,
2073
- isCompressedFile,
2074
- );
2075
- return;
2076
- } catch (error) {
2077
- const errorData = this.extractErrorDataFromError(error);
2078
- logger.error(
2079
- `Failed to download S3 directory "${location}"`,
2080
- errorData,
2081
- );
2082
- throw new PackageNotFoundError(
2083
- `Failed to download S3 directory: ${location}`,
2084
- );
2085
- }
2086
- }
2087
-
2088
- // Handle absolute and relative paths
2089
- if (this.isLocalPath(location)) {
2090
- const packagePath: string = this.resolveLocalPath(location);
2091
- try {
2092
- logger.info(
2093
- `Mounting local directory at "${packagePath}" to "${targetPath}"`,
2094
- );
2095
- await this.mountLocalDirectory(
2096
- packagePath,
2097
- targetPath,
2098
- environmentName,
2099
- packageName,
2100
- );
2101
- return;
2102
- } catch (error) {
2103
- const errorData = this.extractErrorDataFromError(error);
2104
- logger.error(
2105
- `Failed to mount local directory "${packagePath}"`,
2106
- errorData,
2107
- );
2108
- throw new PackageNotFoundError(
2109
- `Failed to mount local directory: ${packagePath}`,
2110
- );
2111
- }
2112
- }
2113
-
2114
- // If we get here, the path format is not supported
2115
- const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
2116
- logger.error(errorMsg, { environmentName, location });
2117
- throw new PackageNotFoundError(errorMsg);
2118
- }
2119
-
2120
- public async mountLocalDirectory(
2121
- environmentPath: string,
2122
- absoluteTargetPath: string,
2123
- environmentName: string,
2124
- packageName: string,
2125
- ) {
2126
- // `environmentPath` is the operator-supplied mount source and may
2127
- // legitimately be a relative path that resolves outside the
2128
- // server root; only the target is asserted.
2129
- assertSafeEnvironmentPath(absoluteTargetPath);
2130
- if (environmentPath.endsWith(".zip")) {
2131
- environmentPath = await this.unzipEnvironment(environmentPath);
2132
- }
2133
- const environmentDirExists =
2134
- (await fs.promises.stat(environmentPath))?.isDirectory() ?? false;
2135
- if (environmentDirExists) {
2136
- await fs.promises.rm(absoluteTargetPath, {
2137
- recursive: true,
2138
- force: true,
2139
- });
2140
- await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
2141
- await fs.promises.cp(environmentPath, absoluteTargetPath, {
2142
- recursive: true,
2143
- });
2144
- } else {
2145
- throw new PackageNotFoundError(
2146
- `Package ${packageName} for environment ${environmentName} not found in "${environmentPath}"`,
2147
- );
2148
- }
2149
- }
2150
-
2151
- async downloadGcsDirectory(
2152
- gcsPath: string,
2153
- environmentName: string,
2154
- absoluteDirPath: string,
2155
- isCompressedFile: boolean,
2156
- ) {
2157
- assertSafeEnvironmentPath(absoluteDirPath);
2158
- const trimmedPath = gcsPath.slice(5);
2159
- const [bucketName, ...prefixParts] = trimmedPath.split("/");
2160
- const prefix = prefixParts.join("/");
2161
- const [files] = await this.gcsClient.bucket(bucketName).getFiles({
2162
- prefix,
2163
- });
2164
- if (files.length === 0) {
2165
- throw new EnvironmentNotFoundError(
2166
- `Environment ${environmentName} not found in ${gcsPath}`,
2167
- );
2168
- }
2169
- if (!isCompressedFile) {
2170
- await fs.promises.rm(absoluteDirPath, {
2171
- recursive: true,
2172
- force: true,
2173
- });
2174
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
2175
- } else {
2176
- absoluteDirPath = `${absoluteDirPath}.zip`;
2177
- }
2178
- await Promise.all(
2179
- files.map(async (file) => {
2180
- // Strip leading `/` left over from prefix removal when the
2181
- // GCS prefix lacked a trailing slash — otherwise
2182
- // `safeJoinUnderRoot` treats it as absolute and rejects.
2183
- const relativeFilePath = file.name
2184
- .replace(prefix, "")
2185
- .replace(/^\/+/, "");
2186
- const absoluteFilePath = isCompressedFile
2187
- ? absoluteDirPath
2188
- : safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
2189
- if (file.name.endsWith("/")) {
2190
- return;
2191
- }
2192
- await fs.promises.mkdir(path.dirname(absoluteFilePath), {
2193
- recursive: true,
2194
- });
2195
- return fs.promises.writeFile(
2196
- absoluteFilePath,
2197
- await file.download(),
2198
- );
2199
- }),
2200
- );
2201
- if (isCompressedFile) {
2202
- await this.unzipEnvironment(absoluteDirPath);
2203
- }
2204
- logger.info(`Downloaded GCS directory ${gcsPath} to ${absoluteDirPath}`);
2205
- }
2206
-
2207
- async downloadS3Directory(
2208
- s3Path: string,
2209
- environmentName: string,
2210
- absoluteDirPath: string,
2211
- isCompressedFile: boolean = false,
2212
- ) {
2213
- assertSafeEnvironmentPath(absoluteDirPath);
2214
- const trimmedPath = s3Path.slice(5);
2215
- const [bucketName, ...prefixParts] = trimmedPath.split("/");
2216
- const prefix = prefixParts.join("/");
2217
-
2218
- if (isCompressedFile) {
2219
- // Download the single zip file
2220
- const zipFilePath = `${absoluteDirPath}.zip`;
2221
- await fs.promises.mkdir(path.dirname(zipFilePath), {
2222
- recursive: true,
2223
- });
2224
-
2225
- const command = new GetObjectCommand({
2226
- Bucket: bucketName,
2227
- Key: prefix,
2228
- });
2229
- const item = await this.s3Client.send(command);
2230
- if (!item.Body) {
2231
- throw new EnvironmentNotFoundError(
2232
- `Environment ${environmentName} not found in ${s3Path}`,
2233
- );
2234
- }
2235
- const file = fs.createWriteStream(zipFilePath);
2236
- item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
2237
- await new Promise<void>((resolve, reject) => {
2238
- file.on("error", reject);
2239
- file.on("finish", resolve);
2240
- });
2241
-
2242
- // Extract the zip file
2243
- await this.unzipEnvironment(zipFilePath);
2244
- logger.info(`Downloaded S3 zip file ${s3Path} to ${absoluteDirPath}`);
2245
- return;
2246
- }
2247
-
2248
- // Original behavior: download directory contents
2249
- const objects = await this.s3Client.listObjectsV2({
2250
- Bucket: bucketName,
2251
- Prefix: prefix,
2252
- });
2253
- await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
2254
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
2255
-
2256
- if (!objects.Contents || objects.Contents.length === 0) {
2257
- throw new EnvironmentNotFoundError(
2258
- `Environment ${environmentName} not found in ${s3Path}`,
2259
- );
2260
- }
2261
- await Promise.all(
2262
- objects.Contents?.map(async (object) => {
2263
- const key = object.Key;
2264
- if (!key) {
2265
- return;
2266
- }
2267
- // Strip leading `/` left over from prefix removal when the
2268
- // S3 prefix lacked a trailing slash — otherwise
2269
- // `safeJoinUnderRoot` treats it as absolute and rejects.
2270
- const relativeFilePath = key
2271
- .replace(prefix, "")
2272
- .replace(/^\/+/, "");
2273
- if (!relativeFilePath || relativeFilePath.endsWith("/")) {
2274
- return;
2275
- }
2276
- const absoluteFilePath = safeJoinUnderRoot(
2277
- absoluteDirPath,
2278
- relativeFilePath,
2279
- );
2280
- await fs.promises.mkdir(path.dirname(absoluteFilePath), {
2281
- recursive: true,
2282
- });
2283
- const command = new GetObjectCommand({
2284
- Bucket: bucketName,
2285
- Key: key,
2286
- });
2287
- const item = await this.s3Client.send(command);
2288
- if (!item.Body) {
2289
- return;
2290
- }
2291
- const file = fs.createWriteStream(absoluteFilePath);
2292
- item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
2293
- await new Promise<void>((resolve, reject) => {
2294
- file.on("error", reject);
2295
- file.on("finish", resolve);
2296
- });
2297
- }),
2298
- );
2299
- logger.info(`Downloaded S3 directory ${s3Path} to ${absoluteDirPath}`);
2300
- }
2301
-
2302
- async downloadGitHubDirectory(
2303
- githubUrl: string,
2304
- absoluteDirPath: string,
2305
- progressContext?: { environmentName?: string; packageNames?: string[] },
2306
- ) {
2307
- assertSafeEnvironmentPath(absoluteDirPath);
2308
- // First we'll clone the repo without the additional path
2309
- // E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
2310
- const githubInfo = parseGitHubUrl(githubUrl);
2311
- if (!githubInfo) {
2312
- throw new Error(`Invalid GitHub URL: ${githubUrl}`);
2313
- }
2314
- const { owner, repoName, packagePath } = githubInfo;
2315
- // `packagePath` is captured with a leading `/`; strip it so the
2316
- // value is a relative segment usable with `safeJoinUnderRoot`.
2317
- const cleanPackagePath = (
2318
- packagePath?.replace("/tree/main", "") || ""
2319
- ).replace(/^\/+/, "");
2320
-
2321
- // We'll make sure whatever was in absoluteDirPath is removed,
2322
- // so we have a nice a clean directory where we can clone the repo
2323
- await fs.promises.rm(absoluteDirPath, {
2324
- recursive: true,
2325
- force: true,
2326
- });
2327
- await fs.promises.mkdir(absoluteDirPath, { recursive: true });
2328
- const repoUrl = `https://github.com/${owner}/${repoName}`;
2329
- const reporter = new CloneProgressReporter(
2330
- cloneProgressLabel(`${owner}/${repoName}`, progressContext),
2331
- );
2332
-
2333
- // We'll clone the repo into absoluteDirPath
2334
- await new Promise<void>((resolve, reject) => {
2335
- simpleGit({
2336
- progress: (event) => reporter.onProgress(event),
2337
- }).clone(repoUrl, absoluteDirPath, GIT_CLONE_OPTIONS, (err) => {
2338
- reporter.done();
2339
- if (err) {
2340
- err.message = stripGitProgressNoise(err.message);
2341
- if (err.stack) {
2342
- // V8 captures the message into the stack at construction,
2343
- // so the spew survives there unless stripped too.
2344
- err.stack = stripGitProgressNoise(err.stack);
2345
- }
2346
- const errorData = this.extractErrorDataFromError(err);
2347
- logger.error(
2348
- `Failed to clone GitHub repository "${repoUrl}"`,
2349
- errorData,
2350
- );
2351
- reject(err);
2352
- return;
2353
- }
2354
- resolve();
2355
- });
2356
- });
2357
-
2358
- // If there's no specific package path, we're done (for grouped downloads)
2359
- if (!cleanPackagePath) {
2360
- logger.info(
2361
- `Successfully cloned entire repository to: ${absoluteDirPath}`,
2362
- );
2363
- return;
2364
- }
2365
-
2366
- // For single package downloads, extract the specific subdirectory
2367
- // After cloning, we'll replace all contents of absoluteDirPath with the contents of absoluteDirPath/cleanPackagePath
2368
- // E.g. we're moving /var/publisher/asd123/imdb/publisher.json into /var/publisher/asd123/publisher.json
2369
-
2370
- // Remove all contents of absoluteDirPath (/var/publisher/asd123)
2371
- // except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
2372
- const packageFullPath = safeJoinUnderRoot(
2373
- absoluteDirPath,
2374
- cleanPackagePath,
2375
- );
2376
-
2377
- // Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
2378
- const packageExists = await fs.promises
2379
- .access(packageFullPath)
2380
- .then(() => true)
2381
- .catch(() => false);
2382
-
2383
- if (!packageExists) {
2384
- throw new Error(
2385
- `Package path "${cleanPackagePath}" does not exist in the cloned repository.`,
2386
- );
2387
- }
2388
-
2389
- // Remove everything in absoluteDirPath (/var/publisher/asd123)
2390
- const dirContents = await fs.promises.readdir(absoluteDirPath);
2391
- for (const entry of dirContents) {
2392
- // Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
2393
- if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
2394
- await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
2395
- recursive: true,
2396
- force: true,
2397
- });
2398
- }
2399
- }
2400
-
2401
- // Now, move the contents of packageFullPath (/var/publisher/asd123/imdb) up to absoluteDirPath (/var/publisher/asd123)
2402
- const packageContents = await fs.promises.readdir(packageFullPath);
2403
- for (const entry of packageContents) {
2404
- await fs.promises.rename(
2405
- safeJoinUnderRoot(packageFullPath, entry),
2406
- safeJoinUnderRoot(absoluteDirPath, entry),
2407
- );
2408
- }
2409
-
2410
- // Remove the now-empty cleanPackagePath directory (/var/publisher/asd123/imdb)
2411
- await fs.promises.rm(packageFullPath, { recursive: true, force: true });
2412
-
2413
- // https://github.com/credibledata/malloy-samples/imdb/publisher.json -> ${absoluteDirPath}/publisher.json
2414
- }
2415
-
2416
- private extractErrorDataFromError(error: unknown): {
2417
- error: string;
2418
- stack?: string;
2419
- task?: unknown;
2420
- } {
2421
- const errorMessage =
2422
- error instanceof Error ? error.message : String(error);
2423
- const errorData: { error: string; stack?: string; task?: unknown } = {
2424
- error: errorMessage,
2425
- };
2426
- if (error instanceof Error && logger.level === "debug") {
2427
- errorData.stack = error.stack;
2428
- }
2429
- if (error && typeof error === "object" && "task" in error) {
2430
- errorData.task = (error as { task?: unknown }).task;
2431
- }
2432
- return errorData;
2433
- }
2434
- }