@malloy-publisher/server 0.0.235 → 0.0.237

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 (319) hide show
  1. package/dist/app/api-doc.yaml +83 -27
  2. package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
  3. package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
  4. package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
  5. package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
  7. package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
  8. package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
  9. package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
  10. package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
  12. package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
  13. package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
  14. package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
  15. package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
  16. package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
  17. package/dist/app/index.html +1 -1
  18. package/dist/package_load_worker.mjs +59 -20
  19. package/dist/server.mjs +1342 -616
  20. package/package.json +8 -2
  21. package/.eslintrc.json +0 -44
  22. package/.prettierignore +0 -1
  23. package/.prettierrc +0 -4
  24. package/build.ts +0 -97
  25. package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
  26. package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
  27. package/eslint.config.mjs +0 -76
  28. package/k6-tests/README.md +0 -144
  29. package/k6-tests/bun.lock +0 -797
  30. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  31. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  32. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  33. package/k6-tests/load-test/load-test-crud.ts +0 -142
  34. package/k6-tests/load-test/load-test.ts +0 -784
  35. package/k6-tests/package.json +0 -27
  36. package/k6-tests/scripts/run-with-otel.sh +0 -50
  37. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  38. package/k6-tests/tsconfig.json +0 -29
  39. package/k6-tests/utils/client_factory.ts +0 -128
  40. package/k6-tests/utils/common.ts +0 -489
  41. package/k6-tests/utils/logger.ts +0 -62
  42. package/k6-tests/utils/types.d.ts +0 -35
  43. package/malloy_mcp.dxt +0 -0
  44. package/publisher.config.example.bigquery.json +0 -33
  45. package/publisher.config.example.duckdb.json +0 -23
  46. package/publisher.config.json +0 -23
  47. package/scripts/bake-duckdb-extensions.js +0 -110
  48. package/src/config.spec.ts +0 -1535
  49. package/src/config.theme.spec.ts +0 -178
  50. package/src/config.ts +0 -1142
  51. package/src/constants.ts +0 -105
  52. package/src/controller/compile.controller.ts +0 -40
  53. package/src/controller/connection.controller.spec.ts +0 -849
  54. package/src/controller/connection.controller.ts +0 -901
  55. package/src/controller/database.controller.ts +0 -24
  56. package/src/controller/materialization.controller.spec.ts +0 -293
  57. package/src/controller/materialization.controller.ts +0 -319
  58. package/src/controller/model.controller.ts +0 -164
  59. package/src/controller/package.controller.spec.ts +0 -273
  60. package/src/controller/package.controller.ts +0 -306
  61. package/src/controller/query.controller.ts +0 -152
  62. package/src/controller/theme.controller.ts +0 -83
  63. package/src/controller/watch-mode.controller.ts +0 -243
  64. package/src/data_styles.ts +0 -71
  65. package/src/default-publisher.config.json +0 -23
  66. package/src/ducklake_version.spec.ts +0 -163
  67. package/src/ducklake_version.ts +0 -153
  68. package/src/errors.spec.ts +0 -119
  69. package/src/errors.ts +0 -237
  70. package/src/filter_deprecation.spec.ts +0 -64
  71. package/src/filter_deprecation.ts +0 -42
  72. package/src/health.spec.ts +0 -123
  73. package/src/health.ts +0 -233
  74. package/src/heap_check.spec.ts +0 -144
  75. package/src/heap_check.ts +0 -144
  76. package/src/instrumentation.ts +0 -214
  77. package/src/json_utils.spec.ts +0 -51
  78. package/src/json_utils.ts +0 -33
  79. package/src/logger.spec.ts +0 -210
  80. package/src/logger.ts +0 -251
  81. package/src/malloy_pin_prereqs.spec.ts +0 -25
  82. package/src/materialization_metrics.spec.ts +0 -169
  83. package/src/materialization_metrics.ts +0 -346
  84. package/src/mcp/error_messages.spec.ts +0 -35
  85. package/src/mcp/error_messages.ts +0 -183
  86. package/src/mcp/handler_utils.spec.ts +0 -108
  87. package/src/mcp/handler_utils.ts +0 -251
  88. package/src/mcp/mcp_constants.ts +0 -6
  89. package/src/mcp/query_envelope.spec.ts +0 -229
  90. package/src/mcp/query_envelope.ts +0 -240
  91. package/src/mcp/server.protocol.spec.ts +0 -250
  92. package/src/mcp/server.ts +0 -87
  93. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  94. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  95. package/src/mcp/skills/skills_bundle.json +0 -1
  96. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  97. package/src/mcp/tool_response.spec.ts +0 -108
  98. package/src/mcp/tool_response.ts +0 -138
  99. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  100. package/src/mcp/tools/compile_tool.ts +0 -208
  101. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  102. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  103. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  104. package/src/mcp/tools/docs_search_tool.ts +0 -128
  105. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  106. package/src/mcp/tools/embedding_index.ts +0 -808
  107. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  108. package/src/mcp/tools/execute_query_tool.ts +0 -316
  109. package/src/mcp/tools/get_context_eval.ts +0 -275
  110. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  111. package/src/mcp/tools/get_context_tool.ts +0 -585
  112. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  113. package/src/mcp/tools/reload_package_tool.ts +0 -132
  114. package/src/mcp_config.spec.ts +0 -919
  115. package/src/mcp_config.ts +0 -425
  116. package/src/oom_guards.integration.spec.ts +0 -269
  117. package/src/package_load/package_load_pool.spec.ts +0 -252
  118. package/src/package_load/package_load_pool.ts +0 -931
  119. package/src/package_load/package_load_worker.ts +0 -1034
  120. package/src/package_load/protocol.ts +0 -364
  121. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  122. package/src/package_load/rpc_wait_accountant.ts +0 -76
  123. package/src/package_load_metrics.spec.ts +0 -114
  124. package/src/package_load_metrics.ts +0 -127
  125. package/src/path_safety.spec.ts +0 -158
  126. package/src/path_safety.ts +0 -146
  127. package/src/pg_helpers.spec.ts +0 -223
  128. package/src/pg_helpers.ts +0 -52
  129. package/src/query_cap_metrics.spec.ts +0 -89
  130. package/src/query_cap_metrics.ts +0 -116
  131. package/src/query_concurrency.spec.ts +0 -247
  132. package/src/query_concurrency.ts +0 -237
  133. package/src/query_metadata_metrics.ts +0 -49
  134. package/src/query_param_utils.ts +0 -29
  135. package/src/query_timeout.spec.ts +0 -224
  136. package/src/query_timeout.ts +0 -179
  137. package/src/runtime/publisher.js +0 -328
  138. package/src/server-old.ts +0 -988
  139. package/src/server.ts +0 -2059
  140. package/src/service/annotations.spec.ts +0 -118
  141. package/src/service/annotations.ts +0 -91
  142. package/src/service/authorize.spec.ts +0 -154
  143. package/src/service/authorize.ts +0 -498
  144. package/src/service/authorize_integration.spec.ts +0 -2041
  145. package/src/service/build_plan.spec.ts +0 -685
  146. package/src/service/build_plan.ts +0 -797
  147. package/src/service/compile_authorize.spec.ts +0 -85
  148. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  149. package/src/service/connection.spec.ts +0 -2360
  150. package/src/service/connection.ts +0 -2187
  151. package/src/service/connection_config.spec.ts +0 -734
  152. package/src/service/connection_config.ts +0 -899
  153. package/src/service/connection_federation.spec.ts +0 -184
  154. package/src/service/connection_fingerprint.spec.ts +0 -102
  155. package/src/service/connection_service.spec.ts +0 -711
  156. package/src/service/connection_service.ts +0 -291
  157. package/src/service/cron_evaluator.spec.ts +0 -79
  158. package/src/service/cron_evaluator.ts +0 -105
  159. package/src/service/db_utils.spec.ts +0 -1085
  160. package/src/service/db_utils.ts +0 -1502
  161. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  162. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  163. package/src/service/embedding_provider.spec.ts +0 -329
  164. package/src/service/embedding_provider.ts +0 -236
  165. package/src/service/environment.ts +0 -2191
  166. package/src/service/environment_admission.spec.ts +0 -344
  167. package/src/service/environment_compile.spec.ts +0 -197
  168. package/src/service/environment_store.spec.ts +0 -2274
  169. package/src/service/environment_store.ts +0 -2434
  170. package/src/service/environment_store_anchoring.spec.ts +0 -107
  171. package/src/service/environment_store_clone.spec.ts +0 -350
  172. package/src/service/explore_visibility.spec.ts +0 -434
  173. package/src/service/exports_probe.spec.ts +0 -107
  174. package/src/service/extension_fetch_policy.spec.ts +0 -256
  175. package/src/service/filter.spec.ts +0 -458
  176. package/src/service/filter.ts +0 -341
  177. package/src/service/filter_bypass.spec.ts +0 -418
  178. package/src/service/filter_integration.spec.ts +0 -1004
  179. package/src/service/freshness.spec.ts +0 -183
  180. package/src/service/freshness.ts +0 -112
  181. package/src/service/gcs_s3_utils.ts +0 -427
  182. package/src/service/given.ts +0 -105
  183. package/src/service/givens_integration.spec.ts +0 -219
  184. package/src/service/manifest_loader.spec.ts +0 -189
  185. package/src/service/manifest_loader.ts +0 -156
  186. package/src/service/materialization_build_session.spec.ts +0 -435
  187. package/src/service/materialization_build_session.ts +0 -681
  188. package/src/service/materialization_config_validation.spec.ts +0 -99
  189. package/src/service/materialization_config_validation.ts +0 -120
  190. package/src/service/materialization_eligibility.spec.ts +0 -158
  191. package/src/service/materialization_eligibility.ts +0 -305
  192. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  193. package/src/service/materialization_scheduler.spec.ts +0 -289
  194. package/src/service/materialization_scheduler.ts +0 -304
  195. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  196. package/src/service/materialization_serve_transform.ts +0 -779
  197. package/src/service/materialization_service.spec.ts +0 -2373
  198. package/src/service/materialization_service.ts +0 -2342
  199. package/src/service/materialization_test_fixtures.ts +0 -249
  200. package/src/service/model.spec.ts +0 -1157
  201. package/src/service/model.ts +0 -3370
  202. package/src/service/model_limits.spec.ts +0 -209
  203. package/src/service/model_limits.ts +0 -131
  204. package/src/service/model_storage_serve.spec.ts +0 -193
  205. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  206. package/src/service/package.spec.ts +0 -612
  207. package/src/service/package.ts +0 -1799
  208. package/src/service/package_manifest.spec.ts +0 -230
  209. package/src/service/package_manifest.ts +0 -241
  210. package/src/service/package_memory_governor.spec.ts +0 -173
  211. package/src/service/package_memory_governor.ts +0 -233
  212. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  213. package/src/service/package_race.spec.ts +0 -208
  214. package/src/service/package_reload_safety.spec.ts +0 -193
  215. package/src/service/package_rollback.spec.ts +0 -190
  216. package/src/service/package_worker_path.spec.ts +0 -645
  217. package/src/service/path_injection.spec.ts +0 -39
  218. package/src/service/persist_annotation_validation.spec.ts +0 -89
  219. package/src/service/persist_annotation_validation.ts +0 -59
  220. package/src/service/persistence_policy.spec.ts +0 -374
  221. package/src/service/proxy.spec.ts +0 -414
  222. package/src/service/proxy.ts +0 -248
  223. package/src/service/query_boundary.spec.ts +0 -470
  224. package/src/service/query_metadata.spec.ts +0 -408
  225. package/src/service/query_metadata.ts +0 -492
  226. package/src/service/query_metadata_identity.spec.ts +0 -149
  227. package/src/service/query_text.spec.ts +0 -79
  228. package/src/service/query_text.ts +0 -43
  229. package/src/service/quoting.spec.ts +0 -135
  230. package/src/service/quoting.ts +0 -78
  231. package/src/service/resolve_environment.ts +0 -15
  232. package/src/service/restricted_mode.spec.ts +0 -299
  233. package/src/service/source_extraction.ts +0 -225
  234. package/src/service/theme_store.ts +0 -199
  235. package/src/storage/DatabaseInterface.ts +0 -198
  236. package/src/storage/StorageManager.ts +0 -113
  237. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  238. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  239. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  240. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  241. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  242. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  243. package/src/storage/duckdb/PackageRepository.ts +0 -135
  244. package/src/storage/duckdb/schema.ts +0 -229
  245. package/src/stream_helpers.spec.ts +0 -280
  246. package/src/stream_helpers.ts +0 -162
  247. package/src/telemetry.ts +0 -20
  248. package/src/test_helpers/metrics_harness.ts +0 -166
  249. package/src/theme_key_parity.spec.ts +0 -57
  250. package/src/utils.ts +0 -29
  251. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  252. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  253. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  254. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  255. package/tests/fixtures/html-pages-test/data.csv +0 -3
  256. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  257. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  258. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  259. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  260. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  261. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  262. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  263. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  264. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  265. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  266. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  267. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  268. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  269. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  270. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  271. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  272. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  273. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  274. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  275. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  276. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  277. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  278. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  279. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  280. package/tests/fixtures/persist-test/publisher.json +0 -5
  281. package/tests/fixtures/publisher.config.json +0 -15
  282. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  283. package/tests/fixtures/query-givens/model.malloy +0 -34
  284. package/tests/fixtures/query-givens/publisher.json +0 -5
  285. package/tests/fixtures/xlsx/database.xlsx +0 -0
  286. package/tests/harness/mcp_test_setup.ts +0 -332
  287. package/tests/harness/rest_e2e.ts +0 -68
  288. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  289. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  290. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  291. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  292. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  293. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  294. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  295. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  296. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  297. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  298. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  299. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  300. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  301. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  302. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  303. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  304. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  305. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  306. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  307. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  308. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  309. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  310. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  311. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  312. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  313. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  314. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  315. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  316. package/tests/unit/duckdb/repositories.test.ts +0 -208
  317. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  318. package/tests/unit/service/theme_store.spec.ts +0 -139
  319. package/tsconfig.json +0 -32
package/src/config.ts DELETED
@@ -1,1142 +0,0 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import { fileURLToPath } from "url";
4
- import { components } from "./api";
5
- import {
6
- API_PREFIX,
7
- DEFAULT_MAX_CONCURRENT_QUERIES,
8
- DEFAULT_MAX_QUERY_ROWS,
9
- DEFAULT_MAX_RESPONSE_BYTES,
10
- DEFAULT_QUERY_ROW_LIMIT,
11
- DEFAULT_QUERY_TIMEOUT_MS,
12
- PUBLISHER_CONFIG_NAME,
13
- } from "./constants";
14
- import { logger } from "./logger";
15
-
16
- /**
17
- * Path to the publisher.config.json file shipped inside the published
18
- * package. Used as a last-resort fallback so `npx @malloy-publisher/server`
19
- * with no args still boots with the DuckDB-only sample packages.
20
- *
21
- * The file is copied next to the running module by `build.ts` at production
22
- * build time. In a source/dev checkout it lives alongside this file.
23
- */
24
- const BUNDLED_DEFAULT_CONFIG_PATH = path.join(
25
- path.dirname(fileURLToPath(import.meta.url)),
26
- "default-publisher.config.json",
27
- );
28
-
29
- /**
30
- * Decide which `publisher.config.json` to read.
31
- *
32
- * Precedence:
33
- * 1. `--config <path>` (surfaced via `process.env.PUBLISHER_CONFIG_PATH`)
34
- * 2. `<serverRoot>/publisher.config.json`
35
- * 3. The bundled default shipped inside the package — ONLY when
36
- * `process.env.PUBLISHER_USE_BUNDLED_DEFAULT === "true"`. server.ts
37
- * sets that flag when the user passed neither `--server_root` nor
38
- * `--config`, so `npx @malloy-publisher/server` with zero args
39
- * boots into something usable. Callers that construct an
40
- * EnvironmentStore programmatically (tests, embeds) don't get
41
- * surprise filesystem fallbacks they didn't ask for.
42
- *
43
- * Returns `null` if step 1 was requested but the file doesn't exist —
44
- * that's an explicit user mistake and the caller should surface it as
45
- * an error rather than silently falling back.
46
- */
47
- function resolvePublisherConfigPath(serverRoot: string): {
48
- path: string;
49
- isBundledDefault: boolean;
50
- } | null {
51
- const explicitPath = process.env.PUBLISHER_CONFIG_PATH;
52
- if (explicitPath && explicitPath.length > 0) {
53
- if (!fs.existsSync(explicitPath)) {
54
- return null;
55
- }
56
- return { path: explicitPath, isBundledDefault: false };
57
- }
58
-
59
- const serverRootPath = path.join(serverRoot, PUBLISHER_CONFIG_NAME);
60
- if (fs.existsSync(serverRootPath)) {
61
- return { path: serverRootPath, isBundledDefault: false };
62
- }
63
-
64
- if (
65
- process.env.PUBLISHER_USE_BUNDLED_DEFAULT === "true" &&
66
- fs.existsSync(BUNDLED_DEFAULT_CONFIG_PATH)
67
- ) {
68
- return { path: BUNDLED_DEFAULT_CONFIG_PATH, isBundledDefault: true };
69
- }
70
-
71
- return null;
72
- }
73
-
74
- // Home paths are POSIX form only: `~/` expands, while bare `~`, `~user/…`,
75
- // and Windows-style `~\` are not local paths and are rejected downstream.
76
- type FilesystemPath =
77
- | `./${string}`
78
- | `../${string}`
79
- | `/${string}`
80
- | `~/${string}`;
81
- type GcsPath = `gs://${string}`;
82
- type ApiConnection = components["schemas"]["Connection"];
83
- export type Theme = components["schemas"]["Theme"];
84
-
85
- /**
86
- * Palette colour keys that have separate light/dark variants. Hand-copied
87
- * from `packages/sdk/src/theme/keys.ts`: the server and the SDK are
88
- * intentionally decoupled (neither imports the other; their only shared
89
- * contract is api-doc.yaml), so the list lives in both. The parity test
90
- * `theme_key_parity.spec.ts` fails if the two copies or the api-doc
91
- * Theme.palette schema drift apart. Exported so that test can import it.
92
- */
93
- export const PER_MODE_COLOR_KEYS = [
94
- "background",
95
- "tableHeader",
96
- "tableHeaderBackground",
97
- "tableBody",
98
- "tile",
99
- "tileTitle",
100
- "mapColor",
101
- ] as const;
102
-
103
- export type Package = {
104
- name: string;
105
- location: FilesystemPath | GcsPath;
106
- };
107
-
108
- export type Connection = {
109
- name: string;
110
- type: string;
111
- };
112
-
113
- export type Environment = {
114
- name: string;
115
- theme?: Theme;
116
- packages: Package[];
117
- connections?: Connection[];
118
- };
119
-
120
- export type PublisherConfig = {
121
- frozenConfig: boolean;
122
- theme?: Theme;
123
- environments: Environment[];
124
- };
125
-
126
- export type ProcessedEnvironment = {
127
- name: string;
128
- theme?: Theme;
129
- packages: Package[];
130
- connections: ApiConnection[];
131
- };
132
-
133
- export type ProcessedPublisherConfig = {
134
- frozenConfig: boolean;
135
- theme?: Theme;
136
- environments: ProcessedEnvironment[];
137
- };
138
-
139
- /**
140
- * Tunables for {@link PackageMemoryGovernor}. All values are sourced
141
- * from environment variables at startup; see {@link getMemoryGovernorConfig}
142
- * for parsing and defaults.
143
- *
144
- * The governor is admission control only: it polls process RSS on
145
- * `checkIntervalMs` and toggles a single `isBackpressured` flag using
146
- * a low/high-water hysteresis band. It does NOT evict, unload, or
147
- * interrupt already-loaded packages — recovery is left to the kernel
148
- * reclaiming pages as in-flight traffic completes.
149
- */
150
- export interface MemoryGovernorConfig {
151
- /** Hard ceiling for process RSS in bytes (the OOM-relevant figure). */
152
- maxMemoryBytes: number;
153
- /** Fraction of `maxMemoryBytes` at which the governor activates back-pressure (new package loads start returning HTTP 503). Must be in (0, 1) and strictly greater than `lowWaterFraction`. */
154
- highWaterFraction: number;
155
- /** Fraction of `maxMemoryBytes` at which the governor clears back-pressure (new package loads admitted again). Must be in (0, 1) and strictly less than `highWaterFraction`; the gap is the hysteresis band that prevents flap. */
156
- lowWaterFraction: number;
157
- /** Polling cadence for the RSS sampler, in milliseconds. */
158
- checkIntervalMs: number;
159
- /** When true, RSS crossings flip the back-pressure flag. When false, the governor still samples and emits metrics but never rejects requests — useful for a monitoring-only rollout before enabling the 503 behaviour. */
160
- backpressureEnabled: boolean;
161
- }
162
-
163
- const DEFAULT_HIGH_WATER_FRACTION = 0.8;
164
- const DEFAULT_LOW_WATER_FRACTION = 0.7;
165
- const DEFAULT_CHECK_INTERVAL_MS = 5_000;
166
- const MIN_CHECK_INTERVAL_MS = 100;
167
-
168
- function parseIntEnv(name: string): number | undefined {
169
- const raw = process.env[name];
170
- if (raw === undefined || raw.trim() === "") return undefined;
171
- const value = Number.parseInt(raw, 10);
172
- if (!Number.isFinite(value) || String(value) !== raw.trim()) {
173
- throw new Error(
174
- `Invalid value for ${name}: expected a base-10 integer, got "${raw}"`,
175
- );
176
- }
177
- return value;
178
- }
179
-
180
- function parseFloatEnv(name: string): number | undefined {
181
- const raw = process.env[name];
182
- if (raw === undefined || raw.trim() === "") return undefined;
183
- const value = Number.parseFloat(raw);
184
- if (!Number.isFinite(value)) {
185
- throw new Error(
186
- `Invalid value for ${name}: expected a finite number, got "${raw}"`,
187
- );
188
- }
189
- return value;
190
- }
191
-
192
- export function parseBoolEnv(name: string): boolean | undefined {
193
- const raw = process.env[name];
194
- if (raw === undefined || raw.trim() === "") return undefined;
195
- const normalised = raw.trim().toLowerCase();
196
- if (["1", "true", "yes", "on"].includes(normalised)) return true;
197
- if (["0", "false", "no", "off"].includes(normalised)) return false;
198
- throw new Error(
199
- `Invalid value for ${name}: expected a boolean (true/false), got "${raw}"`,
200
- );
201
- }
202
-
203
- /**
204
- * Parse memory-governor settings from environment variables and return
205
- * either a fully-validated config or `null` when the feature is
206
- * disabled. The feature is disabled iff `PUBLISHER_MAX_MEMORY_BYTES`
207
- * is unset or set to `0`.
208
- *
209
- * Throws at startup on malformed input so a typo in a k8s manifest
210
- * surfaces as a loud failure rather than silently disabling the cap.
211
- */
212
- export const getMemoryGovernorConfig = (): MemoryGovernorConfig | null => {
213
- const maxMemoryBytes = parseIntEnv("PUBLISHER_MAX_MEMORY_BYTES");
214
- if (maxMemoryBytes === undefined || maxMemoryBytes === 0) {
215
- return null;
216
- }
217
- if (maxMemoryBytes < 0) {
218
- throw new Error(
219
- `PUBLISHER_MAX_MEMORY_BYTES must be a positive integer (got ${maxMemoryBytes})`,
220
- );
221
- }
222
-
223
- const highWaterFraction =
224
- parseFloatEnv("PUBLISHER_MEMORY_HIGH_WATER_FRACTION") ??
225
- DEFAULT_HIGH_WATER_FRACTION;
226
- const lowWaterFraction =
227
- parseFloatEnv("PUBLISHER_MEMORY_LOW_WATER_FRACTION") ??
228
- DEFAULT_LOW_WATER_FRACTION;
229
- const checkIntervalMs =
230
- parseIntEnv("PUBLISHER_MEMORY_CHECK_INTERVAL_MS") ??
231
- DEFAULT_CHECK_INTERVAL_MS;
232
- const backpressureEnabled =
233
- parseBoolEnv("PUBLISHER_MEMORY_BACKPRESSURE") ?? true;
234
-
235
- if (highWaterFraction <= 0 || highWaterFraction >= 1) {
236
- throw new Error(
237
- `PUBLISHER_MEMORY_HIGH_WATER_FRACTION must be in (0, 1) (got ${highWaterFraction})`,
238
- );
239
- }
240
- if (lowWaterFraction <= 0 || lowWaterFraction >= 1) {
241
- throw new Error(
242
- `PUBLISHER_MEMORY_LOW_WATER_FRACTION must be in (0, 1) (got ${lowWaterFraction})`,
243
- );
244
- }
245
- if (lowWaterFraction >= highWaterFraction) {
246
- throw new Error(
247
- `PUBLISHER_MEMORY_LOW_WATER_FRACTION (${lowWaterFraction}) must be strictly less than PUBLISHER_MEMORY_HIGH_WATER_FRACTION (${highWaterFraction})`,
248
- );
249
- }
250
- if (checkIntervalMs < MIN_CHECK_INTERVAL_MS) {
251
- throw new Error(
252
- `PUBLISHER_MEMORY_CHECK_INTERVAL_MS must be >= ${MIN_CHECK_INTERVAL_MS} (got ${checkIntervalMs})`,
253
- );
254
- }
255
-
256
- return {
257
- maxMemoryBytes,
258
- highWaterFraction,
259
- lowWaterFraction,
260
- checkIntervalMs,
261
- backpressureEnabled,
262
- };
263
- };
264
-
265
- /**
266
- * Settings for the optional embedding provider behind semantic
267
- * `malloy_getContext` retrieval. See {@link getEmbeddingConfig}.
268
- */
269
- export interface EmbeddingConfig {
270
- /** Bearer token sent to the embedding endpoint. */
271
- apiKey: string;
272
- /** Embedding model name, e.g. "text-embedding-3-small". */
273
- model: string;
274
- /** Base URL of an OpenAI-compatible API (no trailing slash). */
275
- baseUrl: string;
276
- /**
277
- * Optional `dimensions` request parameter. Omitted from requests when
278
- * unset; the vector length then comes from the provider's response.
279
- */
280
- dimensions?: number;
281
- }
282
-
283
- const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
284
- const DEFAULT_EMBEDDING_API_BASE = "https://api.openai.com/v1";
285
-
286
- /**
287
- * Embedding-provider settings for semantic `malloy_getContext` retrieval,
288
- * or `null` when the feature is disabled. The feature is enabled iff
289
- * `EMBEDDING_API_KEY` is set and non-empty; without it the tool keeps its
290
- * lexical (lunr) ranking unchanged.
291
- *
292
- * The key must be set explicitly. An ambient provider key (for example
293
- * `OPENAI_API_KEY`) is deliberately NOT read: enabling this feature sends
294
- * entity names, `#(doc)` text, and query strings to the configured
295
- * endpoint, and that egress must never switch on just because a commonly
296
- * exported variable happens to be present.
297
- *
298
- * Throws on malformed companion values (bad URL, bad integer) so a typo
299
- * surfaces loudly in the log rather than silently degrading to lexical.
300
- */
301
- export const getEmbeddingConfig = (): EmbeddingConfig | null => {
302
- const apiKey = process.env.EMBEDDING_API_KEY?.trim();
303
- if (!apiKey) {
304
- return null;
305
- }
306
-
307
- const rawBase = process.env.EMBEDDING_API_BASE;
308
- const baseUrl = (rawBase?.trim() || DEFAULT_EMBEDDING_API_BASE).replace(
309
- /\/+$/,
310
- "",
311
- );
312
- try {
313
- new URL(baseUrl);
314
- } catch {
315
- throw new Error(
316
- `Invalid value for EMBEDDING_API_BASE: expected a URL, got "${rawBase}"`,
317
- );
318
- }
319
-
320
- const model = process.env.EMBEDDING_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL;
321
-
322
- const dimensions = parseIntEnv("EMBEDDING_DIMENSIONS");
323
- if (dimensions !== undefined && dimensions <= 0) {
324
- throw new Error(
325
- `EMBEDDING_DIMENSIONS must be a positive integer (got ${dimensions})`,
326
- );
327
- }
328
-
329
- return { apiKey, model, baseUrl, dimensions };
330
- };
331
-
332
- /**
333
- * Tunables for the standalone {@link MaterializationScheduler}. Sourced from
334
- * environment variables at startup; see {@link getMaterializationSchedulerConfig}.
335
- *
336
- * The scheduler fires a package's `materialization.schedule` cron in **standalone**
337
- * deployments (no control plane). It is **disabled by default** — an orchestrated
338
- * deployment, whose control plane already drives materialization, never sets the
339
- * enable flag, so the scheduler is never constructed there.
340
- */
341
- export interface MaterializationSchedulerConfig {
342
- /** Cadence of the due-schedule sweep, in milliseconds. */
343
- tickIntervalMs: number;
344
- /** Max packages fired per tick — a stampede guard for large deployments. */
345
- maxFiresPerTick: number;
346
- }
347
-
348
- const DEFAULT_SCHEDULER_INTERVAL_MS = 60_000;
349
- const MIN_SCHEDULER_INTERVAL_MS = 1_000;
350
- const DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK = 10;
351
-
352
- /**
353
- * Parse standalone-scheduler settings and return a validated config, or `null`
354
- * when the feature is disabled. Disabled iff
355
- * `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is unset/false — the default — so
356
- * the scheduler never runs in an orchestrated deployment (where the control
357
- * plane drives materialization itself).
358
- *
359
- * **Never set `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` on an orchestrated
360
- * worker.** It is the primary safety guard: a control-plane-loaded package that
361
- * is serving live has `manifestLocation === null`, so the scheduler's
362
- * per-package `manifestLocation` skip does not cover it — only this flag being
363
- * off keeps the standalone scheduler from double-driving the control plane.
364
- *
365
- * Throws at startup on malformed input so a typo surfaces loudly rather than
366
- * silently disabling scheduling.
367
- */
368
- export const getMaterializationSchedulerConfig =
369
- (): MaterializationSchedulerConfig | null => {
370
- const enabled =
371
- parseBoolEnv("PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER") ?? false;
372
- if (!enabled) {
373
- return null;
374
- }
375
-
376
- const tickIntervalMs =
377
- parseIntEnv("PUBLISHER_MATERIALIZATION_SCHEDULER_INTERVAL_MS") ??
378
- DEFAULT_SCHEDULER_INTERVAL_MS;
379
- const maxFiresPerTick =
380
- parseIntEnv(
381
- "PUBLISHER_MATERIALIZATION_SCHEDULER_MAX_FIRES_PER_TICK",
382
- ) ?? DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK;
383
-
384
- if (tickIntervalMs < MIN_SCHEDULER_INTERVAL_MS) {
385
- throw new Error(
386
- `PUBLISHER_MATERIALIZATION_SCHEDULER_INTERVAL_MS must be >= ${MIN_SCHEDULER_INTERVAL_MS} (got ${tickIntervalMs})`,
387
- );
388
- }
389
- if (maxFiresPerTick <= 0) {
390
- throw new Error(
391
- `PUBLISHER_MATERIALIZATION_SCHEDULER_MAX_FIRES_PER_TICK must be a positive integer (got ${maxFiresPerTick})`,
392
- );
393
- }
394
-
395
- return { tickIntervalMs, maxFiresPerTick };
396
- };
397
-
398
- /**
399
- * Resolve the row cap applied to ad-hoc connection SQL queries.
400
- * Reads `PUBLISHER_MAX_QUERY_ROWS`; falls back to
401
- * {@link DEFAULT_MAX_QUERY_ROWS} when unset or empty.
402
- *
403
- * Throws at startup on malformed input (matching the loud-failure
404
- * stance of {@link getMemoryGovernorConfig}) so a typo in a k8s
405
- * manifest surfaces immediately instead of silently disabling the
406
- * cap. A value of `0` is accepted and disables wrapping entirely;
407
- * use it only when you intend to opt out of the row cap (e.g. when
408
- * Step 2's byte budget is the only thing you want enforcing the
409
- * bound).
410
- */
411
- export const getMaxQueryRows = (): number => {
412
- const raw = parseIntEnv("PUBLISHER_MAX_QUERY_ROWS");
413
- if (raw === undefined) return DEFAULT_MAX_QUERY_ROWS;
414
- if (raw < 0) {
415
- throw new Error(
416
- `PUBLISHER_MAX_QUERY_ROWS must be a non-negative integer (got ${raw})`,
417
- );
418
- }
419
- return raw;
420
- };
421
-
422
- /**
423
- * Resolve the byte cap applied to ad-hoc connection SQL responses
424
- * when the underlying connection implements `StreamingConnection`.
425
- * Reads `PUBLISHER_MAX_RESPONSE_BYTES`; falls back to
426
- * {@link DEFAULT_MAX_RESPONSE_BYTES} when unset or empty.
427
- *
428
- * Mirrors {@link getMaxQueryRows}'s loud-failure semantics: throws
429
- * at startup on malformed input so a typo in a k8s manifest surfaces
430
- * immediately. A value of `0` is accepted and disables the byte cap
431
- * entirely; use it only when you intend to rely on the row cap alone
432
- * (e.g. for benchmarking).
433
- */
434
- export const getMaxResponseBytes = (): number => {
435
- const raw = parseIntEnv("PUBLISHER_MAX_RESPONSE_BYTES");
436
- if (raw === undefined) return DEFAULT_MAX_RESPONSE_BYTES;
437
- if (raw < 0) {
438
- throw new Error(
439
- `PUBLISHER_MAX_RESPONSE_BYTES must be a non-negative integer (got ${raw})`,
440
- );
441
- }
442
- return raw;
443
- };
444
-
445
- /**
446
- * Resolve the default row limit applied to Malloy model queries
447
- * (the `runnable.run` path used by `getQueryResults` and notebook
448
- * cell execution) when the user's query doesn't carry its own
449
- * `LIMIT`. Reads `PUBLISHER_DEFAULT_QUERY_ROW_LIMIT`; falls back to
450
- * {@link DEFAULT_QUERY_ROW_LIMIT} when unset or empty.
451
- *
452
- * Unlike {@link getMaxQueryRows}, `0` is rejected — a default of
453
- * "return zero rows" is almost certainly a misconfiguration (it
454
- * would silently break every notebook), and the operator probably
455
- * wanted `PUBLISHER_MAX_QUERY_ROWS=0` to opt out of the *hard cap*
456
- * instead. Loud failure surfaces the typo at startup.
457
- */
458
- export const getDefaultQueryRowLimit = (): number => {
459
- const raw = parseIntEnv("PUBLISHER_DEFAULT_QUERY_ROW_LIMIT");
460
- if (raw === undefined) return DEFAULT_QUERY_ROW_LIMIT;
461
- if (raw <= 0) {
462
- throw new Error(
463
- `PUBLISHER_DEFAULT_QUERY_ROW_LIMIT must be a positive integer (got ${raw})`,
464
- );
465
- }
466
- return raw;
467
- };
468
-
469
- /**
470
- * Resolve the per-query wall-clock timeout (milliseconds). Reads
471
- * `PUBLISHER_QUERY_TIMEOUT_MS`; falls back to
472
- * {@link DEFAULT_QUERY_TIMEOUT_MS} when unset or empty.
473
- *
474
- * `0` is accepted and disables the timeout entirely. Loud-failure
475
- * on bad input (negative, non-integer, non-numeric) so a typo in a
476
- * k8s manifest surfaces at startup.
477
- */
478
- export const getQueryTimeoutMs = (): number => {
479
- const raw = parseIntEnv("PUBLISHER_QUERY_TIMEOUT_MS");
480
- if (raw === undefined) return DEFAULT_QUERY_TIMEOUT_MS;
481
- if (raw < 0) {
482
- throw new Error(
483
- `PUBLISHER_QUERY_TIMEOUT_MS must be a non-negative integer (got ${raw})`,
484
- );
485
- }
486
- return raw;
487
- };
488
-
489
- /**
490
- * Resolve the per-pod inbound query concurrency cap. Reads
491
- * `PUBLISHER_MAX_CONCURRENT_QUERIES`; falls back to
492
- * {@link DEFAULT_MAX_CONCURRENT_QUERIES} when unset or empty.
493
- *
494
- * `0` is accepted and disables the cap entirely (use only when you
495
- * have another concurrency control upstream, e.g. an explicit
496
- * connection pool sized at the load balancer). Loud-failure on bad
497
- * input.
498
- */
499
- export const getMaxConcurrentQueries = (): number => {
500
- const raw = parseIntEnv("PUBLISHER_MAX_CONCURRENT_QUERIES");
501
- if (raw === undefined) return DEFAULT_MAX_CONCURRENT_QUERIES;
502
- if (raw < 0) {
503
- throw new Error(
504
- `PUBLISHER_MAX_CONCURRENT_QUERIES must be a non-negative integer (got ${raw})`,
505
- );
506
- }
507
- return raw;
508
- };
509
-
510
- /**
511
- * DuckDB extension-fetch policy. Governs whether Publisher's explicit extension
512
- * INSTALL step (see `installAndLoadExtension` in service/connection.ts) may
513
- * reach the DuckDB extension network.
514
- */
515
- export type ExtensionFetchPolicy = "on-demand" | "local-only";
516
-
517
- /**
518
- * Resolve the DuckDB extension-fetch policy from `EXTENSION_FETCH_POLICY`;
519
- * falls back to `on-demand` when unset or empty.
520
- *
521
- * - `on-demand` (default): preserves prior behaviour. Publisher runs `INSTALL`
522
- * for a missing extension on first use, which fetches it from the DuckDB
523
- * extension network when it is not already present on disk (baked into the
524
- * image). Extensions already baked are used as-is.
525
- * - `local-only`: Publisher never runs `INSTALL`, and turns DuckDB's own
526
- * implicit auto-install off on its build/serve sessions
527
- * (`autoinstall_known_extensions=false`), so no code path reaches the
528
- * network. Auto-LOAD stays on, so an extension already present on disk still
529
- * lazy-loads; a genuinely missing extension surfaces as a loud, actionable
530
- * error instead of a silent fetch. For air-gapped / pinned-image deployments.
531
- *
532
- * Throws on an unrecognised value. Validated at server startup (server.ts calls
533
- * this during boot) so a typo in a k8s manifest fails the boot loudly rather
534
- * than surfacing on the first query that resolves a DuckDB connection — which,
535
- * for a DuckLake-only deployment, could be well after `serving`. Also read on
536
- * each connection resolve, so the value is honoured without a restart.
537
- */
538
- export const getExtensionFetchPolicy = (): ExtensionFetchPolicy => {
539
- const raw = process.env.EXTENSION_FETCH_POLICY;
540
- if (raw === undefined || raw.trim() === "") return "on-demand";
541
- const normalised = raw.trim().toLowerCase();
542
- if (normalised === "on-demand" || normalised === "local-only") {
543
- return normalised;
544
- }
545
- throw new Error(
546
- `Invalid value for EXTENSION_FETCH_POLICY: expected "on-demand" or "local-only", got "${raw}"`,
547
- );
548
- };
549
-
550
- /**
551
- * The three `#@ persist storage=<conn>` deployment modes, read from
552
- * `PERSIST_STORAGE_MODE`. This is a runtime kill switch — flipping DOWN must
553
- * never fail an already-loaded package, only change how `storage=` is honored:
554
- *
555
- * - `off` (default): `storage=` is inert. Sources build into (colocated) and serve
556
- * from their own warehouse exactly as before the feature existed; a source
557
- * that declares `storage=` is served live and surfaced as a package warning.
558
- * The safe resting state and the incident kill switch.
559
- * - `write-only`: builds materialize into the storage destination (so operators
560
- * can measure and inspect the tables), but the serve path still ignores
561
- * `storage=` and serves live. The de-risking / measurement rung.
562
- * - `on`: full end to end — build into storage AND serve via the virtual-source
563
- * transform, with a per-query fallback to live for anything the transform
564
- * cannot yet serve.
565
- *
566
- * The publisher NEVER hard-fails a package on `storage=` in any mode; any
567
- * stricter "refuse a new package that uses storage= while off" policy is the
568
- * caller's, not here (the mechanism/policy split).
569
- */
570
- export type PersistStorageMode = "off" | "write-only" | "on";
571
-
572
- const PERSIST_STORAGE_MODES: readonly PersistStorageMode[] = [
573
- "off",
574
- "write-only",
575
- "on",
576
- ];
577
-
578
- /**
579
- * Resolve the `storage=` deployment mode from `PERSIST_STORAGE_MODE`. Defaults
580
- * to `off` (feature dark) when unset/empty; loud-fails on an unrecognized value
581
- * so a typo can't silently leave the fleet in a surprising mode. Case-insensitive,
582
- * like the sibling `PERSIST_COLLISION_ENFORCE`.
583
- */
584
- export const getPersistStorageMode = (): PersistStorageMode => {
585
- const raw = process.env.PERSIST_STORAGE_MODE;
586
- if (raw === undefined || raw.trim() === "") return "off";
587
- const value = raw.trim().toLowerCase();
588
- if ((PERSIST_STORAGE_MODES as readonly string[]).includes(value)) {
589
- return value as PersistStorageMode;
590
- }
591
- throw new Error(
592
- `PERSIST_STORAGE_MODE must be one of ${PERSIST_STORAGE_MODES.join(
593
- " | ",
594
- )} (got ${JSON.stringify(raw)})`,
595
- );
596
- };
597
-
598
- /**
599
- * Whether a within-package persist-target COLLISION (two distinct persist
600
- * sources resolving to the same physical table in the same destination) is a
601
- * hard publish rejection, from `PERSIST_COLLISION_ENFORCE` (default `false`).
602
- *
603
- * Staged on purpose: a package published BEFORE this check existed may carry a
604
- * latent collision, so the check ships warn-only — surfaced at load and publish
605
- * (so operators can find and remediate) but NOT blocking a re-publish. Flip this
606
- * to `true` only after auditing and remediating known collisions, so the
607
- * transition to reject-at-publish is deliberate and doesn't break routine
608
- * re-publishes of existing packages. Load is ALWAYS warn-only regardless — the
609
- * flag only governs whether publish rejects.
610
- */
611
- export const getPersistCollisionEnforce = (): boolean =>
612
- // parseBoolEnv, not an ad-hoc === "true": an operator who writes `1` or `yes`
613
- // has asked for the check to block, and an ad-hoc compare would silently leave
614
- // it warn-only — the flag failing open in exactly the direction it exists to
615
- // prevent. A typo throws at startup, like every other flag here.
616
- parseBoolEnv("PERSIST_COLLISION_ENFORCE") ?? false;
617
-
618
- /**
619
- * Whether the publisher attaches per-query metadata at all, from
620
- * `PUBLISHER_QUERY_METADATA` (default `off`).
621
- *
622
- * Ships dark for a release, like `PERSIST_STORAGE_MODE` before it, and for the
623
- * same reason: this is the rare feature that touches EVERY statement the server
624
- * sends. On a backend with no native tag facility the bag rides as a leading SQL
625
- * comment, so `on` changes the text of the statement (never its meaning or its
626
- * results) and puts the bag in query logs and `pg_stat_activity`.
627
- *
628
- * The risk that decides the default is upstream, not here. Malloy validates the
629
- * bag at dispatch and THROWS on one it cannot render, and the contract it
630
- * validates against is mirrored in `service/query_metadata.ts` against a pinned
631
- * version. Every mitigation on this path — clamping, shedding, never throwing —
632
- * is downstream of that mirror being right, so a tightened upstream limit would
633
- * surface as failing customer queries on a path nobody opted into. `off` for a
634
- * release means a deployment turns attribution on deliberately, having read
635
- * what it does to its statements.
636
- *
637
- * Case-insensitive; loud-fails on an unrecognized value, so a typo cannot
638
- * silently leave a deployment that asked for attribution without it.
639
- */
640
- export type QueryMetadataMode = "on" | "off";
641
-
642
- export const getQueryMetadataMode = (): QueryMetadataMode => {
643
- const raw = process.env.PUBLISHER_QUERY_METADATA;
644
- if (raw === undefined || raw.trim() === "") return "off";
645
- const value = raw.trim().toLowerCase();
646
- if (value === "on" || value === "off") return value;
647
- throw new Error(
648
- `PUBLISHER_QUERY_METADATA must be on | off (got ${JSON.stringify(raw)})`,
649
- );
650
- };
651
-
652
- function substituteEnvVars(value: string): string {
653
- const envVarPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
654
-
655
- return value.replace(envVarPattern, (_match, varName) => {
656
- const envValue = process.env[varName];
657
-
658
- if (envValue !== undefined) {
659
- return envValue;
660
- }
661
-
662
- throw new Error(
663
- `Environment variable '\${${varName}}' is not set in configuration file`,
664
- );
665
- });
666
- }
667
-
668
- function processConfigValue(value: unknown): unknown {
669
- if (typeof value === "string") {
670
- return substituteEnvVars(value);
671
- }
672
-
673
- if (Array.isArray(value)) {
674
- return value.map((item) => processConfigValue(item));
675
- }
676
-
677
- if (value !== null && typeof value === "object") {
678
- const result: Record<string, unknown> = {};
679
- for (const [key, val] of Object.entries(value)) {
680
- result[key] = processConfigValue(val);
681
- }
682
- return result;
683
- }
684
-
685
- return value;
686
- }
687
-
688
- /**
689
- * Absolute directory that a relative package `location` is resolved against:
690
- * the one holding the active config file.
691
- *
692
- * Null when there is nothing sensible to anchor to, leaving the caller to pick
693
- * a base. That covers three cases: no config at all; the bundled default, which
694
- * lives inside the installed package, where anchoring a user's relative path
695
- * somewhere under node_modules would be meaningless (and which declares only
696
- * remote locations of its own); and a `--config` that names a directory rather
697
- * than a file, which cannot be read as a config, so its parent is not an anchor
698
- * anyone asked for.
699
- */
700
- export const getPublisherConfigDir = (serverRoot: string): string | null => {
701
- const resolved = resolvePublisherConfigPath(serverRoot);
702
- if (!resolved || resolved.isBundledDefault) {
703
- return null;
704
- }
705
- try {
706
- if (!fs.statSync(resolved.path).isFile()) {
707
- return null;
708
- }
709
- } catch {
710
- return null;
711
- }
712
- // Resolve: `--config` may be relative, and an anchor that is itself relative
713
- // would re-resolve against the cwd of whoever reads it.
714
- return path.resolve(path.dirname(resolved.path));
715
- };
716
-
717
- export const getPublisherConfig = (serverRoot: string): PublisherConfig => {
718
- const resolved = resolvePublisherConfigPath(serverRoot);
719
- if (!resolved) {
720
- if (
721
- process.env.PUBLISHER_CONFIG_PATH &&
722
- process.env.PUBLISHER_CONFIG_PATH.length > 0
723
- ) {
724
- // Explicit --config was given but the path didn't exist. Loud
725
- // failure here so a typo in the flag doesn't silently boot the
726
- // server with an empty environment list.
727
- logger.error(
728
- `--config path not found: ${process.env.PUBLISHER_CONFIG_PATH}. Using default empty config.`,
729
- );
730
- }
731
- return {
732
- frozenConfig: false,
733
- environments: [],
734
- };
735
- }
736
- const publisherConfigPath = resolved.path;
737
- if (resolved.isBundledDefault) {
738
- logger.info(
739
- `No publisher.config.json found at ${path.join(serverRoot, PUBLISHER_CONFIG_NAME)}; falling back to bundled DuckDB-only default. Pass --config <path> or place a config in the server root to override.`,
740
- );
741
- }
742
-
743
- let rawConfig: unknown;
744
- try {
745
- const fileContent = fs.readFileSync(publisherConfigPath, "utf8");
746
- rawConfig = JSON.parse(fileContent);
747
- } catch (error) {
748
- const message = error instanceof Error ? error.message : String(error);
749
- logger.error(
750
- `Failed to parse ${publisherConfigPath}: ${message}. Using default empty config.`,
751
- {
752
- path: publisherConfigPath,
753
- error: message,
754
- stack: error instanceof Error ? error.stack : undefined,
755
- },
756
- );
757
- return {
758
- frozenConfig: false,
759
- environments: [],
760
- };
761
- }
762
-
763
- // Process environment variables in config values
764
- const processedConfig = processConfigValue(rawConfig);
765
-
766
- // TODO: Remove this during projects cleanup
767
- // Back-compat: the top-level key was renamed `projects` → `environments`.
768
- // If a config still uses the old key, accept it once with a deprecation
769
- // warning so existing on-disk configs don't silently parse as empty.
770
- if (
771
- processedConfig &&
772
- typeof processedConfig === "object" &&
773
- !("environments" in processedConfig) &&
774
- "projects" in processedConfig
775
- ) {
776
- logger.warn(
777
- `${PUBLISHER_CONFIG_NAME} uses deprecated "projects" key; rename to "environments".`,
778
- );
779
- (processedConfig as Record<string, unknown>).environments = (
780
- processedConfig as Record<string, unknown>
781
- ).projects;
782
- }
783
-
784
- if (
785
- processedConfig &&
786
- typeof processedConfig === "object" &&
787
- "environments" in processedConfig &&
788
- processedConfig.environments &&
789
- typeof processedConfig.environments === "object" &&
790
- !Array.isArray(processedConfig.environments)
791
- ) {
792
- logger.error(
793
- `Invalid ${PUBLISHER_CONFIG_NAME}: the "environments" field must be a JSON array. Using default empty config.`,
794
- );
795
- return {
796
- frozenConfig: false,
797
- environments: [],
798
- };
799
- }
800
-
801
- // Ensure environments is an array
802
- let environments: unknown[] = [];
803
- if (
804
- processedConfig &&
805
- typeof processedConfig === "object" &&
806
- "environments" in processedConfig &&
807
- Array.isArray((processedConfig as { environments: unknown }).environments)
808
- ) {
809
- environments = (processedConfig as { environments: unknown[] })
810
- .environments;
811
- }
812
-
813
- let frozenConfig = false;
814
- if (
815
- processedConfig &&
816
- typeof processedConfig === "object" &&
817
- "frozenConfig" in processedConfig
818
- ) {
819
- frozenConfig = Boolean(
820
- (processedConfig as { frozenConfig: unknown }).frozenConfig,
821
- );
822
- }
823
-
824
- const instanceTheme = sanitizeTheme(
825
- processedConfig &&
826
- typeof processedConfig === "object" &&
827
- "theme" in processedConfig
828
- ? (processedConfig as { theme: unknown }).theme
829
- : undefined,
830
- "publisher.config.json",
831
- );
832
-
833
- return {
834
- frozenConfig,
835
- ...(instanceTheme ? { theme: instanceTheme } : {}),
836
- environments,
837
- } as PublisherConfig;
838
- };
839
-
840
- /**
841
- * Sanitize a raw theme value pulled from JSON. Returns a Theme on success
842
- * or `undefined` if the input is missing/invalid. Bad shapes log a warning
843
- * and are dropped rather than failing the whole config; an unthemed config
844
- * still boots fine.
845
- */
846
- export function sanitizeTheme(
847
- raw: unknown,
848
- context: string,
849
- ): Theme | undefined {
850
- if (raw === undefined || raw === null) return undefined;
851
- if (typeof raw !== "object" || Array.isArray(raw)) {
852
- logger.warn(
853
- `Invalid "theme" in ${context}: expected an object. Ignoring.`,
854
- );
855
- return undefined;
856
- }
857
- const obj = raw as Record<string, unknown>;
858
- const theme: Theme = {};
859
-
860
- if ("defaultMode" in obj) {
861
- const mode = obj.defaultMode;
862
- if (mode === "light" || mode === "dark" || mode === "auto") {
863
- theme.defaultMode = mode;
864
- } else {
865
- logger.warn(
866
- `Invalid "theme.defaultMode" in ${context}: expected "light" | "dark" | "auto" (got ${JSON.stringify(mode)}). Ignoring field.`,
867
- );
868
- }
869
- }
870
- if ("allowUserToggle" in obj) {
871
- const value = obj.allowUserToggle;
872
- if (typeof value === "boolean") {
873
- theme.allowUserToggle = value;
874
- } else {
875
- // Don't coerce: Boolean("false") is true, so a stray string would
876
- // silently invert the operator's intent. Match defaultMode above
877
- // and warn + ignore instead.
878
- logger.warn(
879
- `Invalid "theme.allowUserToggle" in ${context}: expected a boolean (got ${JSON.stringify(value)}). Ignoring field.`,
880
- );
881
- }
882
- }
883
- if ("palette" in obj && obj.palette && typeof obj.palette === "object") {
884
- const palette = obj.palette as Record<string, unknown>;
885
- const sanitized: NonNullable<Theme["palette"]> = {};
886
- if (Array.isArray(palette.series)) {
887
- // Preserve an explicit empty array as a clear-the-palette
888
- // signal; resolveTheme treats [] as a real override.
889
- sanitized.series = palette.series.filter(
890
- (c): c is string => typeof c === "string",
891
- );
892
- }
893
- // Per-mode colour keys share the same shape: { light?: string, dark?: string }.
894
- // Sanitize uniformly so adding a new key only touches PER_MODE_COLOR_KEYS.
895
- for (const key of PER_MODE_COLOR_KEYS) {
896
- const raw = palette[key];
897
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
898
- const r = raw as Record<string, unknown>;
899
- const out: { light?: string; dark?: string } = {};
900
- if (typeof r.light === "string") out.light = r.light;
901
- if (typeof r.dark === "string") out.dark = r.dark;
902
- if (Object.keys(out).length > 0) {
903
- (sanitized as Record<string, unknown>)[key] = out;
904
- }
905
- }
906
- if (Object.keys(sanitized).length > 0) theme.palette = sanitized;
907
- }
908
- if ("font" in obj && obj.font && typeof obj.font === "object") {
909
- const font = obj.font as Record<string, unknown>;
910
- const sanitized: NonNullable<Theme["font"]> = {};
911
- if (typeof font.family === "string") sanitized.family = font.family;
912
- if (typeof font.size === "number" && Number.isFinite(font.size)) {
913
- sanitized.size = font.size;
914
- }
915
- if (Object.keys(sanitized).length > 0) theme.font = sanitized;
916
- }
917
-
918
- return Object.keys(theme).length > 0 ? theme : undefined;
919
- }
920
-
921
- /**
922
- * Merge an environment-level theme on top of the instance default. Both
923
- * inputs are already sanitized. The override wins per key at every level;
924
- * absent keys fall through to the base. Returns `undefined` only when both
925
- * sides are absent.
926
- *
927
- * Per-mode colour objects merge per-mode so an environment that sets
928
- * only `palette.tile.dark` keeps the instance's `palette.tile.light`.
929
- */
930
- export function mergeThemes(
931
- base: Theme | undefined,
932
- override: Theme | undefined,
933
- ): Theme | undefined {
934
- if (!base) return override;
935
- if (!override) return base;
936
- const merged: Theme = { ...base, ...override };
937
- if (base.palette || override.palette) {
938
- merged.palette = {
939
- ...(base.palette ?? {}),
940
- ...(override.palette ?? {}),
941
- };
942
- for (const key of PER_MODE_COLOR_KEYS) {
943
- const b = base.palette?.[key];
944
- const o = override.palette?.[key];
945
- if (b || o) {
946
- merged.palette[key] = { ...(b ?? {}), ...(o ?? {}) };
947
- }
948
- }
949
- }
950
- if (base.font || override.font) {
951
- merged.font = { ...(base.font ?? {}), ...(override.font ?? {}) };
952
- }
953
- return merged;
954
- }
955
-
956
- export const isPublisherConfigFrozen = (serverRoot: string) => {
957
- try {
958
- const publisherConfig = getPublisherConfig(serverRoot);
959
- return Boolean(publisherConfig.frozenConfig);
960
- } catch (error) {
961
- logger.error(
962
- `Error checking if ${PUBLISHER_CONFIG_NAME} is frozen. Defaulting to false.`,
963
- { error },
964
- );
965
- return false;
966
- }
967
- };
968
-
969
- export const getConnectionsFromPublisherConfig = (
970
- serverRoot: string,
971
- environmentName: string,
972
- ): Connection[] => {
973
- try {
974
- const publisherConfig = getPublisherConfig(serverRoot);
975
- if (!Array.isArray(publisherConfig.environments)) {
976
- return [];
977
- }
978
- const environment = publisherConfig.environments.find(
979
- (e) => e && e.name === environmentName,
980
- );
981
- return Array.isArray(environment?.connections)
982
- ? environment.connections
983
- : [];
984
- } catch (error) {
985
- logger.error(
986
- `Error getting connections for environment "${environmentName}" from ${PUBLISHER_CONFIG_NAME}`,
987
- { error },
988
- );
989
- return [];
990
- }
991
- };
992
-
993
- export const convertConnectionsToApiConnections = (
994
- connections: Connection[],
995
- ): ApiConnection[] => {
996
- if (!Array.isArray(connections)) {
997
- return [];
998
- }
999
-
1000
- return connections
1001
- .filter((conn) => {
1002
- if (!conn || typeof conn !== "object") {
1003
- return false;
1004
- }
1005
- if (!conn.name || typeof conn.name !== "string") {
1006
- logger.warn(
1007
- `Invalid connection: missing or invalid "name" field. Skipping.`,
1008
- { connection: conn },
1009
- );
1010
- return false;
1011
- }
1012
- if (!conn.type || typeof conn.type !== "string") {
1013
- logger.warn(
1014
- `Invalid connection "${conn.name}": missing or invalid "type" field. Skipping.`,
1015
- );
1016
- return false;
1017
- }
1018
- return true;
1019
- })
1020
- .map((conn) => ({
1021
- ...conn,
1022
- name: conn.name,
1023
- type: conn.type as ApiConnection["type"],
1024
- resource: `${API_PREFIX}/connections/${conn.name}`,
1025
- }));
1026
- };
1027
-
1028
- export const getProcessedPublisherConfig = (
1029
- serverRoot: string,
1030
- ): ProcessedPublisherConfig => {
1031
- const rawConfig = getPublisherConfig(serverRoot);
1032
-
1033
- // Ensure environments is an array
1034
- if (!Array.isArray(rawConfig.environments)) {
1035
- logger.warn(
1036
- `Invalid ${PUBLISHER_CONFIG_NAME}: the "environments" field must be a JSON array. Using empty array.`,
1037
- );
1038
- return {
1039
- frozenConfig: rawConfig.frozenConfig ?? false,
1040
- environments: [],
1041
- };
1042
- }
1043
-
1044
- // Filter and validate environments, skipping invalid ones
1045
- const validEnvironments: ProcessedEnvironment[] = [];
1046
- for (const environment of rawConfig.environments) {
1047
- if (!environment || typeof environment !== "object") {
1048
- logger.warn(
1049
- `Invalid environment in ${PUBLISHER_CONFIG_NAME}: entry must be an object. Skipping.`,
1050
- );
1051
- continue;
1052
- }
1053
-
1054
- if (!environment.name || typeof environment.name !== "string") {
1055
- logger.warn(
1056
- `Invalid environment in ${PUBLISHER_CONFIG_NAME}: missing or invalid "name" field. Skipping entry.`,
1057
- { environment },
1058
- );
1059
- continue;
1060
- }
1061
-
1062
- if (!Array.isArray(environment.packages)) {
1063
- logger.warn(
1064
- `Invalid environment "${environment.name}" in ${PUBLISHER_CONFIG_NAME}: missing or invalid "packages" field (must be an array). Skipping entry.`,
1065
- );
1066
- continue;
1067
- }
1068
-
1069
- // Validate packages have required fields
1070
- const validPackages = environment.packages.filter((pkg) => {
1071
- if (!pkg || typeof pkg !== "object") {
1072
- logger.warn(
1073
- `Invalid package in environment "${environment.name}": package must be an object. Skipping.`,
1074
- );
1075
- return false;
1076
- }
1077
- if (!pkg.name || typeof pkg.name !== "string") {
1078
- logger.warn(
1079
- `Invalid package in environment "${environment.name}": missing or invalid "name" field. Skipping.`,
1080
- );
1081
- return false;
1082
- }
1083
- if (!pkg.location || typeof pkg.location !== "string") {
1084
- logger.warn(
1085
- `Invalid package "${pkg.name}" in environment "${environment.name}": missing or invalid "location" field. Skipping.`,
1086
- );
1087
- return false;
1088
- }
1089
- return true;
1090
- });
1091
-
1092
- if (validPackages.length === 0) {
1093
- logger.warn(
1094
- `Environment "${environment.name}" has no valid packages. Skipping entry.`,
1095
- );
1096
- continue;
1097
- }
1098
-
1099
- // Per-environment theme override: computed here (the instance default
1100
- // merged with this environment's theme) but NOT yet wired through.
1101
- // addEnvironment() drops this field, so the live Environment never
1102
- // carries it and no viewer applies it today; only the instance-level
1103
- // theme is applied. Kept for the planned per-environment follow-up.
1104
- const envTheme = sanitizeTheme(
1105
- (environment as { theme?: unknown }).theme,
1106
- `environment "${environment.name}"`,
1107
- );
1108
- const resolvedTheme = mergeThemes(rawConfig.theme, envTheme);
1109
-
1110
- validEnvironments.push({
1111
- name: environment.name,
1112
- packages: validPackages,
1113
- connections: convertConnectionsToApiConnections(
1114
- environment.connections || [],
1115
- ),
1116
- ...(resolvedTheme ? { theme: resolvedTheme } : {}),
1117
- });
1118
- }
1119
-
1120
- return {
1121
- frozenConfig: rawConfig.frozenConfig ?? false,
1122
- ...(rawConfig.theme ? { theme: rawConfig.theme } : {}),
1123
- environments: validEnvironments,
1124
- };
1125
- };
1126
-
1127
- /**
1128
- * Convenience accessor for the instance-wide default theme. Used by
1129
- * ServerStatus so the app shell can apply the operator's chosen theme
1130
- * before the viewer has navigated into any specific environment.
1131
- */
1132
- export const getInstanceTheme = (serverRoot: string): Theme | undefined => {
1133
- try {
1134
- return getPublisherConfig(serverRoot).theme;
1135
- } catch (error) {
1136
- logger.error(
1137
- `Error reading instance theme from ${PUBLISHER_CONFIG_NAME}`,
1138
- { error },
1139
- );
1140
- return undefined;
1141
- }
1142
- };