@malloy-publisher/server 0.0.235 → 0.0.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/app/api-doc.yaml +83 -27
  2. package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
  3. package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
  4. package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
  5. package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
  7. package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
  8. package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
  9. package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
  10. package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
  12. package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
  13. package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
  14. package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
  15. package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
  16. package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
  17. package/dist/app/index.html +1 -1
  18. package/dist/package_load_worker.mjs +59 -20
  19. package/dist/server.mjs +1342 -616
  20. package/package.json +8 -2
  21. package/.eslintrc.json +0 -44
  22. package/.prettierignore +0 -1
  23. package/.prettierrc +0 -4
  24. package/build.ts +0 -97
  25. package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
  26. package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
  27. package/eslint.config.mjs +0 -76
  28. package/k6-tests/README.md +0 -144
  29. package/k6-tests/bun.lock +0 -797
  30. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  31. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  32. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  33. package/k6-tests/load-test/load-test-crud.ts +0 -142
  34. package/k6-tests/load-test/load-test.ts +0 -784
  35. package/k6-tests/package.json +0 -27
  36. package/k6-tests/scripts/run-with-otel.sh +0 -50
  37. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  38. package/k6-tests/tsconfig.json +0 -29
  39. package/k6-tests/utils/client_factory.ts +0 -128
  40. package/k6-tests/utils/common.ts +0 -489
  41. package/k6-tests/utils/logger.ts +0 -62
  42. package/k6-tests/utils/types.d.ts +0 -35
  43. package/malloy_mcp.dxt +0 -0
  44. package/publisher.config.example.bigquery.json +0 -33
  45. package/publisher.config.example.duckdb.json +0 -23
  46. package/publisher.config.json +0 -23
  47. package/scripts/bake-duckdb-extensions.js +0 -110
  48. package/src/config.spec.ts +0 -1535
  49. package/src/config.theme.spec.ts +0 -178
  50. package/src/config.ts +0 -1142
  51. package/src/constants.ts +0 -105
  52. package/src/controller/compile.controller.ts +0 -40
  53. package/src/controller/connection.controller.spec.ts +0 -849
  54. package/src/controller/connection.controller.ts +0 -901
  55. package/src/controller/database.controller.ts +0 -24
  56. package/src/controller/materialization.controller.spec.ts +0 -293
  57. package/src/controller/materialization.controller.ts +0 -319
  58. package/src/controller/model.controller.ts +0 -164
  59. package/src/controller/package.controller.spec.ts +0 -273
  60. package/src/controller/package.controller.ts +0 -306
  61. package/src/controller/query.controller.ts +0 -152
  62. package/src/controller/theme.controller.ts +0 -83
  63. package/src/controller/watch-mode.controller.ts +0 -243
  64. package/src/data_styles.ts +0 -71
  65. package/src/default-publisher.config.json +0 -23
  66. package/src/ducklake_version.spec.ts +0 -163
  67. package/src/ducklake_version.ts +0 -153
  68. package/src/errors.spec.ts +0 -119
  69. package/src/errors.ts +0 -237
  70. package/src/filter_deprecation.spec.ts +0 -64
  71. package/src/filter_deprecation.ts +0 -42
  72. package/src/health.spec.ts +0 -123
  73. package/src/health.ts +0 -233
  74. package/src/heap_check.spec.ts +0 -144
  75. package/src/heap_check.ts +0 -144
  76. package/src/instrumentation.ts +0 -214
  77. package/src/json_utils.spec.ts +0 -51
  78. package/src/json_utils.ts +0 -33
  79. package/src/logger.spec.ts +0 -210
  80. package/src/logger.ts +0 -251
  81. package/src/malloy_pin_prereqs.spec.ts +0 -25
  82. package/src/materialization_metrics.spec.ts +0 -169
  83. package/src/materialization_metrics.ts +0 -346
  84. package/src/mcp/error_messages.spec.ts +0 -35
  85. package/src/mcp/error_messages.ts +0 -183
  86. package/src/mcp/handler_utils.spec.ts +0 -108
  87. package/src/mcp/handler_utils.ts +0 -251
  88. package/src/mcp/mcp_constants.ts +0 -6
  89. package/src/mcp/query_envelope.spec.ts +0 -229
  90. package/src/mcp/query_envelope.ts +0 -240
  91. package/src/mcp/server.protocol.spec.ts +0 -250
  92. package/src/mcp/server.ts +0 -87
  93. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  94. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  95. package/src/mcp/skills/skills_bundle.json +0 -1
  96. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  97. package/src/mcp/tool_response.spec.ts +0 -108
  98. package/src/mcp/tool_response.ts +0 -138
  99. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  100. package/src/mcp/tools/compile_tool.ts +0 -208
  101. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  102. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  103. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  104. package/src/mcp/tools/docs_search_tool.ts +0 -128
  105. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  106. package/src/mcp/tools/embedding_index.ts +0 -808
  107. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  108. package/src/mcp/tools/execute_query_tool.ts +0 -316
  109. package/src/mcp/tools/get_context_eval.ts +0 -275
  110. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  111. package/src/mcp/tools/get_context_tool.ts +0 -585
  112. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  113. package/src/mcp/tools/reload_package_tool.ts +0 -132
  114. package/src/mcp_config.spec.ts +0 -919
  115. package/src/mcp_config.ts +0 -425
  116. package/src/oom_guards.integration.spec.ts +0 -269
  117. package/src/package_load/package_load_pool.spec.ts +0 -252
  118. package/src/package_load/package_load_pool.ts +0 -931
  119. package/src/package_load/package_load_worker.ts +0 -1034
  120. package/src/package_load/protocol.ts +0 -364
  121. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  122. package/src/package_load/rpc_wait_accountant.ts +0 -76
  123. package/src/package_load_metrics.spec.ts +0 -114
  124. package/src/package_load_metrics.ts +0 -127
  125. package/src/path_safety.spec.ts +0 -158
  126. package/src/path_safety.ts +0 -146
  127. package/src/pg_helpers.spec.ts +0 -223
  128. package/src/pg_helpers.ts +0 -52
  129. package/src/query_cap_metrics.spec.ts +0 -89
  130. package/src/query_cap_metrics.ts +0 -116
  131. package/src/query_concurrency.spec.ts +0 -247
  132. package/src/query_concurrency.ts +0 -237
  133. package/src/query_metadata_metrics.ts +0 -49
  134. package/src/query_param_utils.ts +0 -29
  135. package/src/query_timeout.spec.ts +0 -224
  136. package/src/query_timeout.ts +0 -179
  137. package/src/runtime/publisher.js +0 -328
  138. package/src/server-old.ts +0 -988
  139. package/src/server.ts +0 -2059
  140. package/src/service/annotations.spec.ts +0 -118
  141. package/src/service/annotations.ts +0 -91
  142. package/src/service/authorize.spec.ts +0 -154
  143. package/src/service/authorize.ts +0 -498
  144. package/src/service/authorize_integration.spec.ts +0 -2041
  145. package/src/service/build_plan.spec.ts +0 -685
  146. package/src/service/build_plan.ts +0 -797
  147. package/src/service/compile_authorize.spec.ts +0 -85
  148. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  149. package/src/service/connection.spec.ts +0 -2360
  150. package/src/service/connection.ts +0 -2187
  151. package/src/service/connection_config.spec.ts +0 -734
  152. package/src/service/connection_config.ts +0 -899
  153. package/src/service/connection_federation.spec.ts +0 -184
  154. package/src/service/connection_fingerprint.spec.ts +0 -102
  155. package/src/service/connection_service.spec.ts +0 -711
  156. package/src/service/connection_service.ts +0 -291
  157. package/src/service/cron_evaluator.spec.ts +0 -79
  158. package/src/service/cron_evaluator.ts +0 -105
  159. package/src/service/db_utils.spec.ts +0 -1085
  160. package/src/service/db_utils.ts +0 -1502
  161. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  162. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  163. package/src/service/embedding_provider.spec.ts +0 -329
  164. package/src/service/embedding_provider.ts +0 -236
  165. package/src/service/environment.ts +0 -2191
  166. package/src/service/environment_admission.spec.ts +0 -344
  167. package/src/service/environment_compile.spec.ts +0 -197
  168. package/src/service/environment_store.spec.ts +0 -2274
  169. package/src/service/environment_store.ts +0 -2434
  170. package/src/service/environment_store_anchoring.spec.ts +0 -107
  171. package/src/service/environment_store_clone.spec.ts +0 -350
  172. package/src/service/explore_visibility.spec.ts +0 -434
  173. package/src/service/exports_probe.spec.ts +0 -107
  174. package/src/service/extension_fetch_policy.spec.ts +0 -256
  175. package/src/service/filter.spec.ts +0 -458
  176. package/src/service/filter.ts +0 -341
  177. package/src/service/filter_bypass.spec.ts +0 -418
  178. package/src/service/filter_integration.spec.ts +0 -1004
  179. package/src/service/freshness.spec.ts +0 -183
  180. package/src/service/freshness.ts +0 -112
  181. package/src/service/gcs_s3_utils.ts +0 -427
  182. package/src/service/given.ts +0 -105
  183. package/src/service/givens_integration.spec.ts +0 -219
  184. package/src/service/manifest_loader.spec.ts +0 -189
  185. package/src/service/manifest_loader.ts +0 -156
  186. package/src/service/materialization_build_session.spec.ts +0 -435
  187. package/src/service/materialization_build_session.ts +0 -681
  188. package/src/service/materialization_config_validation.spec.ts +0 -99
  189. package/src/service/materialization_config_validation.ts +0 -120
  190. package/src/service/materialization_eligibility.spec.ts +0 -158
  191. package/src/service/materialization_eligibility.ts +0 -305
  192. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  193. package/src/service/materialization_scheduler.spec.ts +0 -289
  194. package/src/service/materialization_scheduler.ts +0 -304
  195. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  196. package/src/service/materialization_serve_transform.ts +0 -779
  197. package/src/service/materialization_service.spec.ts +0 -2373
  198. package/src/service/materialization_service.ts +0 -2342
  199. package/src/service/materialization_test_fixtures.ts +0 -249
  200. package/src/service/model.spec.ts +0 -1157
  201. package/src/service/model.ts +0 -3370
  202. package/src/service/model_limits.spec.ts +0 -209
  203. package/src/service/model_limits.ts +0 -131
  204. package/src/service/model_storage_serve.spec.ts +0 -193
  205. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  206. package/src/service/package.spec.ts +0 -612
  207. package/src/service/package.ts +0 -1799
  208. package/src/service/package_manifest.spec.ts +0 -230
  209. package/src/service/package_manifest.ts +0 -241
  210. package/src/service/package_memory_governor.spec.ts +0 -173
  211. package/src/service/package_memory_governor.ts +0 -233
  212. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  213. package/src/service/package_race.spec.ts +0 -208
  214. package/src/service/package_reload_safety.spec.ts +0 -193
  215. package/src/service/package_rollback.spec.ts +0 -190
  216. package/src/service/package_worker_path.spec.ts +0 -645
  217. package/src/service/path_injection.spec.ts +0 -39
  218. package/src/service/persist_annotation_validation.spec.ts +0 -89
  219. package/src/service/persist_annotation_validation.ts +0 -59
  220. package/src/service/persistence_policy.spec.ts +0 -374
  221. package/src/service/proxy.spec.ts +0 -414
  222. package/src/service/proxy.ts +0 -248
  223. package/src/service/query_boundary.spec.ts +0 -470
  224. package/src/service/query_metadata.spec.ts +0 -408
  225. package/src/service/query_metadata.ts +0 -492
  226. package/src/service/query_metadata_identity.spec.ts +0 -149
  227. package/src/service/query_text.spec.ts +0 -79
  228. package/src/service/query_text.ts +0 -43
  229. package/src/service/quoting.spec.ts +0 -135
  230. package/src/service/quoting.ts +0 -78
  231. package/src/service/resolve_environment.ts +0 -15
  232. package/src/service/restricted_mode.spec.ts +0 -299
  233. package/src/service/source_extraction.ts +0 -225
  234. package/src/service/theme_store.ts +0 -199
  235. package/src/storage/DatabaseInterface.ts +0 -198
  236. package/src/storage/StorageManager.ts +0 -113
  237. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  238. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  239. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  240. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  241. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  242. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  243. package/src/storage/duckdb/PackageRepository.ts +0 -135
  244. package/src/storage/duckdb/schema.ts +0 -229
  245. package/src/stream_helpers.spec.ts +0 -280
  246. package/src/stream_helpers.ts +0 -162
  247. package/src/telemetry.ts +0 -20
  248. package/src/test_helpers/metrics_harness.ts +0 -166
  249. package/src/theme_key_parity.spec.ts +0 -57
  250. package/src/utils.ts +0 -29
  251. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  252. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  253. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  254. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  255. package/tests/fixtures/html-pages-test/data.csv +0 -3
  256. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  257. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  258. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  259. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  260. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  261. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  262. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  263. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  264. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  265. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  266. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  267. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  268. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  269. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  270. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  271. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  272. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  273. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  274. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  275. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  276. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  277. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  278. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  279. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  280. package/tests/fixtures/persist-test/publisher.json +0 -5
  281. package/tests/fixtures/publisher.config.json +0 -15
  282. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  283. package/tests/fixtures/query-givens/model.malloy +0 -34
  284. package/tests/fixtures/query-givens/publisher.json +0 -5
  285. package/tests/fixtures/xlsx/database.xlsx +0 -0
  286. package/tests/harness/mcp_test_setup.ts +0 -332
  287. package/tests/harness/rest_e2e.ts +0 -68
  288. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  289. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  290. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  291. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  292. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  293. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  294. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  295. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  296. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  297. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  298. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  299. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  300. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  301. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  302. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  303. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  304. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  305. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  306. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  307. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  308. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  309. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  310. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  311. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  312. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  313. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  314. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  315. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  316. package/tests/unit/duckdb/repositories.test.ts +0 -208
  317. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  318. package/tests/unit/service/theme_store.spec.ts +0 -139
  319. package/tsconfig.json +0 -32
@@ -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
- }