@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.
- package/dist/app/api-doc.yaml +83 -27
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
- package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
- package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +59 -20
- package/dist/server.mjs +1342 -616
- package/package.json +8 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- 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
|
-
};
|