@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/model.ts
DELETED
|
@@ -1,3370 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Annotations,
|
|
3
|
-
API,
|
|
4
|
-
Connection,
|
|
5
|
-
FixedConnectionMap,
|
|
6
|
-
GivenValue,
|
|
7
|
-
InMemoryURLReader,
|
|
8
|
-
isBasicArray,
|
|
9
|
-
isJoined,
|
|
10
|
-
isRepeatedRecord,
|
|
11
|
-
isSourceDef,
|
|
12
|
-
MalloyConfig,
|
|
13
|
-
MalloyError,
|
|
14
|
-
ModelDef,
|
|
15
|
-
modelDefToModelInfo,
|
|
16
|
-
ModelMaterializer,
|
|
17
|
-
NamedQueryDef,
|
|
18
|
-
QueryData,
|
|
19
|
-
QueryMaterializer,
|
|
20
|
-
Runtime,
|
|
21
|
-
type FieldDef,
|
|
22
|
-
type SourceDef,
|
|
23
|
-
type VirtualMap,
|
|
24
|
-
} from "@malloydata/malloy";
|
|
25
|
-
import * as Malloy from "@malloydata/malloy-interfaces";
|
|
26
|
-
import {
|
|
27
|
-
MalloySQLParser,
|
|
28
|
-
MalloySQLStatementType,
|
|
29
|
-
} from "@malloydata/malloy-sql";
|
|
30
|
-
import { DataStyles } from "@malloydata/render";
|
|
31
|
-
import { publisherMeter } from "../telemetry";
|
|
32
|
-
import {
|
|
33
|
-
recordServeShapeTierDrop,
|
|
34
|
-
recordStorageServeRouting,
|
|
35
|
-
} from "../materialization_metrics";
|
|
36
|
-
import * as fs from "fs/promises";
|
|
37
|
-
import { readFileSync } from "fs";
|
|
38
|
-
import { createRequire } from "module";
|
|
39
|
-
import * as path from "path";
|
|
40
|
-
import { fileURLToPath } from "url";
|
|
41
|
-
import { components } from "../api";
|
|
42
|
-
import {
|
|
43
|
-
getDefaultQueryRowLimit,
|
|
44
|
-
getMaxQueryRows,
|
|
45
|
-
getMaxResponseBytes,
|
|
46
|
-
} from "../config";
|
|
47
|
-
import { MODEL_FILE_SUFFIX, NOTEBOOK_FILE_SUFFIX } from "../constants";
|
|
48
|
-
import { HackyDataStylesAccumulator } from "../data_styles";
|
|
49
|
-
import {
|
|
50
|
-
AccessDeniedError,
|
|
51
|
-
BadRequestError,
|
|
52
|
-
ModelCompilationError,
|
|
53
|
-
ModelNotFoundError,
|
|
54
|
-
NotQueryableError,
|
|
55
|
-
PayloadTooLargeError,
|
|
56
|
-
} from "../errors";
|
|
57
|
-
import { getPersistStorageMode } from "../config";
|
|
58
|
-
import { logger } from "../logger";
|
|
59
|
-
import {
|
|
60
|
-
buildServeShapeModelForBindings,
|
|
61
|
-
buildVirtualMap,
|
|
62
|
-
extractJoins,
|
|
63
|
-
extractRefinements,
|
|
64
|
-
extractViews,
|
|
65
|
-
narrowSchemaToPublic,
|
|
66
|
-
sliceSourceRange,
|
|
67
|
-
type ServeBinding,
|
|
68
|
-
type SourceLocation,
|
|
69
|
-
} from "./materialization_serve_transform";
|
|
70
|
-
import { evaluateManifestFreshness } from "./freshness";
|
|
71
|
-
import { deserializeError } from "../package_load/package_load_pool";
|
|
72
|
-
import type {
|
|
73
|
-
SerializedModel,
|
|
74
|
-
SerializedNotebookCell,
|
|
75
|
-
} from "../package_load/protocol";
|
|
76
|
-
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
77
|
-
import { URL_READER } from "../utils";
|
|
78
|
-
import { modelAnnotations } from "./annotations";
|
|
79
|
-
import {
|
|
80
|
-
collectAuthorizeExprs,
|
|
81
|
-
evaluateAuthorize,
|
|
82
|
-
referencedGivenNames,
|
|
83
|
-
validateAuthorizeProbes,
|
|
84
|
-
} from "./authorize";
|
|
85
|
-
import {
|
|
86
|
-
buildFilterClause,
|
|
87
|
-
FilterValidationError,
|
|
88
|
-
injectFilterRefinement,
|
|
89
|
-
type FilterDefinition,
|
|
90
|
-
type FilterParams,
|
|
91
|
-
} from "./filter";
|
|
92
|
-
import { malloyGivenToApi, type MalloyGiven } from "./given";
|
|
93
|
-
import {
|
|
94
|
-
assertWithinModelResponseLimits,
|
|
95
|
-
type QueryRowLimitSource,
|
|
96
|
-
queryRowLimitSource,
|
|
97
|
-
resolveModelQueryRowLimit,
|
|
98
|
-
} from "./model_limits";
|
|
99
|
-
import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
|
|
100
|
-
import {
|
|
101
|
-
mergeQueryMetadata,
|
|
102
|
-
type QueryClass,
|
|
103
|
-
type QueryMetadata,
|
|
104
|
-
} from "./query_metadata";
|
|
105
|
-
import {
|
|
106
|
-
extractQueriesFromModelDef,
|
|
107
|
-
extractSourcesFromModelDef,
|
|
108
|
-
} from "./source_extraction";
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* What a request boundary contributes to a model query's per-query metadata: the
|
|
112
|
-
* caller's own properties and class, the environment the query runs in, and a
|
|
113
|
-
* reader for the executing connection's default (the controller owns the
|
|
114
|
-
* environment, so it supplies the lookup rather than the model reaching for it).
|
|
115
|
-
*/
|
|
116
|
-
export interface ModelQueryMetadataInput {
|
|
117
|
-
request?: QueryMetadata;
|
|
118
|
-
queryClass?: QueryClass;
|
|
119
|
-
environment?: string;
|
|
120
|
-
version?: string;
|
|
121
|
-
/**
|
|
122
|
-
* The id this query is correlated by, minted by the boundary that returns it
|
|
123
|
-
* (see `mintCorrelationId`). Omitted by callers with no response field to
|
|
124
|
-
* carry it, which is why it is not minted here.
|
|
125
|
-
*/
|
|
126
|
-
correlationId?: string;
|
|
127
|
-
/**
|
|
128
|
-
* The executing connection's two metadata layers: its overridable default and
|
|
129
|
-
* the properties the deployment enforces. Supplied by the controller, which
|
|
130
|
-
* owns the environment; the model only knows the connection by name.
|
|
131
|
-
*/
|
|
132
|
-
connectionMetadata?: (connectionName: string) => {
|
|
133
|
-
default?: QueryMetadata | null;
|
|
134
|
-
enforced?: QueryMetadata | null;
|
|
135
|
-
} | null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
139
|
-
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
140
|
-
type ApiRawNotebook = components["schemas"]["RawNotebook"];
|
|
141
|
-
type ApiSource = components["schemas"]["Source"];
|
|
142
|
-
type ApiGiven = components["schemas"]["Given"];
|
|
143
|
-
type ApiQuery = components["schemas"]["Query"];
|
|
144
|
-
export type ApiConnection = components["schemas"]["Connection"];
|
|
145
|
-
export type SnowflakeConnection = components["schemas"]["SnowflakeConnection"];
|
|
146
|
-
export type PostgresConnection = components["schemas"]["PostgresConnection"];
|
|
147
|
-
export type BigqueryConnection = components["schemas"]["BigqueryConnection"];
|
|
148
|
-
export type TrinoConnection = components["schemas"]["TrinoConnection"];
|
|
149
|
-
|
|
150
|
-
const MALLOY_VERSION = (
|
|
151
|
-
createRequire(import.meta.url)("@malloydata/malloy/package.json") as {
|
|
152
|
-
version: string;
|
|
153
|
-
}
|
|
154
|
-
).version;
|
|
155
|
-
|
|
156
|
-
export type ModelType = "model" | "notebook";
|
|
157
|
-
type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
|
|
158
|
-
|
|
159
|
-
/** One reachable authorize gate found by {@link Model.collectAllReachableGates}. */
|
|
160
|
-
type GateEntry = { label: string; exprs: string[]; selfContained: boolean };
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* True for a struct field that Malloy's own `isJoined()` recognizes as a join
|
|
164
|
-
* (`'join' in sd`) purely because it's a nested-column kind — `RecordDef`,
|
|
165
|
-
* `RepeatedRecordDef`, and `BasicArrayDef` all extend `JoinBase` — rather than
|
|
166
|
-
* an actual join to another source. `isSourceDef()` already excludes
|
|
167
|
-
* `'record'`/`'array'`, but that alone doesn't distinguish "ordinary
|
|
168
|
-
* STRUCT/ARRAY/JSON column" from genuine join/source-shape drift; this checks
|
|
169
|
-
* the field kinds explicitly so the two can't be conflated. These field kinds
|
|
170
|
-
* can never carry `#(authorize)`, so a walk must skip them, not treat them as
|
|
171
|
-
* an unresolvable join.
|
|
172
|
-
*/
|
|
173
|
-
function isRecordOrArrayField(field: FieldDef): boolean {
|
|
174
|
-
return (
|
|
175
|
-
(field as { type?: string }).type === "record" ||
|
|
176
|
-
isBasicArray(field) ||
|
|
177
|
-
isRepeatedRecord(field)
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
interface RunnableNotebookCell {
|
|
182
|
-
type: "code" | "markdown";
|
|
183
|
-
text: string;
|
|
184
|
-
runnable?: QueryMaterializer;
|
|
185
|
-
/** Retained so we can rebuild the query with filter refinements at execution time. */
|
|
186
|
-
modelMaterializer?: ModelMaterializer;
|
|
187
|
-
newSources?: Malloy.SourceInfo[];
|
|
188
|
-
queryInfo?: Malloy.QueryInfo;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Backtick-quote a Malloy identifier for safe interpolation into a `run:`
|
|
193
|
-
* query string. Escapes backslashes and backticks (in that order) so a name
|
|
194
|
-
* that needs Malloy quoting (hyphen, space, reserved word, leading digit) or
|
|
195
|
-
* contains an embedded backtick cannot break out of the quotes. Mirrors
|
|
196
|
-
* malloy's internal `identifierCode` / `escapeIdentifier` (to_stable.ts), which
|
|
197
|
-
* is not exported.
|
|
198
|
-
*/
|
|
199
|
-
function quoteMalloyIdentifier(name: string | undefined): string {
|
|
200
|
-
return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* A non-fatal render-tag finding from {@link Model.validateRenderTags}: an
|
|
205
|
-
* error-severity issue that affects only how a field renders, never whether the
|
|
206
|
-
* model compiles or a query runs. `target` is the query or view it sits on
|
|
207
|
-
* (e.g. `by_carrier` or `flights -> by_carrier`).
|
|
208
|
-
*/
|
|
209
|
-
export interface RenderTagWarning {
|
|
210
|
-
target: string;
|
|
211
|
-
message: string;
|
|
212
|
-
severity: "error" | "warn";
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export class Model {
|
|
216
|
-
private packageName: string;
|
|
217
|
-
private modelPath: string;
|
|
218
|
-
private dataStyles: DataStyles;
|
|
219
|
-
private modelType: ModelType;
|
|
220
|
-
private modelMaterializer: ModelMaterializer | undefined;
|
|
221
|
-
private modelDef: ModelDef | undefined;
|
|
222
|
-
private modelInfo: Malloy.ModelInfo | undefined;
|
|
223
|
-
/**
|
|
224
|
-
* Connection config to compile a transient serve-shape model against, when a
|
|
225
|
-
* query is routed through the `storage=` virtual-source transform. Captured
|
|
226
|
-
* at hydration (fromSerialized) so serve can build a fresh Runtime.
|
|
227
|
-
*/
|
|
228
|
-
private serveMalloyConfig?: ModelConnectionInput;
|
|
229
|
-
/**
|
|
230
|
-
* The package's `storage=` serve bindings, set by the owning Package when a
|
|
231
|
-
* build/manifest binds materialized-into-storage sources. Empty ⇒ no serve
|
|
232
|
-
* routing (the common case; the serve path is unchanged).
|
|
233
|
-
*/
|
|
234
|
-
private serveBindings: ServeBinding[] = [];
|
|
235
|
-
/** Memoized serve-shape materializer, keyed by the bound source set. */
|
|
236
|
-
private serveShapeCache?: { key: string; materializer: ModelMaterializer };
|
|
237
|
-
private sources: ApiSource[] | undefined;
|
|
238
|
-
private queries: ApiQuery[] | undefined;
|
|
239
|
-
private sourceInfos: Malloy.SourceInfo[] | undefined;
|
|
240
|
-
private runnableNotebookCells: RunnableNotebookCell[] | undefined;
|
|
241
|
-
private compilationError: MalloyError | Error | undefined;
|
|
242
|
-
/** Parsed #(filter) definitions keyed by source name. */
|
|
243
|
-
private filterMap: Map<string, FilterDefinition[]>;
|
|
244
|
-
/** Givens declared on the model, in declaration order. Malloy's
|
|
245
|
-
* `Model.givens` already collapses inheritance; we just stash the list
|
|
246
|
-
* for surfacing on the compiled-model response. */
|
|
247
|
-
private givens: ApiGiven[] | undefined;
|
|
248
|
-
/** Model-wide `##(authorize)` expressions; apply to every query in the
|
|
249
|
-
* model, including ad-hoc inline sources not declared in the model. */
|
|
250
|
-
private fileLevelAuthorize: string[] = [];
|
|
251
|
-
/** Given names (`$NAME`) referenced by any authorize gate reachable
|
|
252
|
-
* anywhere in this model -- the file-level gate, every top-level
|
|
253
|
-
* source's own gate, and every gate reached transitively from a
|
|
254
|
-
* top-level source via join_* / query-source derivation (the same walk
|
|
255
|
-
* {@link assertAuthorizedForAllSources} runs at request time). Computed
|
|
256
|
-
* once at construction; see {@link filterGivensToModelSurface}, the only
|
|
257
|
-
* consumer. */
|
|
258
|
-
private authorizeReferencedGivenNames: Set<string> = new Set();
|
|
259
|
-
/** Whether discovery accessors curate to the `export {}` closure. Pushed
|
|
260
|
-
* down by the owning Package (see Package.applyDiscoveryPolicyToModels):
|
|
261
|
-
* true only when the package declares `explores` in publisher.json.
|
|
262
|
-
* Defaults to false (legacy listings) so a Model created outside a
|
|
263
|
-
* Package matches pre-opt-in behavior. */
|
|
264
|
-
private discoveryCurationEnabled = false;
|
|
265
|
-
/** Per-package query-boundary policy, pushed down by the owning Package
|
|
266
|
-
* (see `Package.applyQueryBoundaryToModels`). Defaults are inert (mode
|
|
267
|
-
* "all" / not declared) so a Model created outside a Package — or before
|
|
268
|
-
* the policy is applied — never spuriously denies. */
|
|
269
|
-
private queryBoundary: {
|
|
270
|
-
mode: "declared" | "all";
|
|
271
|
-
exploresDeclared: boolean;
|
|
272
|
-
isQueryEntryPoint: boolean;
|
|
273
|
-
} = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
|
|
274
|
-
/** Per-query freshness resolver, pushed down by the owning Package (see
|
|
275
|
-
* Package.wireFreshnessResolvers). Returns the freshness-filtered build
|
|
276
|
-
* manifest for the serve path — threaded into Malloy's per-query
|
|
277
|
-
* `buildManifest` override so a persist source only routes to its
|
|
278
|
-
* materialized table while within its declared freshness window. Undefined
|
|
279
|
-
* (or returning undefined) means no override: the runtime-baked manifest
|
|
280
|
-
* applies, which serves live when unbound. */
|
|
281
|
-
private freshnessResolver?: () => BuildManifest["entries"] | undefined;
|
|
282
|
-
private meter = publisherMeter();
|
|
283
|
-
private queryExecutionHistogram = this.meter.createHistogram(
|
|
284
|
-
"malloy_model_query_duration",
|
|
285
|
-
{
|
|
286
|
-
description: "How long it takes to execute a Malloy model query",
|
|
287
|
-
unit: "ms",
|
|
288
|
-
},
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
constructor(
|
|
292
|
-
packageName: string,
|
|
293
|
-
modelPath: string,
|
|
294
|
-
dataStyles: DataStyles,
|
|
295
|
-
modelType: ModelType,
|
|
296
|
-
modelMaterializer: ModelMaterializer | undefined,
|
|
297
|
-
modelDef: ModelDef | undefined,
|
|
298
|
-
// TODO(jjs) - remove these
|
|
299
|
-
sources: ApiSource[] | undefined,
|
|
300
|
-
queries: ApiQuery[] | undefined,
|
|
301
|
-
sourceInfos: Malloy.SourceInfo[] | undefined,
|
|
302
|
-
runnableNotebookCells: RunnableNotebookCell[] | undefined,
|
|
303
|
-
compilationError: MalloyError | Error | undefined,
|
|
304
|
-
filterMap?: Map<string, FilterDefinition[]>,
|
|
305
|
-
givens?: ApiGiven[],
|
|
306
|
-
/**
|
|
307
|
-
* Precomputed `modelDefToModelInfo(modelDef)`. The package-load
|
|
308
|
-
* worker emits it as part of `SerializedModel` so we don't
|
|
309
|
-
* re-derive it on every package load. Callers that build a
|
|
310
|
-
* `Model` from a raw `modelDef` (e.g. test fixtures via
|
|
311
|
-
* `Model.create`) can omit this and let the constructor
|
|
312
|
-
* derive it lazily.
|
|
313
|
-
*/
|
|
314
|
-
modelInfo?: Malloy.ModelInfo,
|
|
315
|
-
) {
|
|
316
|
-
this.packageName = packageName;
|
|
317
|
-
this.modelPath = modelPath;
|
|
318
|
-
this.dataStyles = dataStyles;
|
|
319
|
-
this.modelType = modelType;
|
|
320
|
-
this.modelDef = modelDef;
|
|
321
|
-
this.modelMaterializer = modelMaterializer;
|
|
322
|
-
this.sources = sources;
|
|
323
|
-
this.queries = queries;
|
|
324
|
-
this.sourceInfos = sourceInfos;
|
|
325
|
-
this.runnableNotebookCells = runnableNotebookCells;
|
|
326
|
-
this.compilationError = compilationError;
|
|
327
|
-
this.filterMap = filterMap ?? new Map();
|
|
328
|
-
this.givens = givens;
|
|
329
|
-
// Model-wide ##(authorize) gates, derived from the file-level annotations
|
|
330
|
-
// on the modelDef (which survives the worker boundary). These apply to
|
|
331
|
-
// any query that resolves to a model source (or to no nameable source),
|
|
332
|
-
// model-wide. (Raw-SQL access to the warehouse is closed separately by
|
|
333
|
-
// restricted mode, which rejects inline `duckdb.sql(...)` on the caller
|
|
334
|
-
// query path before any gate runs — see getQueryResults.) A
|
|
335
|
-
// successfully-loaded model has already had these validated; guard the
|
|
336
|
-
// parse defensively so the constructor never throws.
|
|
337
|
-
try {
|
|
338
|
-
this.fileLevelAuthorize = this.modelDef
|
|
339
|
-
? collectAuthorizeExprs(
|
|
340
|
-
(modelAnnotations(this.modelDef).notes ?? []).map(
|
|
341
|
-
(note) => note.text,
|
|
342
|
-
),
|
|
343
|
-
)
|
|
344
|
-
: [];
|
|
345
|
-
} catch {
|
|
346
|
-
this.fileLevelAuthorize = [];
|
|
347
|
-
}
|
|
348
|
-
// Guarded the same way as fileLevelAuthorize above: a malformed gate
|
|
349
|
-
// reachable only through a join/derivation must not throw out of the
|
|
350
|
-
// constructor (gateExprsForOwnAnnotations already fails closed per
|
|
351
|
-
// struct, so this can only throw on something unrelated).
|
|
352
|
-
try {
|
|
353
|
-
this.authorizeReferencedGivenNames =
|
|
354
|
-
this.computeAuthorizeReferencedGivenNames();
|
|
355
|
-
} catch {
|
|
356
|
-
this.authorizeReferencedGivenNames = new Set();
|
|
357
|
-
}
|
|
358
|
-
this.modelInfo =
|
|
359
|
-
modelInfo ??
|
|
360
|
-
(this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
|
|
361
|
-
|
|
362
|
-
// One-time deprecation notice per Model instance. Surfaces only when
|
|
363
|
-
// the model declares `#(filter)` annotations so operators migrating
|
|
364
|
-
// toward `given:` see a clear pointer in the server log without
|
|
365
|
-
// spamming for models that have already moved over.
|
|
366
|
-
if (this.filterMap.size > 0) {
|
|
367
|
-
logger.warn(
|
|
368
|
-
`Model "${packageName}/${modelPath}" uses deprecated #(filter) annotations. Migrate to given: — see https://github.com/malloydata/publisher/blob/main/docs/givens.md`,
|
|
369
|
-
{
|
|
370
|
-
packageName,
|
|
371
|
-
modelPath,
|
|
372
|
-
filterSourceCount: this.filterMap.size,
|
|
373
|
-
},
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Get the parsed filter definitions for a given source name.
|
|
380
|
-
* Returns an empty array if no filters are declared.
|
|
381
|
-
*/
|
|
382
|
-
public getFilters(sourceName: string): FilterDefinition[] {
|
|
383
|
-
return this.filterMap.get(sourceName) ?? [];
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Given name → declared Malloy type, from this model's own given surface
|
|
388
|
-
* ({@link givens}). Passed to {@link evaluateAuthorize}'s self-contained
|
|
389
|
-
* probe fallback so it prefers the gate author's DECLARED type over
|
|
390
|
-
* inferring one from the caller's JS value (e.g. `$LEVEL > 3` compares
|
|
391
|
-
* numerically even if the caller sends `"5"`). Only reaches a given
|
|
392
|
-
* declared within one import hop of this model — a gate on a source
|
|
393
|
-
* reached through a deeper transitive import isn't on this surface, so
|
|
394
|
-
* that case still falls back to inferring from the value.
|
|
395
|
-
*/
|
|
396
|
-
private givenDeclaredTypes(): Map<string, string> {
|
|
397
|
-
return new Map(
|
|
398
|
-
(this.givens ?? [])
|
|
399
|
-
.filter((g) => g.name != null && g.type != null)
|
|
400
|
-
.map((g) => [g.name, g.type] as [string, string]),
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Effective authorize expressions gating a source: file-level
|
|
406
|
-
* `##(authorize)` followed by the source's own `#(authorize)`, evaluated as
|
|
407
|
-
* one OR disjunction at request time. Empty array means unrestricted. Reads
|
|
408
|
-
* the per-source list surfaced on `sources` (which rides the worker
|
|
409
|
-
* serialization boundary), so it works for both freshly-created and
|
|
410
|
-
* deserialized models.
|
|
411
|
-
*/
|
|
412
|
-
public getAuthorize(sourceName: string): string[] {
|
|
413
|
-
return (
|
|
414
|
-
this.sources?.find((source) => source.name === sourceName)
|
|
415
|
-
?.authorize ?? []
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Filter caller-supplied givens down to the ones safe to forward to the
|
|
421
|
-
* REAL query's `getPreparedResult`/`run`. A caller may legitimately need
|
|
422
|
-
* to supply a value only so a joined source's `#(authorize)` gate can see
|
|
423
|
-
* it — that gate is evaluated separately, against the FULL unfiltered
|
|
424
|
-
* givens (see `assertAuthorizedForAllSources`, which runs before this
|
|
425
|
-
* filter is applied). Malloy's own given-resolution doesn't flatten a
|
|
426
|
-
* `given:` declared more than one import hop away into the entry model's
|
|
427
|
-
* namespace (see `docs/authorize.md`), so passing such an authorize-only
|
|
428
|
-
* name straight through to the real query throws ("givens: unknown
|
|
429
|
-
* given"). Dropping it here is safe: the query itself can't reference a
|
|
430
|
-
* given it doesn't declare, so there is nothing for the dropped value to
|
|
431
|
-
* have affected.
|
|
432
|
-
*
|
|
433
|
-
* Only drops a name that is BOTH absent from this model's own given
|
|
434
|
-
* surface ({@link givens}) AND referenced by an authorize gate reachable
|
|
435
|
-
* in this model ({@link authorizeReferencedGivenNames}) — i.e. a name
|
|
436
|
-
* that could only have been supplied for gate evaluation. A name that no
|
|
437
|
-
* gate references is left untouched even when the model doesn't surface
|
|
438
|
-
* it, so a genuinely unknown / typo'd / legitimately-needed-but-unsurfaced
|
|
439
|
-
* `where:` given still reaches the real query and fails closed via
|
|
440
|
-
* Malloy's own "unknown given" error, instead of being silently swallowed
|
|
441
|
-
* and falling back to its declared default (over-exposure).
|
|
442
|
-
*/
|
|
443
|
-
private filterGivensToModelSurface(
|
|
444
|
-
givens: Record<string, GivenValue> | undefined,
|
|
445
|
-
): Record<string, GivenValue> | undefined {
|
|
446
|
-
if (!givens) return givens;
|
|
447
|
-
const surfaceNames = new Set((this.givens ?? []).map((g) => g.name));
|
|
448
|
-
const filtered: Record<string, GivenValue> = {};
|
|
449
|
-
for (const [name, value] of Object.entries(givens)) {
|
|
450
|
-
const authorizeOnly =
|
|
451
|
-
!surfaceNames.has(name) &&
|
|
452
|
-
this.authorizeReferencedGivenNames.has(name);
|
|
453
|
-
if (!authorizeOnly) filtered[name] = value;
|
|
454
|
-
}
|
|
455
|
-
return filtered;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Compute {@link authorizeReferencedGivenNames}: every given name (`$NAME`)
|
|
460
|
-
* referenced by an authorize gate expression reachable anywhere in this
|
|
461
|
-
* model. Walks the file-level gate plus, for every top-level source in
|
|
462
|
-
* `modelDef.contents`, every gate reached from it via
|
|
463
|
-
* {@link collectAllReachableGates} — the exact same unified traversal
|
|
464
|
-
* {@link assertAuthorizedForAllSources} runs per-query, just rooted at
|
|
465
|
-
* every top-level source instead of one run target. Runs once at
|
|
466
|
-
* construction, not per request.
|
|
467
|
-
*/
|
|
468
|
-
private computeAuthorizeReferencedGivenNames(): Set<string> {
|
|
469
|
-
const names = new Set<string>();
|
|
470
|
-
const addExprs = (exprs: string[]) => {
|
|
471
|
-
for (const expr of exprs) {
|
|
472
|
-
for (const name of referencedGivenNames(expr)) names.add(name);
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
addExprs(this.fileLevelAuthorize);
|
|
476
|
-
const modelDef = this.modelDef;
|
|
477
|
-
if (!modelDef) return names;
|
|
478
|
-
for (const entry of Object.values(modelDef.contents)) {
|
|
479
|
-
if (!isSourceDef(entry)) continue;
|
|
480
|
-
for (const { exprs } of this.collectAllReachableGates(
|
|
481
|
-
entry,
|
|
482
|
-
modelDef,
|
|
483
|
-
new Set(),
|
|
484
|
-
)) {
|
|
485
|
-
addExprs(exprs);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return names;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Whether the model declares any `#(authorize)` / `##(authorize)` gate at all
|
|
493
|
-
* (file-level or on any source). Lets callers cheaply skip authorize work for
|
|
494
|
-
* ungated models without compiling a probe.
|
|
495
|
-
*/
|
|
496
|
-
public hasAuthorize(): boolean {
|
|
497
|
-
return (
|
|
498
|
-
this.fileLevelAuthorize.length > 0 ||
|
|
499
|
-
(this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false)
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Effective authorize expressions for whatever a query runs against:
|
|
505
|
-
* - a declared model source → its own list (file-level ++ source-level);
|
|
506
|
-
* - anything else (an ad-hoc inline `duckdb.sql(...)` source, or a source
|
|
507
|
-
* we couldn't name) → the model-wide file-level `##(authorize)` gates.
|
|
508
|
-
* The second case is what stops a file-level gate from being bypassed by
|
|
509
|
-
* querying the warehouse through raw inline SQL.
|
|
510
|
-
*/
|
|
511
|
-
private effectiveAuthorizeFor(sourceName: string | undefined): string[] {
|
|
512
|
-
if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
|
|
513
|
-
return this.getAuthorize(sourceName);
|
|
514
|
-
}
|
|
515
|
-
return this.fileLevelAuthorize;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Runtime authorize gate. Throws `AccessDeniedError` (403) unless at least
|
|
520
|
-
* one in-scope authorize expression evaluates true for the supplied givens.
|
|
521
|
-
* No in-scope expressions = unrestricted.
|
|
522
|
-
*
|
|
523
|
-
* Fail closed: any failure to evaluate the probe — a missing given value, a
|
|
524
|
-
* transient probe error, a missing/non-true result — denies. (Expression
|
|
525
|
-
* well-formedness was already validated at model load; see authorize.ts.)
|
|
526
|
-
* The 403 message names only the source, never the expression, so gate logic
|
|
527
|
-
* is not leaked to the caller.
|
|
528
|
-
*/
|
|
529
|
-
public async assertAuthorized(
|
|
530
|
-
sourceName: string | undefined,
|
|
531
|
-
givens: Record<string, GivenValue>,
|
|
532
|
-
): Promise<void> {
|
|
533
|
-
await this.assertAuthorizedExprs(
|
|
534
|
-
sourceName ?? "(query)",
|
|
535
|
-
this.effectiveAuthorizeFor(sourceName),
|
|
536
|
-
givens,
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Core runtime gate, factored out of {@link assertAuthorized} so the
|
|
542
|
-
* joined-source walk ({@link assertAuthorizedForAllSources}) can evaluate a
|
|
543
|
-
* gate it read directly off a join field's own struct annotations, without
|
|
544
|
-
* going through a `this.sources` name lookup (which would miss deep
|
|
545
|
-
* transitively-imported and inline-extend joins — see the design doc).
|
|
546
|
-
*
|
|
547
|
-
* `selfContainedFirst`, passed through to {@link evaluateAuthorize},
|
|
548
|
-
* isolates a joined/derived source's gate from the entry model's own
|
|
549
|
-
* ambient given namespace — see {@link collectAllReachableGates}'s
|
|
550
|
-
* `selfContained` tag. Left `false` (ambient-first) for the run target's
|
|
551
|
-
* OWN source gate ({@link assertAuthorized}'s call), which is correct to
|
|
552
|
-
* evaluate against the entry model's ambient namespace.
|
|
553
|
-
*/
|
|
554
|
-
private async assertAuthorizedExprs(
|
|
555
|
-
label: string,
|
|
556
|
-
exprs: string[],
|
|
557
|
-
givens: Record<string, GivenValue>,
|
|
558
|
-
selfContainedFirst = false,
|
|
559
|
-
): Promise<void> {
|
|
560
|
-
if (exprs.length === 0) return; // unrestricted
|
|
561
|
-
const deny = () => {
|
|
562
|
-
throw new AccessDeniedError(`Access denied for source "${label}".`);
|
|
563
|
-
};
|
|
564
|
-
if (!this.modelMaterializer) deny();
|
|
565
|
-
let passed = false;
|
|
566
|
-
try {
|
|
567
|
-
passed = await evaluateAuthorize(
|
|
568
|
-
this.modelMaterializer!,
|
|
569
|
-
exprs,
|
|
570
|
-
givens,
|
|
571
|
-
this.givenDeclaredTypes(),
|
|
572
|
-
{ selfContainedFirst },
|
|
573
|
-
);
|
|
574
|
-
} catch (err) {
|
|
575
|
-
// Fail closed — e.g. a referenced given had no supplied value.
|
|
576
|
-
logger.debug("Authorize probe failed; denying", {
|
|
577
|
-
sourceName: label,
|
|
578
|
-
modelPath: this.modelPath,
|
|
579
|
-
error: err instanceof Error ? err.message : String(err),
|
|
580
|
-
});
|
|
581
|
-
deny();
|
|
582
|
-
}
|
|
583
|
-
if (!passed) deny();
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* Gate a compiled query against every gate reachable in its struct — the
|
|
588
|
-
* run target's own gate PLUS every joined source's gate, recursed
|
|
589
|
-
* transitively (A→B→C). Closes the join-bypass: `#(authorize)` on a source
|
|
590
|
-
* reached only via `join_*` was previously never enforced (see
|
|
591
|
-
* `docs/authorize.md` "Known limitations" and the design doc's W1).
|
|
592
|
-
*
|
|
593
|
-
* Each joined source's gate is read directly from the join field's OWN
|
|
594
|
-
* struct annotations (the struct spread in Malloy's `join.js` preserves
|
|
595
|
-
* them), not from a `this.sources` name lookup — a name lookup would
|
|
596
|
-
* silently miss a deep transitively-imported source (absent from
|
|
597
|
-
* `modelDef.contents`) or an inline-extend join (`referenceID` cleared),
|
|
598
|
-
* leaving both bypassable. File-level `##(authorize)` still prepends to
|
|
599
|
-
* every joined source, so the permissive-admin idiom keeps working
|
|
600
|
-
* transitively. Semantics are AND across sources: any single reachable
|
|
601
|
-
* gate failing denies the whole query, while each source's own list of
|
|
602
|
-
* expressions stays an OR disjunction. Identical expr-lists are evaluated
|
|
603
|
-
* once.
|
|
604
|
-
*
|
|
605
|
-
* Runs UNCONDITIONALLY — NOT guarded by {@link hasAuthorize}. `hasAuthorize`
|
|
606
|
-
* only inspects `this.sources` (top-level `modelDef.contents` sources), so a
|
|
607
|
-
* gated source reached only through a cross-file/deep-transitive join is
|
|
608
|
-
* invisible to it and the walk below would never run. The own-source probe
|
|
609
|
-
* and the joined-gate walk are both cheap no-ops for a genuinely ungated
|
|
610
|
-
* model (empty expr lists / no joined sources to find), so there is nothing
|
|
611
|
-
* to save by skipping them.
|
|
612
|
-
*/
|
|
613
|
-
public async assertAuthorizedForAllSources(
|
|
614
|
-
runnable: { getPreparedQuery(): Promise<unknown> },
|
|
615
|
-
givens: Record<string, GivenValue>,
|
|
616
|
-
): Promise<void> {
|
|
617
|
-
const ownSourceName =
|
|
618
|
-
await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
619
|
-
await this.assertAuthorized(ownSourceName, givens);
|
|
620
|
-
|
|
621
|
-
const { struct, modelDef, compositeResolvedSourceDef, extendSources } =
|
|
622
|
-
await this.resolveRunTargetStruct(runnable);
|
|
623
|
-
const seen = new Set<SourceDef>();
|
|
624
|
-
// `struct` IS the run target itself — its own gate stays ambient-first
|
|
625
|
-
// (`treatAsOwnGate: true`), matching `assertAuthorized` above. Do NOT
|
|
626
|
-
// dedup this against that call: AND-across-sources evaluates every
|
|
627
|
-
// reachable source's gate independently (see the comment below).
|
|
628
|
-
const joinedGates = this.collectAllReachableGates(
|
|
629
|
-
struct,
|
|
630
|
-
modelDef,
|
|
631
|
-
seen,
|
|
632
|
-
true,
|
|
633
|
-
);
|
|
634
|
-
if (modelDef) {
|
|
635
|
-
// Sources joined LOCALLY inside the query's own `-> { join_one: ...
|
|
636
|
-
// }` refinement live on the pipeline segment's `extendSource`, not on
|
|
637
|
-
// the run target's `struct.fields` — the walk above never sees them
|
|
638
|
-
// from `struct` alone. Walk each the same way as everything else.
|
|
639
|
-
const runTargetLabel = ownSourceName ?? "(run target)";
|
|
640
|
-
for (const field of extendSources) {
|
|
641
|
-
const { resolved, denyGate } = this.classifyJoinedField(
|
|
642
|
-
field,
|
|
643
|
-
runTargetLabel,
|
|
644
|
-
);
|
|
645
|
-
if (denyGate) {
|
|
646
|
-
joinedGates.push(denyGate);
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
if (!resolved) continue;
|
|
650
|
-
joinedGates.push(
|
|
651
|
-
...this.collectAllReachableGates(resolved, modelDef, seen),
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
if (compositeResolvedSourceDef && modelDef) {
|
|
656
|
-
// The run target itself may be a composite source (`compose(a, b)`).
|
|
657
|
-
// Malloy resolves it to exactly one concrete member branch per query
|
|
658
|
-
// (surfaced as Query.compositeResolvedSourceDef), based on which
|
|
659
|
-
// fields the query references — so gate everything reachable from
|
|
660
|
-
// that RESOLVED branch, not every member. Gating every member would
|
|
661
|
-
// deny access through an open branch just because a sibling branch
|
|
662
|
-
// happens to be locked. This branch stands in for the run target
|
|
663
|
-
// ITSELF (just the concrete resolved shape), so its own gate stays
|
|
664
|
-
// ambient-first too (`treatAsOwnGate: true`) — same reasoning as the
|
|
665
|
-
// `struct` walk above.
|
|
666
|
-
//
|
|
667
|
-
// `compositeResolvedSourceDef && modelDef` is not a fail-open gap for
|
|
668
|
-
// a runnable composite RUN TARGET: Malloy's composite resolver
|
|
669
|
-
// (`_resolveCompositeSources` in `@malloydata/malloy`'s
|
|
670
|
-
// `composite-source-utils.ts`) marks composite resolution as
|
|
671
|
-
// required as soon as the top-level source is itself a composite,
|
|
672
|
-
// before any field-usage logic runs — so it always resolves to a
|
|
673
|
-
// concrete member (even one no query field discriminates, it just
|
|
674
|
-
// picks the first candidate — see the "no field forcing a choice"
|
|
675
|
-
// test in authorize_integration.spec.ts) or the compile fails
|
|
676
|
-
// outright (no member satisfies the query). The `undefined` case
|
|
677
|
-
// here is only ever "the run target genuinely isn't a composite" —
|
|
678
|
-
// not "a composite run target resolved to nothing". (A composite
|
|
679
|
-
// reached via a JOIN is a different story — Malloy doesn't surface
|
|
680
|
-
// which member it resolved to there, which is why the `joinedSource
|
|
681
|
-
// .type === "composite"` branch above walks every member
|
|
682
|
-
// conservatively instead.)
|
|
683
|
-
joinedGates.push(
|
|
684
|
-
...this.collectAllReachableGates(
|
|
685
|
-
compositeResolvedSourceDef,
|
|
686
|
-
modelDef,
|
|
687
|
-
seen,
|
|
688
|
-
true,
|
|
689
|
-
),
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
// Evaluate every reachable source's gate independently — do NOT dedup by
|
|
693
|
-
// expression text. AND-across-sources requires a probe per source: two
|
|
694
|
-
// distinct sources with identical gate text must each be evaluated, or a
|
|
695
|
-
// non-deterministic gate (e.g. one referencing random()) would be
|
|
696
|
-
// under-enforced (evaluated once, reused) — a fail-open relative to
|
|
697
|
-
// per-source enforcement. Probes are ~microsecond one-row DuckDB queries
|
|
698
|
-
// and reachable gated sources are few, so there is nothing worth deduping.
|
|
699
|
-
// (Cycles/repeat structs are already pruned in collectAllReachableGates
|
|
700
|
-
// by struct identity, so joinedGates holds no literal duplicates.)
|
|
701
|
-
for (const { label, exprs, selfContained } of joinedGates) {
|
|
702
|
-
await this.assertAuthorizedExprs(label, exprs, givens, selfContained);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
/**
|
|
707
|
-
* Resolve the run-target `SourceDef` and its `ModelDef`, for walking joined
|
|
708
|
-
* sources. `prepared._modelDef` is the modelDef the query actually compiled
|
|
709
|
-
* against (falls back to `this.modelDef`); a string `structRef` resolves
|
|
710
|
-
* through `modelDef.contents`. Also surfaces `compositeResolvedSourceDef` —
|
|
711
|
-
* when the run target is itself a composite source (`compose(a, b)`), this
|
|
712
|
-
* is the ONE concrete member branch Malloy resolved the query against (see
|
|
713
|
-
* {@link assertAuthorizedForAllSources}). Also surfaces `extendSources` —
|
|
714
|
-
* `FieldDef[]` pulled from every pipeline segment's `extendSource`, i.e.
|
|
715
|
-
* every source joined LOCALLY inside the query's own `-> { join_one: ...
|
|
716
|
-
* }` refinement. Such a join lives on the query pipeline's segment, not on
|
|
717
|
-
* the run target's own `struct.fields`, so {@link collectAllReachableGates}
|
|
718
|
-
* walking `struct.fields` alone would never see it. Returns `undefined`s /
|
|
719
|
-
* an empty array if any of these can't be resolved — callers treat that as
|
|
720
|
-
* "no joins to check" rather than denying, since
|
|
721
|
-
* {@link assertAuthorizedForAllSources}'s own-source gate above is still the
|
|
722
|
-
* authoritative deny for an unresolvable target.
|
|
723
|
-
*/
|
|
724
|
-
private async resolveRunTargetStruct(runnable: {
|
|
725
|
-
getPreparedQuery(): Promise<unknown>;
|
|
726
|
-
}): Promise<{
|
|
727
|
-
struct: SourceDef | undefined;
|
|
728
|
-
modelDef: ModelDef | undefined;
|
|
729
|
-
compositeResolvedSourceDef: SourceDef | undefined;
|
|
730
|
-
extendSources: FieldDef[];
|
|
731
|
-
}> {
|
|
732
|
-
try {
|
|
733
|
-
const prepared = (await runnable.getPreparedQuery()) as {
|
|
734
|
-
_query?: {
|
|
735
|
-
structRef?: unknown;
|
|
736
|
-
compositeResolvedSourceDef?: SourceDef;
|
|
737
|
-
pipeline?: { extendSource?: FieldDef[] }[];
|
|
738
|
-
};
|
|
739
|
-
_modelDef?: ModelDef;
|
|
740
|
-
};
|
|
741
|
-
const modelDef = prepared._modelDef ?? this.modelDef;
|
|
742
|
-
if (!modelDef)
|
|
743
|
-
return {
|
|
744
|
-
struct: undefined,
|
|
745
|
-
modelDef: undefined,
|
|
746
|
-
compositeResolvedSourceDef: undefined,
|
|
747
|
-
extendSources: [],
|
|
748
|
-
};
|
|
749
|
-
const structRef = prepared._query?.structRef;
|
|
750
|
-
const struct =
|
|
751
|
-
typeof structRef === "string"
|
|
752
|
-
? modelDef.contents[structRef]
|
|
753
|
-
: structRef;
|
|
754
|
-
const extendSources = (prepared._query?.pipeline ?? []).flatMap(
|
|
755
|
-
(segment) => segment.extendSource ?? [],
|
|
756
|
-
);
|
|
757
|
-
return {
|
|
758
|
-
struct:
|
|
759
|
-
struct && typeof struct === "object"
|
|
760
|
-
? (struct as SourceDef)
|
|
761
|
-
: undefined,
|
|
762
|
-
modelDef,
|
|
763
|
-
compositeResolvedSourceDef:
|
|
764
|
-
prepared._query?.compositeResolvedSourceDef,
|
|
765
|
-
extendSources,
|
|
766
|
-
};
|
|
767
|
-
} catch {
|
|
768
|
-
// Not fail-open: if getPreparedQuery() throws here, execution's own
|
|
769
|
-
// getPreparedResult()/run() (same compilation) throws too, so no data
|
|
770
|
-
// is returned. Safety depends on execution sharing this compilation.
|
|
771
|
-
return {
|
|
772
|
-
struct: undefined,
|
|
773
|
-
modelDef: undefined,
|
|
774
|
-
compositeResolvedSourceDef: undefined,
|
|
775
|
-
extendSources: [],
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/**
|
|
781
|
-
* Effective authorize exprs read directly off a struct's OWN block
|
|
782
|
-
* annotations (file-level `##(authorize)` ++ its own `#(authorize)`). Used
|
|
783
|
-
* by the joined-source and composite-member walks below, which read a
|
|
784
|
-
* struct's gate straight off its own annotations rather than through a
|
|
785
|
-
* `this.sources` name lookup (a name lookup only covers top-level
|
|
786
|
-
* `modelDef.contents` sources and would miss these).
|
|
787
|
-
*
|
|
788
|
-
* Fails CLOSED: `extractSourcesFromModelDef` only validates authorize
|
|
789
|
-
* annotations for top-level `modelDef.contents` sources at model load, so
|
|
790
|
-
* a malformed gate on a source reachable ONLY through a join or a
|
|
791
|
-
* composite member is never probed there — a parse failure here can't be
|
|
792
|
-
* assumed unreachable/already-validated. Force denial (a single
|
|
793
|
-
* unsatisfiable `"false"` expr) rather than treating the parse failure as
|
|
794
|
-
* "no gate" (fail-open).
|
|
795
|
-
*/
|
|
796
|
-
private gateExprsForOwnAnnotations(struct: SourceDef): string[] {
|
|
797
|
-
const ownNotes = (struct.annotations?.blockNotes ?? []).map(
|
|
798
|
-
(note) => note.text,
|
|
799
|
-
);
|
|
800
|
-
try {
|
|
801
|
-
return [
|
|
802
|
-
...this.fileLevelAuthorize,
|
|
803
|
-
...collectAuthorizeExprs(ownNotes),
|
|
804
|
-
];
|
|
805
|
-
} catch {
|
|
806
|
-
return ["false"];
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Classify one struct field found while walking a join site — shared by
|
|
812
|
-
* every call site in this file that iterates a `FieldDef[]` looking for
|
|
813
|
-
* joins (`collectAllReachableGates`'s own `struct.fields` walk, the
|
|
814
|
-
* `extendSources` walk, and the query-source inner-join walk), so the
|
|
815
|
-
* record/array-skip + drift-deny logic below lives in exactly one place.
|
|
816
|
-
*
|
|
817
|
-
* Returns:
|
|
818
|
-
* - `{ resolved }` — `field` is a genuine joined `SourceDef`; caller
|
|
819
|
-
* recurses into it via {@link collectAllReachableGates};
|
|
820
|
-
* - `{}` (both empty) — `field` isn't a join at all, or is a
|
|
821
|
-
* record/array-typed nested column ({@link isRecordOrArrayField}) that
|
|
822
|
-
* merely LOOKS like a join to `isJoined()`; skip it, there's no gate to
|
|
823
|
-
* find;
|
|
824
|
-
* - `{ denyGate }` — `field` is joined but resolved to neither of the
|
|
825
|
-
* above: real join/source-shape drift. Log loudly and return a
|
|
826
|
-
* synthetic always-false gate entry rather than silently `continue`-ing
|
|
827
|
-
* past it (fail OPEN), which is exactly the join-bypass this walk
|
|
828
|
-
* exists to close.
|
|
829
|
-
*/
|
|
830
|
-
private classifyJoinedField(
|
|
831
|
-
field: FieldDef,
|
|
832
|
-
parentLabel: string,
|
|
833
|
-
): { resolved?: SourceDef; denyGate?: GateEntry } {
|
|
834
|
-
if (!isJoined(field)) return {};
|
|
835
|
-
if (isRecordOrArrayField(field)) return {};
|
|
836
|
-
if (!isSourceDef(field)) {
|
|
837
|
-
logger.error(
|
|
838
|
-
"authorize: joined field failed to resolve to a walkable SourceDef; denying rather than silently skipping its gate (possible Malloy struct-shape drift)",
|
|
839
|
-
{
|
|
840
|
-
modelPath: this.modelPath,
|
|
841
|
-
parentSource: parentLabel,
|
|
842
|
-
fieldName: (field as { name?: string }).name,
|
|
843
|
-
fieldType: (field as { type?: string }).type,
|
|
844
|
-
},
|
|
845
|
-
);
|
|
846
|
-
return {
|
|
847
|
-
denyGate: {
|
|
848
|
-
label: `${parentLabel} (unresolvable joined source)`,
|
|
849
|
-
exprs: ["false"],
|
|
850
|
-
selfContained: false,
|
|
851
|
-
},
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
return { resolved: field as SourceDef };
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Canonical entry point for "every gate reachable from `struct`" — the
|
|
859
|
-
* single walk every call site in this file uses, replacing what used to
|
|
860
|
-
* be a hand-composed sequence of narrower collectors per call site (the
|
|
861
|
-
* inconsistency between those sequences is what let two derivations
|
|
862
|
-
* launder a locked base's gate away — see the composite-branch and
|
|
863
|
-
* query-local-join call sites in {@link assertAuthorizedForAllSources}).
|
|
864
|
-
* Collects, for `struct` and recursively for everything below:
|
|
865
|
-
* - its own annotations ({@link gateExprsForOwnAnnotations});
|
|
866
|
-
* - every joined source (`struct.fields`), including every member of a
|
|
867
|
-
* joined composite (`compose(a, b)` reached via `join_*`) — Malloy
|
|
868
|
-
* doesn't surface which branch a JOINED composite resolved to (unlike
|
|
869
|
-
* a run target's own composite resolution, which the caller handles
|
|
870
|
-
* by passing the resolved branch in as `struct` directly), so a joined
|
|
871
|
-
* composite is walked conservatively: every member gated, any one
|
|
872
|
-
* failing denies the whole query;
|
|
873
|
-
* - if `struct` is itself query-derived (`source: x is y -> {...}`), the
|
|
874
|
-
* base it derives from (`query.structRef`, resolved the same way
|
|
875
|
-
* {@link resolveRunTargetStruct} resolves a run target's structRef) —
|
|
876
|
-
* a `QuerySourceDef`'s own `.fields`/`.annotations` reflect the
|
|
877
|
-
* DERIVED shape, not `y`'s gate, so without this the derivation
|
|
878
|
-
* launders the base's gate away;
|
|
879
|
-
* - if `struct` is itself query-derived, its own inner-pipeline
|
|
880
|
-
* `join_one`s (`-> { extend: { join_one: locked ... } ... }`), which
|
|
881
|
-
* live on the derivation's pipeline segment, not on `struct.fields`
|
|
882
|
-
* and not reachable via `query.structRef` — mirrors the run target's
|
|
883
|
-
* own `extendSources` handling in {@link assertAuthorizedForAllSources},
|
|
884
|
-
* applied to a query-source's `query.pipeline` instead of the run
|
|
885
|
-
* query's;
|
|
886
|
-
* - if `struct` is itself query-derived AND its base is a composite
|
|
887
|
-
* (`source: x is compose(a, b) -> {...}`), the ONE resolved member
|
|
888
|
-
* branch Malloy picked for THIS derivation (`query.compositeResolvedSourceDef`)
|
|
889
|
-
* — mirrors the run target's own composite-resolution handling in
|
|
890
|
-
* {@link assertAuthorizedForAllSources}, applied to a query-source's
|
|
891
|
-
* own resolution instead of the run query's.
|
|
892
|
-
* Every case above recurses through this SAME function, so a derivation,
|
|
893
|
-
* a join, and a composite member compose uniformly no matter how deep
|
|
894
|
-
* (a query-source over a joined query-source, a chained derivation, a
|
|
895
|
-
* composite member that is itself query-derived, etc).
|
|
896
|
-
*
|
|
897
|
-
* `seen` (struct-identity keyed) is shared across the whole walk by the
|
|
898
|
-
* caller, guarding cycles and repeat structs — a struct already visited
|
|
899
|
-
* anywhere in the walk is not walked again.
|
|
900
|
-
*
|
|
901
|
-
* `QuerySourceDef` isn't re-exported from the package root (same
|
|
902
|
-
* situation as `given.ts`'s `MalloyGiven` duck type), so query-source
|
|
903
|
-
* detection checks `.type` and reaches `.query.structRef`/`.query.pipeline`
|
|
904
|
-
* through a local shape rather than importing the real type.
|
|
905
|
-
*
|
|
906
|
-
* Each returned entry carries `selfContained`, telling the caller which
|
|
907
|
-
* order {@link evaluateAuthorize} should try for that gate (see
|
|
908
|
-
* `assertAuthorizedExprs`'s `selfContainedFirst`). `treatAsOwnGate` — true
|
|
909
|
-
* only for the run target's own struct and its own resolved composite
|
|
910
|
-
* branch (the two call sites in {@link assertAuthorizedForAllSources} that
|
|
911
|
-
* represent the run target ITSELF, not something reached by walking a
|
|
912
|
-
* join/derivation) — marks `struct`'s OWN top-level annotations entry
|
|
913
|
-
* `selfContained: false` (ambient-first, matching {@link assertAuthorized}'s
|
|
914
|
-
* treatment of the same gate). Every other entry — everything reached by
|
|
915
|
-
* recursing into a joined field, a composite member, a query-source's
|
|
916
|
-
* base, or an inner pipeline join, no matter how deep — is tagged
|
|
917
|
-
* `selfContained: true`: those are gates that live in a DIFFERENT source
|
|
918
|
-
* (possibly a different file/given-namespace) than the run target, so
|
|
919
|
-
* evaluating them ambiently against the entry model's own namespace risks
|
|
920
|
-
* a name collision silently granting access off the wrong given (see
|
|
921
|
-
* `evaluateAuthorize`'s `selfContainedFirst` doc).
|
|
922
|
-
*/
|
|
923
|
-
private collectAllReachableGates(
|
|
924
|
-
struct: SourceDef | undefined,
|
|
925
|
-
modelDef: ModelDef | undefined,
|
|
926
|
-
seen: Set<SourceDef> = new Set(),
|
|
927
|
-
treatAsOwnGate = false,
|
|
928
|
-
): GateEntry[] {
|
|
929
|
-
if (!struct || !modelDef || seen.has(struct)) return [];
|
|
930
|
-
seen.add(struct);
|
|
931
|
-
|
|
932
|
-
const results: GateEntry[] = [];
|
|
933
|
-
const label = (struct as { as?: string }).as ?? struct.name;
|
|
934
|
-
const ownExprs = this.gateExprsForOwnAnnotations(struct);
|
|
935
|
-
if (ownExprs.length > 0) {
|
|
936
|
-
results.push({
|
|
937
|
-
label,
|
|
938
|
-
exprs: ownExprs,
|
|
939
|
-
selfContained: !treatAsOwnGate,
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
for (const field of struct.fields as FieldDef[]) {
|
|
944
|
-
const { resolved, denyGate } = this.classifyJoinedField(field, label);
|
|
945
|
-
if (denyGate) {
|
|
946
|
-
results.push(denyGate);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
if (!resolved) continue;
|
|
950
|
-
const joinedSource = resolved;
|
|
951
|
-
results.push(
|
|
952
|
-
...this.collectAllReachableGates(joinedSource, modelDef, seen),
|
|
953
|
-
);
|
|
954
|
-
// A JOINED source that is itself a composite (`compose(a, b)`
|
|
955
|
-
// reached via `join_*`) is walked conservatively: unlike a run
|
|
956
|
-
// target's own composite resolution (handled by the caller passing
|
|
957
|
-
// the resolved branch in as `struct` directly, never as a joined
|
|
958
|
-
// field), Malloy doesn't surface which member a JOINED composite
|
|
959
|
-
// resolved to, so every member is gated — any member's gate
|
|
960
|
-
// failing denies the whole query.
|
|
961
|
-
if (joinedSource.type === "composite") {
|
|
962
|
-
const members = (
|
|
963
|
-
joinedSource as SourceDef & { sources: SourceDef[] }
|
|
964
|
-
).sources;
|
|
965
|
-
for (const member of members) {
|
|
966
|
-
results.push(
|
|
967
|
-
...this.collectAllReachableGates(member, modelDef, seen),
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const duck = struct as unknown as {
|
|
974
|
-
type: string;
|
|
975
|
-
query?: {
|
|
976
|
-
structRef?: SourceDef | string;
|
|
977
|
-
compositeResolvedSourceDef?: SourceDef;
|
|
978
|
-
pipeline?: { extendSource?: FieldDef[] }[];
|
|
979
|
-
};
|
|
980
|
-
};
|
|
981
|
-
if (duck.type === "query_source") {
|
|
982
|
-
const ref = duck.query?.structRef;
|
|
983
|
-
const base = typeof ref === "string" ? modelDef.contents[ref] : ref;
|
|
984
|
-
if (base && isSourceDef(base)) {
|
|
985
|
-
results.push(
|
|
986
|
-
...this.collectAllReachableGates(
|
|
987
|
-
base as SourceDef,
|
|
988
|
-
modelDef,
|
|
989
|
-
seen,
|
|
990
|
-
),
|
|
991
|
-
);
|
|
992
|
-
}
|
|
993
|
-
// A query-source's own base may itself be a composite
|
|
994
|
-
// (`source: qs is compose(a, b) -> {...}`) — Malloy resolves that
|
|
995
|
-
// composite to exactly one concrete member branch for THIS
|
|
996
|
-
// query-source's derivation (surfaced the same way as a run
|
|
997
|
-
// target's own composite resolution, see
|
|
998
|
-
// assertAuthorizedForAllSources), carried on the query-source's
|
|
999
|
-
// OWN `query.compositeResolvedSourceDef`, not on `query.structRef`
|
|
1000
|
-
// (the raw composite) or `query.pipeline`. Without walking it, a
|
|
1001
|
-
// query-source derived from a locked composite member laundered
|
|
1002
|
-
// that member's gate away. Recursing through this same function
|
|
1003
|
-
// means a query-source nested at any depth (query-source over
|
|
1004
|
-
// composite over query-source, etc) is covered uniformly.
|
|
1005
|
-
const resolved = duck.query?.compositeResolvedSourceDef;
|
|
1006
|
-
if (resolved) {
|
|
1007
|
-
results.push(
|
|
1008
|
-
...this.collectAllReachableGates(resolved, modelDef, seen),
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
const innerJoins = (duck.query?.pipeline ?? []).flatMap(
|
|
1012
|
-
(segment) => segment.extendSource ?? [],
|
|
1013
|
-
);
|
|
1014
|
-
for (const field of innerJoins) {
|
|
1015
|
-
const { resolved: innerJoinSource, denyGate: innerJoinDenyGate } =
|
|
1016
|
-
this.classifyJoinedField(field, label);
|
|
1017
|
-
if (innerJoinDenyGate) {
|
|
1018
|
-
results.push(innerJoinDenyGate);
|
|
1019
|
-
continue;
|
|
1020
|
-
}
|
|
1021
|
-
if (!innerJoinSource) continue;
|
|
1022
|
-
results.push(
|
|
1023
|
-
...this.collectAllReachableGates(
|
|
1024
|
-
innerJoinSource,
|
|
1025
|
-
modelDef,
|
|
1026
|
-
seen,
|
|
1027
|
-
),
|
|
1028
|
-
);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
return results;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* Gate ad-hoc compile/query text by the named source it targets. Resolves the
|
|
1037
|
-
* source from surface syntax (`extractRunTargetSourceName`) and applies the
|
|
1038
|
-
* gate. An
|
|
1039
|
-
* unnamed/inline source resolves to `undefined`, so only the model-wide
|
|
1040
|
-
* file-level gate applies — the same top-level-only boundary as the query
|
|
1041
|
-
* path's early gate. Used by the `/compile` path, which has no runnable to
|
|
1042
|
-
* resolve before it decides whether to compile at all.
|
|
1043
|
-
*/
|
|
1044
|
-
public async assertAuthorizedForText(
|
|
1045
|
-
text: string,
|
|
1046
|
-
givens: Record<string, GivenValue>,
|
|
1047
|
-
): Promise<void> {
|
|
1048
|
-
await this.assertAuthorized(extractRunTargetSourceName(text), givens);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
/**
|
|
1052
|
-
* Gate a compiled query by the source it actually reads, resolved from the
|
|
1053
|
-
* prepared query's `structRef` (authoritative — survives named-query and
|
|
1054
|
-
* multi-statement indirection that surface syntax misses, e.g. the executed
|
|
1055
|
-
* `run:` statement isn't the first one), PLUS every source reached
|
|
1056
|
-
* transitively via join_* (see assertAuthorizedForAllSources). Used as the
|
|
1057
|
-
* `/compile` backstop once a runnable exists, so `/compile` inherits the
|
|
1058
|
-
* same join-bypass protection as the query path.
|
|
1059
|
-
*/
|
|
1060
|
-
public async assertAuthorizedForRunnable(
|
|
1061
|
-
runnable: { getPreparedQuery(): Promise<unknown> },
|
|
1062
|
-
givens: Record<string, GivenValue>,
|
|
1063
|
-
): Promise<void> {
|
|
1064
|
-
await this.assertAuthorizedForAllSources(runnable, givens);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
/**
|
|
1068
|
-
* Resolve the source a compiled query reads, from its prepared query's
|
|
1069
|
-
* `structRef`. This is authoritative — it survives named-query indirection
|
|
1070
|
-
* and bare `run: <query>` forms that surface-syntax extraction misses — so
|
|
1071
|
-
* the authorize gate can't be dodged by how a request names the query.
|
|
1072
|
-
* Returns undefined if the source can't be determined.
|
|
1073
|
-
*/
|
|
1074
|
-
private async resolveAuthorizeSourceFromRunnable(runnable: {
|
|
1075
|
-
getPreparedQuery(): Promise<unknown>;
|
|
1076
|
-
}): Promise<string | undefined> {
|
|
1077
|
-
try {
|
|
1078
|
-
const prepared = (await runnable.getPreparedQuery()) as {
|
|
1079
|
-
_query?: { structRef?: unknown };
|
|
1080
|
-
};
|
|
1081
|
-
const structRef = prepared._query?.structRef;
|
|
1082
|
-
if (typeof structRef === "string") return structRef;
|
|
1083
|
-
if (structRef && typeof structRef === "object") {
|
|
1084
|
-
const s = structRef as { as?: string; name?: string };
|
|
1085
|
-
return s.as || s.name;
|
|
1086
|
-
}
|
|
1087
|
-
} catch {
|
|
1088
|
-
// Can't resolve — caller simply has no name to gate on here.
|
|
1089
|
-
}
|
|
1090
|
-
return undefined;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Best-effort extraction of a source name from an ad-hoc Malloy query string.
|
|
1095
|
-
* Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
|
|
1096
|
-
*/
|
|
1097
|
-
/**
|
|
1098
|
-
* Resolve the run target of an ad-hoc query to the model-defined source
|
|
1099
|
-
* whose filters apply, following source-derivation declarations so that a
|
|
1100
|
-
* filter-protected source carries its filter requirements when read under a
|
|
1101
|
-
* derived name. The declared filter belongs to the source, not to the name
|
|
1102
|
-
* it is read under. Returns undefined when the run target does not derive
|
|
1103
|
-
* from a protected source.
|
|
1104
|
-
*/
|
|
1105
|
-
private resolveFilterSource(query?: string): string | undefined {
|
|
1106
|
-
const target = extractRunTargetSourceName(query);
|
|
1107
|
-
if (!target || !query) return undefined;
|
|
1108
|
-
|
|
1109
|
-
const aliasOf = buildSourceAliasMap(query);
|
|
1110
|
-
|
|
1111
|
-
// Walk the derivation chain until we hit a protected source or run out.
|
|
1112
|
-
let current: string | undefined = target;
|
|
1113
|
-
const seen = new Set<string>();
|
|
1114
|
-
while (current && !seen.has(current)) {
|
|
1115
|
-
if (this.filterMap.has(current)) return current;
|
|
1116
|
-
seen.add(current);
|
|
1117
|
-
current = aliasOf.get(current);
|
|
1118
|
-
}
|
|
1119
|
-
return undefined;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Compile a single model in-process. Kept as a library entry point
|
|
1124
|
-
* for test fixtures and any future caller that needs an ad-hoc
|
|
1125
|
-
* `Model` from a `.malloy` / `.malloynb` file. Production package
|
|
1126
|
-
* loads (`Package.create`) and reloads (`Package.reloadAllModels`)
|
|
1127
|
-
* route through the package-load worker pool and dispatch through
|
|
1128
|
-
* {@link Model.fromSerialized} instead — neither calls this on the
|
|
1129
|
-
* main thread.
|
|
1130
|
-
*/
|
|
1131
|
-
public static async create(
|
|
1132
|
-
packageName: string,
|
|
1133
|
-
packagePath: string,
|
|
1134
|
-
modelPath: string,
|
|
1135
|
-
malloyConfig: ModelConnectionInput,
|
|
1136
|
-
options?: { buildManifest?: BuildManifest["entries"] },
|
|
1137
|
-
): Promise<Model> {
|
|
1138
|
-
// getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
|
|
1139
|
-
// to pass a valid model path or handle the error.
|
|
1140
|
-
const { runtime, modelURL, importBaseURL, dataStyles, modelType } =
|
|
1141
|
-
await Model.getModelRuntime(
|
|
1142
|
-
packagePath,
|
|
1143
|
-
modelPath,
|
|
1144
|
-
malloyConfig,
|
|
1145
|
-
options,
|
|
1146
|
-
);
|
|
1147
|
-
|
|
1148
|
-
try {
|
|
1149
|
-
const { modelMaterializer, runnableNotebookCells } =
|
|
1150
|
-
await Model.getModelMaterializer(
|
|
1151
|
-
runtime,
|
|
1152
|
-
importBaseURL,
|
|
1153
|
-
modelURL,
|
|
1154
|
-
modelPath,
|
|
1155
|
-
);
|
|
1156
|
-
|
|
1157
|
-
let modelDef = undefined;
|
|
1158
|
-
let sources = undefined;
|
|
1159
|
-
let queries = undefined;
|
|
1160
|
-
let filterMap: Map<string, FilterDefinition[]> | undefined;
|
|
1161
|
-
let givens: ApiGiven[] | undefined;
|
|
1162
|
-
const sourceInfos: Malloy.SourceInfo[] = [];
|
|
1163
|
-
if (modelMaterializer) {
|
|
1164
|
-
const compiledModel = await modelMaterializer.getModel();
|
|
1165
|
-
modelDef = compiledModel._modelDef;
|
|
1166
|
-
// Malloy's `Model.givens` already collapses inheritance from imports
|
|
1167
|
-
// and applies any `finalizeGivens` runtime config. Just read it.
|
|
1168
|
-
const malloyGivens = Array.from(
|
|
1169
|
-
compiledModel.givens.values(),
|
|
1170
|
-
) as MalloyGiven[];
|
|
1171
|
-
givens =
|
|
1172
|
-
malloyGivens.length > 0
|
|
1173
|
-
? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
|
|
1174
|
-
: undefined;
|
|
1175
|
-
const sourceResult = Model.getSources(modelDef, givens);
|
|
1176
|
-
sources = sourceResult.sources;
|
|
1177
|
-
filterMap = sourceResult.filterMap;
|
|
1178
|
-
queries = Model.getQueries(modelDef);
|
|
1179
|
-
|
|
1180
|
-
// Translation-time validation of #(authorize) annotations (shared
|
|
1181
|
-
// with the package-load worker so both compile paths validate
|
|
1182
|
-
// identically). Compiling the probe surfaces unknown givens and
|
|
1183
|
-
// source-field references at model-load instead of first request.
|
|
1184
|
-
await validateAuthorizeProbes(modelMaterializer, sources ?? []);
|
|
1185
|
-
|
|
1186
|
-
// Collect sourceInfos from imported models first
|
|
1187
|
-
// This follows the same pattern as notebook imports handling
|
|
1188
|
-
const imports = modelDef.imports || [];
|
|
1189
|
-
const importedSourceNames = new Set<string>();
|
|
1190
|
-
for (const importLocation of imports) {
|
|
1191
|
-
try {
|
|
1192
|
-
const modelString = await runtime.urlReader.readURL(
|
|
1193
|
-
new URL(importLocation.importURL),
|
|
1194
|
-
);
|
|
1195
|
-
const importedModelDef = (
|
|
1196
|
-
await runtime
|
|
1197
|
-
.loadModel(modelString as string, { importBaseURL })
|
|
1198
|
-
.getModel()
|
|
1199
|
-
)._modelDef;
|
|
1200
|
-
const importedModelInfo =
|
|
1201
|
-
modelDefToModelInfo(importedModelDef);
|
|
1202
|
-
const importedSources = importedModelInfo.entries.filter(
|
|
1203
|
-
(entry) => entry.kind === "source",
|
|
1204
|
-
) as Malloy.SourceInfo[];
|
|
1205
|
-
for (const source of importedSources) {
|
|
1206
|
-
if (!importedSourceNames.has(source.name)) {
|
|
1207
|
-
sourceInfos.push(source);
|
|
1208
|
-
importedSourceNames.add(source.name);
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
} catch (importError) {
|
|
1212
|
-
// Log but don't fail if we can't load an import's sourceInfo
|
|
1213
|
-
logger.warn("Failed to load sourceInfo from import", {
|
|
1214
|
-
importURL: importLocation.importURL,
|
|
1215
|
-
error: importError,
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// Add locally-defined sources (not already added from imports)
|
|
1221
|
-
const localModelInfo = modelDefToModelInfo(modelDef);
|
|
1222
|
-
const localSources = localModelInfo.entries.filter(
|
|
1223
|
-
(entry) => entry.kind === "source",
|
|
1224
|
-
) as Malloy.SourceInfo[];
|
|
1225
|
-
for (const source of localSources) {
|
|
1226
|
-
if (!importedSourceNames.has(source.name)) {
|
|
1227
|
-
sourceInfos.push(source);
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
return new Model(
|
|
1233
|
-
packageName,
|
|
1234
|
-
modelPath,
|
|
1235
|
-
dataStyles,
|
|
1236
|
-
modelType,
|
|
1237
|
-
modelMaterializer,
|
|
1238
|
-
modelDef,
|
|
1239
|
-
sources,
|
|
1240
|
-
queries,
|
|
1241
|
-
sourceInfos.length > 0 ? sourceInfos : undefined,
|
|
1242
|
-
runnableNotebookCells,
|
|
1243
|
-
undefined,
|
|
1244
|
-
filterMap,
|
|
1245
|
-
givens,
|
|
1246
|
-
);
|
|
1247
|
-
} catch (error) {
|
|
1248
|
-
let computedError = error;
|
|
1249
|
-
if (error instanceof Error && error.stack) {
|
|
1250
|
-
logger.error("Error stack", error.stack);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
if (error instanceof MalloyError) {
|
|
1254
|
-
const problems = error.problems;
|
|
1255
|
-
for (const problem of problems) {
|
|
1256
|
-
logger.error("Problem", problem);
|
|
1257
|
-
}
|
|
1258
|
-
computedError = new ModelCompilationError(error);
|
|
1259
|
-
}
|
|
1260
|
-
return new Model(
|
|
1261
|
-
packageName,
|
|
1262
|
-
modelPath,
|
|
1263
|
-
dataStyles,
|
|
1264
|
-
modelType,
|
|
1265
|
-
undefined,
|
|
1266
|
-
undefined,
|
|
1267
|
-
undefined,
|
|
1268
|
-
undefined,
|
|
1269
|
-
undefined,
|
|
1270
|
-
undefined,
|
|
1271
|
-
computedError as Error,
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
/**
|
|
1277
|
-
* Construct a `Model` from a worker-compiled `SerializedModel`. All
|
|
1278
|
-
* the heavy compile work (parse, type check, IR build, sourceInfo
|
|
1279
|
-
* extraction, per-cell notebook compile) already ran inside a
|
|
1280
|
-
* `worker_threads` worker; this factory just rewraps the wire data
|
|
1281
|
-
* into a live `Model`.
|
|
1282
|
-
*
|
|
1283
|
-
* Hydrates the `ModelMaterializer` (and, for notebooks, the
|
|
1284
|
-
* per-cell materializers + runnables) **eagerly** via
|
|
1285
|
-
* `Runtime._loadModelFromModelDef` /
|
|
1286
|
-
* `ModelMaterializer._loadQueryFromQueryDef`. These are constant-time
|
|
1287
|
-
* wraps around the worker's pre-compiled `modelDef` / `queryDef` —
|
|
1288
|
-
* no parse, no type-check, no schema fetch — so doing it here at
|
|
1289
|
-
* package-load time costs microseconds per model and keeps the
|
|
1290
|
-
* resulting `Model` interchangeable with one produced by
|
|
1291
|
-
* `Model.create` (no lazy-init branches in the hot path).
|
|
1292
|
-
*/
|
|
1293
|
-
public static fromSerialized(
|
|
1294
|
-
packageName: string,
|
|
1295
|
-
_packagePath: string,
|
|
1296
|
-
malloyConfig: ModelConnectionInput,
|
|
1297
|
-
data: SerializedModel,
|
|
1298
|
-
options?: { buildManifest?: BuildManifest["entries"] },
|
|
1299
|
-
): Model {
|
|
1300
|
-
const modelDef = data.modelDef as ModelDef | undefined;
|
|
1301
|
-
const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
|
|
1302
|
-
const dataStyles = (data.dataStyles ?? {}) as DataStyles;
|
|
1303
|
-
const sources = data.sources as ApiSource[] | undefined;
|
|
1304
|
-
const queries = data.queries as ApiQuery[] | undefined;
|
|
1305
|
-
const sourceInfos = data.sourceInfos as Malloy.SourceInfo[] | undefined;
|
|
1306
|
-
const givens = data.givens as ApiGiven[] | undefined;
|
|
1307
|
-
const filterMap = data.filterMap
|
|
1308
|
-
? new Map(data.filterMap as Array<[string, FilterDefinition[]]>)
|
|
1309
|
-
: undefined;
|
|
1310
|
-
|
|
1311
|
-
// No modelDef → either an empty notebook (no MALLOY statements)
|
|
1312
|
-
// or a corrupt worker payload. Build a Model with no materializer;
|
|
1313
|
-
// downstream getQueryResults / executeNotebookCell will throw a
|
|
1314
|
-
// clean BadRequestError if a caller tries to run a query. We
|
|
1315
|
-
// still preserve markdown cells for an all-markdown notebook so
|
|
1316
|
-
// `getNotebook()` can serve raw text.
|
|
1317
|
-
if (!modelDef) {
|
|
1318
|
-
return new Model(
|
|
1319
|
-
packageName,
|
|
1320
|
-
data.modelPath,
|
|
1321
|
-
dataStyles,
|
|
1322
|
-
data.modelType,
|
|
1323
|
-
undefined,
|
|
1324
|
-
undefined,
|
|
1325
|
-
sources,
|
|
1326
|
-
queries,
|
|
1327
|
-
sourceInfos,
|
|
1328
|
-
data.modelType === "notebook"
|
|
1329
|
-
? hydrateMarkdownOnlyCells(data.notebookCells)
|
|
1330
|
-
: undefined,
|
|
1331
|
-
undefined,
|
|
1332
|
-
filterMap,
|
|
1333
|
-
givens,
|
|
1334
|
-
modelInfo,
|
|
1335
|
-
);
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
const runtime = makeHydrationRuntime(
|
|
1339
|
-
malloyConfig,
|
|
1340
|
-
options?.buildManifest,
|
|
1341
|
-
);
|
|
1342
|
-
const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
|
|
1343
|
-
const runnableNotebookCells =
|
|
1344
|
-
data.modelType === "notebook"
|
|
1345
|
-
? hydrateNotebookCells(runtime, data.notebookCells)
|
|
1346
|
-
: undefined;
|
|
1347
|
-
|
|
1348
|
-
const model = new Model(
|
|
1349
|
-
packageName,
|
|
1350
|
-
data.modelPath,
|
|
1351
|
-
dataStyles,
|
|
1352
|
-
data.modelType,
|
|
1353
|
-
modelMaterializer,
|
|
1354
|
-
modelDef,
|
|
1355
|
-
sources,
|
|
1356
|
-
queries,
|
|
1357
|
-
sourceInfos,
|
|
1358
|
-
runnableNotebookCells,
|
|
1359
|
-
undefined, // compilationError
|
|
1360
|
-
filterMap,
|
|
1361
|
-
givens,
|
|
1362
|
-
modelInfo,
|
|
1363
|
-
);
|
|
1364
|
-
// Capture the config so a storage= serve query can compile a transient
|
|
1365
|
-
// serve-shape model against the same connections.
|
|
1366
|
-
model.setServeMalloyConfig(malloyConfig);
|
|
1367
|
-
return model;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Build a Model representing a compilation failure (no modelDef,
|
|
1372
|
-
* no materializer). Matches the shape `Model.create` returns when
|
|
1373
|
-
* it catches a `MalloyError`, so the rest of the system handles
|
|
1374
|
-
* both paths uniformly (the iteration loop in `Package.create`
|
|
1375
|
-
* reads `compilationError` via a structural cast).
|
|
1376
|
-
*/
|
|
1377
|
-
public static fromCompilationError(
|
|
1378
|
-
packageName: string,
|
|
1379
|
-
modelPath: string,
|
|
1380
|
-
modelType: ModelType,
|
|
1381
|
-
error: Error,
|
|
1382
|
-
): Model {
|
|
1383
|
-
return new Model(
|
|
1384
|
-
packageName,
|
|
1385
|
-
modelPath,
|
|
1386
|
-
{} as DataStyles,
|
|
1387
|
-
modelType,
|
|
1388
|
-
undefined,
|
|
1389
|
-
undefined,
|
|
1390
|
-
undefined,
|
|
1391
|
-
undefined,
|
|
1392
|
-
undefined,
|
|
1393
|
-
undefined,
|
|
1394
|
-
error,
|
|
1395
|
-
);
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
/** Look up the deserialized error helper for callers (e.g. Package.create). */
|
|
1399
|
-
public static deserializeCompilationError = deserializeError;
|
|
1400
|
-
|
|
1401
|
-
public getPath(): string {
|
|
1402
|
-
return this.modelPath;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
public getType(): ModelType {
|
|
1406
|
-
return this.modelType;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1410
|
-
* Restrict a list of named objects to the model's re-export closure
|
|
1411
|
-
* (`modelDef.exports`) for discovery. This mirrors what Malloy's stable
|
|
1412
|
-
* `modelDefToModelInfo` already does for `modelInfo`/`sourceInfos` (and what
|
|
1413
|
-
* the app renders), so the publisher-extracted `sources`/`queries` stay
|
|
1414
|
-
* consistent with `modelInfo` within a single response. A model with no
|
|
1415
|
-
* `export { … }` has `exports` = all top-level names, so this is a no-op
|
|
1416
|
-
* there. Only active when the package declares `explores` (see
|
|
1417
|
-
* `discoveryCurationEnabled`). `this.sources`/`this.queries` stay complete so
|
|
1418
|
-
* #(authorize)/filter enforcement and join/extend resolution are unaffected.
|
|
1419
|
-
* Whether a non-exported source is also non-*queryable* depends on the
|
|
1420
|
-
* package's `queryableSources` policy — see {@link assertQueryBoundaryEarly}.
|
|
1421
|
-
*/
|
|
1422
|
-
private curateForDiscovery<T extends { name?: string }>(
|
|
1423
|
-
items: T[] | undefined,
|
|
1424
|
-
): T[] | undefined {
|
|
1425
|
-
if (!items) return items;
|
|
1426
|
-
if (!this.discoveryCurationEnabled) return items;
|
|
1427
|
-
const exports = this.modelDef?.exports;
|
|
1428
|
-
if (!Array.isArray(exports)) return items;
|
|
1429
|
-
const exported = new Set(exports);
|
|
1430
|
-
return items.filter(
|
|
1431
|
-
(item) => item.name !== undefined && exported.has(item.name),
|
|
1432
|
-
);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/** Set by the owning Package; see {@link curateForDiscovery}. */
|
|
1436
|
-
public setDiscoveryCuration(enabled: boolean): void {
|
|
1437
|
-
this.discoveryCurationEnabled = enabled;
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
/**
|
|
1441
|
-
* Set by the owning Package (see Package.wireFreshnessResolvers). Supplies
|
|
1442
|
-
* the freshness-filtered build manifest the serve path threads into Malloy's
|
|
1443
|
-
* per-query `buildManifest` override so stale persist sources fall back per
|
|
1444
|
-
* their declared policy. See {@link resolveFreshBuildManifest}.
|
|
1445
|
-
*/
|
|
1446
|
-
public setFreshnessResolver(
|
|
1447
|
-
resolver: () => BuildManifest["entries"] | undefined,
|
|
1448
|
-
): void {
|
|
1449
|
-
this.freshnessResolver = resolver;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
/**
|
|
1453
|
-
* The freshness-filtered build manifest for this query, or undefined when the
|
|
1454
|
-
* package is unbound / has no resolver (⇒ no per-query override; the runtime
|
|
1455
|
-
* serves live). Evaluated per call so a table that crosses its window while
|
|
1456
|
-
* the package stays loaded is gated on the very next query.
|
|
1457
|
-
*/
|
|
1458
|
-
private resolveFreshBuildManifest(): BuildManifest | undefined {
|
|
1459
|
-
const entries = this.freshnessResolver?.();
|
|
1460
|
-
return entries ? { entries, strict: false } : undefined;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
public getSources(): ApiSource[] | undefined {
|
|
1464
|
-
return this.curateForDiscovery(this.sources);
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
public getSourceInfos(): Malloy.SourceInfo[] | undefined {
|
|
1468
|
-
return this.curateForDiscovery(this.sourceInfos);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
public getQueries(): ApiQuery[] | undefined {
|
|
1472
|
-
return this.curateForDiscovery(this.queries);
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
/**
|
|
1476
|
-
* True when this is an import-only model: it imports other files but
|
|
1477
|
-
* declares and re-exports nothing of its own, so `modelDef.exports` is
|
|
1478
|
-
* empty and its discovery surface lists no sources or queries. Legitimate
|
|
1479
|
-
* as plumbing, but confusing when the model is *listed* — the page renders
|
|
1480
|
-
* blank. Used by the load-time warning (Package.emptyDiscoveryWarnings);
|
|
1481
|
-
* the fix is to re-export what should be visible (`export { name }`).
|
|
1482
|
-
*/
|
|
1483
|
-
public hasEmptyDiscoverySurface(): boolean {
|
|
1484
|
-
// No curation (no `explores`) ⇒ legacy listings include imported sources.
|
|
1485
|
-
if (!this.discoveryCurationEnabled) return false;
|
|
1486
|
-
if (this.modelType !== "model" || !this.modelDef) return false;
|
|
1487
|
-
const exports = this.modelDef.exports;
|
|
1488
|
-
if (!Array.isArray(exports) || exports.length > 0) return false;
|
|
1489
|
-
return (this.modelDef.imports?.length ?? 0) > 0;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/** Set by the owning Package; see {@link assertQueryBoundaryEarly}. */
|
|
1493
|
-
public setQueryBoundary(policy: {
|
|
1494
|
-
mode: "declared" | "all";
|
|
1495
|
-
exploresDeclared: boolean;
|
|
1496
|
-
isQueryEntryPoint: boolean;
|
|
1497
|
-
}): void {
|
|
1498
|
-
this.queryBoundary = policy;
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
/**
|
|
1502
|
-
* Query boundary, step 1 of 2 — the PRE-compilation gate. Enforces the rule
|
|
1503
|
-
* "queryable == discoverable": a source is a valid top-level query target
|
|
1504
|
-
* only if it is in the package's discovery surface (`explores` files +
|
|
1505
|
-
* their `export {}` closure). This is the *what* axis (identity-free);
|
|
1506
|
-
* `#(authorize)` is the orthogonal *who* axis, and both must pass. Denials
|
|
1507
|
-
* are a generic 404 ({@link NotQueryableError}) so a hidden target is
|
|
1508
|
-
* indistinguishable from a non-existent one. Inert unless `explores` is
|
|
1509
|
-
* declared AND mode is "declared" (the default). Notebooks are exempt
|
|
1510
|
-
* (always public) and never call this.
|
|
1511
|
-
*
|
|
1512
|
-
* This step runs before compilation so a denied target can't be probed via
|
|
1513
|
-
* compile errors (schema oracle), and it only POSITIVELY denies — explicit
|
|
1514
|
-
* names it can check, and ad-hoc text whose surface-resolved target is a
|
|
1515
|
-
* model-declared non-curated source. Anything it can't pin returns
|
|
1516
|
-
* "deferred" for {@link assertQueryBoundaryCompiled} to settle against the
|
|
1517
|
-
* compiled run target. "cleared" admissions (explicit curated names) skip
|
|
1518
|
-
* the backstop: an exported named query may read hidden sources internally —
|
|
1519
|
-
* that is the author's deliberate exposure, and re-deriving its source from
|
|
1520
|
-
* the compiled query must not re-deny it.
|
|
1521
|
-
*/
|
|
1522
|
-
public assertQueryBoundaryEarly(
|
|
1523
|
-
sourceName?: string,
|
|
1524
|
-
queryName?: string,
|
|
1525
|
-
query?: string,
|
|
1526
|
-
): "cleared" | "deferred" {
|
|
1527
|
-
// Notebooks are always public — they can't be explores and are never
|
|
1528
|
-
// gated by the boundary, even when reached via the /compile path.
|
|
1529
|
-
if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) return "cleared";
|
|
1530
|
-
const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
|
|
1531
|
-
// No opt-in surface (no explores) or explicitly decoupled ("all") ⇒ the
|
|
1532
|
-
// boundary is discovery-only; everything compiled stays queryable.
|
|
1533
|
-
if (mode === "all" || !exploresDeclared) return "cleared";
|
|
1534
|
-
|
|
1535
|
-
// File-level: a non-`explores` model file is not a query entry point.
|
|
1536
|
-
// This is the robust line — the file is named by the request URL, so
|
|
1537
|
-
// there is nothing to resolve and nothing to evade.
|
|
1538
|
-
if (!isQueryEntryPoint) {
|
|
1539
|
-
throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
const curatedSources = this.curatedSourceNames();
|
|
1543
|
-
const curatedQueries = new Set(
|
|
1544
|
-
(this.getQueries() ?? []).map((q) => q.name).filter(Boolean),
|
|
1545
|
-
);
|
|
1546
|
-
|
|
1547
|
-
// A named query/view is an author-exported entry point (the author chose
|
|
1548
|
-
// to expose it, even if it reads hidden sources internally) — admit it on
|
|
1549
|
-
// its own name, or on the explicitly-named curated source it runs against.
|
|
1550
|
-
if (queryName && !query) {
|
|
1551
|
-
// The exported-query fast-path admits a *pure* named query (`run: q`)
|
|
1552
|
-
// on its own name. It must NOT fire when a source is also named: a
|
|
1553
|
-
// request is `run: <sourceName>-><queryName>`, so a hidden source
|
|
1554
|
-
// could otherwise be reached via a view whose name happens to collide
|
|
1555
|
-
// with an exported top-level query (and clearing skips the compiled
|
|
1556
|
-
// backstop). With a source prefix, only the named source's curation
|
|
1557
|
-
// gates the request.
|
|
1558
|
-
if (!sourceName && curatedQueries.has(queryName)) return "cleared";
|
|
1559
|
-
if (sourceName && curatedSources.has(sourceName)) return "cleared";
|
|
1560
|
-
throw new NotQueryableError(`No queryable query "${queryName}".`);
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// An explicitly-named source: admit iff curated.
|
|
1564
|
-
if (sourceName) {
|
|
1565
|
-
if (curatedSources.has(sourceName)) return "cleared";
|
|
1566
|
-
throw new NotQueryableError(`No queryable source "${sourceName}".`);
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
// Ad-hoc text: positively deny only a surface-resolved target that is a
|
|
1570
|
-
// model-declared source outside the curated surface (and doesn't derive
|
|
1571
|
-
// from a curated one) — pre-compile, so its compile errors can't leak
|
|
1572
|
-
// schema. Everything else (inline derivations, multi-statement, forms
|
|
1573
|
-
// the regex can't read) defers to the compiled backstop.
|
|
1574
|
-
if (query) {
|
|
1575
|
-
const target = extractRunTargetSourceName(query);
|
|
1576
|
-
if (
|
|
1577
|
-
target &&
|
|
1578
|
-
!curatedSources.has(target) &&
|
|
1579
|
-
!this.derivesFromCurated(target, query) &&
|
|
1580
|
-
this.sources?.some((s) => s.name === target)
|
|
1581
|
-
) {
|
|
1582
|
-
throw new NotQueryableError(`No queryable source "${target}".`);
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
return "deferred";
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
/**
|
|
1589
|
-
* Query boundary, step 2 of 2 — the POST-compilation backstop for
|
|
1590
|
-
* "deferred" admissions. `compiledSource` is the run target read off the
|
|
1591
|
-
* compiled query's `structRef` (see
|
|
1592
|
-
* {@link resolveAuthorizeSourceFromRunnable}) — the source Malloy actually
|
|
1593
|
-
* executes, surviving named-query indirection, multi-statement forms (the
|
|
1594
|
-
* LAST `run:` wins), and derivation. This inspects compiler *output* only;
|
|
1595
|
-
* compilation itself is never altered or restricted.
|
|
1596
|
-
*
|
|
1597
|
-
* Admit iff the compiled target is curated, or is an ad-hoc alias that
|
|
1598
|
-
* derives from a curated source (`source: x is customers extend { … }` →
|
|
1599
|
-
* `run: x` — composing over a queryable source is itself queryable). FAIL
|
|
1600
|
-
* CLOSED otherwise, including when the target can't be resolved at all.
|
|
1601
|
-
* (Raw inline `conn.sql(...)` never reaches here on the query path —
|
|
1602
|
-
* restricted-mode compilation rejects it first.)
|
|
1603
|
-
*/
|
|
1604
|
-
public assertQueryBoundaryCompiled(
|
|
1605
|
-
compiledSource: string | undefined,
|
|
1606
|
-
query?: string,
|
|
1607
|
-
): void {
|
|
1608
|
-
// Notebooks are always public (mirrors assertQueryBoundaryEarly).
|
|
1609
|
-
if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) return;
|
|
1610
|
-
const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
|
|
1611
|
-
if (mode === "all" || !exploresDeclared) return;
|
|
1612
|
-
if (!isQueryEntryPoint) {
|
|
1613
|
-
throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
|
|
1614
|
-
}
|
|
1615
|
-
if (compiledSource) {
|
|
1616
|
-
if (this.curatedSourceNames().has(compiledSource)) return;
|
|
1617
|
-
if (query && this.derivesFromCurated(compiledSource, query)) return;
|
|
1618
|
-
}
|
|
1619
|
-
throw new NotQueryableError("Query target is not queryable.");
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
/**
|
|
1623
|
-
* The /compile-path wrapper: resolve the compiled run target from the
|
|
1624
|
-
* materializer and apply the boundary backstop. No-ops when the boundary is
|
|
1625
|
-
* inert, so the resolution work is skipped for unaffected packages.
|
|
1626
|
-
*/
|
|
1627
|
-
public async assertQueryBoundaryForRunnable(
|
|
1628
|
-
runnable: { getPreparedQuery(): Promise<unknown> },
|
|
1629
|
-
query?: string,
|
|
1630
|
-
): Promise<void> {
|
|
1631
|
-
const { mode, exploresDeclared } = this.queryBoundary;
|
|
1632
|
-
if (mode === "all" || !exploresDeclared) return;
|
|
1633
|
-
this.assertQueryBoundaryCompiled(
|
|
1634
|
-
await this.resolveAuthorizeSourceFromRunnable(runnable),
|
|
1635
|
-
query,
|
|
1636
|
-
);
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
/** Source names in the export-curated discovery surface (= the directly
|
|
1640
|
-
* queryable set under the "declared" boundary). */
|
|
1641
|
-
private curatedSourceNames(): Set<string> {
|
|
1642
|
-
return new Set(
|
|
1643
|
-
(this.getSources() ?? [])
|
|
1644
|
-
.map((s) => s.name)
|
|
1645
|
-
.filter((n): n is string => n !== undefined),
|
|
1646
|
-
);
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
/** True if `name` reaches a curated source by walking the ad-hoc text's
|
|
1650
|
-
* `source: NAME is BASE` derivation declarations — composition over a
|
|
1651
|
-
* queryable source is itself queryable. */
|
|
1652
|
-
private derivesFromCurated(name: string, query: string): boolean {
|
|
1653
|
-
const curated = this.curatedSourceNames();
|
|
1654
|
-
const aliasOf = buildSourceAliasMap(query);
|
|
1655
|
-
let current: string | undefined = name;
|
|
1656
|
-
const seen = new Set<string>();
|
|
1657
|
-
while (current && !seen.has(current)) {
|
|
1658
|
-
if (curated.has(current)) return true;
|
|
1659
|
-
seen.add(current);
|
|
1660
|
-
current = aliasOf.get(current);
|
|
1661
|
-
}
|
|
1662
|
-
return false;
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/**
|
|
1666
|
-
* Compile-time renderer-tag validation, run on the main thread.
|
|
1667
|
-
*
|
|
1668
|
-
* The renderer (`@malloydata/render`) is a large solid-js bundle that mutates
|
|
1669
|
-
* DOM globals at import; loading it inside the package-load worker isolate
|
|
1670
|
-
* destabilizes that thread (it is deliberately kept pure-CPU). So validation
|
|
1671
|
-
* runs here, after the worker has hydrated this Model, where the renderer is
|
|
1672
|
-
* already used at query time (see query.controller.ts / execute_query_tool.ts).
|
|
1673
|
-
*
|
|
1674
|
-
* Prepares each annotated top-level named query (`run: <name>`) and each
|
|
1675
|
-
* annotated source view (`run: <source> -> <view>`) compile-only -- no
|
|
1676
|
-
* execution -- to get a stable result schema, then runs the renderer's
|
|
1677
|
-
* headless `validateRenderTags`. Targets with no annotations carry no render
|
|
1678
|
-
* tags, so they are skipped without compiling. Any error-severity finding
|
|
1679
|
-
* (e.g. a child-only `# big_value { sparkline=... }` placed on a view with no
|
|
1680
|
-
* activating big_value) is logged as a warning naming the offending target;
|
|
1681
|
-
* it does not fail the package load. Such a tag still renders as
|
|
1682
|
-
* "[object Object]" at query time, so the warning is the operator-facing
|
|
1683
|
-
* signal. Lower-severity findings are left for the query-time `renderLogs`
|
|
1684
|
-
* surface. The findings are returned so the owning Package can surface them
|
|
1685
|
-
* as non-fatal `warnings` on its response.
|
|
1686
|
-
*/
|
|
1687
|
-
public async validateRenderTags(): Promise<RenderTagWarning[]> {
|
|
1688
|
-
const mm = this.modelMaterializer;
|
|
1689
|
-
if (!mm) {
|
|
1690
|
-
return [];
|
|
1691
|
-
}
|
|
1692
|
-
// Dynamic import (like execute_query_tool.ts): the renderer is heavy and
|
|
1693
|
-
// mutates DOM globals on load, so only pull it in when there's a model to
|
|
1694
|
-
// validate.
|
|
1695
|
-
const { validateRenderTags } = await import(
|
|
1696
|
-
"@malloydata/render-validator"
|
|
1697
|
-
);
|
|
1698
|
-
|
|
1699
|
-
// Renderable targets: top-level named queries and every view declared on a
|
|
1700
|
-
// source. Source views are where render tags like `# big_value` usually
|
|
1701
|
-
// live, and they are NOT in `this.queries`.
|
|
1702
|
-
const targets: { label: string; queryString: string }[] = [];
|
|
1703
|
-
for (const query of this.queries ?? []) {
|
|
1704
|
-
// Only an annotated, named query can carry a render tag to validate;
|
|
1705
|
-
// skip the rest rather than compiling every query in the package.
|
|
1706
|
-
if (!query.name || !query.annotations?.length) {
|
|
1707
|
-
continue;
|
|
1708
|
-
}
|
|
1709
|
-
// Quote the identifier (see quoteMalloyIdentifier) so a name needing
|
|
1710
|
-
// Malloy quoting still lexes and cannot break out of the quotes.
|
|
1711
|
-
targets.push({
|
|
1712
|
-
label: query.name,
|
|
1713
|
-
queryString: `run: ${quoteMalloyIdentifier(query.name)}`,
|
|
1714
|
-
});
|
|
1715
|
-
}
|
|
1716
|
-
for (const source of this.sources ?? []) {
|
|
1717
|
-
for (const view of source.views ?? []) {
|
|
1718
|
-
// Render tags live on the view's own or inherited annotations, not
|
|
1719
|
-
// via source-to-view inheritance, so an unannotated view has
|
|
1720
|
-
// nothing to validate and need not be compiled. (A model-level
|
|
1721
|
-
// `##` tag is the one case this gate doesn't reach, but those are
|
|
1722
|
-
// theme/config, not the child-only chart tags this guards against.)
|
|
1723
|
-
if (!view.annotations?.length) {
|
|
1724
|
-
continue;
|
|
1725
|
-
}
|
|
1726
|
-
// Quote both identifiers (see quoteMalloyIdentifier): an unquoted
|
|
1727
|
-
// name like `gated-source` fails to lex, and the catch below would
|
|
1728
|
-
// then silently skip the very view this is meant to validate.
|
|
1729
|
-
targets.push({
|
|
1730
|
-
label: `${source.name} -> ${view.name}`,
|
|
1731
|
-
queryString: `run: ${quoteMalloyIdentifier(source.name)} -> ${quoteMalloyIdentifier(view.name)}`,
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
const findings: RenderTagWarning[] = [];
|
|
1737
|
-
for (const target of targets) {
|
|
1738
|
-
let result: Malloy.Result;
|
|
1739
|
-
try {
|
|
1740
|
-
const prepared = await mm
|
|
1741
|
-
.loadQuery(target.queryString)
|
|
1742
|
-
.getPreparedResult();
|
|
1743
|
-
result = prepared.toStableResult();
|
|
1744
|
-
} catch {
|
|
1745
|
-
// A view/query that fails to prepare is reported by the normal
|
|
1746
|
-
// compile path; don't mask that with a render-tag error.
|
|
1747
|
-
continue;
|
|
1748
|
-
}
|
|
1749
|
-
const errors = validateRenderTags(result).filter(
|
|
1750
|
-
(log) => log.severity === "error",
|
|
1751
|
-
);
|
|
1752
|
-
if (errors.length > 0) {
|
|
1753
|
-
logger.warn(
|
|
1754
|
-
`Invalid renderer configuration on '${target.label}': ${errors
|
|
1755
|
-
.map((e) => e.message)
|
|
1756
|
-
.join("; ")}`,
|
|
1757
|
-
);
|
|
1758
|
-
for (const e of errors) {
|
|
1759
|
-
findings.push({
|
|
1760
|
-
target: target.label,
|
|
1761
|
-
message: e.message,
|
|
1762
|
-
severity: "error",
|
|
1763
|
-
});
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
return findings;
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
public async getModel(): Promise<ApiCompiledModel> {
|
|
1771
|
-
if (this.compilationError) {
|
|
1772
|
-
throw this.compilationError;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
if (this.modelType === "model") {
|
|
1776
|
-
return this.getStandardModel();
|
|
1777
|
-
} else {
|
|
1778
|
-
throw new ModelNotFoundError(
|
|
1779
|
-
`${this.modelPath} is not a valid model name. Model files must end in .malloy.`,
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
public getNotebookError(): MalloyError | Error | undefined {
|
|
1785
|
-
return this.compilationError;
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
public async getNotebook(): Promise<ApiRawNotebook> {
|
|
1789
|
-
if (this.compilationError) {
|
|
1790
|
-
throw this.compilationError;
|
|
1791
|
-
}
|
|
1792
|
-
if (this.modelType === "notebook") {
|
|
1793
|
-
return this.getNotebookModel();
|
|
1794
|
-
} else {
|
|
1795
|
-
throw new ModelNotFoundError(
|
|
1796
|
-
`${this.modelPath} is not a valid notebook name. Notebook files must end in .malloynb.`,
|
|
1797
|
-
);
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
/** Capture the config used to compile a transient serve-shape model. */
|
|
1802
|
-
public setServeMalloyConfig(config: ModelConnectionInput): void {
|
|
1803
|
-
this.serveMalloyConfig = config;
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
/**
|
|
1807
|
-
* Set (or clear) this model's `storage=` serve bindings. Invalidates the
|
|
1808
|
-
* memoized serve-shape materializer so the next routed query recompiles
|
|
1809
|
-
* against the new binding set.
|
|
1810
|
-
*/
|
|
1811
|
-
public setServeBindings(bindings: ServeBinding[]): void {
|
|
1812
|
-
this.serveBindings = bindings;
|
|
1813
|
-
this.serveShapeCache = undefined;
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
/**
|
|
1817
|
-
* Compile an untrusted query against the transient serve-shape model that
|
|
1818
|
-
* rebinds this package's materialized-into-storage sources to virtual sources
|
|
1819
|
-
* on their storage connection. Returns the runnable and the `virtualMap` that
|
|
1820
|
-
* binds each virtual handle to its physical table.
|
|
1821
|
-
*
|
|
1822
|
-
* Throwing is the eligibility signal: a query that references a refinement
|
|
1823
|
-
* the serve shape does not reproduce (a measure/dim/join defined on the
|
|
1824
|
-
* source in the author's model, or a source with no binding) fails to compile
|
|
1825
|
-
* here, and {@link getQueryResults} falls back to serving it live. The
|
|
1826
|
-
* compiled materializer is memoized per binding set (the serve-variant cache).
|
|
1827
|
-
*/
|
|
1828
|
-
/**
|
|
1829
|
-
* The serve bindings that may serve from their materialized table right NOW:
|
|
1830
|
-
* fresh, un-gated, or stale-under-`stale_ok`. A stale binding whose fallback is
|
|
1831
|
-
* `live`/`fail` evaluates to `serve_live` and is dropped, so the serve shape
|
|
1832
|
-
* omits that source and any query touching it falls through to live — the SAME
|
|
1833
|
-
* freshness gate the colocated serve applies (`getFreshBuildManifest`
|
|
1834
|
-
* → `evaluateManifestFreshness`). Placement (`storage=`) is orthogonal to
|
|
1835
|
-
* freshness: flip a colocated build ↔ `storage=lake` and this behaves identically.
|
|
1836
|
-
*/
|
|
1837
|
-
private freshServeBindings(now: number): ServeBinding[] {
|
|
1838
|
-
const at = new Date(now);
|
|
1839
|
-
return this.serveBindings.filter(
|
|
1840
|
-
(b) =>
|
|
1841
|
-
evaluateManifestFreshness(
|
|
1842
|
-
{
|
|
1843
|
-
tableName: b.tablePath,
|
|
1844
|
-
dataAsOf: b.freshAsOf,
|
|
1845
|
-
freshnessWindowSeconds: b.freshnessWindowSeconds,
|
|
1846
|
-
freshnessFallback: b.freshnessFallback,
|
|
1847
|
-
},
|
|
1848
|
-
at,
|
|
1849
|
-
) === "serve_table",
|
|
1850
|
-
);
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
private async loadServeShapeQuery(queryString: string): Promise<{
|
|
1854
|
-
runnable: QueryMaterializer;
|
|
1855
|
-
virtualMap: VirtualMap;
|
|
1856
|
-
/**
|
|
1857
|
-
* The fresh bindings the shape was built from. Returned so a caller making
|
|
1858
|
-
* a per-binding decision reads the set that actually produced this shape
|
|
1859
|
-
* rather than the package-wide field.
|
|
1860
|
-
*/
|
|
1861
|
-
bindings: ServeBinding[];
|
|
1862
|
-
}> {
|
|
1863
|
-
// Gate by freshness first: only bindings that should serve their table now
|
|
1864
|
-
// enter the shape. Keying the cache on the FRESH subset means it recompiles
|
|
1865
|
-
// when a binding crosses its window (drops out) — the storage analogue of
|
|
1866
|
-
// the colocated path's memoized getFreshBuildManifest.
|
|
1867
|
-
const freshBindings = this.freshServeBindings(Date.now());
|
|
1868
|
-
if (freshBindings.length === 0) {
|
|
1869
|
-
// Every bound source is stale past its window with a live/fail fallback —
|
|
1870
|
-
// nothing to serve from storage; fall through to live (caller's catch).
|
|
1871
|
-
throw new Error("no fresh storage serve bindings for this query");
|
|
1872
|
-
}
|
|
1873
|
-
const key = freshBindings
|
|
1874
|
-
.map((b) => `${b.sourceName}@${b.connectionName}/${b.virtualHandle}`)
|
|
1875
|
-
.sort()
|
|
1876
|
-
.join("|");
|
|
1877
|
-
if (!this.serveShapeCache || this.serveShapeCache.key !== key) {
|
|
1878
|
-
this.serveShapeCache = {
|
|
1879
|
-
key,
|
|
1880
|
-
materializer: await this.compileServeShape(
|
|
1881
|
-
this.serveBindingsWithRefinements(freshBindings),
|
|
1882
|
-
),
|
|
1883
|
-
};
|
|
1884
|
-
}
|
|
1885
|
-
const virtualMap = buildVirtualMap(freshBindings);
|
|
1886
|
-
const runnable =
|
|
1887
|
-
this.serveShapeCache.materializer.loadRestrictedQuery(queryString);
|
|
1888
|
-
// Compile eagerly so ineligibility (a refinement the serve shape lacks, an
|
|
1889
|
-
// unbound source, a bad connection) surfaces HERE — Malloy compiles lazily,
|
|
1890
|
-
// so without this the error would escape at prepare/run instead of at the
|
|
1891
|
-
// caller's try, defeating the safe fallback. Cheap relative to the run. The
|
|
1892
|
-
// serve shape is pure virtual sources, so no buildManifest is needed.
|
|
1893
|
-
await runnable.getSQL({ virtualMap });
|
|
1894
|
-
return { runnable, virtualMap, bindings: freshBindings };
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
/**
|
|
1898
|
-
* Compile the serve-shape materializer for a binding set, degrading
|
|
1899
|
-
* gracefully if the richest shape does not compile. A single un-carriable
|
|
1900
|
-
* refinement (a join or view that reaches a non-materialized source, a
|
|
1901
|
-
* non-portable expression) would otherwise fail the WHOLE shape's compile and
|
|
1902
|
-
* disable storage serving for every source in the package. So the shape is
|
|
1903
|
-
* validated once (per binding set — the result is cached) and, on failure,
|
|
1904
|
-
* the riskiest refinement category is dropped and it retries: full → drop
|
|
1905
|
-
* views → drop views + joins → base-only. Base-only is pure virtual sources
|
|
1906
|
-
* and always compiles, so it is the guaranteed floor. Each surviving tier
|
|
1907
|
-
* still serves everything it can; the per-query eager compile in
|
|
1908
|
-
* {@link loadServeShapeQuery} remains the final net for query-specific
|
|
1909
|
-
* ineligibility.
|
|
1910
|
-
*/
|
|
1911
|
-
private async compileServeShape(
|
|
1912
|
-
enriched: ServeBinding[],
|
|
1913
|
-
): Promise<ModelMaterializer> {
|
|
1914
|
-
// Richest first; each predicate keeps fewer refinement kinds than the last.
|
|
1915
|
-
const keepKinds: Array<ReadonlySet<string>> = [
|
|
1916
|
-
new Set(["join", "dimension", "measure", "view"]),
|
|
1917
|
-
new Set(["join", "dimension", "measure"]),
|
|
1918
|
-
new Set(["dimension", "measure"]),
|
|
1919
|
-
new Set(),
|
|
1920
|
-
];
|
|
1921
|
-
// Skip escalation entirely when nothing beyond the base is carried.
|
|
1922
|
-
const hasRefinements = enriched.some(
|
|
1923
|
-
(b) => (b.refinements ?? []).length > 0,
|
|
1924
|
-
);
|
|
1925
|
-
const lastTier = keepKinds.length - 1;
|
|
1926
|
-
for (let tier = 0; tier <= lastTier; tier++) {
|
|
1927
|
-
const keep = keepKinds[tier];
|
|
1928
|
-
const shaped =
|
|
1929
|
-
tier === 0
|
|
1930
|
-
? enriched
|
|
1931
|
-
: enriched.map((b) =>
|
|
1932
|
-
b.refinements
|
|
1933
|
-
? {
|
|
1934
|
-
...b,
|
|
1935
|
-
refinements: b.refinements.filter((r) =>
|
|
1936
|
-
keep.has(r.kind),
|
|
1937
|
-
),
|
|
1938
|
-
}
|
|
1939
|
-
: b,
|
|
1940
|
-
);
|
|
1941
|
-
const materializer = this.buildServeShapeMaterializer(shaped);
|
|
1942
|
-
// Base-only (last tier) always compiles; trust it without a probe. And
|
|
1943
|
-
// when there are no refinements at all, tier 0 IS the base — skip too.
|
|
1944
|
-
if (tier === lastTier || (tier === 0 && !hasRefinements)) {
|
|
1945
|
-
return materializer;
|
|
1946
|
-
}
|
|
1947
|
-
try {
|
|
1948
|
-
await materializer.getModel();
|
|
1949
|
-
return materializer;
|
|
1950
|
-
} catch (err) {
|
|
1951
|
-
recordServeShapeTierDrop(tier);
|
|
1952
|
-
logger.warn(
|
|
1953
|
-
"Storage serve shape failed to compile; dropping the riskiest refinement category and retrying",
|
|
1954
|
-
{
|
|
1955
|
-
model: this.modelPath,
|
|
1956
|
-
tier,
|
|
1957
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1958
|
-
},
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
// Unreachable: the last tier returns above. Satisfy the type checker.
|
|
1963
|
-
return this.buildServeShapeMaterializer(
|
|
1964
|
-
enriched.map((b) => ({ ...b, refinements: [] })),
|
|
1965
|
-
);
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
/** Build the transient serve-shape materializer for a set of bindings. */
|
|
1969
|
-
private buildServeShapeMaterializer(
|
|
1970
|
-
bindings: ServeBinding[],
|
|
1971
|
-
): ModelMaterializer {
|
|
1972
|
-
const { modelText } = buildServeShapeModelForBindings(bindings);
|
|
1973
|
-
const root = "file:///storage-serve-shape/";
|
|
1974
|
-
const url = `${root}shape.malloy`;
|
|
1975
|
-
const runtime = new Runtime({
|
|
1976
|
-
urlReader: new InMemoryURLReader(new Map([[url, modelText]])),
|
|
1977
|
-
config: Model.toMalloyConfig(this.serveMalloyConfig!),
|
|
1978
|
-
});
|
|
1979
|
-
return runtime.loadModel(new URL(url), {
|
|
1980
|
-
importBaseURL: new URL(root),
|
|
1981
|
-
});
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
/**
|
|
1985
|
-
* The serve bindings enriched with the refinements to re-declare on each
|
|
1986
|
-
* virtual base — the source's dimensions/measures (computed from the stored
|
|
1987
|
-
* columns), its joins whose target source is ALSO materialized (the join runs
|
|
1988
|
-
* over the stored tables), and its views (turtles). All are read from this
|
|
1989
|
-
* model's compiled definition. Analytic source-fields are not carried; a
|
|
1990
|
-
* query using one falls back to live.
|
|
1991
|
-
*
|
|
1992
|
-
* Join and view declaration text is lifted verbatim from the author's source
|
|
1993
|
-
* files by location (read once per file, best-effort — an unreadable file
|
|
1994
|
-
* just drops that source's joins/views, which fall back). The join
|
|
1995
|
-
* materialization gate is a `sourceID`-keyed lookup, so it never depends on
|
|
1996
|
-
* parsing that text; views are emitted optimistically and pruned by the
|
|
1997
|
-
* shape-compile escalation in {@link compileServeShape} if they don't hold.
|
|
1998
|
-
*/
|
|
1999
|
-
private serveBindingsWithRefinements(
|
|
2000
|
-
bindings: ServeBinding[] = this.serveBindings,
|
|
2001
|
-
): ServeBinding[] {
|
|
2002
|
-
const contents = (
|
|
2003
|
-
this.modelDef as
|
|
2004
|
-
| {
|
|
2005
|
-
contents?: Record<
|
|
2006
|
-
string,
|
|
2007
|
-
{ sourceID?: unknown; fields?: unknown[] }
|
|
2008
|
-
>;
|
|
2009
|
-
}
|
|
2010
|
-
| undefined
|
|
2011
|
-
)?.contents;
|
|
2012
|
-
// sourceID -> author source name, for the join materialization gate.
|
|
2013
|
-
const sourceNameById = new Map<string, string>();
|
|
2014
|
-
for (const [name, def] of Object.entries(contents ?? {})) {
|
|
2015
|
-
if (typeof def?.sourceID === "string") {
|
|
2016
|
-
sourceNameById.set(def.sourceID, name);
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
const materializedSourceNames = new Set(
|
|
2020
|
-
bindings.map((b) => b.sourceName),
|
|
2021
|
-
);
|
|
2022
|
-
// Cache each source file's text (or null when unreadable) across bindings.
|
|
2023
|
-
const fileCache = new Map<string, string | null>();
|
|
2024
|
-
const liftText = (location: SourceLocation): string | undefined => {
|
|
2025
|
-
if (!location?.url?.startsWith("file:")) return undefined;
|
|
2026
|
-
if (!fileCache.has(location.url)) {
|
|
2027
|
-
try {
|
|
2028
|
-
fileCache.set(
|
|
2029
|
-
location.url,
|
|
2030
|
-
readFileSync(fileURLToPath(location.url), "utf8"),
|
|
2031
|
-
);
|
|
2032
|
-
} catch {
|
|
2033
|
-
fileCache.set(location.url, null);
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
const text = fileCache.get(location.url);
|
|
2037
|
-
return text ? sliceSourceRange(text, location.range) : undefined;
|
|
2038
|
-
};
|
|
2039
|
-
return (
|
|
2040
|
-
bindings
|
|
2041
|
-
.map((b) => {
|
|
2042
|
-
const fields = contents?.[b.sourceName]?.fields;
|
|
2043
|
-
// Narrow the declared ::Shape to the source's PUBLIC columns: the
|
|
2044
|
-
// build materializes every projected column (incl. `except:`-ed /
|
|
2045
|
-
// access-restricted ones), so the captured schema can be wider
|
|
2046
|
-
// than the source's public surface. Declaring a hidden column
|
|
2047
|
-
// would expose it over storage when live hides it — always applied
|
|
2048
|
-
// (even with no refinements), so the serve surface never widens
|
|
2049
|
-
// the source's.
|
|
2050
|
-
const schema = narrowSchemaToPublic(b.schema, fields);
|
|
2051
|
-
const refinements = [
|
|
2052
|
-
...extractJoins(fields, {
|
|
2053
|
-
sourceNameById,
|
|
2054
|
-
materializedSourceNames,
|
|
2055
|
-
liftText,
|
|
2056
|
-
}),
|
|
2057
|
-
...extractRefinements(fields),
|
|
2058
|
-
...extractViews(fields, liftText),
|
|
2059
|
-
];
|
|
2060
|
-
return { ...b, schema, refinements };
|
|
2061
|
-
})
|
|
2062
|
-
// Drop any binding whose public schema is empty. Bindings are pushed
|
|
2063
|
-
// to EVERY model in the package, so a model receives bindings for
|
|
2064
|
-
// sources it doesn't define (defined in a sibling model) — those have
|
|
2065
|
-
// no field list here, hence an empty narrowed schema. An empty
|
|
2066
|
-
// `type: X__shape is {}` is a Malloy parse error that would fail the
|
|
2067
|
-
// ENTIRE serve-shape model (breaking the base-only-always-compiles
|
|
2068
|
-
// fallback invariant) and silently drop storage serving for this
|
|
2069
|
-
// model's own sources too. Omitting them sends queries on an
|
|
2070
|
-
// undefined source to live (where this model refuses them anyway) and
|
|
2071
|
-
// keeps a source from being served through a model that doesn't
|
|
2072
|
-
// declare it.
|
|
2073
|
-
.filter((b) => b.schema.length > 0)
|
|
2074
|
-
);
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
public async getQueryResults(
|
|
2078
|
-
sourceName?: string,
|
|
2079
|
-
queryName?: string,
|
|
2080
|
-
query?: string,
|
|
2081
|
-
filterParams?: FilterParams,
|
|
2082
|
-
bypassFilters?: boolean,
|
|
2083
|
-
givens?: Record<string, GivenValue>,
|
|
2084
|
-
// Optional caller-supplied abort signal. Plumbed straight into
|
|
2085
|
-
// `runnable.run` so a publisher-issued query timeout (see
|
|
2086
|
-
// `runWithQueryTimeout`) actually cancels the work in flight
|
|
2087
|
-
// instead of just unblocking the awaiter. Pass `undefined` to
|
|
2088
|
-
// keep the legacy "no timeout" behavior — useful for
|
|
2089
|
-
// background callers (materialization, tests) that own their
|
|
2090
|
-
// own deadline.
|
|
2091
|
-
abortSignal?: AbortSignal,
|
|
2092
|
-
/**
|
|
2093
|
-
* Per-query metadata inputs from the request boundary (see
|
|
2094
|
-
* {@link ModelQueryMetadataInput}). Omitting it still tags the query with
|
|
2095
|
-
* the server context; what is lost is the caller's own properties, the
|
|
2096
|
-
* connection default, and the correlation id.
|
|
2097
|
-
*/
|
|
2098
|
-
queryMetadataInput?: ModelQueryMetadataInput,
|
|
2099
|
-
): Promise<{
|
|
2100
|
-
result: Malloy.Result;
|
|
2101
|
-
compactResult: QueryData;
|
|
2102
|
-
modelInfo: Malloy.ModelInfo;
|
|
2103
|
-
dataStyles: DataStyles;
|
|
2104
|
-
/** Row cap pushed into the SQL: the query's own LIMIT, else the default. */
|
|
2105
|
-
rowLimit: number;
|
|
2106
|
-
/** Which of those two the cap came from. */
|
|
2107
|
-
rowLimitSource: QueryRowLimitSource;
|
|
2108
|
-
/**
|
|
2109
|
-
* The `query_id` property attached to this query's statements, which is the
|
|
2110
|
-
* caller's join key into the backend's own query record. Null when no
|
|
2111
|
-
* metadata was attached.
|
|
2112
|
-
*/
|
|
2113
|
-
queryCorrelationId: string | null;
|
|
2114
|
-
}> {
|
|
2115
|
-
const startTime = performance.now();
|
|
2116
|
-
if (this.compilationError) {
|
|
2117
|
-
// Re-throw MalloyError and ModelCompilationError as-is (they map to 400/424)
|
|
2118
|
-
if (
|
|
2119
|
-
this.compilationError instanceof MalloyError ||
|
|
2120
|
-
this.compilationError instanceof ModelCompilationError
|
|
2121
|
-
) {
|
|
2122
|
-
throw this.compilationError;
|
|
2123
|
-
}
|
|
2124
|
-
// For other compilation errors, wrap as BadRequestError (400)
|
|
2125
|
-
throw new BadRequestError(
|
|
2126
|
-
`Model compilation failed: ${this.compilationError.message}`,
|
|
2127
|
-
);
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
let runnable: QueryMaterializer;
|
|
2131
|
-
let liveRunnable: QueryMaterializer | undefined;
|
|
2132
|
-
// Set when this query is routed through the `storage=` serve-shape
|
|
2133
|
-
// transform; threaded into prepare + run so the virtual sources resolve to
|
|
2134
|
-
// their physical tables. Undefined ⇒ served live (the default path).
|
|
2135
|
-
let serveVirtualMap: VirtualMap | undefined;
|
|
2136
|
-
// The bindings that actually PRODUCED the serve shape — the fresh subset,
|
|
2137
|
-
// not the package's whole set. Per-binding decisions (freshnessFallback)
|
|
2138
|
-
// must read this: `this.serveBindings` is pushed package-wide, so a
|
|
2139
|
-
// sibling source's value would otherwise decide this query's behavior.
|
|
2140
|
-
let serveShapeBindings: ServeBinding[] = [];
|
|
2141
|
-
// How the answer was ultimately produced, for the query histogram. Absent
|
|
2142
|
-
// when the query never routed (an off/live deployment's histogram is
|
|
2143
|
-
// unchanged); "storage" when served from a materialized table;
|
|
2144
|
-
// "live_fallback" when it routed but a run-time store failure degraded it
|
|
2145
|
-
// to live — which must NOT count as a storage hit, since the hit rate is
|
|
2146
|
-
// the tier's headline KPI and would otherwise rise while the tier is down.
|
|
2147
|
-
let servedFrom: "storage" | "live_fallback" | undefined;
|
|
2148
|
-
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
2149
|
-
throw new BadRequestError("Model has no queryable entities.");
|
|
2150
|
-
|
|
2151
|
-
// Query boundary FIRST (the *what* axis): reject a target that isn't in
|
|
2152
|
-
// the package's queryable surface with a generic 404, before authorize
|
|
2153
|
-
// (the *who* axis) and before compilation — so a non-queryable source is
|
|
2154
|
-
// indistinguishable from a non-existent one and can't be probed.
|
|
2155
|
-
// "deferred" means the early gate couldn't pin the target; the compiled
|
|
2156
|
-
// backstop below settles it against the source the query actually runs.
|
|
2157
|
-
const boundary = this.assertQueryBoundaryEarly(
|
|
2158
|
-
sourceName,
|
|
2159
|
-
queryName,
|
|
2160
|
-
query,
|
|
2161
|
-
);
|
|
2162
|
-
|
|
2163
|
-
// Early fast-path authorize gate (before loadQuery). Resolve the source
|
|
2164
|
-
// from surface syntax; gate if it names one. This runs BEFORE compilation
|
|
2165
|
-
// so the gate can't be used as a schema oracle — without it, a denied
|
|
2166
|
-
// caller probing `run: gated -> { group_by: maybe_field }` would get a
|
|
2167
|
-
// Malloy "field not found" vs a 403 and learn the gated source's columns.
|
|
2168
|
-
// It does NOT replace the authoritative compiled-source gate below (which
|
|
2169
|
-
// always runs and catches named-query / multi-statement forms surface
|
|
2170
|
-
// syntax can't resolve); it only fails fast for the common case.
|
|
2171
|
-
const earlySource =
|
|
2172
|
-
sourceName ||
|
|
2173
|
-
(queryName
|
|
2174
|
-
? this.queries?.find((q) => q.name === queryName)?.sourceName
|
|
2175
|
-
: undefined) ||
|
|
2176
|
-
extractRunTargetSourceName(query);
|
|
2177
|
-
if (earlySource) {
|
|
2178
|
-
await this.assertAuthorized(earlySource, givens ?? {});
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
2182
|
-
try {
|
|
2183
|
-
let queryString: string;
|
|
2184
|
-
if (!sourceName && !queryName && query) {
|
|
2185
|
-
queryString = "\n" + query;
|
|
2186
|
-
} else if (queryName && !query) {
|
|
2187
|
-
queryString = `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
2188
|
-
} else {
|
|
2189
|
-
const endTime = performance.now();
|
|
2190
|
-
const executionTime = endTime - startTime;
|
|
2191
|
-
this.queryExecutionHistogram.record(executionTime, {
|
|
2192
|
-
"malloy.model.path": this.modelPath,
|
|
2193
|
-
"malloy.model.query.name": queryName,
|
|
2194
|
-
"malloy.model.query.source": sourceName,
|
|
2195
|
-
"malloy.model.query.query": query,
|
|
2196
|
-
"malloy.model.query.status": "error",
|
|
2197
|
-
});
|
|
2198
|
-
throw new BadRequestError(
|
|
2199
|
-
"Invalid query request. (Query AND !sourceName) OR (queryName AND sourceName) must be defined.",
|
|
2200
|
-
);
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
// Distinguishes free-form query text from the named `source->view`
|
|
2204
|
-
// form. Both are driven by untrusted caller input and compiled in
|
|
2205
|
-
// restricted mode below; this flag only controls how the protected
|
|
2206
|
-
// source is resolved for filter injection.
|
|
2207
|
-
const isAdHocQuery = !sourceName && !queryName && !!query;
|
|
2208
|
-
|
|
2209
|
-
// Inject source filter predicates unless bypassed. For ad-hoc queries
|
|
2210
|
-
// resolve the run target through any alias/extend/chain so a protected
|
|
2211
|
-
// source can't be read unfiltered under a different name.
|
|
2212
|
-
if (!bypassFilters) {
|
|
2213
|
-
const effectiveSource = isAdHocQuery
|
|
2214
|
-
? this.resolveFilterSource(query)
|
|
2215
|
-
: sourceName;
|
|
2216
|
-
if (effectiveSource) {
|
|
2217
|
-
const filters = this.getFilters(effectiveSource);
|
|
2218
|
-
if (filters.length > 0) {
|
|
2219
|
-
const filterClause = buildFilterClause(
|
|
2220
|
-
filters,
|
|
2221
|
-
filterParams ?? {},
|
|
2222
|
-
);
|
|
2223
|
-
queryString = injectFilterRefinement(
|
|
2224
|
-
queryString,
|
|
2225
|
-
filterClause,
|
|
2226
|
-
);
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
// Restricted mode keeps untrusted query text inside the model's curated
|
|
2232
|
-
// surface — it rejects `import`, raw `connection.table(...)` /
|
|
2233
|
-
// `connection.sql(...)`, raw-SQL functions, and `##!` flags. The
|
|
2234
|
-
// model's own definitions are unaffected. Both the ad-hoc `query` text
|
|
2235
|
-
// and the `run: source->view` string built from the caller-supplied
|
|
2236
|
-
// `sourceName`/`queryName` pair are untrusted, so both compile here;
|
|
2237
|
-
// only author-curated notebook cells use the unrestricted `loadQuery`.
|
|
2238
|
-
runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
|
|
2239
|
-
// Kept so a routed query can still be answered live if the store fails
|
|
2240
|
-
// underneath it at RUN time (see the freshnessFallback retry below).
|
|
2241
|
-
liveRunnable = runnable;
|
|
2242
|
-
|
|
2243
|
-
// storage= serve routing: when enabled and this package has sources
|
|
2244
|
-
// materialized into a storage destination, try compiling the query
|
|
2245
|
-
// against the transient serve-shape model (materialized sources rebound
|
|
2246
|
-
// to virtual sources on their storage connection). If it compiles, we
|
|
2247
|
-
// serve from the materialized tables via the virtualMap; if it does not
|
|
2248
|
-
// (a refinement the shape lacks, or an unbound source), we keep the
|
|
2249
|
-
// original runnable and serve live — safe fallback, no behavior change
|
|
2250
|
-
// for anything the transform can't yet reproduce. Off / write-only and
|
|
2251
|
-
// packages with no storage bindings skip this entirely.
|
|
2252
|
-
if (
|
|
2253
|
-
getPersistStorageMode() === "on" &&
|
|
2254
|
-
this.serveBindings.length > 0 &&
|
|
2255
|
-
this.serveMalloyConfig
|
|
2256
|
-
) {
|
|
2257
|
-
try {
|
|
2258
|
-
const shaped = await this.loadServeShapeQuery(queryString);
|
|
2259
|
-
runnable = shaped.runnable;
|
|
2260
|
-
serveVirtualMap = shaped.virtualMap;
|
|
2261
|
-
serveShapeBindings = shaped.bindings;
|
|
2262
|
-
servedFrom = "storage";
|
|
2263
|
-
recordStorageServeRouting("storage");
|
|
2264
|
-
logger.info("Serving query from storage tier (virtual-source)", {
|
|
2265
|
-
modelPath: this.modelPath,
|
|
2266
|
-
// The sources in the SHAPE, not the package's whole binding set:
|
|
2267
|
-
// a stale binding is dropped before the shape is built.
|
|
2268
|
-
storageSources: shaped.bindings.map((b) => b.sourceName),
|
|
2269
|
-
});
|
|
2270
|
-
} catch (shapeErr) {
|
|
2271
|
-
recordStorageServeRouting("live_fallback");
|
|
2272
|
-
logger.debug(
|
|
2273
|
-
"storage serve-shape ineligible for this query; serving live",
|
|
2274
|
-
{
|
|
2275
|
-
modelPath: this.modelPath,
|
|
2276
|
-
error:
|
|
2277
|
-
shapeErr instanceof Error
|
|
2278
|
-
? shapeErr.message
|
|
2279
|
-
: String(shapeErr),
|
|
2280
|
-
},
|
|
2281
|
-
);
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
} catch (error) {
|
|
2285
|
-
// Re-throw BadRequestError as-is
|
|
2286
|
-
if (error instanceof BadRequestError) {
|
|
2287
|
-
throw error;
|
|
2288
|
-
}
|
|
2289
|
-
// Source filter validation errors are client errors (400)
|
|
2290
|
-
if (error instanceof FilterValidationError) {
|
|
2291
|
-
throw new BadRequestError(error.message);
|
|
2292
|
-
}
|
|
2293
|
-
// Re-throw MalloyError as-is (maps to 400)
|
|
2294
|
-
if (error instanceof MalloyError) {
|
|
2295
|
-
throw error;
|
|
2296
|
-
}
|
|
2297
|
-
// For other query parsing errors, wrap as BadRequestError
|
|
2298
|
-
const errorMessage =
|
|
2299
|
-
error instanceof Error ? error.message : String(error);
|
|
2300
|
-
logger.error("Query parsing error", {
|
|
2301
|
-
error,
|
|
2302
|
-
errorMessage,
|
|
2303
|
-
environmentName: this.packageName,
|
|
2304
|
-
modelPath: this.modelPath,
|
|
2305
|
-
query,
|
|
2306
|
-
queryName,
|
|
2307
|
-
sourceName,
|
|
2308
|
-
});
|
|
2309
|
-
throw new BadRequestError(`Invalid query: ${errorMessage}`);
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
// Authoritative authorize gate: resolve the gated source from the
|
|
2313
|
-
// COMPILED query — the source Malloy actually runs (the LAST `run:`
|
|
2314
|
-
// statement) — not from surface syntax. Surface-syntax resolution alone
|
|
2315
|
-
// is both bypassable (first-statement regex vs. last-statement execution:
|
|
2316
|
-
// `run: ungated\nrun: gated` would gate `ungated` while running `gated`)
|
|
2317
|
-
// and over-restrictive, so this compiled check always runs and is the
|
|
2318
|
-
// source of truth; it handles named-query / blank-source / multi-statement
|
|
2319
|
-
// forms uniformly. Skip only the redundant re-probe when it's the same
|
|
2320
|
-
// source the early gate already cleared. Outside the loadQuery try so
|
|
2321
|
-
// AccessDeniedError stays a 403; independent of bypassFilters.
|
|
2322
|
-
const compiledSource =
|
|
2323
|
-
await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
2324
|
-
|
|
2325
|
-
// Boundary backstop (the *what* axis, 404) BEFORE the authorize gate
|
|
2326
|
-
// (the *who* axis, 403): settle "deferred" early-gate admissions against
|
|
2327
|
-
// the compiled run target. Skipped for "cleared" admissions — an
|
|
2328
|
-
// explicitly-named exported query may read hidden sources internally
|
|
2329
|
-
// (the author's deliberate exposure), and must not be re-denied here.
|
|
2330
|
-
if (boundary === "deferred") {
|
|
2331
|
-
this.assertQueryBoundaryCompiled(compiledSource, query);
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
// Gate the compiled run target's own source PLUS every source reached
|
|
2335
|
-
// transitively via join_* (assertAuthorizedForAllSources) — this MUST
|
|
2336
|
-
// run unconditionally, not just when compiledSource !== earlySource:
|
|
2337
|
-
// the common `run: joiner -> {...}` case has an ungated top-level source
|
|
2338
|
-
// (so compiledSource === earlySource and the own-source re-probe really
|
|
2339
|
-
// would be redundant), but a joined source's gate has never been checked
|
|
2340
|
-
// yet on this request. The walk runs UNCONDITIONALLY (do NOT re-add a
|
|
2341
|
-
// hasAuthorize() guard here — it only sees top-level sources, so guarding
|
|
2342
|
-
// on it re-opens the deep-import join bypass); it is a cheap no-op for an
|
|
2343
|
-
// ungated model. When compiledSource is unknown/unresolved,
|
|
2344
|
-
// the own-source half still applies the model-wide file-level gate via
|
|
2345
|
-
// effectiveAuthorizeFor. Note: on this path an ad-hoc inline
|
|
2346
|
-
// `duckdb.sql(...)` query is rejected by restricted mode (the raw-SQL
|
|
2347
|
-
// ban from loadRestrictedQuery above) before it can run, so the
|
|
2348
|
-
// raw-warehouse bypass is closed by restricted mode — not by this gate.
|
|
2349
|
-
await this.assertAuthorizedForAllSources(runnable, givens ?? {});
|
|
2350
|
-
|
|
2351
|
-
const maxRows = getMaxQueryRows();
|
|
2352
|
-
const maxBytes = getMaxResponseBytes();
|
|
2353
|
-
// Per-query freshness gate (persistence.md §9.3): resolve the
|
|
2354
|
-
// freshness-filtered manifest once and thread it into both the prepare
|
|
2355
|
-
// (for the row limit) and the run so a stale persist source falls back per
|
|
2356
|
-
// its declared policy — and prep/run agree on the same substitution.
|
|
2357
|
-
const buildManifest = this.resolveFreshBuildManifest();
|
|
2358
|
-
// The serve-shape runnable resolves its tables through `virtualMap`, not
|
|
2359
|
-
// the same-connection build manifest, and its transient model carries no
|
|
2360
|
-
// `##! experimental.persistence` — so passing a non-empty buildManifest to
|
|
2361
|
-
// it errors. When routing through the shape, suppress the manifest; the
|
|
2362
|
-
// original (live) runnable still gets it.
|
|
2363
|
-
const effectiveBuildManifest = serveVirtualMap
|
|
2364
|
-
? undefined
|
|
2365
|
-
: buildManifest;
|
|
2366
|
-
|
|
2367
|
-
// Prepare INSIDE the run try/catch: a bad-given / value-type throw at
|
|
2368
|
-
// prepare time (getPreparedResult binds the givens) gets the same
|
|
2369
|
-
// MalloyError→rethrow / else→400 handling as run, instead of escaping as
|
|
2370
|
-
// a 500. `executionTime` is still captured after prepare and before run,
|
|
2371
|
-
// preserving the pre-existing timing recorded by the success histogram.
|
|
2372
|
-
let rowLimit = 0;
|
|
2373
|
-
let rowLimitSource: QueryRowLimitSource = "server_default";
|
|
2374
|
-
let executionTime = 0;
|
|
2375
|
-
let queryResults;
|
|
2376
|
-
let appliedQueryMetadata: QueryMetadata | undefined;
|
|
2377
|
-
// Givens supplied only so a joined source's authorize gate could see
|
|
2378
|
-
// them (checked above, against the full unfiltered set) must not reach
|
|
2379
|
-
// the real query if this model doesn't itself surface them — see
|
|
2380
|
-
// filterGivensToModelSurface.
|
|
2381
|
-
const querySurfaceGivens = this.filterGivensToModelSurface(givens);
|
|
2382
|
-
// Same reason as effectiveBuildManifest: the serve shape is built from
|
|
2383
|
-
// given-FREE sources, so it surfaces no `given:` and Malloy rejects any
|
|
2384
|
-
// supplied name with "unknown given" — a spurious 400, past the routing
|
|
2385
|
-
// fallback, on a query that should just serve from storage. Nothing in the
|
|
2386
|
-
// shape can read them; the authorize gate above already saw the full set.
|
|
2387
|
-
const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
|
|
2388
|
-
try {
|
|
2389
|
-
// The prepared result is also where the executing connection's name
|
|
2390
|
-
// comes from, which is what makes the connection's default metadata
|
|
2391
|
-
// layer resolvable BEFORE the statement is issued.
|
|
2392
|
-
const preparedResult = await runnable.getPreparedResult({
|
|
2393
|
-
givens: effectiveGivens,
|
|
2394
|
-
buildManifest: effectiveBuildManifest,
|
|
2395
|
-
virtualMap: serveVirtualMap,
|
|
2396
|
-
});
|
|
2397
|
-
const preparedLimit = preparedResult.resultExplore.limit;
|
|
2398
|
-
rowLimitSource = queryRowLimitSource(preparedLimit);
|
|
2399
|
-
rowLimit = resolveModelQueryRowLimit(preparedLimit, {
|
|
2400
|
-
defaultLimit: getDefaultQueryRowLimit(),
|
|
2401
|
-
maxRows,
|
|
2402
|
-
});
|
|
2403
|
-
appliedQueryMetadata = this.resolveQueryMetadata(
|
|
2404
|
-
queryMetadataInput,
|
|
2405
|
-
preparedResult.connectionName,
|
|
2406
|
-
);
|
|
2407
|
-
executionTime = performance.now() - startTime;
|
|
2408
|
-
|
|
2409
|
-
queryResults = await runnable.run({
|
|
2410
|
-
rowLimit,
|
|
2411
|
-
givens: effectiveGivens,
|
|
2412
|
-
abortSignal,
|
|
2413
|
-
buildManifest: effectiveBuildManifest,
|
|
2414
|
-
virtualMap: serveVirtualMap,
|
|
2415
|
-
queryMetadata: appliedQueryMetadata,
|
|
2416
|
-
});
|
|
2417
|
-
} catch (error) {
|
|
2418
|
-
// A binding that declares `freshnessFallback=live` is saying the tier is
|
|
2419
|
-
// a performance optimisation, not a dependency — so a store that fails
|
|
2420
|
-
// UNDER a routed query should degrade to serving live, the same answer
|
|
2421
|
-
// the compile-time ladder already gives when the shape can't be built.
|
|
2422
|
-
// Without this the store is a hard dependency the moment a query routes:
|
|
2423
|
-
// a not-yet-converged rebind or an over-eager GC turns into a user-facing
|
|
2424
|
-
// error for a source explicitly marked as safe to serve live.
|
|
2425
|
-
//
|
|
2426
|
-
// Deliberately narrow:
|
|
2427
|
-
// - only when the query actually routed to storage (`serveVirtualMap`);
|
|
2428
|
-
// - only when every binding THAT PRODUCED THIS SHAPE says `live` —
|
|
2429
|
-
// `fail` (and the `stale_ok` default) keep surfacing, so one
|
|
2430
|
-
// fail-closed source is not degraded by a permissive neighbour.
|
|
2431
|
-
// Read off the shape's own bindings, not the package's: bindings are
|
|
2432
|
-
// pushed package-wide and `freshnessFallback` is per entry, so a
|
|
2433
|
-
// mixed set is normal and a sibling must not decide this query;
|
|
2434
|
-
// - never for a client error (a bad given) or an abort, where a retry
|
|
2435
|
-
// would just reproduce it or defy the caller;
|
|
2436
|
-
// - the retry re-supplies the REAL givens. The storage path suppresses
|
|
2437
|
-
// them because the shape is built from given-free sources; the live
|
|
2438
|
-
// source may filter on them, and running it without them would serve
|
|
2439
|
-
// unfiltered rows.
|
|
2440
|
-
const canDegradeToLive =
|
|
2441
|
-
!!serveVirtualMap &&
|
|
2442
|
-
!!liveRunnable &&
|
|
2443
|
-
!abortSignal?.aborted &&
|
|
2444
|
-
!String((error as { code?: string })?.code ?? "").startsWith(
|
|
2445
|
-
"runtime-given-",
|
|
2446
|
-
) &&
|
|
2447
|
-
serveShapeBindings.length > 0 &&
|
|
2448
|
-
serveShapeBindings.every((b) => b.freshnessFallback === "live");
|
|
2449
|
-
// Both the original failure and a failure OF THE RETRY end here: record
|
|
2450
|
-
// the error metric, then map the error. A broad outage takes the source
|
|
2451
|
-
// warehouse down alongside the store, so the retry failing is ordinary —
|
|
2452
|
-
// and without this it would escape the given-mapping, the MalloyError
|
|
2453
|
-
// rethrow and the error metric, turning a clean 400 into an untracked 500.
|
|
2454
|
-
// Annotated on the CONST, not just the arrow: control-flow analysis only
|
|
2455
|
-
// treats a call as never-returning when the callee has an explicit type
|
|
2456
|
-
// annotation, and the fall-through below depends on that narrowing.
|
|
2457
|
-
const failQuery: (err: unknown) => never = (err) => {
|
|
2458
|
-
// Record error metrics
|
|
2459
|
-
const errorEndTime = performance.now();
|
|
2460
|
-
const errorExecutionTime = errorEndTime - startTime;
|
|
2461
|
-
this.queryExecutionHistogram.record(errorExecutionTime, {
|
|
2462
|
-
"malloy.model.path": this.modelPath,
|
|
2463
|
-
"malloy.model.query.name": queryName,
|
|
2464
|
-
"malloy.model.query.source": sourceName,
|
|
2465
|
-
"malloy.model.query.query": query,
|
|
2466
|
-
"malloy.model.query.status": "error",
|
|
2467
|
-
// Ships dark: only tag queries that routed. A live/off query gets
|
|
2468
|
-
// no new attribute, so an off deployment's histogram is unchanged.
|
|
2469
|
-
...(servedFrom
|
|
2470
|
-
? { "malloy.model.query.served_from": servedFrom }
|
|
2471
|
-
: {}),
|
|
2472
|
-
});
|
|
2473
|
-
|
|
2474
|
-
// Bad client-supplied givens (unknown name, wrong-typed value, an
|
|
2475
|
-
// operator-finalized override, ...) all surface as a Malloy
|
|
2476
|
-
// `runtime-given-*` error. Malloy is the single validator; the publisher
|
|
2477
|
-
// just maps its rejection to a clean 400. Duck-type on `.code`
|
|
2478
|
-
// (MalloyCompileError extends Error, not MalloyError, and isn't
|
|
2479
|
-
// root-exported). The `runtime-given-` prefix is a pinned coupling to
|
|
2480
|
-
// Malloy's error codes (@malloydata/malloy given_binding.ts / runtime.ts);
|
|
2481
|
-
// if they're renamed upstream, update it here (and in environment.ts) —
|
|
2482
|
-
// otherwise these fall through to the generic 400 below with a worse
|
|
2483
|
-
// message, and the /compile path silently omits `sql`.
|
|
2484
|
-
const givenCode = (err as { code?: string })?.code;
|
|
2485
|
-
if (
|
|
2486
|
-
typeof givenCode === "string" &&
|
|
2487
|
-
givenCode.startsWith("runtime-given-")
|
|
2488
|
-
) {
|
|
2489
|
-
logger.debug("Rejected client-supplied given", {
|
|
2490
|
-
environmentName: this.packageName,
|
|
2491
|
-
modelPath: this.modelPath,
|
|
2492
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2493
|
-
});
|
|
2494
|
-
throw new BadRequestError(
|
|
2495
|
-
err instanceof Error ? err.message : String(err),
|
|
2496
|
-
);
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
// Re-throw Malloy errors as-is (they will be handled by error handler)
|
|
2500
|
-
if (err instanceof MalloyError) {
|
|
2501
|
-
throw err;
|
|
2502
|
-
}
|
|
2503
|
-
|
|
2504
|
-
// For other runtime errors (like divide by zero), throw as BadRequestError
|
|
2505
|
-
const errorMessage =
|
|
2506
|
-
err instanceof Error ? err.message : String(err);
|
|
2507
|
-
logger.error("Query execution error", {
|
|
2508
|
-
error: err,
|
|
2509
|
-
errorMessage,
|
|
2510
|
-
environmentName: this.packageName,
|
|
2511
|
-
modelPath: this.modelPath,
|
|
2512
|
-
query,
|
|
2513
|
-
queryName,
|
|
2514
|
-
sourceName,
|
|
2515
|
-
});
|
|
2516
|
-
throw new BadRequestError(
|
|
2517
|
-
`Query execution failed: ${errorMessage}`,
|
|
2518
|
-
);
|
|
2519
|
-
};
|
|
2520
|
-
if (!canDegradeToLive) failQuery(error);
|
|
2521
|
-
logger.warn(
|
|
2522
|
-
"Storage-served query failed at run time; falling back to live (freshnessFallback=live)",
|
|
2523
|
-
{
|
|
2524
|
-
modelPath: this.modelPath,
|
|
2525
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2526
|
-
},
|
|
2527
|
-
);
|
|
2528
|
-
recordStorageServeRouting("runtime_live_fallback");
|
|
2529
|
-
try {
|
|
2530
|
-
// Re-derive the row cap from the LIVE shape. `rowLimit` is assigned
|
|
2531
|
-
// inside the try above, from the storage prepare — so a store failure
|
|
2532
|
-
// that surfaces AT PREPARE (a manifest naming a table that is missing
|
|
2533
|
-
// or malformed resolves through `virtualMap` there) leaves it 0, which
|
|
2534
|
-
// the connector reads as a hard cap and stops before the first row: a
|
|
2535
|
-
// successful, EMPTY answer. Asking the live shape is also the honest
|
|
2536
|
-
// limit, since the live shape is what runs.
|
|
2537
|
-
const livePrepared = await liveRunnable!.getPreparedResult({
|
|
2538
|
-
givens: querySurfaceGivens,
|
|
2539
|
-
buildManifest,
|
|
2540
|
-
});
|
|
2541
|
-
const livePreparedLimit = livePrepared.resultExplore.limit;
|
|
2542
|
-
rowLimitSource = queryRowLimitSource(livePreparedLimit);
|
|
2543
|
-
rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
|
|
2544
|
-
defaultLimit: getDefaultQueryRowLimit(),
|
|
2545
|
-
maxRows,
|
|
2546
|
-
});
|
|
2547
|
-
// Re-resolve rather than reuse: the bag above was resolved against
|
|
2548
|
-
// the STORAGE connection, which on this tier is routinely not the
|
|
2549
|
-
// one the live shape runs on, so reusing it would stamp another
|
|
2550
|
-
// connection's default and enforced layers on this statement. The
|
|
2551
|
-
// correlation id rides in the input, so the retry keeps the id the
|
|
2552
|
-
// response returns — one API call, one join key, two statements.
|
|
2553
|
-
appliedQueryMetadata = this.resolveQueryMetadata(
|
|
2554
|
-
queryMetadataInput,
|
|
2555
|
-
livePrepared.connectionName,
|
|
2556
|
-
);
|
|
2557
|
-
queryResults = await liveRunnable!.run({
|
|
2558
|
-
rowLimit,
|
|
2559
|
-
givens: querySurfaceGivens,
|
|
2560
|
-
abortSignal,
|
|
2561
|
-
buildManifest,
|
|
2562
|
-
queryMetadata: appliedQueryMetadata,
|
|
2563
|
-
});
|
|
2564
|
-
} catch (retryError) {
|
|
2565
|
-
failQuery(retryError);
|
|
2566
|
-
}
|
|
2567
|
-
// The answer came from the live warehouse, so it is NOT a storage hit —
|
|
2568
|
-
// `runtime_live_fallback` above is the signal that the tier is degraded.
|
|
2569
|
-
servedFrom = "live_fallback";
|
|
2570
|
-
executionTime = performance.now() - startTime;
|
|
2571
|
-
// Fall through: `queryResults` is set, so the normal post-run path
|
|
2572
|
-
// wraps and returns it exactly as a live query would.
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
const wrappedResult = API.util.wrapResult(queryResults);
|
|
2576
|
-
// Best-effort byte check: we've already buffered `queryResults` and
|
|
2577
|
-
// built `wrappedResult` by the time we get here, so this surfaces
|
|
2578
|
-
// oversize responses with a clean HTTP 413 instead of letting the
|
|
2579
|
-
// controller transmit a half-megabyte payload — it is not OOM
|
|
2580
|
-
// prevention. True prevention requires streaming `Result`
|
|
2581
|
-
// construction, which is out of scope for this step. The row cap
|
|
2582
|
-
// above is the primary OOM defense.
|
|
2583
|
-
const serializedBytes =
|
|
2584
|
-
maxBytes > 0
|
|
2585
|
-
? Buffer.byteLength(JSON.stringify(wrappedResult), "utf8")
|
|
2586
|
-
: 0;
|
|
2587
|
-
assertWithinModelResponseLimits(
|
|
2588
|
-
queryResults.totalRows,
|
|
2589
|
-
serializedBytes,
|
|
2590
|
-
{ maxRows, maxBytes },
|
|
2591
|
-
"model_query",
|
|
2592
|
-
);
|
|
2593
|
-
this.queryExecutionHistogram.record(executionTime, {
|
|
2594
|
-
"malloy.model.path": this.modelPath,
|
|
2595
|
-
"malloy.model.query.name": queryName,
|
|
2596
|
-
"malloy.model.query.source": sourceName,
|
|
2597
|
-
"malloy.model.query.query": query,
|
|
2598
|
-
"malloy.model.query.rows_limit": rowLimit,
|
|
2599
|
-
"malloy.model.query.rows_total": queryResults.totalRows,
|
|
2600
|
-
"malloy.model.query.connection": queryResults.connectionName,
|
|
2601
|
-
"malloy.model.query.status": "success",
|
|
2602
|
-
// Ships dark: only tag queries that routed (see the error path).
|
|
2603
|
-
// "live_fallback" here is a SUCCESS answered by the live warehouse, so
|
|
2604
|
-
// it must not inflate the storage hit rate.
|
|
2605
|
-
...(servedFrom
|
|
2606
|
-
? { "malloy.model.query.served_from": servedFrom }
|
|
2607
|
-
: {}),
|
|
2608
|
-
});
|
|
2609
|
-
return {
|
|
2610
|
-
result: wrappedResult,
|
|
2611
|
-
compactResult: queryResults.data.value,
|
|
2612
|
-
modelInfo: this.modelInfo,
|
|
2613
|
-
dataStyles: this.dataStyles,
|
|
2614
|
-
// The cap actually pushed into the SQL. A caller cannot otherwise tell
|
|
2615
|
-
// a complete result from one the row limit cut off: with no LIMIT of
|
|
2616
|
-
// its own a query silently gets DEFAULT_QUERY_ROW_LIMIT rows, and that
|
|
2617
|
-
// is under maxRows, so assertWithinModelResponseLimits raises nothing.
|
|
2618
|
-
// Returning the number lets a caller compare it against the row count
|
|
2619
|
-
// and say so, with no extra query.
|
|
2620
|
-
rowLimit,
|
|
2621
|
-
// Whether that cap was the author's own limit:/top: or the silent
|
|
2622
|
-
// default. Only the second means rows were probably left behind; a
|
|
2623
|
-
// deliberate `top: 10` returning 10 rows is a complete answer.
|
|
2624
|
-
rowLimitSource,
|
|
2625
|
-
// The id from the bag that was actually attached, not the one supplied:
|
|
2626
|
-
// a bag shed under budget pressure sheds context last, but a caller
|
|
2627
|
-
// should be told the truth about what it can look up.
|
|
2628
|
-
queryCorrelationId: appliedQueryMetadata?.query_id ?? null,
|
|
2629
|
-
};
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
/**
|
|
2633
|
-
* The per-query metadata for one model query: the executing connection's
|
|
2634
|
-
* default, the caller's request override, and the server's context (which
|
|
2635
|
-
* package, which model, which class of work), merged most-specific-wins.
|
|
2636
|
-
*
|
|
2637
|
-
* There is no model-side layer here. `materialization.queryMetadata` describes
|
|
2638
|
-
* how a persist source is BUILT; a live query against the model is a different
|
|
2639
|
-
* unit of work, and inheriting a build's tags would attribute interactive
|
|
2640
|
-
* traffic to the build that happens to share the source.
|
|
2641
|
-
*
|
|
2642
|
-
* Fails open, like every other metadata path: a connection whose config can't
|
|
2643
|
-
* be read contributes no default rather than failing the query.
|
|
2644
|
-
*/
|
|
2645
|
-
private resolveQueryMetadata(
|
|
2646
|
-
input: ModelQueryMetadataInput | undefined,
|
|
2647
|
-
connectionName: string | undefined,
|
|
2648
|
-
): QueryMetadata | undefined {
|
|
2649
|
-
let connectionLayers: {
|
|
2650
|
-
default?: QueryMetadata | null;
|
|
2651
|
-
enforced?: QueryMetadata | null;
|
|
2652
|
-
} | null = null;
|
|
2653
|
-
if (connectionName && input?.connectionMetadata) {
|
|
2654
|
-
try {
|
|
2655
|
-
connectionLayers = input.connectionMetadata(connectionName);
|
|
2656
|
-
} catch {
|
|
2657
|
-
connectionLayers = null;
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
|
-
const resolved = mergeQueryMetadata({
|
|
2661
|
-
connection: connectionLayers?.default,
|
|
2662
|
-
enforced: connectionLayers?.enforced,
|
|
2663
|
-
request: input?.request,
|
|
2664
|
-
context: {
|
|
2665
|
-
queryClass: input?.queryClass ?? "interactive",
|
|
2666
|
-
environment: input?.environment,
|
|
2667
|
-
package: this.packageName,
|
|
2668
|
-
model: this.modelPath,
|
|
2669
|
-
version: input?.version,
|
|
2670
|
-
correlationId: input?.correlationId,
|
|
2671
|
-
},
|
|
2672
|
-
});
|
|
2673
|
-
if (resolved.drops.length > 0) {
|
|
2674
|
-
logger.warn("Dropped query-metadata properties for a query", {
|
|
2675
|
-
modelPath: this.modelPath,
|
|
2676
|
-
drops: resolved.drops,
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
return resolved.metadata;
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
private getStandardModel(): ApiCompiledModel {
|
|
2683
|
-
return {
|
|
2684
|
-
type: "source",
|
|
2685
|
-
packageName: this.packageName,
|
|
2686
|
-
modelPath: this.modelPath,
|
|
2687
|
-
malloyVersion: MALLOY_VERSION,
|
|
2688
|
-
dataStyles: JSON.stringify(this.dataStyles),
|
|
2689
|
-
modelDef: JSON.stringify(this.modelDef),
|
|
2690
|
-
// `this.modelInfo` is precomputed once at construction (either
|
|
2691
|
-
// by the worker or in the Model.create constructor); don't
|
|
2692
|
-
// re-run `modelDefToModelInfo` on every API hit.
|
|
2693
|
-
modelInfo: JSON.stringify(this.modelInfo ?? {}),
|
|
2694
|
-
sourceInfos: this.getSourceInfos()?.map((sourceInfo) =>
|
|
2695
|
-
JSON.stringify(sourceInfo),
|
|
2696
|
-
),
|
|
2697
|
-
// Discovery surface: an explore lists only its export closure
|
|
2698
|
-
// (getSources/getQueries curate); `this.sources` stays complete for
|
|
2699
|
-
// enforcement and resolution.
|
|
2700
|
-
sources: this.getSources(),
|
|
2701
|
-
queries: this.getQueries(),
|
|
2702
|
-
givens: this.givens,
|
|
2703
|
-
} as ApiCompiledModel;
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
/**
|
|
2707
|
-
* Serialize a notebook cell's `newSources` to the wire shape (an array
|
|
2708
|
-
* of JSON strings), embedding the model-level `givens` on every
|
|
2709
|
-
* SourceInfo so consumers iterating `newSources` can render `given:`
|
|
2710
|
-
* inputs without a second getModel round-trip. Matches `Source.givens`
|
|
2711
|
-
* in the API spec ("Identical to CompiledModel.givens") and how
|
|
2712
|
-
* `getSources` already copies the full list onto each CompiledModel
|
|
2713
|
-
* source. When the model declares no givens, the SourceInfo is emitted
|
|
2714
|
-
* untouched (no empty `givens` key).
|
|
2715
|
-
*
|
|
2716
|
-
* Shared by `getNotebookModel` (the notebook GET endpoint) and
|
|
2717
|
-
* `executeNotebookCell` (the cell-run endpoint) so both surface givens
|
|
2718
|
-
* identically.
|
|
2719
|
-
*/
|
|
2720
|
-
private serializeNewSources(
|
|
2721
|
-
newSources: Malloy.SourceInfo[] | undefined,
|
|
2722
|
-
): string[] | undefined {
|
|
2723
|
-
return newSources?.map((source) =>
|
|
2724
|
-
JSON.stringify(
|
|
2725
|
-
this.givens && this.givens.length > 0
|
|
2726
|
-
? { ...source, givens: this.givens }
|
|
2727
|
-
: source,
|
|
2728
|
-
),
|
|
2729
|
-
);
|
|
2730
|
-
}
|
|
2731
|
-
|
|
2732
|
-
private async getNotebookModel(): Promise<ApiRawNotebook> {
|
|
2733
|
-
// Return raw cell contents without executing them
|
|
2734
|
-
const notebookCells: ApiNotebookCell[] = (
|
|
2735
|
-
this.runnableNotebookCells as RunnableNotebookCell[]
|
|
2736
|
-
).map((cell) => {
|
|
2737
|
-
return {
|
|
2738
|
-
type: cell.type,
|
|
2739
|
-
text: cell.text,
|
|
2740
|
-
newSources: this.serializeNewSources(cell.newSources),
|
|
2741
|
-
queryInfo: cell.queryInfo
|
|
2742
|
-
? JSON.stringify(cell.queryInfo)
|
|
2743
|
-
: undefined,
|
|
2744
|
-
} as ApiNotebookCell;
|
|
2745
|
-
});
|
|
2746
|
-
|
|
2747
|
-
const allAnnotations = this.modelDef
|
|
2748
|
-
? new Annotations(modelAnnotations(this.modelDef)).texts()
|
|
2749
|
-
: [];
|
|
2750
|
-
|
|
2751
|
-
return {
|
|
2752
|
-
type: "notebook",
|
|
2753
|
-
packageName: this.packageName,
|
|
2754
|
-
modelPath: this.modelPath,
|
|
2755
|
-
malloyVersion: MALLOY_VERSION,
|
|
2756
|
-
modelInfo: JSON.stringify(this.modelInfo ?? {}),
|
|
2757
|
-
// Raw-notebook view is uncurated (complete `this.sources`/`this.queries`,
|
|
2758
|
-
// not the export-filtered `getSources`/`getQueries`): notebooks can't be
|
|
2759
|
-
// imported, so their in-file sources have no internal/import-only role to
|
|
2760
|
-
// hide — they're always public. Model files curate; notebooks don't.
|
|
2761
|
-
sources: this.modelDef && this.sources,
|
|
2762
|
-
queries: this.modelDef && this.queries,
|
|
2763
|
-
annotations: allAnnotations,
|
|
2764
|
-
notebookCells,
|
|
2765
|
-
} as ApiRawNotebook;
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
public async executeNotebookCell(
|
|
2769
|
-
cellIndex: number,
|
|
2770
|
-
filterParams?: FilterParams,
|
|
2771
|
-
bypassFilters?: boolean,
|
|
2772
|
-
givens?: Record<string, GivenValue>,
|
|
2773
|
-
// See `getQueryResults`: forwarded into `runnable.run` so the
|
|
2774
|
-
// publisher's wall-clock timeout actually cancels the query.
|
|
2775
|
-
abortSignal?: AbortSignal,
|
|
2776
|
-
// A notebook cell issues real backend SQL on an interactive path, so it is
|
|
2777
|
-
// tagged like any other query. No correlation id: the cell response has no
|
|
2778
|
-
// field to hand one back on, and an id nobody can read costs the result
|
|
2779
|
-
// cache for nothing.
|
|
2780
|
-
queryMetadataInput?: ModelQueryMetadataInput,
|
|
2781
|
-
): Promise<{
|
|
2782
|
-
type: "code" | "markdown";
|
|
2783
|
-
text: string;
|
|
2784
|
-
queryName?: string;
|
|
2785
|
-
result?: string;
|
|
2786
|
-
newSources?: string[];
|
|
2787
|
-
}> {
|
|
2788
|
-
if (this.compilationError) {
|
|
2789
|
-
throw this.compilationError;
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
if (!this.runnableNotebookCells) {
|
|
2793
|
-
throw new BadRequestError("No notebook cells available");
|
|
2794
|
-
}
|
|
2795
|
-
|
|
2796
|
-
if (cellIndex < 0 || cellIndex >= this.runnableNotebookCells.length) {
|
|
2797
|
-
throw new BadRequestError(
|
|
2798
|
-
`Cell index ${cellIndex} out of range (0-${this.runnableNotebookCells.length - 1})`,
|
|
2799
|
-
);
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
const cell = this.runnableNotebookCells[cellIndex];
|
|
2803
|
-
|
|
2804
|
-
if (cell.type === "markdown") {
|
|
2805
|
-
return {
|
|
2806
|
-
type: cell.type,
|
|
2807
|
-
text: cell.text,
|
|
2808
|
-
};
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
// Authorize gate — only cells that actually run a query touch data, so
|
|
2812
|
-
// gate exactly those (a source-def / import cell has no runnable and
|
|
2813
|
-
// accesses nothing). Gates the COMPILED cell query's own source (the
|
|
2814
|
-
// model-wide file-level gate for an unknown/inline source) PLUS every
|
|
2815
|
-
// source reached transitively via join_* — see
|
|
2816
|
-
// assertAuthorizedForAllSources. Before the execution try below so
|
|
2817
|
-
// AccessDeniedError stays a 403; independent of bypassFilters.
|
|
2818
|
-
if (cell.runnable) {
|
|
2819
|
-
await this.assertAuthorizedForAllSources(cell.runnable, givens ?? {});
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2822
|
-
// For code cells, execute the runnable if available
|
|
2823
|
-
let queryName: string | undefined = undefined;
|
|
2824
|
-
let queryResult: string | undefined = undefined;
|
|
2825
|
-
|
|
2826
|
-
if (cell.runnable) {
|
|
2827
|
-
try {
|
|
2828
|
-
let runnableToExecute = cell.runnable;
|
|
2829
|
-
|
|
2830
|
-
// If filters need to be applied, rebuild the query with a refinement
|
|
2831
|
-
if (!bypassFilters && cell.modelMaterializer) {
|
|
2832
|
-
const effectiveSource = extractRunTargetSourceName(cell.text);
|
|
2833
|
-
if (effectiveSource) {
|
|
2834
|
-
const filters = this.getFilters(effectiveSource);
|
|
2835
|
-
if (filters.length > 0) {
|
|
2836
|
-
const filterClause = buildFilterClause(
|
|
2837
|
-
filters,
|
|
2838
|
-
filterParams ?? {},
|
|
2839
|
-
);
|
|
2840
|
-
if (filterClause) {
|
|
2841
|
-
const refinedQuery = injectFilterRefinement(
|
|
2842
|
-
cell.text,
|
|
2843
|
-
filterClause,
|
|
2844
|
-
);
|
|
2845
|
-
runnableToExecute =
|
|
2846
|
-
cell.modelMaterializer.loadQuery(refinedQuery);
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
const cellMaxRows = getMaxQueryRows();
|
|
2853
|
-
const cellMaxBytes = getMaxResponseBytes();
|
|
2854
|
-
// Per-query freshness gate (see getQueryResults): the same
|
|
2855
|
-
// freshness-filtered manifest gates notebook-cell queries.
|
|
2856
|
-
const buildManifest = this.resolveFreshBuildManifest();
|
|
2857
|
-
// See getQueryResults / filterGivensToModelSurface: the gate
|
|
2858
|
-
// above already saw the full unfiltered givens.
|
|
2859
|
-
const cellSurfaceGivens = this.filterGivensToModelSurface(givens);
|
|
2860
|
-
const preparedCell = await runnableToExecute.getPreparedResult({
|
|
2861
|
-
givens: cellSurfaceGivens,
|
|
2862
|
-
buildManifest,
|
|
2863
|
-
});
|
|
2864
|
-
const rowLimit = resolveModelQueryRowLimit(
|
|
2865
|
-
preparedCell.resultExplore.limit,
|
|
2866
|
-
{
|
|
2867
|
-
defaultLimit: getDefaultQueryRowLimit(),
|
|
2868
|
-
maxRows: cellMaxRows,
|
|
2869
|
-
},
|
|
2870
|
-
);
|
|
2871
|
-
const result = await runnableToExecute.run({
|
|
2872
|
-
rowLimit,
|
|
2873
|
-
givens: cellSurfaceGivens,
|
|
2874
|
-
abortSignal,
|
|
2875
|
-
buildManifest,
|
|
2876
|
-
queryMetadata: this.resolveQueryMetadata(
|
|
2877
|
-
queryMetadataInput,
|
|
2878
|
-
preparedCell.connectionName,
|
|
2879
|
-
),
|
|
2880
|
-
});
|
|
2881
|
-
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
2882
|
-
queryName = (query as NamedQueryDef).as || query.name;
|
|
2883
|
-
queryResult =
|
|
2884
|
-
result?._queryResult &&
|
|
2885
|
-
this.modelInfo &&
|
|
2886
|
-
JSON.stringify(API.util.wrapResult(result));
|
|
2887
|
-
// Same caveat as `getQueryResults`: by the time we measure
|
|
2888
|
-
// bytes the response has already been buffered and stringified,
|
|
2889
|
-
// so this is loud-failure detection (clean 413 instead of
|
|
2890
|
-
// partial transmission), not OOM prevention. The row cap above
|
|
2891
|
-
// is the primary defense.
|
|
2892
|
-
if (result?._queryResult && queryResult) {
|
|
2893
|
-
assertWithinModelResponseLimits(
|
|
2894
|
-
result.totalRows,
|
|
2895
|
-
Buffer.byteLength(queryResult, "utf8"),
|
|
2896
|
-
{ maxRows: cellMaxRows, maxBytes: cellMaxBytes },
|
|
2897
|
-
"notebook_cell",
|
|
2898
|
-
);
|
|
2899
|
-
}
|
|
2900
|
-
} catch (error) {
|
|
2901
|
-
if (error instanceof FilterValidationError) {
|
|
2902
|
-
throw new BadRequestError(error.message);
|
|
2903
|
-
}
|
|
2904
|
-
// Bad client-supplied givens (unknown name, wrong-typed value,
|
|
2905
|
-
// finalized override, ...) surface as a Malloy `runtime-given-*`
|
|
2906
|
-
// error; see getQueryResults. Malloy validates, the publisher maps
|
|
2907
|
-
// to 400. Duck-type on `.code` (not a MalloyError, not root-exported).
|
|
2908
|
-
const givenCode = (error as { code?: string })?.code;
|
|
2909
|
-
if (
|
|
2910
|
-
typeof givenCode === "string" &&
|
|
2911
|
-
givenCode.startsWith("runtime-given-")
|
|
2912
|
-
) {
|
|
2913
|
-
logger.debug("Rejected client-supplied given", {
|
|
2914
|
-
environmentName: this.packageName,
|
|
2915
|
-
modelPath: this.modelPath,
|
|
2916
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2917
|
-
});
|
|
2918
|
-
throw new BadRequestError(
|
|
2919
|
-
error instanceof Error ? error.message : String(error),
|
|
2920
|
-
);
|
|
2921
|
-
}
|
|
2922
|
-
if (error instanceof MalloyError) {
|
|
2923
|
-
throw error;
|
|
2924
|
-
}
|
|
2925
|
-
// Surface PayloadTooLargeError as-is so the error middleware
|
|
2926
|
-
// maps it to HTTP 413; without this it would get swallowed
|
|
2927
|
-
// into a generic 400 BadRequestError below.
|
|
2928
|
-
if (error instanceof PayloadTooLargeError) {
|
|
2929
|
-
throw error;
|
|
2930
|
-
}
|
|
2931
|
-
const errorMessage =
|
|
2932
|
-
error instanceof Error ? error.message : String(error);
|
|
2933
|
-
if (errorMessage.trim() === "Model has no queries.") {
|
|
2934
|
-
return {
|
|
2935
|
-
type: "code",
|
|
2936
|
-
text: cell.text,
|
|
2937
|
-
};
|
|
2938
|
-
} else {
|
|
2939
|
-
logger.error("Error message: ", errorMessage);
|
|
2940
|
-
}
|
|
2941
|
-
throw new BadRequestError(`Cell execution failed: ${errorMessage}`);
|
|
2942
|
-
}
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
return {
|
|
2946
|
-
type: cell.type,
|
|
2947
|
-
text: cell.text,
|
|
2948
|
-
queryName: queryName,
|
|
2949
|
-
result: queryResult,
|
|
2950
|
-
newSources: this.serializeNewSources(cell.newSources),
|
|
2951
|
-
};
|
|
2952
|
-
}
|
|
2953
|
-
|
|
2954
|
-
static async getModelRuntime(
|
|
2955
|
-
packagePath: string,
|
|
2956
|
-
modelPath: string,
|
|
2957
|
-
malloyConfig: ModelConnectionInput,
|
|
2958
|
-
options?: { buildManifest?: BuildManifest["entries"] },
|
|
2959
|
-
): Promise<{
|
|
2960
|
-
runtime: Runtime;
|
|
2961
|
-
modelURL: URL;
|
|
2962
|
-
importBaseURL: URL;
|
|
2963
|
-
dataStyles: DataStyles;
|
|
2964
|
-
modelType: ModelType;
|
|
2965
|
-
}> {
|
|
2966
|
-
const fullModelPath = path.join(packagePath, modelPath);
|
|
2967
|
-
try {
|
|
2968
|
-
if (!(await fs.stat(fullModelPath)).isFile()) {
|
|
2969
|
-
throw new ModelNotFoundError(`${modelPath} is not a file.`);
|
|
2970
|
-
}
|
|
2971
|
-
} catch {
|
|
2972
|
-
throw new ModelNotFoundError(`${modelPath} does not exist.`);
|
|
2973
|
-
}
|
|
2974
|
-
|
|
2975
|
-
let modelType: ModelType;
|
|
2976
|
-
if (modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
2977
|
-
modelType = "model";
|
|
2978
|
-
} else if (modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
2979
|
-
modelType = "notebook";
|
|
2980
|
-
} else {
|
|
2981
|
-
throw new ModelNotFoundError(
|
|
2982
|
-
`${modelPath} is not a valid model name. Model files must end in .malloy or .malloynb.`,
|
|
2983
|
-
);
|
|
2984
|
-
}
|
|
2985
|
-
|
|
2986
|
-
const modelURL = new URL(`file://${fullModelPath}`);
|
|
2987
|
-
const baseUrl = new URL(".", modelURL);
|
|
2988
|
-
const importBaseURL = baseUrl;
|
|
2989
|
-
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
2990
|
-
|
|
2991
|
-
// Request runtimes borrow the cached package MalloyConfig. The package
|
|
2992
|
-
// owns release; callers must not release this runtime per request.
|
|
2993
|
-
const runtime = new Runtime({
|
|
2994
|
-
urlReader,
|
|
2995
|
-
config: Model.toMalloyConfig(malloyConfig),
|
|
2996
|
-
buildManifest: options?.buildManifest
|
|
2997
|
-
? { entries: options.buildManifest, strict: false }
|
|
2998
|
-
: undefined,
|
|
2999
|
-
});
|
|
3000
|
-
const dataStyles = urlReader.getHackyAccumulatedDataStyles();
|
|
3001
|
-
return { runtime, modelURL, importBaseURL, dataStyles, modelType };
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
private static toMalloyConfig(input: ModelConnectionInput): MalloyConfig {
|
|
3005
|
-
if (input instanceof MalloyConfig) {
|
|
3006
|
-
return input;
|
|
3007
|
-
}
|
|
3008
|
-
|
|
3009
|
-
const malloyConfig = new MalloyConfig({ connections: {} });
|
|
3010
|
-
malloyConfig.wrapConnections(
|
|
3011
|
-
() => new FixedConnectionMap(input, "duckdb"),
|
|
3012
|
-
);
|
|
3013
|
-
return malloyConfig;
|
|
3014
|
-
}
|
|
3015
|
-
|
|
3016
|
-
private static getQueries(modelDef: ModelDef): ApiQuery[] {
|
|
3017
|
-
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
3018
|
-
return extractQueriesFromModelDef(modelDef) as ApiQuery[];
|
|
3019
|
-
}
|
|
3020
|
-
|
|
3021
|
-
private static getSources(
|
|
3022
|
-
modelDef: ModelDef,
|
|
3023
|
-
givens?: ApiGiven[],
|
|
3024
|
-
): {
|
|
3025
|
-
sources: ApiSource[];
|
|
3026
|
-
filterMap: Map<string, FilterDefinition[]>;
|
|
3027
|
-
} {
|
|
3028
|
-
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
3029
|
-
// The service path logs filter parse failures; the worker stays silent.
|
|
3030
|
-
const { sources, filterMap } = extractSourcesFromModelDef(
|
|
3031
|
-
modelDef,
|
|
3032
|
-
givens,
|
|
3033
|
-
(sourceName, err) =>
|
|
3034
|
-
logger.warn(
|
|
3035
|
-
`Failed to parse filter annotations on source "${sourceName}"`,
|
|
3036
|
-
{ error: err },
|
|
3037
|
-
),
|
|
3038
|
-
);
|
|
3039
|
-
return { sources: sources as unknown as ApiSource[], filterMap };
|
|
3040
|
-
}
|
|
3041
|
-
|
|
3042
|
-
static async getModelMaterializer(
|
|
3043
|
-
runtime: Runtime,
|
|
3044
|
-
importBaseURL: URL,
|
|
3045
|
-
modelURL: URL,
|
|
3046
|
-
modelPath: string,
|
|
3047
|
-
): Promise<{
|
|
3048
|
-
modelMaterializer: ModelMaterializer | undefined;
|
|
3049
|
-
runnableNotebookCells: RunnableNotebookCell[] | undefined;
|
|
3050
|
-
}> {
|
|
3051
|
-
if (modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
3052
|
-
const modelMaterializer = await Model.getStandardModelMaterializer(
|
|
3053
|
-
runtime,
|
|
3054
|
-
importBaseURL,
|
|
3055
|
-
modelURL,
|
|
3056
|
-
modelPath,
|
|
3057
|
-
);
|
|
3058
|
-
return {
|
|
3059
|
-
modelMaterializer,
|
|
3060
|
-
runnableNotebookCells: undefined,
|
|
3061
|
-
};
|
|
3062
|
-
} else if (modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
3063
|
-
const { modelMaterializer: mm, runnableNotebookCells: rnc } =
|
|
3064
|
-
await Model.getNotebookModelMaterializer(
|
|
3065
|
-
runtime,
|
|
3066
|
-
importBaseURL,
|
|
3067
|
-
modelURL,
|
|
3068
|
-
modelPath,
|
|
3069
|
-
);
|
|
3070
|
-
return {
|
|
3071
|
-
modelMaterializer: mm,
|
|
3072
|
-
runnableNotebookCells: rnc,
|
|
3073
|
-
};
|
|
3074
|
-
} else {
|
|
3075
|
-
throw new Error(
|
|
3076
|
-
`${modelPath} is not a valid model name. Model files must end in .malloy or .malloynb.`,
|
|
3077
|
-
);
|
|
3078
|
-
}
|
|
3079
|
-
}
|
|
3080
|
-
|
|
3081
|
-
private static async getStandardModelMaterializer(
|
|
3082
|
-
runtime: Runtime,
|
|
3083
|
-
importBaseURL: URL,
|
|
3084
|
-
modelURL: URL,
|
|
3085
|
-
modelPath: string,
|
|
3086
|
-
): Promise<ModelMaterializer> {
|
|
3087
|
-
const mm = runtime.loadModel(modelURL, { importBaseURL });
|
|
3088
|
-
if (!mm) {
|
|
3089
|
-
throw new Error(`Invalid model ${modelPath}.`);
|
|
3090
|
-
}
|
|
3091
|
-
return mm;
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
|
-
private static async getNotebookModelMaterializer(
|
|
3095
|
-
runtime: Runtime,
|
|
3096
|
-
importBaseURL: URL,
|
|
3097
|
-
modelURL: URL,
|
|
3098
|
-
modelPath: string,
|
|
3099
|
-
): Promise<{
|
|
3100
|
-
modelMaterializer: ModelMaterializer | undefined;
|
|
3101
|
-
runnableNotebookCells: RunnableNotebookCell[];
|
|
3102
|
-
}> {
|
|
3103
|
-
let fileContents = undefined;
|
|
3104
|
-
let parse = undefined;
|
|
3105
|
-
|
|
3106
|
-
try {
|
|
3107
|
-
fileContents = await fs.readFile(modelURL, "utf8");
|
|
3108
|
-
} catch {
|
|
3109
|
-
throw new ModelNotFoundError("Model not found: " + modelPath);
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
try {
|
|
3113
|
-
parse = MalloySQLParser.parse(fileContents, modelPath);
|
|
3114
|
-
} catch {
|
|
3115
|
-
throw new Error("Could not parse model: " + modelPath);
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
let mm: ModelMaterializer | undefined = undefined;
|
|
3119
|
-
const oldImports: string[] = [];
|
|
3120
|
-
const oldSources: Record<string, Malloy.SourceInfo> = {};
|
|
3121
|
-
// First generate the sequence of ModelMaterializers.
|
|
3122
|
-
// This has to happen sync, since mm.getModel() is async and
|
|
3123
|
-
// may execute out-of-order.
|
|
3124
|
-
const mms = parse.statements.map((stmt) => {
|
|
3125
|
-
if (stmt.type === MalloySQLStatementType.MALLOY) {
|
|
3126
|
-
if (!mm) {
|
|
3127
|
-
mm = runtime.loadModel(stmt.text, { importBaseURL });
|
|
3128
|
-
} else {
|
|
3129
|
-
mm = mm.extendModel(stmt.text, { importBaseURL });
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
3132
|
-
return mm;
|
|
3133
|
-
});
|
|
3134
|
-
const runnableNotebookCells: RunnableNotebookCell[] = (
|
|
3135
|
-
await Promise.all(
|
|
3136
|
-
parse.statements.map(async (stmt, index) => {
|
|
3137
|
-
if (stmt.type === MalloySQLStatementType.MALLOY) {
|
|
3138
|
-
// Get the Materializer for the current cell/statement.
|
|
3139
|
-
const localMM = mms[index];
|
|
3140
|
-
if (!localMM) {
|
|
3141
|
-
// This can't happen because the to be in this branch there stmt must be
|
|
3142
|
-
// MalloySQLStatementType.MALLOY and we must have a model materializer.
|
|
3143
|
-
throw new Error("Model materializer is undefined");
|
|
3144
|
-
}
|
|
3145
|
-
// Pull available sources from the current model.
|
|
3146
|
-
// Add any of then that are new into newSources and then add them to oldSources.
|
|
3147
|
-
const currentModelDef = (await localMM.getModel())._modelDef;
|
|
3148
|
-
let newSources: Malloy.SourceInfo[] = [];
|
|
3149
|
-
const newImports = currentModelDef.imports?.slice(
|
|
3150
|
-
oldImports.length,
|
|
3151
|
-
);
|
|
3152
|
-
if (newImports) {
|
|
3153
|
-
await Promise.all(
|
|
3154
|
-
newImports.map(async (importLocation) => {
|
|
3155
|
-
const modelString = await runtime.urlReader.readURL(
|
|
3156
|
-
new URL(importLocation.importURL),
|
|
3157
|
-
);
|
|
3158
|
-
const importModel = (
|
|
3159
|
-
await runtime
|
|
3160
|
-
.loadModel(modelString as string, {
|
|
3161
|
-
importBaseURL,
|
|
3162
|
-
})
|
|
3163
|
-
.getModel()
|
|
3164
|
-
)._modelDef;
|
|
3165
|
-
const importModelInfo =
|
|
3166
|
-
modelDefToModelInfo(importModel);
|
|
3167
|
-
newSources = importModelInfo.entries
|
|
3168
|
-
.filter((entry) => entry.kind === "source")
|
|
3169
|
-
.filter(
|
|
3170
|
-
(source) => !(source.name in oldSources),
|
|
3171
|
-
) as Malloy.SourceInfo[];
|
|
3172
|
-
oldImports.push(importLocation.importURL.toString());
|
|
3173
|
-
}),
|
|
3174
|
-
);
|
|
3175
|
-
}
|
|
3176
|
-
const currentModelInfo = modelDefToModelInfo(currentModelDef);
|
|
3177
|
-
newSources = newSources.concat(
|
|
3178
|
-
currentModelInfo.entries
|
|
3179
|
-
.filter((entry) => entry.kind === "source")
|
|
3180
|
-
.filter(
|
|
3181
|
-
(source) => !(source.name in oldSources),
|
|
3182
|
-
) as Malloy.SourceInfo[],
|
|
3183
|
-
);
|
|
3184
|
-
|
|
3185
|
-
for (const source of newSources) {
|
|
3186
|
-
oldSources[source.name] = source;
|
|
3187
|
-
}
|
|
3188
|
-
|
|
3189
|
-
const runnable = localMM.loadFinalQuery();
|
|
3190
|
-
|
|
3191
|
-
// Extract QueryInfo from the runnable
|
|
3192
|
-
let queryInfo: Malloy.QueryInfo | undefined = undefined;
|
|
3193
|
-
try {
|
|
3194
|
-
const preparedQuery = await runnable.getPreparedQuery();
|
|
3195
|
-
const query = preparedQuery._query as NamedQueryDef;
|
|
3196
|
-
const queryName = query.as || query.name;
|
|
3197
|
-
const anonymousQuery =
|
|
3198
|
-
currentModelInfo.anonymous_queries[
|
|
3199
|
-
currentModelInfo.anonymous_queries.length - 1
|
|
3200
|
-
];
|
|
3201
|
-
|
|
3202
|
-
if (anonymousQuery) {
|
|
3203
|
-
queryInfo = {
|
|
3204
|
-
name: queryName,
|
|
3205
|
-
schema: anonymousQuery.schema,
|
|
3206
|
-
annotations: anonymousQuery.annotations,
|
|
3207
|
-
definition: anonymousQuery.definition,
|
|
3208
|
-
code: anonymousQuery.code,
|
|
3209
|
-
location: anonymousQuery.location,
|
|
3210
|
-
} as Malloy.QueryInfo;
|
|
3211
|
-
}
|
|
3212
|
-
} catch (_error) {
|
|
3213
|
-
// If we can't extract query info (e.g., no query in cell), that's okay
|
|
3214
|
-
// This can happen for cells that only define sources
|
|
3215
|
-
}
|
|
3216
|
-
|
|
3217
|
-
return {
|
|
3218
|
-
type: "code",
|
|
3219
|
-
text: stmt.text,
|
|
3220
|
-
runnable: runnable,
|
|
3221
|
-
modelMaterializer: localMM,
|
|
3222
|
-
newSources,
|
|
3223
|
-
queryInfo,
|
|
3224
|
-
} as RunnableNotebookCell;
|
|
3225
|
-
} else if (stmt.type === MalloySQLStatementType.MARKDOWN) {
|
|
3226
|
-
return {
|
|
3227
|
-
type: "markdown",
|
|
3228
|
-
text: stmt.text,
|
|
3229
|
-
} as RunnableNotebookCell;
|
|
3230
|
-
} else {
|
|
3231
|
-
return undefined;
|
|
3232
|
-
}
|
|
3233
|
-
}),
|
|
3234
|
-
)
|
|
3235
|
-
).filter((cell) => cell !== undefined);
|
|
3236
|
-
|
|
3237
|
-
return {
|
|
3238
|
-
modelMaterializer: mm,
|
|
3239
|
-
runnableNotebookCells: runnableNotebookCells,
|
|
3240
|
-
};
|
|
3241
|
-
}
|
|
3242
|
-
|
|
3243
|
-
public getModelType(): ModelType {
|
|
3244
|
-
return this.modelType;
|
|
3245
|
-
}
|
|
3246
|
-
|
|
3247
|
-
public async getFileText(packagePath: string): Promise<string> {
|
|
3248
|
-
const fullPath = path.join(packagePath, this.modelPath);
|
|
3249
|
-
try {
|
|
3250
|
-
return await fs.readFile(fullPath, "utf8");
|
|
3251
|
-
} catch {
|
|
3252
|
-
throw new ModelNotFoundError(
|
|
3253
|
-
`Model file not found: ${this.modelPath}`,
|
|
3254
|
-
);
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
// ──────────────────────────────────────────────────────────────────────
|
|
3260
|
-
// Helpers for hydrating worker-compiled models on the main thread
|
|
3261
|
-
// ──────────────────────────────────────────────────────────────────────
|
|
3262
|
-
|
|
3263
|
-
/**
|
|
3264
|
-
* Minimal subset of `Runtime` we use here. The `_` methods are
|
|
3265
|
-
* marked `@internal` in Malloy but are the only API for constructing
|
|
3266
|
-
* a materializer / query materializer from an existing `modelDef` /
|
|
3267
|
-
* queryDef — the public `loadModel(url)` path always recompiles.
|
|
3268
|
-
*/
|
|
3269
|
-
type HydrationRuntime = Runtime & {
|
|
3270
|
-
_loadModelFromModelDef(modelDef: ModelDef): ModelMaterializer;
|
|
3271
|
-
};
|
|
3272
|
-
type HydrationMaterializer = ModelMaterializer & {
|
|
3273
|
-
_loadQueryFromQueryDef(query: unknown): QueryMaterializer;
|
|
3274
|
-
};
|
|
3275
|
-
|
|
3276
|
-
function makeHydrationRuntime(
|
|
3277
|
-
malloyConfig: ModelConnectionInput,
|
|
3278
|
-
buildManifest?: BuildManifest["entries"],
|
|
3279
|
-
): HydrationRuntime {
|
|
3280
|
-
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
3281
|
-
const config =
|
|
3282
|
-
malloyConfig instanceof MalloyConfig
|
|
3283
|
-
? malloyConfig
|
|
3284
|
-
: (() => {
|
|
3285
|
-
const c = new MalloyConfig({ connections: {} });
|
|
3286
|
-
c.wrapConnections(
|
|
3287
|
-
() => new FixedConnectionMap(malloyConfig, "duckdb"),
|
|
3288
|
-
);
|
|
3289
|
-
return c;
|
|
3290
|
-
})();
|
|
3291
|
-
// Thread the package's bound build manifest into the *serve* runtime. Malloy
|
|
3292
|
-
// substitutes a persisted source for its materialized table at query
|
|
3293
|
-
// (getSQL) time, gated on `prepareResultOptions.buildManifest`; without this
|
|
3294
|
-
// the hydrated model always recomputes from the base tables even though the
|
|
3295
|
-
// manifest was bound at load. `strict: false` keeps serving live for any
|
|
3296
|
-
// source whose sourceEntityId is absent from the manifest.
|
|
3297
|
-
return new Runtime({
|
|
3298
|
-
urlReader,
|
|
3299
|
-
config,
|
|
3300
|
-
buildManifest: buildManifest
|
|
3301
|
-
? { entries: buildManifest, strict: false }
|
|
3302
|
-
: undefined,
|
|
3303
|
-
}) as HydrationRuntime;
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
/**
|
|
3307
|
-
* Build the live `RunnableNotebookCell[]` from worker-emitted
|
|
3308
|
-
* per-cell data. Each MALLOY cell is hydrated via
|
|
3309
|
-
* `Runtime._loadModelFromModelDef` (for the cell's scope) and
|
|
3310
|
-
* `ModelMaterializer._loadQueryFromQueryDef` (for the cell's
|
|
3311
|
-
* runnable) — no recompile.
|
|
3312
|
-
*/
|
|
3313
|
-
function hydrateNotebookCells(
|
|
3314
|
-
runtime: HydrationRuntime,
|
|
3315
|
-
notebookCells: SerializedNotebookCell[] | undefined,
|
|
3316
|
-
): RunnableNotebookCell[] {
|
|
3317
|
-
if (!notebookCells) return [];
|
|
3318
|
-
return notebookCells.map((sc): RunnableNotebookCell => {
|
|
3319
|
-
if (sc.type === "markdown") {
|
|
3320
|
-
return { type: "markdown", text: sc.text };
|
|
3321
|
-
}
|
|
3322
|
-
const cellModelDef = sc.cellModelDef as ModelDef | undefined;
|
|
3323
|
-
let modelMaterializer: ModelMaterializer | undefined;
|
|
3324
|
-
let runnable: QueryMaterializer | undefined;
|
|
3325
|
-
if (cellModelDef) {
|
|
3326
|
-
modelMaterializer = runtime._loadModelFromModelDef(cellModelDef);
|
|
3327
|
-
if (sc.cellQueryDef !== undefined) {
|
|
3328
|
-
try {
|
|
3329
|
-
runnable = (
|
|
3330
|
-
modelMaterializer as HydrationMaterializer
|
|
3331
|
-
)._loadQueryFromQueryDef(sc.cellQueryDef);
|
|
3332
|
-
} catch (error) {
|
|
3333
|
-
// Hydration shouldn't fail for a queryDef the worker
|
|
3334
|
-
// already prepared, but if Malloy's internal shape
|
|
3335
|
-
// drifts we'd rather drop the runnable than crash the
|
|
3336
|
-
// whole notebook. The cell remains markdown-runnable.
|
|
3337
|
-
logger.warn("Failed to hydrate notebook cell queryDef", {
|
|
3338
|
-
error,
|
|
3339
|
-
});
|
|
3340
|
-
}
|
|
3341
|
-
}
|
|
3342
|
-
}
|
|
3343
|
-
return {
|
|
3344
|
-
type: "code",
|
|
3345
|
-
text: sc.text,
|
|
3346
|
-
runnable,
|
|
3347
|
-
modelMaterializer,
|
|
3348
|
-
newSources: sc.newSources as Malloy.SourceInfo[] | undefined,
|
|
3349
|
-
queryInfo: sc.queryInfo as Malloy.QueryInfo | undefined,
|
|
3350
|
-
};
|
|
3351
|
-
});
|
|
3352
|
-
}
|
|
3353
|
-
|
|
3354
|
-
/**
|
|
3355
|
-
* For an all-markdown notebook (no MALLOY statements → no
|
|
3356
|
-
* `modelDef`), we still want to preserve the cell list so
|
|
3357
|
-
* `getNotebook()` can serve raw text. This skips materializer
|
|
3358
|
-
* hydration (there's nothing to hydrate) and returns markdown-only
|
|
3359
|
-
* cells.
|
|
3360
|
-
*/
|
|
3361
|
-
function hydrateMarkdownOnlyCells(
|
|
3362
|
-
notebookCells: SerializedNotebookCell[] | undefined,
|
|
3363
|
-
): RunnableNotebookCell[] | undefined {
|
|
3364
|
-
if (!notebookCells) return undefined;
|
|
3365
|
-
return notebookCells.map((sc): RunnableNotebookCell => {
|
|
3366
|
-
if (sc.type === "markdown") return { type: "markdown", text: sc.text };
|
|
3367
|
-
// A code cell without a hydratable scope — surface text only.
|
|
3368
|
-
return { type: "code", text: sc.text };
|
|
3369
|
-
});
|
|
3370
|
-
}
|