@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/service/package.ts
DELETED
|
@@ -1,1799 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs/promises";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
|
|
4
|
-
import "@malloydata/db-duckdb/native";
|
|
5
|
-
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
6
|
-
import {
|
|
7
|
-
Connection,
|
|
8
|
-
ConnectionRuntime,
|
|
9
|
-
contextOverlay,
|
|
10
|
-
EmptyURLReader,
|
|
11
|
-
FixedConnectionMap,
|
|
12
|
-
MalloyConfig,
|
|
13
|
-
MalloyError,
|
|
14
|
-
SourceDef,
|
|
15
|
-
} from "@malloydata/malloy";
|
|
16
|
-
import { publisherMeter } from "../telemetry";
|
|
17
|
-
import recursive from "recursive-readdir";
|
|
18
|
-
import { components } from "../api";
|
|
19
|
-
import { getPackageLoadPool } from "../package_load/package_load_pool";
|
|
20
|
-
import {
|
|
21
|
-
API_PREFIX,
|
|
22
|
-
MODEL_FILE_SUFFIX,
|
|
23
|
-
NOTEBOOK_FILE_SUFFIX,
|
|
24
|
-
PACKAGE_MANIFEST_NAME,
|
|
25
|
-
} from "../constants";
|
|
26
|
-
import {
|
|
27
|
-
ModelCompilationError,
|
|
28
|
-
PackageNotFoundError,
|
|
29
|
-
ServiceUnavailableError,
|
|
30
|
-
} from "../errors";
|
|
31
|
-
import { applyExtensionSessionSettings } from "./connection";
|
|
32
|
-
import { formatDuration, logger } from "../logger";
|
|
33
|
-
import {
|
|
34
|
-
recordBuildPlanComputeDuration,
|
|
35
|
-
recordManifestBindDegraded,
|
|
36
|
-
} from "../materialization_metrics";
|
|
37
|
-
import {
|
|
38
|
-
LOAD_DURATION_BUCKETS_MS,
|
|
39
|
-
recordPackageLoadPhases,
|
|
40
|
-
type PackageLoadStatus,
|
|
41
|
-
} from "../package_load_metrics";
|
|
42
|
-
import { assertSafeEnvironmentPath, safeJoinUnderRoot } from "../path_safety";
|
|
43
|
-
import {
|
|
44
|
-
BuildManifest,
|
|
45
|
-
BuildPlan,
|
|
46
|
-
FreshnessManifest,
|
|
47
|
-
ManifestEntry,
|
|
48
|
-
} from "../storage/DatabaseInterface";
|
|
49
|
-
import { errMessage, ignoreDotfiles } from "../utils";
|
|
50
|
-
import { getPersistCollisionEnforce, getPersistStorageMode } from "../config";
|
|
51
|
-
import { deriveServeBindings } from "./materialization_serve_transform";
|
|
52
|
-
import { computePackageBuildPlan, SourceEligibility } from "./build_plan";
|
|
53
|
-
import { materializationConfigWarnings } from "./materialization_config_validation";
|
|
54
|
-
import { CronEvaluator } from "./cron_evaluator";
|
|
55
|
-
import { filterFreshManifest } from "./freshness";
|
|
56
|
-
import { isQuotedIdentifierPath, quoteManifestTablePath } from "./quoting";
|
|
57
|
-
import { Model } from "./model";
|
|
58
|
-
import { assertPersistNamesQuoted } from "./persist_annotation_validation";
|
|
59
|
-
|
|
60
|
-
type ApiDatabase = components["schemas"]["Database"];
|
|
61
|
-
type ApiModel = components["schemas"]["Model"];
|
|
62
|
-
type ApiNotebook = components["schemas"]["Notebook"];
|
|
63
|
-
export type ApiPackage = components["schemas"]["Package"];
|
|
64
|
-
type ApiPackageWarning = NonNullable<ApiPackage["warnings"]>[number];
|
|
65
|
-
type ApiColumn = components["schemas"]["Column"];
|
|
66
|
-
type ApiTableDescription = components["schemas"]["TableDescription"];
|
|
67
|
-
// A thunk lets callers pass a live reference to the *current* environment
|
|
68
|
-
// MalloyConfig so the package wrapper resolves environment connections against the
|
|
69
|
-
// generation that's active at lookup time, not the one that was current when
|
|
70
|
-
// the package was first loaded.
|
|
71
|
-
type PackageConnectionInput =
|
|
72
|
-
| MalloyConfig
|
|
73
|
-
| Map<string, Connection>
|
|
74
|
-
| (() => MalloyConfig);
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Project the full wire entries down to the Malloy-runtime binding map
|
|
78
|
-
* (`sourceEntityId -> { tableName }`) used to hydrate models. The freshness
|
|
79
|
-
* fields are dropped here — they gate the serve path per query, not model
|
|
80
|
-
* hydration.
|
|
81
|
-
*/
|
|
82
|
-
function toTableNameManifest(
|
|
83
|
-
entries: FreshnessManifest,
|
|
84
|
-
): BuildManifest["entries"] {
|
|
85
|
-
const out: BuildManifest["entries"] = {};
|
|
86
|
-
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
87
|
-
out[sourceEntityId] = { tableName: entry.tableName };
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Classify a failed package load into the `status` label shared by the
|
|
94
|
-
* `malloy_package_load_duration` histogram and the per-phase load metrics, so
|
|
95
|
-
* both slice failures identically. A real Malloy/model compile error is a 4xx
|
|
96
|
-
* `compilation_error`; a rewrapped pool-infrastructure failure is a transient
|
|
97
|
-
* `pool_unavailable`; anything else is a generic `error`.
|
|
98
|
-
*/
|
|
99
|
-
function packageLoadFailureStatus(error: unknown): PackageLoadStatus {
|
|
100
|
-
if (error instanceof ModelCompilationError || error instanceof MalloyError) {
|
|
101
|
-
return "compilation_error";
|
|
102
|
-
}
|
|
103
|
-
if (error instanceof ServiceUnavailableError) {
|
|
104
|
-
return "pool_unavailable";
|
|
105
|
-
}
|
|
106
|
-
return "error";
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export class Package {
|
|
110
|
-
private environmentName: string;
|
|
111
|
-
private packageName: string;
|
|
112
|
-
private packageMetadata: ApiPackage;
|
|
113
|
-
private databases: ApiDatabase[];
|
|
114
|
-
private models: Map<string, Model> = new Map();
|
|
115
|
-
private packagePath: string;
|
|
116
|
-
private malloyConfig: MalloyConfig;
|
|
117
|
-
// Build-manifest binding state (Malloy Persistence v0). When bound, these
|
|
118
|
-
// entries (sourceEntityId -> { tableName }) are what served queries use to route
|
|
119
|
-
// persist sources to their materialized physical tables; they are also reused
|
|
120
|
-
// by the /compile preview so previewed SQL matches executed SQL. Surfaced on
|
|
121
|
-
// /status (via getPackageMetadata) so the control plane can confirm a worker
|
|
122
|
-
// actually bound the distributed manifest instead of inferring it from logs.
|
|
123
|
-
private buildManifestEntries: BuildManifest["entries"] | undefined;
|
|
124
|
-
// The full wire entries retained at bind (sourceEntityId -> { tableName,
|
|
125
|
-
// dataAsOf, freshnessWindowSeconds, freshnessFallback }). Unlike
|
|
126
|
-
// {@link buildManifestEntries} (the tableName-only projection baked into the
|
|
127
|
-
// hydration runtime and reused by /compile), this keeps the control-plane
|
|
128
|
-
// freshness fields so the serve path can re-evaluate `age vs window` per
|
|
129
|
-
// query. Undefined when the package is serving live (unbound).
|
|
130
|
-
private freshnessEntries: FreshnessManifest | undefined;
|
|
131
|
-
// `storage=` serve bindings (materialized-into-storage sources), derived from
|
|
132
|
-
// a build's full manifest entries and pushed onto each Model so a query can
|
|
133
|
-
// route through the virtual-source serve transform. Distinct from the
|
|
134
|
-
// same-connection freshnessEntries above (which the manifest substitution
|
|
135
|
-
// uses); a storage source serves cross-connection via bindings, not the
|
|
136
|
-
// manifest. Empty ⇒ no storage serve routing.
|
|
137
|
-
private storageServeBindings: ReturnType<typeof deriveServeBindings> = [];
|
|
138
|
-
// Memoized freshness-filtered manifest for the serve path. Almost all queries
|
|
139
|
-
// in a window share the same included set, so this is recomputed only when a
|
|
140
|
-
// retained entry actually crosses its window (`validUntil`, the next
|
|
141
|
-
// staleSince) or when a rebind replaces the entries (cleared in
|
|
142
|
-
// recordManifestBinding). validUntil === Infinity means no included entry has
|
|
143
|
-
// an evaluable window, so the result is stable until the next rebind.
|
|
144
|
-
private freshManifestCache:
|
|
145
|
-
| { manifest: BuildManifest["entries"]; validUntil: number }
|
|
146
|
-
| undefined;
|
|
147
|
-
private manifestBindingStatus: "unbound" | "bound" | "live_fallback" =
|
|
148
|
-
"unbound";
|
|
149
|
-
private manifestEntryCount = 0;
|
|
150
|
-
private boundManifestUri: string | null = null;
|
|
151
|
-
// The package's persist build plan: a deterministic property of the compiled
|
|
152
|
-
// package (per-source sourceEntityId, columns, build SQL, dependency graphs),
|
|
153
|
-
// computed once at load from the live (unbound) models so it is stable for a
|
|
154
|
-
// given (package version, connection config). Null when the package declares
|
|
155
|
-
// no persist source. Surfaced read-only on getPackageMetadata() so a caller
|
|
156
|
-
// can derive build instructions without a separate plan round-trip.
|
|
157
|
-
private buildPlan: BuildPlan | null = null;
|
|
158
|
-
// Sources annotated `#@ persist` that Malloy's getBuildPlan() did not
|
|
159
|
-
// recognize as a materializable build root, so they produced no plan entry
|
|
160
|
-
// and would be a silent no-op (served live). Surfaced as an operator warning
|
|
161
|
-
// and hard-refused at build. See build_plan.detectDroppedPersistSources.
|
|
162
|
-
private droppedPersistSources: { name: string; modelPath: string }[] = [];
|
|
163
|
-
// Which sources may be served from a materialized table, decided at compile
|
|
164
|
-
// (build_plan.collectSourceEligibility) because that is where the compiled
|
|
165
|
-
// sources exist. Consulted when binding serve bindings, which deliberately
|
|
166
|
-
// does not recompile.
|
|
167
|
-
//
|
|
168
|
-
// `undefined` means NOT KNOWN — the plan compute failed (it does live schema
|
|
169
|
-
// RPCs, so a warehouse blip is the ordinary way) and the package loaded
|
|
170
|
-
// anyway. Distinct from "nothing is eligible", and refused the same way: an
|
|
171
|
-
// unknown answer must not read as consent. See bindStorageServeBindings.
|
|
172
|
-
private sourceEligibility: SourceEligibility | undefined = undefined;
|
|
173
|
-
// Non-fatal render-tag findings aggregated across the package's models (each
|
|
174
|
-
// tagged with its model path), surfaced read-only on
|
|
175
|
-
// getPackageMetadata().warnings. Refreshed on load and reload. A bad render
|
|
176
|
-
// tag does not fail the load (see Model.validateRenderTags); this is the
|
|
177
|
-
// response-level signal that a tag is misconfigured.
|
|
178
|
-
private renderTagWarnings: ApiPackageWarning[] = [];
|
|
179
|
-
/**
|
|
180
|
-
* Manifest-shape deprecations the load tolerated (a root-level `scope`), kept
|
|
181
|
-
* so publish can report a still-parsing-but-outdated manifest. Not on the wire
|
|
182
|
-
* package: it is a property of the manifest text, not of the loaded package.
|
|
183
|
-
*/
|
|
184
|
-
private manifestWarnings: string[] = [];
|
|
185
|
-
private static meter = publisherMeter();
|
|
186
|
-
private static packageLoadHistogram = this.meter.createHistogram(
|
|
187
|
-
"malloy_package_load_duration",
|
|
188
|
-
{
|
|
189
|
-
description: "Time taken to load a Malloy package",
|
|
190
|
-
unit: "ms",
|
|
191
|
-
// OTel's default buckets top out at 10s, censoring the slow-load tail.
|
|
192
|
-
// Use the shared load-duration buckets (→5min) so p95/p99 of large
|
|
193
|
-
// package loads are resolvable. See LOAD_DURATION_BUCKETS_MS.
|
|
194
|
-
advice: { explicitBucketBoundaries: LOAD_DURATION_BUCKETS_MS },
|
|
195
|
-
},
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
constructor(
|
|
199
|
-
environmentName: string,
|
|
200
|
-
packageName: string,
|
|
201
|
-
packagePath: string,
|
|
202
|
-
packageMetadata: ApiPackage,
|
|
203
|
-
databases: ApiDatabase[],
|
|
204
|
-
models: Map<string, Model>,
|
|
205
|
-
malloyConfig: MalloyConfig = new MalloyConfig({ connections: {} }),
|
|
206
|
-
) {
|
|
207
|
-
this.environmentName = environmentName;
|
|
208
|
-
this.packageName = packageName;
|
|
209
|
-
this.packagePath = packagePath;
|
|
210
|
-
this.packageMetadata = packageMetadata;
|
|
211
|
-
this.databases = databases;
|
|
212
|
-
this.models = models;
|
|
213
|
-
this.malloyConfig = malloyConfig;
|
|
214
|
-
this.applyDiscoveryPolicyToModels();
|
|
215
|
-
this.applyQueryBoundaryToModels();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Push the discovery-curation policy down onto each Model. Curation (file
|
|
220
|
-
* listing via `explores` and within-file `export {}` filtering) is enabled
|
|
221
|
-
* only when `explores` is declared in publisher.json — absent/empty
|
|
222
|
-
* `explores` preserves legacy listings. Re-derived on reload and metadata
|
|
223
|
-
* PATCH (the inputs can change there).
|
|
224
|
-
*/
|
|
225
|
-
/** True when the package opts into curated discovery via a non-empty
|
|
226
|
-
* `explores`. Single source of truth so the curation/boundary/listing
|
|
227
|
-
* derivations can't drift out of sync. */
|
|
228
|
-
private exploresDeclared(): boolean {
|
|
229
|
-
const explores = this.packageMetadata.explores;
|
|
230
|
-
return !!(explores && explores.length > 0);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** The declared explore set, or null when discovery is uncurated. */
|
|
234
|
-
private exploreSet(): Set<string> | null {
|
|
235
|
-
const explores = this.packageMetadata.explores;
|
|
236
|
-
return explores && explores.length > 0 ? new Set(explores) : null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
private applyDiscoveryPolicyToModels(): void {
|
|
240
|
-
const curationEnabled = this.exploresDeclared();
|
|
241
|
-
for (const model of this.models.values()) {
|
|
242
|
-
model.setDiscoveryCuration(curationEnabled);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Push the package-level query-boundary policy down onto each Model so the
|
|
248
|
-
* query chokepoints can enforce it without a back-reference to the Package:
|
|
249
|
-
* `Model.getQueryResults` (the HTTP query route and the MCP tool) and the
|
|
250
|
-
* `/compile` path (via `assertQueryBoundaryForRunnable`). Derived once here
|
|
251
|
-
* (and on reload) rather than per query: the policy only changes when the
|
|
252
|
-
* manifest is (re)read.
|
|
253
|
-
*
|
|
254
|
-
* Policy: queryable == discoverable. The boundary is inert unless `explores`
|
|
255
|
-
* is declared (no curated surface ⇒ nothing to restrict) AND
|
|
256
|
-
* `queryableSources` is "declared" (the default; "all" decouples the axes).
|
|
257
|
-
* When active, a model file is a query entry point only if it is listed in
|
|
258
|
-
* `explores`; within-file curation (`export {}`) is read off each Model.
|
|
259
|
-
*/
|
|
260
|
-
private applyQueryBoundaryToModels(): void {
|
|
261
|
-
const exploresDeclared = this.exploresDeclared();
|
|
262
|
-
const exploreSet = this.exploreSet();
|
|
263
|
-
const mode =
|
|
264
|
-
this.packageMetadata.queryableSources === "all" ? "all" : "declared";
|
|
265
|
-
for (const [modelPath, model] of this.models) {
|
|
266
|
-
model.setQueryBoundary({
|
|
267
|
-
mode,
|
|
268
|
-
exploresDeclared,
|
|
269
|
-
isQueryEntryPoint: exploreSet ? exploreSet.has(modelPath) : true,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
static async create(
|
|
275
|
-
environmentName: string,
|
|
276
|
-
packageName: string,
|
|
277
|
-
packagePath: string,
|
|
278
|
-
environmentMalloyConfig: PackageConnectionInput,
|
|
279
|
-
/**
|
|
280
|
-
* Delete `packagePath` if the load fails. Opt-in, and only correct for a
|
|
281
|
-
* caller that created the directory itself (an install staged into place),
|
|
282
|
-
* where the half-built tree is Publisher's to clean up and `installPackage`
|
|
283
|
-
* rolls the previous one back. Every other caller loads a directory that
|
|
284
|
-
* already existed: a reload of a package that is currently serving, or a
|
|
285
|
-
* user directory registered via addPackage. Deleting those on a transient
|
|
286
|
-
* compile error destroys the source and takes the package offline, so the
|
|
287
|
-
* default is to leave the directory alone.
|
|
288
|
-
*/
|
|
289
|
-
cleanupDirectoryOnFailure: boolean = false,
|
|
290
|
-
): Promise<Package> {
|
|
291
|
-
assertSafeEnvironmentPath(packagePath);
|
|
292
|
-
const startTime = performance.now();
|
|
293
|
-
await Package.validatePackageManifestExistsOrThrowError(packagePath);
|
|
294
|
-
const manifestValidationTime = performance.now();
|
|
295
|
-
logger.info("Package manifest validation completed", {
|
|
296
|
-
packageName,
|
|
297
|
-
duration: formatDuration(manifestValidationTime - startTime),
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
// The MalloyConfig is always built on the main thread — it
|
|
302
|
-
// owns the live native connection handles the package needs
|
|
303
|
-
// to *serve queries* after load (workers can't share native
|
|
304
|
-
// handles across the V8 isolate boundary). The worker proxies
|
|
305
|
-
// non-duckdb connection lookups back through this MalloyConfig
|
|
306
|
-
// during compile.
|
|
307
|
-
const malloyConfig = Package.buildPackageMalloyConfig(
|
|
308
|
-
packagePath,
|
|
309
|
-
typeof environmentMalloyConfig === "function"
|
|
310
|
-
? environmentMalloyConfig
|
|
311
|
-
: () => Package.toMalloyConfig(environmentMalloyConfig),
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
return await Package.loadViaWorker(
|
|
315
|
-
environmentName,
|
|
316
|
-
packageName,
|
|
317
|
-
packagePath,
|
|
318
|
-
malloyConfig,
|
|
319
|
-
startTime,
|
|
320
|
-
manifestValidationTime,
|
|
321
|
-
);
|
|
322
|
-
} catch (error) {
|
|
323
|
-
logger.error(`Error loading package ${packageName}`, { error });
|
|
324
|
-
console.error(error);
|
|
325
|
-
const endTime = performance.now();
|
|
326
|
-
const executionTime = endTime - startTime;
|
|
327
|
-
this.packageLoadHistogram.record(executionTime, {
|
|
328
|
-
malloy_package_name: packageName,
|
|
329
|
-
status: packageLoadFailureStatus(error),
|
|
330
|
-
});
|
|
331
|
-
// Clean up the package directory only when the caller opted in (an
|
|
332
|
-
// install that staged this tree), and never when packagePath is an
|
|
333
|
-
// in-place mount symlink (watch mode). Removing it would unmount the
|
|
334
|
-
// package, so a transient compile error from a half-typed model saved
|
|
335
|
-
// mid-edit would brick the package until a restart. The symlink points
|
|
336
|
-
// at the user's live source, which is left untouched; the next save
|
|
337
|
-
// recompiles against it.
|
|
338
|
-
try {
|
|
339
|
-
if (!cleanupDirectoryOnFailure) {
|
|
340
|
-
logger.info(
|
|
341
|
-
`Preserving existing package directory after failed load: ${packagePath}`,
|
|
342
|
-
);
|
|
343
|
-
} else {
|
|
344
|
-
const stat = await fs.lstat(packagePath).catch(() => null);
|
|
345
|
-
if (stat?.isSymbolicLink()) {
|
|
346
|
-
logger.info(
|
|
347
|
-
`Skipping cleanup of symlinked package path on failure: ${packagePath}`,
|
|
348
|
-
);
|
|
349
|
-
} else {
|
|
350
|
-
await fs.rm(packagePath, { recursive: true, force: true });
|
|
351
|
-
logger.info(
|
|
352
|
-
`Cleaned up failed package directory: ${packagePath}`,
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
} catch (cleanupError) {
|
|
357
|
-
logger.warn(`Failed to clean up package directory ${packagePath}`, {
|
|
358
|
-
error: cleanupError,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
throw error;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Load the package via the package-load worker pool. The worker
|
|
367
|
-
* performs the CPU-bound bulk of the load off-thread (manifest
|
|
368
|
-
* read, every `.malloy` / `.malloynb` compile) and ships back a
|
|
369
|
-
* structured-clonable `LoadPackageOutcome`. Database probes
|
|
370
|
-
* (`.parquet` / `.csv`) run on the main thread, in parallel with
|
|
371
|
-
* the worker compile, against the package's existing DuckDB
|
|
372
|
-
* connection — they're async-IO-bound and don't compete with the
|
|
373
|
-
* worker for CPU.
|
|
374
|
-
*
|
|
375
|
-
* Pool-infrastructure failures (worker crash, RPC timeout, pool
|
|
376
|
-
* shutting down) are rewrapped as `ServiceUnavailableError` so
|
|
377
|
-
* the HTTP layer responds 503 (transient, retryable). Real compile
|
|
378
|
-
* errors (`MalloyError` / `ModelCompilationError`) propagate
|
|
379
|
-
* unchanged so they keep their 4xx mapping.
|
|
380
|
-
*/
|
|
381
|
-
private static async loadViaWorker(
|
|
382
|
-
environmentName: string,
|
|
383
|
-
packageName: string,
|
|
384
|
-
packagePath: string,
|
|
385
|
-
malloyConfig: MalloyConfig,
|
|
386
|
-
startTime: number,
|
|
387
|
-
manifestValidationTime: number,
|
|
388
|
-
): Promise<Package> {
|
|
389
|
-
const pool = getPackageLoadPool();
|
|
390
|
-
const dispatchTime = performance.now();
|
|
391
|
-
// Submit the worker job and run database probing on the main
|
|
392
|
-
// thread in parallel. We isolate the worker-job promise inside
|
|
393
|
-
// a wrapper so we can map pool-infrastructure failures (worker
|
|
394
|
-
// crash, RPC timeout, pool shutting down) to a 503 without
|
|
395
|
-
// accidentally re-mapping `readDatabases`'s own errors.
|
|
396
|
-
const workerOutcome = pool
|
|
397
|
-
.loadPackage({
|
|
398
|
-
packagePath,
|
|
399
|
-
packageName,
|
|
400
|
-
malloyConfig,
|
|
401
|
-
defaultConnectionName: "duckdb",
|
|
402
|
-
})
|
|
403
|
-
.catch((err: unknown) => {
|
|
404
|
-
// Compile errors surface in-band via
|
|
405
|
-
// `LoadPackageOutcome.models[i].compilationError`; if the
|
|
406
|
-
// pool itself rejects, it's an infra-side failure
|
|
407
|
-
// (shutting down, worker spawn failed, worker crashed,
|
|
408
|
-
// RPC timeout) and the client should retry. Real Malloy
|
|
409
|
-
// compile errors deserialised by the pool still carry
|
|
410
|
-
// their MalloyError / ModelCompilationError identity —
|
|
411
|
-
// let those bubble untouched so they keep their 4xx
|
|
412
|
-
// mapping in `errors.ts`.
|
|
413
|
-
const realError =
|
|
414
|
-
err instanceof Error
|
|
415
|
-
? err
|
|
416
|
-
: new Error(
|
|
417
|
-
`Package-load worker pool failure: ${String(err)}`,
|
|
418
|
-
);
|
|
419
|
-
if (
|
|
420
|
-
realError instanceof MalloyError ||
|
|
421
|
-
realError instanceof ModelCompilationError
|
|
422
|
-
) {
|
|
423
|
-
throw realError;
|
|
424
|
-
}
|
|
425
|
-
throw new ServiceUnavailableError(
|
|
426
|
-
`Package-load worker pool unavailable: ${realError.message}`,
|
|
427
|
-
);
|
|
428
|
-
});
|
|
429
|
-
const [outcome, databases] = await Promise.all([
|
|
430
|
-
workerOutcome,
|
|
431
|
-
Package.readDatabases(packagePath, malloyConfig),
|
|
432
|
-
]);
|
|
433
|
-
const workerDoneTime = performance.now();
|
|
434
|
-
logger.info("Package load via worker pool completed", {
|
|
435
|
-
packageName,
|
|
436
|
-
manifestValidationMs: dispatchTime - manifestValidationTime,
|
|
437
|
-
workerDurationMs: outcome.loadDurationMs,
|
|
438
|
-
dispatchOverheadMs:
|
|
439
|
-
workerDoneTime - dispatchTime - outcome.loadDurationMs,
|
|
440
|
-
// Phase split of the worker duration (remainder = setup + extraction).
|
|
441
|
-
compileDurationMs: outcome.timings.compileDurationMs,
|
|
442
|
-
schemaFetchDurationMs: outcome.timings.schemaFetchDurationMs,
|
|
443
|
-
schemaFetchCount: outcome.timings.schemaFetchCount,
|
|
444
|
-
modelCount: outcome.models.length,
|
|
445
|
-
databaseCount: databases.length,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Override the manifest-derived resource URI — the worker only
|
|
449
|
-
// returns name/description from publisher.json, but the rest of
|
|
450
|
-
// the API surface expects a `resource` field too.
|
|
451
|
-
const packageConfig: ApiPackage = {
|
|
452
|
-
name: outcome.packageMetadata.name,
|
|
453
|
-
description: outcome.packageMetadata.description,
|
|
454
|
-
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
|
|
455
|
-
explores: outcome.packageMetadata.explores,
|
|
456
|
-
queryableSources: outcome.packageMetadata.queryableSources,
|
|
457
|
-
manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
|
|
458
|
-
// Always surface a non-null `materialization` object once the package
|
|
459
|
-
// has loaded (schedule null when the manifest declares no policy). The
|
|
460
|
-
// control plane treats object-present as the authoritative "this is
|
|
461
|
-
// what the manifest says" signal and object-absent as "metadata not
|
|
462
|
-
// available this request" — so it must never be dropped to null on a
|
|
463
|
-
// successfully loaded package, or the CP can misread a transient
|
|
464
|
-
// absence as a schedule removal. See `parsePackageMaterialization`.
|
|
465
|
-
materialization: outcome.packageMetadata.materialization ?? {
|
|
466
|
-
schedule: null,
|
|
467
|
-
freshness: null,
|
|
468
|
-
},
|
|
469
|
-
// Package-level persist scope mode, applied uniformly to every persist
|
|
470
|
-
// source/index. Defaults to "package" (cross-version reuse) when the
|
|
471
|
-
// manifest omits it.
|
|
472
|
-
scope: outcome.packageMetadata.scope ?? "package",
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
// Build live `Model`s from worker output. Any per-model compile
|
|
476
|
-
// failure aborts the load — matches the historical behaviour of
|
|
477
|
-
// `Package.create` failing the whole package on the first model
|
|
478
|
-
// error. (`Package.reloadAllModels` keeps the failed-model
|
|
479
|
-
// placeholders instead; that branch goes through a different
|
|
480
|
-
// hydration path.)
|
|
481
|
-
const models = new Map<string, Model>();
|
|
482
|
-
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
483
|
-
try {
|
|
484
|
-
for (const sm of outcome.models) {
|
|
485
|
-
if (sm.compilationError) {
|
|
486
|
-
const err = Model.deserializeCompilationError(
|
|
487
|
-
sm.compilationError,
|
|
488
|
-
);
|
|
489
|
-
logger.error("Model compilation failed", {
|
|
490
|
-
packageName,
|
|
491
|
-
modelPath: sm.modelPath,
|
|
492
|
-
error: err.message,
|
|
493
|
-
});
|
|
494
|
-
// The outer catch in Package.create records the total metric +
|
|
495
|
-
// cleans the package directory.
|
|
496
|
-
throw err;
|
|
497
|
-
}
|
|
498
|
-
const model = Model.fromSerialized(
|
|
499
|
-
packageName,
|
|
500
|
-
packagePath,
|
|
501
|
-
malloyConfig,
|
|
502
|
-
sm,
|
|
503
|
-
);
|
|
504
|
-
// Validate renderer tags on the main thread (the renderer is too
|
|
505
|
-
// heavy to load inside the pure-CPU package-load worker). A
|
|
506
|
-
// misconfigured tag is logged as a warning naming the target; it
|
|
507
|
-
// does not fail the load. The findings also ride the package
|
|
508
|
-
// response as non-fatal `warnings`.
|
|
509
|
-
for (const w of await model.validateRenderTags()) {
|
|
510
|
-
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
511
|
-
}
|
|
512
|
-
// Reject unquoted `#@ persist name=` annotations the same way: an
|
|
513
|
-
// unquoted name is dropped from the build plan, so the source would
|
|
514
|
-
// publish but never materialize. Scan the raw `.malloy` source (the
|
|
515
|
-
// ground truth for quoting); throws a ModelCompilationError (424).
|
|
516
|
-
if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
517
|
-
const modelSource = await fs.readFile(
|
|
518
|
-
path.join(packagePath, sm.modelPath),
|
|
519
|
-
"utf-8",
|
|
520
|
-
);
|
|
521
|
-
assertPersistNamesQuoted(modelSource, sm.modelPath);
|
|
522
|
-
}
|
|
523
|
-
models.set(sm.modelPath, model);
|
|
524
|
-
}
|
|
525
|
-
} catch (err) {
|
|
526
|
-
// Record the load's phase cost tagged with the terminal status before
|
|
527
|
-
// the error propagates to the outer catch (which records the total).
|
|
528
|
-
// Only in-band compile failures reach here — the worker already
|
|
529
|
-
// produced `outcome.timings`; a pool failure throws before `outcome`
|
|
530
|
-
// exists and carries no timings, so it's simply not recorded.
|
|
531
|
-
recordPackageLoadPhases(
|
|
532
|
-
outcome.timings,
|
|
533
|
-
packageLoadFailureStatus(err),
|
|
534
|
-
);
|
|
535
|
-
throw err;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const endTime = performance.now();
|
|
539
|
-
const executionTime = endTime - startTime;
|
|
540
|
-
this.packageLoadHistogram.record(executionTime, {
|
|
541
|
-
malloy_package_name: packageName,
|
|
542
|
-
status: "success",
|
|
543
|
-
});
|
|
544
|
-
recordPackageLoadPhases(outcome.timings, "success");
|
|
545
|
-
logger.info(`Successfully loaded package ${packageName}`, {
|
|
546
|
-
packageName,
|
|
547
|
-
duration: formatDuration(executionTime),
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
const pkg = new Package(
|
|
551
|
-
environmentName,
|
|
552
|
-
packageName,
|
|
553
|
-
packagePath,
|
|
554
|
-
packageConfig,
|
|
555
|
-
databases,
|
|
556
|
-
models,
|
|
557
|
-
malloyConfig,
|
|
558
|
-
);
|
|
559
|
-
pkg.renderTagWarnings = renderTagWarnings;
|
|
560
|
-
pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
561
|
-
// Install the per-query freshness resolver on the freshly-built models.
|
|
562
|
-
// At create time no manifest is bound yet, so the resolver returns
|
|
563
|
-
// undefined (serve live) until a subsequent bindManifest → reloadAllModels.
|
|
564
|
-
pkg.wireFreshnessResolvers();
|
|
565
|
-
|
|
566
|
-
// Compute the persist build plan off the live (unbound) models, before the
|
|
567
|
-
// caller binds any configured manifest, so the surfaced plan reflects the
|
|
568
|
-
// canonical build (not the manifest-rewritten SQL). Best-effort: a plan
|
|
569
|
-
// failure is logged, not fatal — the package still serves; the plan is
|
|
570
|
-
// just absent. Recompiles the models (duplicate schema RPCs vs the worker
|
|
571
|
-
// compile); accepted for now.
|
|
572
|
-
//
|
|
573
|
-
// A failure also leaves `sourceEligibility` unknown, which refuses every
|
|
574
|
-
// storage serve binding until a load succeeds (see
|
|
575
|
-
// bindStorageServeBindings). Serving live is a slower answer, not a wrong
|
|
576
|
-
// one; admitting an unexamined binding would be a wrong one.
|
|
577
|
-
try {
|
|
578
|
-
const buildPlanStart = Date.now();
|
|
579
|
-
const { plan, droppedPersistSources, sourceEligibility } =
|
|
580
|
-
await computePackageBuildPlan(pkg);
|
|
581
|
-
pkg.buildPlan = plan;
|
|
582
|
-
pkg.droppedPersistSources = droppedPersistSources;
|
|
583
|
-
pkg.sourceEligibility = sourceEligibility;
|
|
584
|
-
recordBuildPlanComputeDuration(Date.now() - buildPlanStart);
|
|
585
|
-
} catch (err) {
|
|
586
|
-
logger.warn(
|
|
587
|
-
`Failed to compute build plan for package ${packageName}`,
|
|
588
|
-
{
|
|
589
|
-
packageName,
|
|
590
|
-
error: errMessage(err),
|
|
591
|
-
},
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// Fail-safe at load: a bad explores entry doesn't fail the package
|
|
596
|
-
// (its models still load and listModels hides the unmatched entry — it
|
|
597
|
-
// never falls back to listing everything). Warn so the misconfig is
|
|
598
|
-
// visible; the publish path rejects it outright (see package.controller).
|
|
599
|
-
const invalidMsg = pkg.formatInvalidExplores();
|
|
600
|
-
if (invalidMsg) {
|
|
601
|
-
logger.warn(`Package ${packageName} has invalid explores`, {
|
|
602
|
-
packageName,
|
|
603
|
-
detail: invalidMsg,
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
// Same fail-safe split for the persistence-policy gate: an existing
|
|
607
|
-
// package whose manifest violates the scope/schedule/freshness rules
|
|
608
|
-
// still loads (warn), but a publish of it is rejected (see
|
|
609
|
-
// package.controller).
|
|
610
|
-
const invalidPolicy = pkg.formatInvalidPersistencePolicy();
|
|
611
|
-
if (invalidPolicy) {
|
|
612
|
-
logger.warn(
|
|
613
|
-
`Package ${packageName} has an invalid persistence policy`,
|
|
614
|
-
{
|
|
615
|
-
packageName,
|
|
616
|
-
detail: invalidPolicy,
|
|
617
|
-
},
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
// Persist-target collisions are ALWAYS warn-only at load (never fail an
|
|
621
|
-
// already-published package), regardless of PERSIST_COLLISION_ENFORCE —
|
|
622
|
-
// the flag only governs whether they REJECT a publish (see
|
|
623
|
-
// package.controller). Surface them so an operator can remediate.
|
|
624
|
-
const collisions = pkg.persistenceCollisionWarnings();
|
|
625
|
-
if (collisions.length > 0) {
|
|
626
|
-
logger.warn(`Package ${packageName} has persist-target collisions`, {
|
|
627
|
-
packageName,
|
|
628
|
-
detail: collisions.join("\n"),
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
pkg.logEmptyDiscoveryWarnings();
|
|
632
|
-
|
|
633
|
-
return pkg;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
public getPackageName(): string {
|
|
637
|
-
return this.packageName;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* The package's persist build plan (per-source sourceEntityId, columns, build SQL,
|
|
642
|
-
* dependency graphs), or null when the package declares no persist source.
|
|
643
|
-
* A deterministic property of the compiled package; callers derive build
|
|
644
|
-
* instructions from it for an orchestrated materialization.
|
|
645
|
-
*/
|
|
646
|
-
public getBuildPlan(): BuildPlan | null {
|
|
647
|
-
return this.buildPlan;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* The package-level `materialization` config (from malloy-publisher.json),
|
|
652
|
-
* the least-specific layer for resolving per-source freshness/schedule in the
|
|
653
|
-
* build plan. Null when the package declares no policy.
|
|
654
|
-
*/
|
|
655
|
-
public getMaterializationConfig(): NonNullable<
|
|
656
|
-
ApiPackage["materialization"]
|
|
657
|
-
> | null {
|
|
658
|
-
return this.packageMetadata.materialization ?? null;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
public getPackageMetadata(): ApiPackage {
|
|
662
|
-
// Overlay the server-computed fields onto the stored metadata: the
|
|
663
|
-
// explores misconfig warnings (loading is fail-safe — the package still
|
|
664
|
-
// serves with the bad entry hidden — so this is the only non-log signal
|
|
665
|
-
// that it's broken) and the manifest-binding state (so /status reflects
|
|
666
|
-
// whether persist sources are actually routed to materialized tables).
|
|
667
|
-
// Always returns a copy so these overlays never mutate the stored
|
|
668
|
-
// metadata; the binding fields are authoritative from the private state.
|
|
669
|
-
//
|
|
670
|
-
// `name` is the registered package name (the environment's identity for
|
|
671
|
-
// this package), not the value read from the package's own manifest —
|
|
672
|
-
// those can differ (e.g. a package installed under a different name than
|
|
673
|
-
// its publisher.json declares). `listPackages` already overrides it to
|
|
674
|
-
// the registered name; surfacing it here keeps the single-package GET
|
|
675
|
-
// consistent without relying on a returned-reference mutation.
|
|
676
|
-
const metadata: ApiPackage = {
|
|
677
|
-
...this.packageMetadata,
|
|
678
|
-
name: this.packageName,
|
|
679
|
-
manifestBindingStatus: this.manifestBindingStatus,
|
|
680
|
-
manifestEntryCount: this.manifestEntryCount,
|
|
681
|
-
boundManifestUri: this.boundManifestUri,
|
|
682
|
-
buildPlan: this.buildPlan,
|
|
683
|
-
};
|
|
684
|
-
const warnings = this.exploreWarnings();
|
|
685
|
-
if (warnings.length > 0) {
|
|
686
|
-
metadata.exploresWarnings = warnings;
|
|
687
|
-
}
|
|
688
|
-
// Render-tag findings and storage= warnings share the one operator-facing
|
|
689
|
-
// warnings array (the {model, target, message} shape carries both).
|
|
690
|
-
const allWarnings = [
|
|
691
|
-
...this.renderTagWarnings,
|
|
692
|
-
...this.storageWarnings(),
|
|
693
|
-
...this.droppedPersistWarnings(),
|
|
694
|
-
// A within-package persist-target collision spans two or more sources, so
|
|
695
|
-
// there is no single target field; the message names them. Surfaced here
|
|
696
|
-
// (alongside the load-path log) so an operator can see it on the status
|
|
697
|
-
// API like the other persist warnings — see persistenceCollisionWarnings.
|
|
698
|
-
...this.persistenceCollisionWarnings().map((message) => ({ message })),
|
|
699
|
-
// Materialization-config findings: a queryMetadata property that will
|
|
700
|
-
// not do what it says, and manifest shapes that still parse but are
|
|
701
|
-
// deprecated. Advisory by design — none of these blocks a publish.
|
|
702
|
-
...materializationConfigWarnings({
|
|
703
|
-
packageMaterialization: this.packageMetadata.materialization,
|
|
704
|
-
sources: this.buildPlan?.sources
|
|
705
|
-
? Object.values(this.buildPlan.sources)
|
|
706
|
-
: [],
|
|
707
|
-
manifestWarnings: this.manifestWarnings,
|
|
708
|
-
}),
|
|
709
|
-
];
|
|
710
|
-
if (allWarnings.length > 0) {
|
|
711
|
-
metadata.warnings = allWarnings;
|
|
712
|
-
}
|
|
713
|
-
// Surface what's bound for the cross-connection storage serve so a caller
|
|
714
|
-
// can confirm a materialized source is routed (vs. inferring from logs).
|
|
715
|
-
if (this.storageServeBindings.length > 0) {
|
|
716
|
-
metadata.storageServeBindings = this.storageServeBindings.map((b) => ({
|
|
717
|
-
sourceName: b.sourceName,
|
|
718
|
-
storageConnectionName: b.connectionName,
|
|
719
|
-
tablePath: b.tablePath,
|
|
720
|
-
}));
|
|
721
|
-
}
|
|
722
|
-
return metadata;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* The currently-bound build-manifest entries (sourceEntityId -> { tableName }), or
|
|
727
|
-
* undefined when the package is serving live. Reused by the /compile preview
|
|
728
|
-
* so previewed SQL gets the same persist-source -> physical-table routing as
|
|
729
|
-
* execution.
|
|
730
|
-
*/
|
|
731
|
-
public getBuildManifestEntries(): BuildManifest["entries"] | undefined {
|
|
732
|
-
return this.buildManifestEntries;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Whether the package currently has a bound (non-empty) same-connection
|
|
737
|
-
* `tableName` manifest — i.e. a prior bind substituted colocated physical tables
|
|
738
|
-
* at compile time. Used by the manifest-rebind tier split to decide whether a
|
|
739
|
-
* pure-storage refresh can skip the {@link reloadAllModels} recompile: it can
|
|
740
|
-
* only skip when there is nothing to substitute now AND nothing was
|
|
741
|
-
* substituted before (otherwise dropping the last colocated entry must recompile
|
|
742
|
-
* to revert it).
|
|
743
|
-
*/
|
|
744
|
-
public hasBoundTableNameManifest(): boolean {
|
|
745
|
-
return (
|
|
746
|
-
this.buildManifestEntries !== undefined &&
|
|
747
|
-
Object.keys(this.buildManifestEntries).length > 0
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Whether the package currently has (non-empty) `storage=` serve bindings.
|
|
753
|
-
* Used by the manifest-rebind tier split so a refresh whose storage entries
|
|
754
|
-
* vanished still clears the old bindings (rather than leaving them routing at
|
|
755
|
-
* a table the host no longer vouches for) — the storage mirror of
|
|
756
|
-
* {@link hasBoundTableNameManifest}.
|
|
757
|
-
*/
|
|
758
|
-
public hasStorageServeBindings(): boolean {
|
|
759
|
-
return this.storageServeBindings.length > 0;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* The freshness-filtered build manifest for the serve path, evaluated at
|
|
764
|
-
* `now`. Persistence.md §9.3 Phase B: a query on a `#@ persist` source may
|
|
765
|
-
* use its materialized table only while the table is within its declared
|
|
766
|
-
* freshness window; otherwise it falls back per the entry's `fallback`
|
|
767
|
-
* (`live`/`fail` drop the entry → serve live; `stale_ok` keeps it → serve the
|
|
768
|
-
* stale table). Un-gated entries (no window) always route to the table.
|
|
769
|
-
*
|
|
770
|
-
* Undefined when the package is serving live (unbound) — callers then apply
|
|
771
|
-
* no per-query override and the runtime serves live.
|
|
772
|
-
*
|
|
773
|
-
* Memoized: recomputed only when a retained entry crosses its window
|
|
774
|
-
* (monotonic fresh→stale, so a single `validUntil` deadline suffices) or when
|
|
775
|
-
* a rebind replaces the entries. Cheap O(1) hit on the common path.
|
|
776
|
-
*/
|
|
777
|
-
public getFreshBuildManifest(
|
|
778
|
-
now: number = Date.now(),
|
|
779
|
-
): BuildManifest["entries"] | undefined {
|
|
780
|
-
if (!this.freshnessEntries) return undefined;
|
|
781
|
-
if (this.freshManifestCache && now < this.freshManifestCache.validUntil) {
|
|
782
|
-
return this.freshManifestCache.manifest;
|
|
783
|
-
}
|
|
784
|
-
const { manifest, nextStaleSince } = filterFreshManifest(
|
|
785
|
-
this.freshnessEntries,
|
|
786
|
-
new Date(now),
|
|
787
|
-
);
|
|
788
|
-
this.freshManifestCache = {
|
|
789
|
-
manifest,
|
|
790
|
-
validUntil: nextStaleSince ?? Infinity,
|
|
791
|
-
};
|
|
792
|
-
return manifest;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Install the per-query freshness resolver on every owned model so the serve
|
|
797
|
-
* path (Model.getQueryResults / executeNotebookCell) applies the
|
|
798
|
-
* freshness-filtered manifest as a per-query Malloy `buildManifest` override.
|
|
799
|
-
* Idempotent; called after (re)building the model set.
|
|
800
|
-
*/
|
|
801
|
-
private wireFreshnessResolvers(): void {
|
|
802
|
-
for (const model of this.models.values()) {
|
|
803
|
-
model.setFreshnessResolver(() => this.getFreshBuildManifest());
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
/**
|
|
808
|
-
* Record the URI whose manifest is currently bound to the served models. May
|
|
809
|
-
* differ from `manifestLocation` after an in-memory auto-load following a
|
|
810
|
-
* materialization build (no URI), in which case it stays null.
|
|
811
|
-
*/
|
|
812
|
-
public setBoundManifestUri(uri: string | null): void {
|
|
813
|
-
this.boundManifestUri = uri;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* Mark that a configured `manifestLocation` could not be fetched/bound, so
|
|
818
|
-
* the package is serving live despite intending to be materialized-routed.
|
|
819
|
-
*/
|
|
820
|
-
public markManifestBindFailed(): void {
|
|
821
|
-
this.manifestBindingStatus = "live_fallback";
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* Store the entries applied by the latest (re)bind for reuse (/compile) and
|
|
826
|
-
* observability (/status). An empty map means the manifest was cleared, so
|
|
827
|
-
* the package reverts to live (unbound).
|
|
828
|
-
*/
|
|
829
|
-
private recordManifestBinding(entries: FreshnessManifest): void {
|
|
830
|
-
const count = Object.keys(entries).length;
|
|
831
|
-
// Full wire entries (with freshness) drive the per-query serve-path gate;
|
|
832
|
-
// the tableName-only projection is what /compile and /status consume.
|
|
833
|
-
this.freshnessEntries = count > 0 ? entries : undefined;
|
|
834
|
-
this.buildManifestEntries =
|
|
835
|
-
count > 0 ? toTableNameManifest(entries) : undefined;
|
|
836
|
-
this.manifestEntryCount = count;
|
|
837
|
-
this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
|
|
838
|
-
// A rebind replaces the entries, so any memoized freshness-filtered
|
|
839
|
-
// manifest is stale — drop it so the next query recomputes.
|
|
840
|
-
this.freshManifestCache = undefined;
|
|
841
|
-
if (count === 0) {
|
|
842
|
-
this.boundManifestUri = null;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* Bind (or clear) the package's `storage=` serve bindings from a build's full
|
|
848
|
-
* manifest entries, and push them onto every loaded model so a query can be
|
|
849
|
-
* routed through the virtual-source serve transform. Called by the build's
|
|
850
|
-
* post-run distribution with the full {@link ManifestEntry} map (which
|
|
851
|
-
* carries `storageConnectionName` + captured `schema`); only entries that
|
|
852
|
-
* were materialized into a storage destination produce a binding. Re-applied
|
|
853
|
-
* on model reload via {@link pushStorageServeBindingsToModels}.
|
|
854
|
-
*/
|
|
855
|
-
/**
|
|
856
|
-
* Re-establish the colocated (same-connection) serve routing from a
|
|
857
|
-
* FreshnessManifest WITHOUT recompiling — the analogue of
|
|
858
|
-
* {@link bindStorageServeBindings} for the in-warehouse tier, used to
|
|
859
|
-
* restore serving on load from the package's own latest persisted
|
|
860
|
-
* materialization. Colocated routing is applied at query time as a per-query
|
|
861
|
-
* `buildManifest` override (see {@link getFreshBuildManifest} and
|
|
862
|
-
* Model.getQueryResults), so setting the in-memory entries is sufficient; the
|
|
863
|
-
* freshness resolver is already wired on every model from
|
|
864
|
-
* {@link Package.create}. Distinct from {@link reloadAllModels}, which also
|
|
865
|
-
* recompiles — the recompile is not what routes the query, so a pure
|
|
866
|
-
* restore-on-load skips it (no double compile after the load-time compile).
|
|
867
|
-
* An empty map clears the binding (reverts to serving live).
|
|
868
|
-
*/
|
|
869
|
-
public bindColocatedServeManifest(entries: FreshnessManifest): void {
|
|
870
|
-
this.recordManifestBinding(entries);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
/**
|
|
874
|
-
* Bind the storage serve bindings a host vouches for — minus any whose source
|
|
875
|
-
* this package's own eligibility gate refuses.
|
|
876
|
-
*
|
|
877
|
-
* The host is authoritative about WHICH TABLE backs a source; it owns
|
|
878
|
-
* generations and rollout. It cannot be authoritative about WHETHER a source
|
|
879
|
-
* may be served from a frozen table at all, because that is decided by
|
|
880
|
-
* compiling the model, which the host does not do. A `given`-referencing
|
|
881
|
-
* source is the case that matters: a given binds per query for row-level
|
|
882
|
-
* access control, so one table built once and served to everyone hands every
|
|
883
|
-
* caller the rows filtered for whoever built it.
|
|
884
|
-
*
|
|
885
|
-
* A manifest can name such a source without anyone being careless — a source
|
|
886
|
-
* that was given-free when it was built acquires a `given` on the next model
|
|
887
|
-
* edit, and the old manifest still points at a real table until convergence
|
|
888
|
-
* catches up.
|
|
889
|
-
*
|
|
890
|
-
* Eligibility must be established POSITIVELY: a binding is honored only if its
|
|
891
|
-
* source is in the eligible set. Two states would otherwise pass as consent,
|
|
892
|
-
* and both are reachable without anyone forging anything —
|
|
893
|
-
* - the plan compute failed, so nothing was examined at all;
|
|
894
|
-
* - the source never reached the plan, because Malloy admits only
|
|
895
|
-
* query-shaped sources as build roots and a `#@ persist` on a filtered
|
|
896
|
-
* pass-through is dropped (see `droppedPersistSources`) — the same
|
|
897
|
-
* row-level-access shape this gate exists for.
|
|
898
|
-
*
|
|
899
|
-
* Refused bindings are DROPPED, not fatal: that source serves live, which is
|
|
900
|
-
* always correct because the tier is a performance tier. The rest bind.
|
|
901
|
-
*/
|
|
902
|
-
public bindStorageServeBindings(
|
|
903
|
-
entries: Record<string, ManifestEntry>,
|
|
904
|
-
): void {
|
|
905
|
-
const derived = deriveServeBindings(entries);
|
|
906
|
-
const eligibility = this.sourceEligibility;
|
|
907
|
-
const eligible = new Set(eligibility?.eligible ?? []);
|
|
908
|
-
const allowed = derived.filter((binding) => {
|
|
909
|
-
if (eligible.has(binding.sourceName)) return true;
|
|
910
|
-
const reason =
|
|
911
|
-
eligibility === undefined
|
|
912
|
-
? "the package build plan could not be computed, so no source was examined for eligibility"
|
|
913
|
-
: (eligibility.refused[binding.sourceName] ??
|
|
914
|
-
"the source is not in the package build plan, so it was never examined (a `#@ persist` on a non-query-shaped source is dropped)");
|
|
915
|
-
logger.warn(
|
|
916
|
-
"Refusing a storage serve binding: the source is not eligible to be served from a materialized table",
|
|
917
|
-
{
|
|
918
|
-
packageName: this.packageName,
|
|
919
|
-
sourceName: binding.sourceName,
|
|
920
|
-
reason,
|
|
921
|
-
},
|
|
922
|
-
);
|
|
923
|
-
return false;
|
|
924
|
-
});
|
|
925
|
-
this.storageServeBindings = allowed;
|
|
926
|
-
this.pushStorageServeBindingsToModels();
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/** Push the current storage serve bindings onto every loaded model. */
|
|
930
|
-
private pushStorageServeBindingsToModels(): void {
|
|
931
|
-
for (const model of this.models.values()) {
|
|
932
|
-
model.setServeBindings(this.storageServeBindings);
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* Declared `explores` (publisher.json) that don't resolve to a real
|
|
938
|
-
* `.malloy` model in this package, each with an actionable reason. Empty
|
|
939
|
-
* when explores is absent/empty or every entry resolves.
|
|
940
|
-
*
|
|
941
|
-
* The listing already fails safe — a non-resolving entry matches no model in
|
|
942
|
-
* `listModels`, so it hides rather than exposes. This surfaces *why*, so the
|
|
943
|
-
* load path can warn and the publish path can reject (see package.controller).
|
|
944
|
-
*/
|
|
945
|
-
public getInvalidExplores(
|
|
946
|
-
exploresOverride?: string[],
|
|
947
|
-
): { entry: string; reason: string }[] {
|
|
948
|
-
const declared = exploresOverride ?? this.packageMetadata.explores;
|
|
949
|
-
if (!declared || declared.length === 0) return [];
|
|
950
|
-
const malloyModels = new Set(
|
|
951
|
-
Array.from(this.models.keys()).filter((p) =>
|
|
952
|
-
p.endsWith(MODEL_FILE_SUFFIX),
|
|
953
|
-
),
|
|
954
|
-
);
|
|
955
|
-
const problems: { entry: string; reason: string }[] = [];
|
|
956
|
-
for (const entry of declared) {
|
|
957
|
-
if (entry.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
958
|
-
problems.push({
|
|
959
|
-
entry,
|
|
960
|
-
reason:
|
|
961
|
-
`notebooks are always public and cannot be explores. ` +
|
|
962
|
-
`Fix: remove it, and list a ${MODEL_FILE_SUFFIX} model file instead.`,
|
|
963
|
-
});
|
|
964
|
-
} else if (!malloyModels.has(entry)) {
|
|
965
|
-
problems.push({
|
|
966
|
-
entry,
|
|
967
|
-
reason:
|
|
968
|
-
`file not found in the package. Fix: list a ${MODEL_FILE_SUFFIX} ` +
|
|
969
|
-
`file relative to the package root (e.g. "index.malloy").`,
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return problems;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/** One actionable message per invalid entry (empty when all resolve). */
|
|
977
|
-
public exploreWarnings(exploresOverride?: string[]): string[] {
|
|
978
|
-
return this.getInvalidExplores(exploresOverride).map(
|
|
979
|
-
(p) =>
|
|
980
|
-
`Invalid explores entry '${p.entry}' in ${PACKAGE_MANIFEST_NAME}: ${p.reason}`,
|
|
981
|
-
);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
/**
|
|
985
|
-
* The {@link exploreWarnings} joined into one string, or "" if none.
|
|
986
|
-
* Newline-separated so multiple invalid entries stay one-per-line in the
|
|
987
|
-
* 400 message rather than running together.
|
|
988
|
-
*/
|
|
989
|
-
public formatInvalidExplores(exploresOverride?: string[]): string {
|
|
990
|
-
return this.exploreWarnings(exploresOverride).join("\n");
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/**
|
|
994
|
-
* Publish-gate for the package's Malloy Persistence policy (persistence.md
|
|
995
|
-
* §3.1, §9.2–§9.5). Scope is a single package-level mode (`Package.scope`)
|
|
996
|
-
* and a materialization cron is package-root-only and version-scope-only.
|
|
997
|
-
* The rules, read off the resolved build plan + manifest:
|
|
998
|
-
*
|
|
999
|
-
* 1. Per-source `#@ persist ... sharing=`/`schedule=` are retired — declaring
|
|
1000
|
-
* either on a source is an error (scope is package-level; a schedule is
|
|
1001
|
-
* package-root-only). Detected via the raw `annotationFields`.
|
|
1002
|
-
* 2. `materialization.schedule` is legal only when `scope: version` (a
|
|
1003
|
-
* package-scoped lineage is reused across versions, so a single
|
|
1004
|
-
* per-version cadence is meaningless).
|
|
1005
|
-
* 3. `materialization.schedule` and freshness (package `materialization.freshness`
|
|
1006
|
-
* or any source `freshness`) are mutually exclusive — declare either the
|
|
1007
|
-
* power tier or the objective tier, never both.
|
|
1008
|
-
*
|
|
1009
|
-
* The cross-package dependency rule (a `scope: version` scheduled package may
|
|
1010
|
-
* not depend on a package-scoped upstream) is not enforced here: scope is
|
|
1011
|
-
* uniform within a package, so an intra-package dependency can never violate
|
|
1012
|
-
* it, and the publisher's build plan is intra-package only — a cross-package
|
|
1013
|
-
* upstream's scope is not resolvable from the single `Package` served. It is
|
|
1014
|
-
* deferred to the scheduler slice (R1–R3), which resolves cross-package scope.
|
|
1015
|
-
*
|
|
1016
|
-
* Strict at publish (package.controller), warn-only at load/reload
|
|
1017
|
-
* (loadViaWorker) — same split as explores.
|
|
1018
|
-
*/
|
|
1019
|
-
/**
|
|
1020
|
-
* Operator-facing warnings for `#@ persist storage=<conn>` sources whose
|
|
1021
|
-
* storage annotation is NOT being honored on the serve path, so a degraded
|
|
1022
|
-
* source is visible on `/status` rather than silently serving live. Emitted
|
|
1023
|
-
* per {@link getPersistStorageMode}:
|
|
1024
|
-
* - `off`: the annotation is ignored entirely (serving live from the source
|
|
1025
|
-
* warehouse) — the kill-switch resting state.
|
|
1026
|
-
* - `write-only`: the source materializes into storage but the serve path is
|
|
1027
|
-
* not routed to the materialized table (served live).
|
|
1028
|
-
* - `on`: no warning — the source is (or falls back to being) served per the
|
|
1029
|
-
* transform; per-query fallback is a query-time event, not a load warning.
|
|
1030
|
-
* Read straight off the compiled build plan's `annotationFields.storage`
|
|
1031
|
-
* (undefined when the package declares no persist sources).
|
|
1032
|
-
*/
|
|
1033
|
-
/**
|
|
1034
|
-
* Operator-facing warnings for `#@ persist` sources that Malloy's
|
|
1035
|
-
* getBuildPlan() did not recognize as a materializable build root (observed
|
|
1036
|
-
* for a filtered pass-through `X is <table> extend { where … }`). Without a
|
|
1037
|
-
* signal the annotation is a silent no-op — no build, no error, served live —
|
|
1038
|
-
* so surface it on `/status`. The build path additionally hard-refuses (see
|
|
1039
|
-
* MaterializationService). Independent of {@link buildPlan} being null, since
|
|
1040
|
-
* a package whose ONLY persist source is dropped has no plan at all.
|
|
1041
|
-
*/
|
|
1042
|
-
private droppedPersistWarnings(): ApiPackageWarning[] {
|
|
1043
|
-
return this.droppedPersistSources.map((d) => ({
|
|
1044
|
-
model: d.modelPath,
|
|
1045
|
-
target: d.name,
|
|
1046
|
-
message:
|
|
1047
|
-
`is annotated '#@ persist' but was not recognized as a ` +
|
|
1048
|
-
`materializable source, so nothing is materialized and it is served ` +
|
|
1049
|
-
`live. Only query/aggregate sources build; a filtered pass-through ` +
|
|
1050
|
-
`does not. Persist a query source, or invoke a parameterized source ` +
|
|
1051
|
-
`with a bound argument.`,
|
|
1052
|
-
}));
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
private storageWarnings(): ApiPackageWarning[] {
|
|
1056
|
-
const mode = getPersistStorageMode();
|
|
1057
|
-
if (mode === "on" || !this.buildPlan?.sources) return [];
|
|
1058
|
-
const warnings: ApiPackageWarning[] = [];
|
|
1059
|
-
for (const source of Object.values(this.buildPlan.sources)) {
|
|
1060
|
-
const storage = source.annotationFields?.storage?.trim();
|
|
1061
|
-
if (!storage) continue;
|
|
1062
|
-
const message =
|
|
1063
|
-
mode === "off"
|
|
1064
|
-
? `declares storage="${storage}" but PERSIST_STORAGE_MODE is off; ` +
|
|
1065
|
-
`the annotation is ignored and the source is served live from ` +
|
|
1066
|
-
`its own warehouse.`
|
|
1067
|
-
: `is materialized into storage "${storage}" but ` +
|
|
1068
|
-
`PERSIST_STORAGE_MODE is write-only; the serve path is not ` +
|
|
1069
|
-
`routed to the materialized table (served live).`;
|
|
1070
|
-
warnings.push({
|
|
1071
|
-
model: source.modelPath ?? "",
|
|
1072
|
-
target: source.name,
|
|
1073
|
-
message,
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
return warnings;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
public persistencePolicyWarnings(): string[] {
|
|
1080
|
-
// REJECTION rules only: a non-empty result fails a publish and disarms the
|
|
1081
|
-
// scheduler. Advisory findings (queryMetadata problems, a deprecated
|
|
1082
|
-
// manifest shape) go to the operator warnings array instead — see
|
|
1083
|
-
// materializationConfigWarnings.
|
|
1084
|
-
const warnings: string[] = [];
|
|
1085
|
-
const sources = this.buildPlan?.sources
|
|
1086
|
-
? Object.values(this.buildPlan.sources)
|
|
1087
|
-
: [];
|
|
1088
|
-
const materialization = this.packageMetadata.materialization;
|
|
1089
|
-
const packageSchedule = materialization?.schedule ?? null;
|
|
1090
|
-
const packageFreshness = materialization?.freshness ?? null;
|
|
1091
|
-
const scope = this.packageMetadata.scope ?? "package";
|
|
1092
|
-
|
|
1093
|
-
// Rule 1: per-source sharing/schedule are no longer part of the model.
|
|
1094
|
-
for (const source of sources) {
|
|
1095
|
-
const fields = source.annotationFields ?? {};
|
|
1096
|
-
if (fields.sharing !== undefined) {
|
|
1097
|
-
warnings.push(
|
|
1098
|
-
`#@ persist source "${source.name}" declares sharing=... which is ` +
|
|
1099
|
-
`no longer supported: scope is a single package-level mode. Set ` +
|
|
1100
|
-
`"materialization": { "scope": "version" | "package" } in ` +
|
|
1101
|
-
`${PACKAGE_MANIFEST_NAME} instead.`,
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
if (fields.schedule !== undefined) {
|
|
1105
|
-
warnings.push(
|
|
1106
|
-
`#@ persist source "${source.name}" declares schedule=... which is ` +
|
|
1107
|
-
`no longer supported: a schedule is package-root-only. Declare ` +
|
|
1108
|
-
`"materialization.schedule" at the ${PACKAGE_MANIFEST_NAME} root ` +
|
|
1109
|
-
`(requires "scope": "version") instead.`,
|
|
1110
|
-
);
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
// Rule 2: a package-level cron is legal only with scope: version.
|
|
1115
|
-
if (packageSchedule && scope !== "version") {
|
|
1116
|
-
warnings.push(
|
|
1117
|
-
`materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} requires ` +
|
|
1118
|
-
`"scope": "version": a package-scoped lineage is reused across ` +
|
|
1119
|
-
`versions, so a single per-version cadence is meaningless. Set ` +
|
|
1120
|
-
`"scope": "version", or remove the schedule.`,
|
|
1121
|
-
);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Rule 3: schedule and freshness are mutually exclusive.
|
|
1125
|
-
if (packageSchedule) {
|
|
1126
|
-
const freshnessDeclared =
|
|
1127
|
-
!!(
|
|
1128
|
-
packageFreshness &&
|
|
1129
|
-
(packageFreshness.window || packageFreshness.fallback)
|
|
1130
|
-
) || sources.some((s) => s.freshness != null);
|
|
1131
|
-
if (freshnessDeclared) {
|
|
1132
|
-
warnings.push(
|
|
1133
|
-
`materialization.schedule and freshness are mutually exclusive in ` +
|
|
1134
|
-
`${PACKAGE_MANIFEST_NAME}: declare either a schedule (power tier) ` +
|
|
1135
|
-
`or freshness (objective tier), never both.`,
|
|
1136
|
-
);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// Rule 4: the cron must be a valid 5-field UNIX expression (no L/W/#/?
|
|
1141
|
-
// extensions — see CronEvaluator). Enforced here so publish (strict),
|
|
1142
|
-
// PATCH (strict), package load (warn), and the standalone scheduler all
|
|
1143
|
-
// apply the identical rule — a garbage cron can no longer pass publish
|
|
1144
|
-
// and then silently never arm.
|
|
1145
|
-
if (packageSchedule && !new CronEvaluator().isValid(packageSchedule)) {
|
|
1146
|
-
warnings.push(
|
|
1147
|
-
`materialization.schedule in ${PACKAGE_MANIFEST_NAME} is not a valid ` +
|
|
1148
|
-
`5-field UNIX cron: ${JSON.stringify(packageSchedule)}. Use ` +
|
|
1149
|
-
`"minute hour day-of-month month day-of-week" (no L/W/#/? ` +
|
|
1150
|
-
`extensions).`,
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
return warnings;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* The {@link persistencePolicyWarnings} joined into one string, or "" if the
|
|
1159
|
-
* package's persistence policy is valid.
|
|
1160
|
-
*/
|
|
1161
|
-
public formatInvalidPersistencePolicy(): string {
|
|
1162
|
-
return this.persistencePolicyWarnings().join("\n");
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Within-package persist-target COLLISION warnings: two DISTINCT persist
|
|
1167
|
-
* sources (different `sourceEntityId`) that resolve to the same physical
|
|
1168
|
-
* table — the same resolved `name=` (or source-name fallback) in the same
|
|
1169
|
-
* destination connection. The publisher self-assigns a materialized table's
|
|
1170
|
-
* physical name from `name=` verbatim, so two such sources would clobber each
|
|
1171
|
-
* other: the second build's `CREATE OR REPLACE` overwrites the first, two
|
|
1172
|
-
* manifest entries point at one table, and a GC drop of one takes out the
|
|
1173
|
-
* other. Two IMPORTS of the SAME source (identical `sourceEntityId`) are
|
|
1174
|
-
* intentional dedup and NOT flagged.
|
|
1175
|
-
*
|
|
1176
|
-
* The destination is resolved from the DECLARED annotation, independent of
|
|
1177
|
-
* {@link getPersistStorageMode}, so the kill switch never changes whether a
|
|
1178
|
-
* package has a latent collision.
|
|
1179
|
-
*
|
|
1180
|
-
* Deliberately SEPARATE from {@link persistencePolicyWarnings} because the
|
|
1181
|
-
* rollout is staged: these are surfaced warn-only at load AND publish, and
|
|
1182
|
-
* only block a publish once `PERSIST_COLLISION_ENFORCE` is set (see
|
|
1183
|
-
* {@link getPersistCollisionEnforce}) — a package published before this check
|
|
1184
|
-
* existed may carry a latent collision, so an un-gated reject would break a
|
|
1185
|
-
* routine re-publish. This is a WITHIN-package/version check only: a `name=`
|
|
1186
|
-
* change ACROSS versions, or a cross-package collision, needs the
|
|
1187
|
-
* cross-version/global view the host has and the publisher
|
|
1188
|
-
* does not.
|
|
1189
|
-
*/
|
|
1190
|
-
public persistenceCollisionWarnings(): string[] {
|
|
1191
|
-
const sources = this.buildPlan?.sources
|
|
1192
|
-
? Object.values(this.buildPlan.sources)
|
|
1193
|
-
: [];
|
|
1194
|
-
const targets = new Map<
|
|
1195
|
-
string,
|
|
1196
|
-
{ name: string; destination: string; sources: Map<string, string> }
|
|
1197
|
-
>();
|
|
1198
|
-
for (const source of sources) {
|
|
1199
|
-
const fields = source.annotationFields ?? {};
|
|
1200
|
-
const physicalName = (fields.name || source.name).trim();
|
|
1201
|
-
const storage = fields.storage?.trim();
|
|
1202
|
-
// storage= into a real destination lands there; absent, the source is
|
|
1203
|
-
// built colocated (into its own warehouse).
|
|
1204
|
-
const destination = storage ? storage : source.connectionName;
|
|
1205
|
-
// Verbatim key. Limitation: names that differ only by case or quoting
|
|
1206
|
-
// (`Foo` vs `foo`, `"foo"` vs `foo`) key distinctly here but may fold to
|
|
1207
|
-
// the same physical table on a case-folding destination — such variants
|
|
1208
|
-
// evade this within-package check. Normalizing correctly is
|
|
1209
|
-
// destination-dialect-dependent, so it's left verbatim; the host's
|
|
1210
|
-
// ownership-scoped production naming and the destination's own identifier
|
|
1211
|
-
// rules are the backstop.
|
|
1212
|
-
const key = `${destination} ${physicalName}`;
|
|
1213
|
-
const bucket = targets.get(key) ?? {
|
|
1214
|
-
name: physicalName,
|
|
1215
|
-
destination,
|
|
1216
|
-
sources: new Map<string, string>(),
|
|
1217
|
-
};
|
|
1218
|
-
// Keyed by sourceEntityId so identical-content imports collapse to one.
|
|
1219
|
-
bucket.sources.set(source.sourceEntityId, source.name);
|
|
1220
|
-
targets.set(key, bucket);
|
|
1221
|
-
}
|
|
1222
|
-
const warnings: string[] = [];
|
|
1223
|
-
for (const {
|
|
1224
|
-
name,
|
|
1225
|
-
destination,
|
|
1226
|
-
sources: colliding,
|
|
1227
|
-
} of targets.values()) {
|
|
1228
|
-
if (colliding.size < 2) continue;
|
|
1229
|
-
const names = [...colliding.values()].sort();
|
|
1230
|
-
warnings.push(
|
|
1231
|
-
`#@ persist sources ${names
|
|
1232
|
-
.map((n) => `"${n}"`)
|
|
1233
|
-
.join(", ")} all resolve to the same materialized table ` +
|
|
1234
|
-
`"${name}" in destination "${destination}". Distinct sources ` +
|
|
1235
|
-
`must not share a physical target — the second build would ` +
|
|
1236
|
-
`overwrite the first, and a serve binding or GC drop for one ` +
|
|
1237
|
-
`would affect the other. Give each a distinct #@ persist name=.`,
|
|
1238
|
-
);
|
|
1239
|
-
}
|
|
1240
|
-
return warnings;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* Collision warnings joined for the publish gate: the string is non-empty
|
|
1245
|
-
* (and thus a publish rejection) only when `PERSIST_COLLISION_ENFORCE` is set;
|
|
1246
|
-
* otherwise collisions are surfaced warn-only (at load and publish) without
|
|
1247
|
-
* blocking. See {@link persistenceCollisionWarnings}.
|
|
1248
|
-
*/
|
|
1249
|
-
public formatPersistenceCollisionRejections(): string {
|
|
1250
|
-
if (!getPersistCollisionEnforce()) return "";
|
|
1251
|
-
return this.persistenceCollisionWarnings().join("\n");
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* One message per LISTED model whose discovery surface is empty because it
|
|
1256
|
-
* is import-only (imports other files, declares/re-exports nothing). Such a
|
|
1257
|
-
* model renders a blank page, which reads as broken; the fix is an explicit
|
|
1258
|
-
* re-export. Log-only (see loadViaWorker/reloadAllModels) — deliberately
|
|
1259
|
-
* NOT part of exploreWarnings, which is strict-at-publish: import-only
|
|
1260
|
-
* files are a legitimate pattern and must not block a publish. Hidden
|
|
1261
|
-
* (non-listed) models are skipped — nobody browses them, so an empty
|
|
1262
|
-
* surface there is just normal plumbing.
|
|
1263
|
-
*/
|
|
1264
|
-
public emptyDiscoveryWarnings(): string[] {
|
|
1265
|
-
const exploreSet = this.exploreSet();
|
|
1266
|
-
const warnings: string[] = [];
|
|
1267
|
-
for (const [modelPath, model] of this.models) {
|
|
1268
|
-
if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) continue;
|
|
1269
|
-
if (exploreSet && !exploreSet.has(modelPath)) continue;
|
|
1270
|
-
if (model.hasEmptyDiscoverySurface()) {
|
|
1271
|
-
warnings.push(
|
|
1272
|
-
`Model "${modelPath}" is listed but exposes nothing: it only ` +
|
|
1273
|
-
`imports other files and re-exports none of their sources. ` +
|
|
1274
|
-
`Add e.g. 'export { source_name }' to surface sources on ` +
|
|
1275
|
-
`this model.`,
|
|
1276
|
-
);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
return warnings;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
/** Log {@link emptyDiscoveryWarnings}; shared by load and reload. */
|
|
1283
|
-
private logEmptyDiscoveryWarnings(): void {
|
|
1284
|
-
for (const warning of this.emptyDiscoveryWarnings()) {
|
|
1285
|
-
logger.warn(`Package ${this.packageName} has a blank-looking model`, {
|
|
1286
|
-
packageName: this.packageName,
|
|
1287
|
-
detail: warning,
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
public listDatabases(): ApiDatabase[] {
|
|
1293
|
-
return this.databases;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
public getModel(modelPath: string): Model | undefined {
|
|
1297
|
-
return this.models.get(modelPath);
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
public async getMalloyConnection(
|
|
1301
|
-
connectionName: string,
|
|
1302
|
-
): Promise<Connection> {
|
|
1303
|
-
return this.malloyConfig.connections.lookupConnection(connectionName);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
/**
|
|
1307
|
-
* Quote each manifest entry's physical table path for its connection's
|
|
1308
|
-
* dialect, mirroring the build side: the builder CREATEs the table with
|
|
1309
|
-
* {@link quoteTablePath} (per-segment, case-preserved), so on a case-folding
|
|
1310
|
-
* engine (Snowflake uppercases unquoted identifiers) the stored name is only
|
|
1311
|
-
* reachable through the same quoting. Malloy pastes a manifest `tableName`
|
|
1312
|
-
* into `FROM` verbatim by contract (a bare name means "let the engine
|
|
1313
|
-
* fold"), so the case-preserving producer — us — must hand it the quoted
|
|
1314
|
-
* form. Same module quotes CREATE and read: the two sides cannot drift.
|
|
1315
|
-
*
|
|
1316
|
-
* A name already carrying a quote character is passed through verbatim (it
|
|
1317
|
-
* is already canonical SQL; control-plane-assigned names are sanitized to
|
|
1318
|
-
* `[A-Za-z0-9_\-.]` and can never contain one).
|
|
1319
|
-
*
|
|
1320
|
-
* Two cases bind verbatim, and they are NOT the same signal:
|
|
1321
|
-
* - No `connectionName`: a bare/engine-folding producer that never recorded
|
|
1322
|
-
* a connection (simple_builder / malloy-cli). Expected and benign — the
|
|
1323
|
-
* pre-change behavior, bound silently, nothing regresses.
|
|
1324
|
-
* - `connectionName` present but unresolvable: a genuine misconfiguration
|
|
1325
|
-
* (the connection was renamed/removed, or the manifest is out of sync with
|
|
1326
|
-
* this package's config). This one entry is degraded — but the source
|
|
1327
|
-
* would not serve regardless of quoting, since Malloy needs the connection
|
|
1328
|
-
* to run any query against it, so we degrade just this entry rather than
|
|
1329
|
-
* fail the whole package bind, and log at ERROR with a fix.
|
|
1330
|
-
*/
|
|
1331
|
-
private async quoteBoundTableNames(
|
|
1332
|
-
entries: FreshnessManifest,
|
|
1333
|
-
): Promise<FreshnessManifest> {
|
|
1334
|
-
const out: FreshnessManifest = {};
|
|
1335
|
-
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
1336
|
-
let tableName = entry.tableName;
|
|
1337
|
-
if (entry.connectionName && !isQuotedIdentifierPath(tableName)) {
|
|
1338
|
-
try {
|
|
1339
|
-
const connection = await this.getMalloyConnection(
|
|
1340
|
-
entry.connectionName,
|
|
1341
|
-
);
|
|
1342
|
-
tableName = quoteManifestTablePath(
|
|
1343
|
-
tableName,
|
|
1344
|
-
connection.dialectName,
|
|
1345
|
-
);
|
|
1346
|
-
} catch (err) {
|
|
1347
|
-
recordManifestBindDegraded();
|
|
1348
|
-
logger.error(
|
|
1349
|
-
`Manifest entry '${sourceEntityId}' names connection ` +
|
|
1350
|
-
`'${entry.connectionName}', which this package cannot ` +
|
|
1351
|
-
`resolve: binding its table path unquoted, so this source ` +
|
|
1352
|
-
`will not serve on a case-folding engine (and queries ` +
|
|
1353
|
-
`against it fail regardless, since Malloy needs the ` +
|
|
1354
|
-
`connection to run them). Fix: ensure a connection named ` +
|
|
1355
|
-
`'${entry.connectionName}' exists in this package's ` +
|
|
1356
|
-
`config, or rebuild the manifest against the current config.`,
|
|
1357
|
-
{
|
|
1358
|
-
packageName: this.packageName,
|
|
1359
|
-
sourceEntityId,
|
|
1360
|
-
connectionName: entry.connectionName,
|
|
1361
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1362
|
-
},
|
|
1363
|
-
);
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
out[sourceEntityId] = { ...entry, tableName };
|
|
1367
|
-
}
|
|
1368
|
-
return out;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
public getMalloyConfig(): MalloyConfig {
|
|
1372
|
-
return this.malloyConfig;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
public getPackagePath(): string {
|
|
1376
|
-
return this.packagePath;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
public getModelPaths(): string[] {
|
|
1380
|
-
return Array.from(this.models.keys());
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
/**
|
|
1384
|
-
* Re-compile every model in the package against a new build
|
|
1385
|
-
* manifest (called after a materialization build commits new
|
|
1386
|
-
* physicalised tables). Runs through the package-load worker pool
|
|
1387
|
-
* — same off-main-thread compile path as initial `Package.create`
|
|
1388
|
-
* — so a reload of a large package can't block the K8s liveness
|
|
1389
|
-
* probe.
|
|
1390
|
-
*
|
|
1391
|
-
* Unlike `Package.create`, a per-model compile failure here does
|
|
1392
|
-
* NOT abort the reload: we keep the failed model as a placeholder
|
|
1393
|
-
* (`Model.fromCompilationError`) in `this.models`, matching the
|
|
1394
|
-
* historical reload semantics. Whole-pool failures (worker crash,
|
|
1395
|
-
* timeout, pool shutting down) propagate as `ServiceUnavailableError`
|
|
1396
|
-
* — the caller (manifest service) decides how to retry.
|
|
1397
|
-
*/
|
|
1398
|
-
public async reloadAllModels(entries: FreshnessManifest): Promise<void> {
|
|
1399
|
-
// Quote each bound physical name for its connection's dialect BEFORE any
|
|
1400
|
-
// projection: everything downstream (model hydration, the per-query
|
|
1401
|
-
// freshness gate, /compile, /status) reads the entries recorded here, so
|
|
1402
|
-
// this is the one place the write side's quoting is mirrored onto reads.
|
|
1403
|
-
entries = await this.quoteBoundTableNames(entries);
|
|
1404
|
-
// Models are hydrated against the tableName-only projection; the freshness
|
|
1405
|
-
// fields gate the serve path per query (via getFreshBuildManifest), not
|
|
1406
|
-
// model hydration.
|
|
1407
|
-
const buildManifest = toTableNameManifest(entries);
|
|
1408
|
-
const modelPaths = Array.from(this.models.keys());
|
|
1409
|
-
logger.info("Reloading all models with build manifest", {
|
|
1410
|
-
packageName: this.packageName,
|
|
1411
|
-
modelCount: modelPaths.length,
|
|
1412
|
-
manifestEntryCount: Object.keys(buildManifest).length,
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
const pool = getPackageLoadPool();
|
|
1416
|
-
let outcome;
|
|
1417
|
-
try {
|
|
1418
|
-
outcome = await pool.loadPackage({
|
|
1419
|
-
packagePath: this.packagePath,
|
|
1420
|
-
packageName: this.packageName,
|
|
1421
|
-
malloyConfig: this.malloyConfig,
|
|
1422
|
-
defaultConnectionName: "duckdb",
|
|
1423
|
-
buildManifest,
|
|
1424
|
-
});
|
|
1425
|
-
} catch (err) {
|
|
1426
|
-
const realError =
|
|
1427
|
-
err instanceof Error
|
|
1428
|
-
? err
|
|
1429
|
-
: new Error(`Package-load worker pool failure: ${String(err)}`);
|
|
1430
|
-
if (
|
|
1431
|
-
realError instanceof MalloyError ||
|
|
1432
|
-
realError instanceof ModelCompilationError
|
|
1433
|
-
) {
|
|
1434
|
-
throw realError;
|
|
1435
|
-
}
|
|
1436
|
-
throw new ServiceUnavailableError(
|
|
1437
|
-
`Package-load worker pool unavailable: ${realError.message}`,
|
|
1438
|
-
);
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
const nextModels = new Map<string, Model>();
|
|
1442
|
-
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
1443
|
-
for (const sm of outcome.models) {
|
|
1444
|
-
if (sm.compilationError) {
|
|
1445
|
-
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
1446
|
-
logger.warn("Model compilation failed during reload", {
|
|
1447
|
-
packageName: this.packageName,
|
|
1448
|
-
modelPath: sm.modelPath,
|
|
1449
|
-
error: err.message,
|
|
1450
|
-
});
|
|
1451
|
-
nextModels.set(
|
|
1452
|
-
sm.modelPath,
|
|
1453
|
-
Model.fromCompilationError(
|
|
1454
|
-
this.packageName,
|
|
1455
|
-
sm.modelPath,
|
|
1456
|
-
sm.modelType,
|
|
1457
|
-
err,
|
|
1458
|
-
),
|
|
1459
|
-
);
|
|
1460
|
-
} else {
|
|
1461
|
-
const model = Model.fromSerialized(
|
|
1462
|
-
this.packageName,
|
|
1463
|
-
this.packagePath,
|
|
1464
|
-
this.malloyConfig,
|
|
1465
|
-
sm,
|
|
1466
|
-
{ buildManifest },
|
|
1467
|
-
);
|
|
1468
|
-
// Validate renderer tags here too (loadViaWorker does it for the
|
|
1469
|
-
// create path). Render-tag findings are logged as warnings inside
|
|
1470
|
-
// validateRenderTags and never throw. The catch is defensive: an
|
|
1471
|
-
// unexpected internal failure is recorded as this model's
|
|
1472
|
-
// compilationError rather than aborting the whole reload.
|
|
1473
|
-
try {
|
|
1474
|
-
for (const w of await model.validateRenderTags()) {
|
|
1475
|
-
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
1476
|
-
}
|
|
1477
|
-
nextModels.set(sm.modelPath, model);
|
|
1478
|
-
} catch (renderErr) {
|
|
1479
|
-
const err =
|
|
1480
|
-
renderErr instanceof Error
|
|
1481
|
-
? renderErr
|
|
1482
|
-
: new Error(String(renderErr));
|
|
1483
|
-
logger.warn("Render-tag validation failed during reload", {
|
|
1484
|
-
packageName: this.packageName,
|
|
1485
|
-
modelPath: sm.modelPath,
|
|
1486
|
-
error: err.message,
|
|
1487
|
-
});
|
|
1488
|
-
nextModels.set(
|
|
1489
|
-
sm.modelPath,
|
|
1490
|
-
Model.fromCompilationError(
|
|
1491
|
-
this.packageName,
|
|
1492
|
-
sm.modelPath,
|
|
1493
|
-
sm.modelType,
|
|
1494
|
-
err,
|
|
1495
|
-
),
|
|
1496
|
-
);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
this.models = nextModels;
|
|
1501
|
-
// The freshly-compiled models start with no serve bindings; re-apply the
|
|
1502
|
-
// package's current storage= bindings so a reload preserves serve routing.
|
|
1503
|
-
this.pushStorageServeBindingsToModels();
|
|
1504
|
-
this.renderTagWarnings = renderTagWarnings;
|
|
1505
|
-
this.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
|
|
1506
|
-
// A reload re-reads publisher.json in the worker; pick up any change to
|
|
1507
|
-
// the explore set and query-boundary mode so listModels()/the gate
|
|
1508
|
-
// reflect edited explores without a full Package.create.
|
|
1509
|
-
this.packageMetadata.explores = outcome.packageMetadata.explores;
|
|
1510
|
-
this.packageMetadata.queryableSources =
|
|
1511
|
-
outcome.packageMetadata.queryableSources;
|
|
1512
|
-
this.packageMetadata.manifestLocation =
|
|
1513
|
-
outcome.packageMetadata.manifestLocation ?? null;
|
|
1514
|
-
this.applyDiscoveryPolicyToModels();
|
|
1515
|
-
this.applyQueryBoundaryToModels();
|
|
1516
|
-
// Remember what we just bound so /compile can route identically and
|
|
1517
|
-
// /status can report the binding. An empty map reverts to live (unbound).
|
|
1518
|
-
// Retains the full freshness entries so the serve-path gate can evaluate
|
|
1519
|
-
// them per query.
|
|
1520
|
-
this.recordManifestBinding(entries);
|
|
1521
|
-
// Install the per-query freshness resolver on the rebuilt model set so the
|
|
1522
|
-
// serve path applies the freshness-filtered manifest as a per-query
|
|
1523
|
-
// override.
|
|
1524
|
-
this.wireFreshnessResolvers();
|
|
1525
|
-
// Re-run the fail-safe warning against the refreshed model set: an edit
|
|
1526
|
-
// to publisher.json that introduces a bad entry should surface in the
|
|
1527
|
-
// logs on reload too, not only at initial load (loadViaWorker).
|
|
1528
|
-
const invalidMsg = this.formatInvalidExplores();
|
|
1529
|
-
if (invalidMsg) {
|
|
1530
|
-
logger.warn(`Package ${this.packageName} has invalid explores`, {
|
|
1531
|
-
packageName: this.packageName,
|
|
1532
|
-
detail: invalidMsg,
|
|
1533
|
-
});
|
|
1534
|
-
}
|
|
1535
|
-
this.logEmptyDiscoveryWarnings();
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
public async getModelFileText(modelPath: string): Promise<string> {
|
|
1539
|
-
const model = this.getModel(modelPath);
|
|
1540
|
-
if (!model) {
|
|
1541
|
-
throw new Error(`Model not found: ${modelPath}`);
|
|
1542
|
-
}
|
|
1543
|
-
return await model.getFileText(this.packagePath);
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
public async listModels(): Promise<ApiModel[]> {
|
|
1547
|
-
// When `explores` is declared in publisher.json, only those models
|
|
1548
|
-
// form the public surface; every other .malloy file still compiles for
|
|
1549
|
-
// import/join resolution but is hidden from the listing. Absent/empty →
|
|
1550
|
-
// every model is listed (backward-compatible default). Notebooks are
|
|
1551
|
-
// unaffected (see listNotebooks) — they are always public.
|
|
1552
|
-
const exploreSet = this.exploreSet();
|
|
1553
|
-
const values = await Promise.all(
|
|
1554
|
-
Array.from(this.models.keys())
|
|
1555
|
-
.filter((modelPath) => {
|
|
1556
|
-
if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) return false;
|
|
1557
|
-
return exploreSet ? exploreSet.has(modelPath) : true;
|
|
1558
|
-
})
|
|
1559
|
-
.map(async (modelPath) => {
|
|
1560
|
-
let error: string | undefined;
|
|
1561
|
-
try {
|
|
1562
|
-
await this.models.get(modelPath)?.getModel();
|
|
1563
|
-
} catch (modelError) {
|
|
1564
|
-
error =
|
|
1565
|
-
modelError instanceof Error
|
|
1566
|
-
? modelError.message
|
|
1567
|
-
: undefined;
|
|
1568
|
-
}
|
|
1569
|
-
return {
|
|
1570
|
-
environmentName: this.environmentName,
|
|
1571
|
-
path: modelPath,
|
|
1572
|
-
packageName: this.packageName,
|
|
1573
|
-
error,
|
|
1574
|
-
};
|
|
1575
|
-
}),
|
|
1576
|
-
);
|
|
1577
|
-
return values;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
public async listNotebooks(): Promise<ApiNotebook[]> {
|
|
1581
|
-
return await Promise.all(
|
|
1582
|
-
Array.from(this.models.keys())
|
|
1583
|
-
.filter((modelPath) => {
|
|
1584
|
-
return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
|
|
1585
|
-
})
|
|
1586
|
-
.map(async (modelPath) => {
|
|
1587
|
-
const error = this.models.get(modelPath)?.getNotebookError();
|
|
1588
|
-
return {
|
|
1589
|
-
environmentName: this.environmentName,
|
|
1590
|
-
packageName: this.packageName,
|
|
1591
|
-
path: modelPath,
|
|
1592
|
-
error: error?.message,
|
|
1593
|
-
};
|
|
1594
|
-
}),
|
|
1595
|
-
);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
private static buildPackageMalloyConfig(
|
|
1599
|
-
packagePath: string,
|
|
1600
|
-
getEnvironmentMalloyConfig: () => MalloyConfig,
|
|
1601
|
-
): MalloyConfig {
|
|
1602
|
-
const malloyConfig = new MalloyConfig(
|
|
1603
|
-
{
|
|
1604
|
-
connections: {
|
|
1605
|
-
duckdb: {
|
|
1606
|
-
is: "duckdb",
|
|
1607
|
-
databasePath: ":memory:",
|
|
1608
|
-
},
|
|
1609
|
-
},
|
|
1610
|
-
},
|
|
1611
|
-
{
|
|
1612
|
-
config: contextOverlay({ rootDirectory: packagePath }),
|
|
1613
|
-
},
|
|
1614
|
-
);
|
|
1615
|
-
|
|
1616
|
-
malloyConfig.wrapConnections((base) => ({
|
|
1617
|
-
lookupConnection: async (name?: string) => {
|
|
1618
|
-
if (!name || name === "duckdb") {
|
|
1619
|
-
const connection = await base.lookupConnection(name);
|
|
1620
|
-
// The per-package :memory: sandbox is a Publisher-owned DuckDB
|
|
1621
|
-
// session too. Pin it against implicit auto-install so
|
|
1622
|
-
// EXTENSION_FETCH_POLICY covers it — otherwise local-only's
|
|
1623
|
-
// no-network guarantee had a hole here (the sandbox has no
|
|
1624
|
-
// attached databases, so the env attach paths never touch it).
|
|
1625
|
-
if (connection instanceof DuckDBConnection) {
|
|
1626
|
-
await applyExtensionSessionSettings(connection);
|
|
1627
|
-
}
|
|
1628
|
-
return connection;
|
|
1629
|
-
}
|
|
1630
|
-
// Resolve against the *current* environment MalloyConfig so a
|
|
1631
|
-
// connection-generation swap on Environment propagates without a
|
|
1632
|
-
// package reload.
|
|
1633
|
-
return getEnvironmentMalloyConfig().connections.lookupConnection(
|
|
1634
|
-
name,
|
|
1635
|
-
);
|
|
1636
|
-
},
|
|
1637
|
-
}));
|
|
1638
|
-
|
|
1639
|
-
return malloyConfig;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
private static toMalloyConfig(
|
|
1643
|
-
input: MalloyConfig | Map<string, Connection>,
|
|
1644
|
-
): MalloyConfig {
|
|
1645
|
-
if (input instanceof MalloyConfig) {
|
|
1646
|
-
return input;
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
const malloyConfig = new MalloyConfig({ connections: {} });
|
|
1650
|
-
malloyConfig.wrapConnections(
|
|
1651
|
-
() => new FixedConnectionMap(input, "duckdb"),
|
|
1652
|
-
);
|
|
1653
|
-
return malloyConfig;
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
private static async validatePackageManifestExistsOrThrowError(
|
|
1657
|
-
packagePath: string,
|
|
1658
|
-
) {
|
|
1659
|
-
const packageConfigPath = safeJoinUnderRoot(
|
|
1660
|
-
packagePath,
|
|
1661
|
-
PACKAGE_MANIFEST_NAME,
|
|
1662
|
-
);
|
|
1663
|
-
try {
|
|
1664
|
-
await fs.stat(packageConfigPath);
|
|
1665
|
-
} catch {
|
|
1666
|
-
logger.error(`Can't find ${packageConfigPath}`);
|
|
1667
|
-
throw new PackageNotFoundError(
|
|
1668
|
-
`Package manifest for ${packagePath} does not exist.`,
|
|
1669
|
-
);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
private static async readDatabases(
|
|
1674
|
-
packagePath: string,
|
|
1675
|
-
malloyConfig: MalloyConfig,
|
|
1676
|
-
): Promise<ApiDatabase[]> {
|
|
1677
|
-
const databasePaths = await Package.getDatabasePaths(packagePath);
|
|
1678
|
-
if (databasePaths.length === 0) {
|
|
1679
|
-
return [];
|
|
1680
|
-
}
|
|
1681
|
-
// Resolve the package's duckdb connection ONCE and reuse it for
|
|
1682
|
-
// every schema/row-count probe in this package. Malloy caches the
|
|
1683
|
-
// materialized connection on the MalloyConfig so the same instance
|
|
1684
|
-
// will be returned to model compiles later in `Package.create`.
|
|
1685
|
-
// This is the substantive optimization over the previous code:
|
|
1686
|
-
// we go from `databasePaths.length` separate DuckDBConnections
|
|
1687
|
-
// (each doing its own native init + extension load) to one.
|
|
1688
|
-
const conn = await malloyConfig.connections.lookupConnection("duckdb");
|
|
1689
|
-
return await Promise.all(
|
|
1690
|
-
databasePaths.map(async (databasePath): Promise<ApiDatabase> => {
|
|
1691
|
-
try {
|
|
1692
|
-
return {
|
|
1693
|
-
path: databasePath,
|
|
1694
|
-
info: await Package.getDatabaseInfo(
|
|
1695
|
-
packagePath,
|
|
1696
|
-
databasePath,
|
|
1697
|
-
conn,
|
|
1698
|
-
),
|
|
1699
|
-
type: "embedded" as const,
|
|
1700
|
-
};
|
|
1701
|
-
} catch (error) {
|
|
1702
|
-
// One unreadable data file (a partial or corrupt spreadsheet,
|
|
1703
|
-
// an interrupted download, an extension that failed to bake)
|
|
1704
|
-
// must not drop the whole package. Report it the way a model
|
|
1705
|
-
// that fails to compile is reported: the entry stays in the
|
|
1706
|
-
// listing carrying `error` instead of `info`, so the failure
|
|
1707
|
-
// is visible over the API rather than looking like the file
|
|
1708
|
-
// was never there.
|
|
1709
|
-
const message =
|
|
1710
|
-
error instanceof Error ? error.message : String(error);
|
|
1711
|
-
logger.warn("Could not read package database", {
|
|
1712
|
-
packagePath,
|
|
1713
|
-
databasePath,
|
|
1714
|
-
error: message,
|
|
1715
|
-
});
|
|
1716
|
-
return {
|
|
1717
|
-
path: databasePath,
|
|
1718
|
-
type: "embedded" as const,
|
|
1719
|
-
error: message,
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
}),
|
|
1723
|
-
);
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
private static async getDatabasePaths(
|
|
1727
|
-
packagePath: string,
|
|
1728
|
-
): Promise<string[]> {
|
|
1729
|
-
const files = await recursive(packagePath, [ignoreDotfiles]);
|
|
1730
|
-
return files
|
|
1731
|
-
.map((fullPath: string) => {
|
|
1732
|
-
return path.relative(packagePath, fullPath).replace(/\\/g, "/");
|
|
1733
|
-
})
|
|
1734
|
-
.filter((modelPath: string) => {
|
|
1735
|
-
// Excel writes a sibling owner file (~$name.xlsx) whenever the
|
|
1736
|
-
// workbook is open. It is not real data and is not a valid zip, so
|
|
1737
|
-
// skip it before the extension match rather than fail to probe it.
|
|
1738
|
-
if (path.basename(modelPath).startsWith("~$")) {
|
|
1739
|
-
return false;
|
|
1740
|
-
}
|
|
1741
|
-
return (
|
|
1742
|
-
modelPath.endsWith(".parquet") ||
|
|
1743
|
-
modelPath.endsWith(".csv") ||
|
|
1744
|
-
modelPath.endsWith(".xlsx")
|
|
1745
|
-
);
|
|
1746
|
-
});
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
private static async getDatabaseInfo(
|
|
1750
|
-
packagePath: string,
|
|
1751
|
-
databasePath: string,
|
|
1752
|
-
conn: Connection,
|
|
1753
|
-
): Promise<ApiTableDescription> {
|
|
1754
|
-
const fullPath = path.join(packagePath, databasePath);
|
|
1755
|
-
|
|
1756
|
-
// Create a DuckDB source then:
|
|
1757
|
-
// 1. Load the model and get the table schema from model
|
|
1758
|
-
// 2. Run a query to get the row count from the table
|
|
1759
|
-
// ConnectionRuntime is cheap (just a wrapper), and creating one
|
|
1760
|
-
// per call keeps each probe's compile state isolated. The
|
|
1761
|
-
// expensive piece — the underlying DuckDBConnection — is shared
|
|
1762
|
-
// across all probes via `conn` (resolved once in readDatabases).
|
|
1763
|
-
const runtime = new ConnectionRuntime({
|
|
1764
|
-
urlReader: new EmptyURLReader(),
|
|
1765
|
-
connections: [conn],
|
|
1766
|
-
});
|
|
1767
|
-
// Normalize path to use forward slashes for cross-platform compatibility
|
|
1768
|
-
// DuckDB on Windows supports forward slashes, and this avoids escaping issues
|
|
1769
|
-
const normalizedPath = fullPath.replace(/\\/g, "/");
|
|
1770
|
-
const model = runtime.loadModel(
|
|
1771
|
-
`source: temp is duckdb.table('${normalizedPath}')`,
|
|
1772
|
-
);
|
|
1773
|
-
const modelDef = await model.getModel();
|
|
1774
|
-
const fields = (modelDef._modelDef.contents["temp"] as SourceDef).fields;
|
|
1775
|
-
const schema = fields.map((field): ApiColumn => {
|
|
1776
|
-
return { type: field.type, name: field.name };
|
|
1777
|
-
});
|
|
1778
|
-
const runner = model.loadQuery(
|
|
1779
|
-
"run: temp->{aggregate: row_count is count()}",
|
|
1780
|
-
);
|
|
1781
|
-
const result = await runner.run();
|
|
1782
|
-
const rowCount = result.data.value[0].row_count?.valueOf() as number;
|
|
1783
|
-
return { name: databasePath, rowCount, columns: schema };
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
public setName(name: string) {
|
|
1787
|
-
this.packageName = name;
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
public setEnvironmentName(environmentName: string) {
|
|
1791
|
-
this.environmentName = environmentName;
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
public setPackageMetadata(packageMetadata: ApiPackage) {
|
|
1795
|
-
this.packageMetadata = packageMetadata;
|
|
1796
|
-
this.applyDiscoveryPolicyToModels();
|
|
1797
|
-
this.applyQueryBoundaryToModels();
|
|
1798
|
-
}
|
|
1799
|
-
}
|