@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
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
- };