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