@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,2342 +0,0 @@
1
- import type {
2
- Connection as MalloyConnection,
3
- PersistSource,
4
- } from "@malloydata/malloy";
5
- import { Manifest } from "@malloydata/malloy";
6
- import {
7
- BadRequestError,
8
- InvalidStateTransitionError,
9
- MaterializationConflictError,
10
- MaterializationEligibilityError,
11
- MaterializationNotFoundError,
12
- } from "../errors";
13
- import { logger } from "../logger";
14
- import {
15
- MaterializationMode,
16
- recordAutoLoadOutcome,
17
- recordChainedStorageBuild,
18
- recordDropTables,
19
- recordManifestBindDegraded,
20
- recordMaterializationRun,
21
- recordSourceBuildDuration,
22
- recordSourcesOutcome,
23
- recordStorageBuildFailure,
24
- } from "../materialization_metrics";
25
- import {
26
- BuildInstruction,
27
- BuildManifestResult,
28
- BuildPlan,
29
- FreshnessManifest,
30
- Materialization,
31
- MaterializationStatus,
32
- MaterializationUpdate,
33
- ManifestEntry,
34
- ManifestReference,
35
- ResourceRepository,
36
- } from "../storage/DatabaseInterface";
37
- import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
38
- import { errMessage } from "../utils";
39
- import {
40
- CompiledBuildPlan,
41
- compilePackageBuildPlan,
42
- computeSourceEntityId,
43
- deriveAnnotationFields,
44
- projectToPublicColumns,
45
- iterGraphSources,
46
- resolveQueryMetadata,
47
- } from "./build_plan";
48
- import {
49
- mergeQueryMetadata,
50
- type QueryContext,
51
- type QueryMetadata,
52
- } from "./query_metadata";
53
- import type { components } from "../api";
54
- import { getPersistStorageMode } from "../config";
55
- import { EnvironmentStore } from "./environment_store";
56
- import { assertMaterializationEligible } from "./materialization_eligibility";
57
- import {
58
- assertStorageServeShapeCompiles,
59
- buildDownstreamIntoStorage,
60
- buildSourceIntoStorage,
61
- dropStorageTable,
62
- type StorageBuildResult,
63
- } from "./materialization_build_session";
64
- import { escapeSQL } from "./connection";
65
- import {
66
- buildChainedStorageBuildModel,
67
- buildVirtualMap,
68
- deriveServeBindings,
69
- type ServeBinding,
70
- type SourceLocation,
71
- sliceSourceRange,
72
- } from "./materialization_serve_transform";
73
- import type { ApiConnection } from "./model";
74
- import { fetchManifestEntries, splitManifestEntries } from "./manifest_loader";
75
- import { readFileSync } from "fs";
76
- import { fileURLToPath } from "url";
77
- import {
78
- bareTableName,
79
- quoteIdentifier,
80
- quoteManifestTablePath,
81
- quoteTablePath,
82
- } from "./quoting";
83
- import { resolveEnvironmentId } from "./resolve_environment";
84
- import { redactPgSecrets } from "../pg_helpers";
85
-
86
- /**
87
- * The narrow environment surface the build path needs to materialize into a
88
- * `storage=` destination: resolve a connection's config by name (source creds to
89
- * federate, destination catalog to attach) and the environment root (to derive a
90
- * plain-DuckDB destination's file path). Kept minimal to avoid coupling the
91
- * materialization service to the full Environment type.
92
- */
93
- interface BuildEnvironment {
94
- getApiConnection(connectionName: string): ApiConnection;
95
- getEnvironmentPath(): string;
96
- }
97
-
98
- /**
99
- * What a build needs to tag the statements it issues: the package-level layer of
100
- * per-query metadata, and the run's own context. Assembled once per run — every
101
- * field is constant for the run, so a source only adds its own name.
102
- */
103
- interface BuildQueryMetadata {
104
- packageMaterialization:
105
- | components["schemas"]["PackageMaterializationConfig"]
106
- | null;
107
- context: QueryContext;
108
- }
109
-
110
- /**
111
- * A connection's two metadata layers: the overridable default and the properties
112
- * the deployment enforces.
113
- *
114
- * Fails open — a build must not fail because a connection's config could not be
115
- * read for its tags — so an unreadable connection costs the layers, never the
116
- * statement.
117
- */
118
- function connectionMetadataLayers(
119
- environment: BuildEnvironment,
120
- connectionName: string,
121
- ): { default: QueryMetadata | null; enforced: QueryMetadata | null } {
122
- try {
123
- const connection = environment.getApiConnection(connectionName);
124
- return {
125
- default: connection?.queryMetadata ?? null,
126
- enforced: connection?.queryMetadataEnforced ?? null,
127
- };
128
- } catch (error) {
129
- // Diagnosable rather than silent: the layer this costs is the enforced
130
- // one, and it is the only drop with no metric behind it.
131
- logger.debug("No query-metadata layers for connection", {
132
- connectionName,
133
- error,
134
- });
135
- return { default: null, enforced: null };
136
- }
137
- }
138
-
139
- /**
140
- * Length of the sourceEntityId prefix used when synthesizing staging table
141
- * names. 12 hex chars is 48 bits of entropy, well inside every dialect's
142
- * identifier limit (Postgres is the tightest at 63).
143
- */
144
- const STAGING_ID_LEN = 12;
145
-
146
- /** Staging suffix appended to a table name while it is being built. */
147
- export function stagingSuffix(sourceEntityId: string): string {
148
- // Drop hyphens so the suffix stays a bare identifier fragment when the id
149
- // becomes a UUID5 (a no-op for the current hex ids).
150
- return `_${sourceEntityId.replace(/-/g, "").substring(0, STAGING_ID_LEN)}`;
151
- }
152
-
153
- /**
154
- * Physical table name the publisher self-assigns in auto-run mode: the
155
- * `#@ persist name=<table>` value if present, else the Malloy source name.
156
- * The author owns quoting the `name=` value for the dialect.
157
- */
158
- function selfAssignTableName(persistSource: PersistSource): string {
159
- return deriveAnnotationFields(persistSource).name || persistSource.name;
160
- }
161
-
162
- /**
163
- * The build manifest for a `storage=` build, with storage-materialized entries
164
- * removed. A storage build runs in the source warehouse (passthrough), so it
165
- * cannot reference an upstream that landed in a DuckDB/DuckLake store — dropping
166
- * those entries makes the compiler INLINE the upstream (non-strict) or raise a
167
- * clean strict-miss (strict), instead of emitting a cross-engine table
168
- * reference. colocated entries are kept — the warehouse build can
169
- * reference them, carrying forward the DIALECT-QUOTED table path the seed loop
170
- * already stamped (publisher #904's quoteSeedTablePath), so the downstream FROM
171
- * resolves a case-preserved upstream on a case-folding engine. Preserves the
172
- * manifest's `strict` flag.
173
- */
174
- export function manifestExcludingStorage(
175
- manifest: Manifest,
176
- builtEntries: Record<string, ManifestEntry>,
177
- ): Manifest["buildManifest"] {
178
- const reduced = new Manifest();
179
- reduced.strict = manifest.strict;
180
- // The source manifest's entries are already dialect-quoted (#904); reuse that
181
- // quoting rather than the raw physical name so the kept colocated references
182
- // stay canonical for the downstream FROM.
183
- const quoted = manifest.buildManifest.entries;
184
- for (const [id, entry] of Object.entries(builtEntries)) {
185
- if (!entry.storageConnectionName && quoted[id]) {
186
- reduced.update(id, { tableName: quoted[id].tableName });
187
- }
188
- }
189
- return reduced.buildManifest;
190
- }
191
-
192
- /**
193
- * The `storage=` destination a source DECLARES (external-tier intent), or
194
- * undefined. Independent of `PERSIST_STORAGE_MODE` — reflects author intent, so
195
- * the build can tell a `storage=` source apart from a plain colocated
196
- * `#@ persist` even when the tier is off.
197
- */
198
- function declaredStorage(persistSource: PersistSource): string | undefined {
199
- return deriveAnnotationFields(persistSource).storage?.trim() || undefined;
200
- }
201
-
202
- /**
203
- * Resolve a persist source's `#@ persist storage=<ref>` to the EFFECTIVE
204
- * destination connection name for a build, or undefined for the default
205
- * colocated path (the source materializes into its own warehouse). Read
206
- * publisher-side from the compiled annotation (the same `annotationFields` map
207
- * the plan echoes); the reference resolves generically against registered
208
- * connections. Absent `storage=` ⇒ undefined (colocated); any value names a
209
- * registered connection to materialize into. Any managed-tier alias is resolved
210
- * by the host upstream and set on the wire instruction's `destination` — it
211
- * never reaches this publisher-side generic resolution.
212
- *
213
- * When `PERSIST_STORAGE_MODE=off` this returns undefined regardless of the
214
- * annotation, so the feature is a runtime kill switch that never fails a
215
- * package (the ignored `storage=` is surfaced as a package warning, not an
216
- * error). Undefined here does NOT mean "build it colocated" for a source that
217
- * declared `storage=` — `deriveSelfInstructions` skips such a source entirely so
218
- * it serves live; see {@link declaredStorage}.
219
- */
220
- function resolveStorageDestination(
221
- persistSource: PersistSource,
222
- ): string | undefined {
223
- if (getPersistStorageMode() === "off") return undefined;
224
- return declaredStorage(persistSource);
225
- }
226
-
227
- /** Connection-config keys whose string values are credentials to redact. */
228
- const SENSITIVE_KEY =
229
- /pass(word)?|secret|private_?key|service_?account|access_?key|token|connection_?string|account/i;
230
-
231
- /** Collect credential string values (from sensitively-named keys) in a config. */
232
- function collectSensitiveValues(value: unknown, out: Set<string>): void {
233
- if (value === null || typeof value !== "object") return;
234
- if (Array.isArray(value)) {
235
- for (const v of value) collectSensitiveValues(v, out);
236
- return;
237
- }
238
- for (const [key, v] of Object.entries(value)) {
239
- if (typeof v === "string" && v.length >= 4 && SENSITIVE_KEY.test(key)) {
240
- out.add(v);
241
- } else {
242
- collectSensitiveValues(v, out);
243
- }
244
- }
245
- }
246
-
247
- /**
248
- * Redact the actual credential values (from the given connection configs) out
249
- * of an error message, then surface the message. This keeps a build error
250
- * legible — a "schema not found" or "table does not exist" tells the operator
251
- * exactly what to fix — while never leaking the passwords / secrets / service
252
- * account JSON / connection strings a federation or attach error can echo. Only
253
- * the concrete secret values are removed, not the message structure.
254
- */
255
- export function redactConnectionSecrets(
256
- message: string,
257
- ...connections: unknown[]
258
- ): string {
259
- const secrets = new Set<string>();
260
- for (const c of connections) collectSensitiveValues(c, secrets);
261
- let redacted = redactPgSecrets(message);
262
- for (const s of secrets) {
263
- redacted = redacted.split(s).join("***");
264
- // A DuckDB error often echoes the offending SQL statement, in which a
265
- // secret containing a single quote appears single-quote-escaped (`''`) —
266
- // so the raw value won't match. Also redact the escaped form.
267
- const escaped = escapeSQL(s);
268
- if (escaped !== s) redacted = redacted.split(escaped).join("***");
269
- }
270
- return redacted;
271
- }
272
-
273
- /** Classify a thrown build error as cancelled (cooperative abort) or failed. */
274
- function outcomeFor(
275
- _err: unknown,
276
- signal: AbortSignal | undefined,
277
- ): "failed" | "cancelled" {
278
- return signal?.aborted ? "cancelled" : "failed";
279
- }
280
-
281
- /**
282
- * Allowed status transitions. The build runs without an intermediate
283
- * plan-ready pause: PENDING advances straight to MANIFEST_ROWS_READY once the
284
- * tables are built, then to MANIFEST_FILE_READY. MANIFEST_FILE_READY, FAILED,
285
- * and CANCELLED are terminal.
286
- */
287
- const VALID_TRANSITIONS: Record<
288
- MaterializationStatus,
289
- MaterializationStatus[]
290
- > = {
291
- PENDING: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
292
- MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
293
- MANIFEST_FILE_READY: [],
294
- FAILED: [],
295
- CANCELLED: [],
296
- };
297
-
298
- /**
299
- * Orchestrates single-call materialization builds.
300
- *
301
- * The build plan is a deterministic property of the compiled package
302
- * (`Package.buildPlan`), so there is no separate plan round-trip. On create
303
- * the publisher either auto-runs (self-assigns physical names from the
304
- * `#@ persist name=` annotation and builds + auto-loads every persist source)
305
- * or, when the caller supplies `buildInstructions` derived from
306
- * `Package.buildPlan`, builds directly into the caller-assigned names without
307
- * auto-loading the manifest. Both paths build in the background and return the
308
- * PENDING record immediately.
309
- *
310
- * At most one active materialization per (environment, package) is enforced
311
- * by the DB-level unique index on `materializations.active_key` (see
312
- * {@link MaterializationRepository}). Cancellation is cooperative via
313
- * AbortController.
314
- */
315
- export class MaterializationService {
316
- /** In-flight runs, so they can be cancelled. In-process only. */
317
- private runningAbortControllers = new Map<string, AbortController>();
318
-
319
- constructor(private environmentStore: EnvironmentStore) {}
320
-
321
- private get repository(): ResourceRepository {
322
- return this.environmentStore.storageManager.getRepository();
323
- }
324
-
325
- // ==================== STATE MACHINE ====================
326
-
327
- private validateTransition(
328
- current: MaterializationStatus,
329
- next: MaterializationStatus,
330
- ): void {
331
- if (!VALID_TRANSITIONS[current].includes(next)) {
332
- throw new InvalidStateTransitionError(
333
- `Cannot transition from ${current} to ${next}`,
334
- );
335
- }
336
- }
337
-
338
- private async transition(
339
- id: string,
340
- next: MaterializationStatus,
341
- extra?: Omit<MaterializationUpdate, "status">,
342
- ): Promise<Materialization> {
343
- const current = await this.repository.getMaterializationById(id);
344
- if (!current) {
345
- throw new MaterializationNotFoundError(
346
- `Materialization ${id} not found`,
347
- );
348
- }
349
- this.validateTransition(current.status, next);
350
- // Terminal transitions are operationally interesting (info); the
351
- // intermediate MANIFEST_ROWS_READY hop is routine bookkeeping (debug).
352
- const terminal =
353
- next === "MANIFEST_FILE_READY" ||
354
- next === "FAILED" ||
355
- next === "CANCELLED";
356
- logger[terminal ? "info" : "debug"]("Materialization transition", {
357
- materializationId: id,
358
- packageName: current.packageName,
359
- from: current.status,
360
- to: next,
361
- });
362
- return this.repository.updateMaterialization(id, {
363
- status: next,
364
- ...extra,
365
- });
366
- }
367
-
368
- // ==================== QUERIES ====================
369
-
370
- async listMaterializations(
371
- environmentName: string,
372
- packageName: string,
373
- options?: { limit?: number; offset?: number },
374
- ): Promise<Materialization[]> {
375
- const environmentId = await this.resolveEnvironmentId(environmentName);
376
- return this.repository.listMaterializations(
377
- environmentId,
378
- packageName,
379
- options,
380
- );
381
- }
382
-
383
- /**
384
- * Every materialization across all packages in an environment, newest first.
385
- * Each record carries its `packageName`, so an env-scoped view can group or
386
- * label by package without a per-package fan-out.
387
- */
388
- async listEnvironmentMaterializations(
389
- environmentName: string,
390
- options?: { limit?: number; offset?: number },
391
- ): Promise<Materialization[]> {
392
- const environmentId = await this.resolveEnvironmentId(environmentName);
393
- return this.repository.listMaterializationsByEnvironment(
394
- environmentId,
395
- options,
396
- );
397
- }
398
-
399
- /**
400
- * `created_at` of the newest scheduler-fired materialization for a package,
401
- * or null if none. The standalone scheduler uses this on its first arm to
402
- * recover a fire missed during downtime (see MaterializationScheduler.arm).
403
- */
404
- async getLatestScheduledFireAt(
405
- environmentName: string,
406
- packageName: string,
407
- ): Promise<Date | null> {
408
- const environmentId = await this.resolveEnvironmentId(environmentName);
409
- return this.repository.getLatestScheduledFireAt(
410
- environmentId,
411
- packageName,
412
- );
413
- }
414
-
415
- async getMaterialization(
416
- environmentName: string,
417
- packageName: string,
418
- id: string,
419
- ): Promise<Materialization> {
420
- const environmentId = await this.resolveEnvironmentId(environmentName);
421
- const m = await this.repository.getMaterializationById(id);
422
- if (
423
- !m ||
424
- m.environmentId !== environmentId ||
425
- m.packageName !== packageName
426
- ) {
427
- throw new MaterializationNotFoundError(
428
- `Materialization ${id} not found for package ${packageName}`,
429
- );
430
- }
431
- return m;
432
- }
433
-
434
- // ==================== CREATE + BUILD ====================
435
-
436
- /**
437
- * Create a materialization and build it in the background. Returns the
438
- * PENDING record immediately.
439
- *
440
- * Auto-run (default, no `buildInstructions`): self-assign physical names and
441
- * build + auto-load every persist source. Orchestrated (`buildInstructions`
442
- * present): build directly into the caller-assigned names from the package's
443
- * build plan, without auto-loading the manifest. When `buildInstructions` is
444
- * present it is validated synchronously against the package's compiled build
445
- * plan so a bad instruction is rejected at create time rather than failing
446
- * the background run.
447
- */
448
- async createMaterialization(
449
- environmentName: string,
450
- packageName: string,
451
- options: {
452
- forceRefresh?: boolean;
453
- sourceNames?: string[];
454
- buildInstructions?: BuildInstruction[];
455
- referenceManifest?: ManifestReference[];
456
- strictUpstreams?: boolean;
457
- /**
458
- * What initiated this run. `ON_DEMAND` (default) = a manual/API create;
459
- * `SCHEDULER` = the standalone materialization scheduler firing a
460
- * package's `materialization.schedule` cron. Recorded on the run
461
- * metadata so a scheduled rebuild is distinguishable from a manual one.
462
- */
463
- trigger?: "ON_DEMAND" | "SCHEDULER";
464
- /**
465
- * What the caller knows about this run and the publisher does not,
466
- * attached as query metadata to the statements the build issues. Its
467
- * `trigger` also covers the case the publisher's own `trigger` cannot
468
- * express (a publish), and its `runId` lets a caller's own id group the
469
- * build's statements instead of the publisher's materialization id.
470
- */
471
- runContext?: components["schemas"]["RunContext"] | null;
472
- } = {},
473
- ): Promise<Materialization> {
474
- const environmentId = await this.resolveEnvironmentId(environmentName);
475
-
476
- const environment = await this.environmentStore.getEnvironment(
477
- environmentName,
478
- false,
479
- );
480
- const pkg = await environment.getPackage(packageName, false);
481
-
482
- const buildInstructions = options.buildInstructions;
483
- const orchestrated = buildInstructions !== undefined;
484
- if (orchestrated) {
485
- this.validateInstructions(pkg.getBuildPlan(), buildInstructions);
486
- }
487
-
488
- const active = await this.repository.getActiveMaterialization(
489
- environmentId,
490
- packageName,
491
- );
492
- if (active) {
493
- throw this.activeConflict(packageName, active.id);
494
- }
495
-
496
- const forceRefresh = options.forceRefresh ?? false;
497
- const trigger = options.trigger ?? "ON_DEMAND";
498
- const metadata = {
499
- forceRefresh,
500
- sourceNames: options.sourceNames ?? null,
501
- mode: orchestrated ? "orchestrated" : "auto",
502
- trigger,
503
- };
504
-
505
- let created: Materialization;
506
- try {
507
- created = await this.repository.createMaterialization(
508
- environmentId,
509
- packageName,
510
- "PENDING",
511
- metadata,
512
- );
513
- } catch (err) {
514
- if (err instanceof DuplicateActiveMaterializationError) {
515
- const winner = await this.repository.getActiveMaterialization(
516
- environmentId,
517
- packageName,
518
- );
519
- throw this.activeConflict(packageName, winner?.id);
520
- }
521
- throw err;
522
- }
523
-
524
- this.runInBackground(created.id, (signal) =>
525
- this.runBuild(
526
- created.id,
527
- environmentName,
528
- packageName,
529
- {
530
- sourceNames: options.sourceNames,
531
- forceRefresh,
532
- buildInstructions,
533
- referenceManifest: options.referenceManifest,
534
- strictUpstreams: options.strictUpstreams,
535
- trigger,
536
- runContext: options.runContext ?? undefined,
537
- },
538
- signal,
539
- ),
540
- );
541
-
542
- return created;
543
- }
544
-
545
- /**
546
- * Single-call build, shared by auto-run and orchestrated mode. Compiles the
547
- * package build plan, derives the build instructions (self-assigned for
548
- * auto-run; caller-supplied for orchestrated), builds the instructed sources
549
- * into their physical tables, and commits the manifest. Auto-run additionally
550
- * loads the fresh manifest into the package models; orchestrated leaves
551
- * distribution to the caller.
552
- */
553
- private async runBuild(
554
- id: string,
555
- environmentName: string,
556
- packageName: string,
557
- opts: {
558
- sourceNames: string[] | undefined;
559
- forceRefresh: boolean;
560
- buildInstructions: BuildInstruction[] | undefined;
561
- referenceManifest: ManifestReference[] | undefined;
562
- strictUpstreams: boolean | undefined;
563
- trigger: "ON_DEMAND" | "SCHEDULER";
564
- runContext?: components["schemas"]["RunContext"];
565
- },
566
- signal: AbortSignal,
567
- ): Promise<void> {
568
- const orchestrated = opts.buildInstructions !== undefined;
569
- const mode: MaterializationMode = orchestrated ? "orchestrated" : "auto";
570
- logger.info("Materialization build started", {
571
- materializationId: id,
572
- packageName,
573
- mode,
574
- });
575
- const startedAt = Date.now();
576
-
577
- try {
578
- // Persist the run's start time so the UI can compute a duration
579
- // (start -> now while in-flight, start -> completed once terminal).
580
- await this.repository.updateMaterialization(id, {
581
- startedAt: new Date(startedAt),
582
- });
583
-
584
- const environmentId = await this.resolveEnvironmentId(environmentName);
585
- const environment = await this.environmentStore.getEnvironment(
586
- environmentName,
587
- false,
588
- );
589
- const pkg = await environment.getPackage(packageName, false);
590
-
591
- const compiled = await environment.withPackageLock(packageName, () =>
592
- compilePackageBuildPlan(pkg, signal),
593
- );
594
-
595
- // Backstop: refuse loudly if the package annotated a `#@ persist` source
596
- // that Malloy's getBuildPlan() silently dropped (a shape it doesn't
597
- // treat as a materializable root — see detectDroppedPersistSources).
598
- // Without this the build would report success with an empty manifest and
599
- // the source would serve live, contradicting the "hard refuse, never a
600
- // silent fallback" contract. Scoped to the sources this build targets so
601
- // a build of unrelated sources isn't blocked by a dropped sibling.
602
- const relevantDropped = (compiled.droppedPersistSources ?? []).filter(
603
- (d) => !opts.sourceNames || opts.sourceNames.includes(d.name),
604
- );
605
- if (relevantDropped.length > 0) {
606
- const names = relevantDropped.map((d) => `'${d.name}'`).join(", ");
607
- throw new MaterializationEligibilityError({
608
- message:
609
- `Source(s) ${names} are annotated '#@ persist' but were not ` +
610
- `recognized as a materializable source, so nothing would be ` +
611
- `built (they would be served live). Only query/aggregate ` +
612
- `sources materialize; a filtered pass-through does not. Persist ` +
613
- `a query source, or invoke a parameterized source with a bound ` +
614
- `argument, or drop the annotation to serve live.`,
615
- });
616
- }
617
-
618
- let instructions: BuildInstruction[];
619
- let carried: Record<string, ManifestEntry>;
620
- if (orchestrated) {
621
- instructions = opts.buildInstructions!;
622
- // Seed the build Manifest with the caller-supplied upstream
623
- // references so a downstream source's persist upstream (built in a
624
- // prior run, not rebuilt here) resolves to its existing physical
625
- // table instead of recomputing live. The reference key is the
626
- // compiler's manifest-lookup sourceEntityId (see ManifestReference).
627
- carried = this.referenceManifestToEntries(opts.referenceManifest);
628
- // Upstream resolution for an orchestrated build draws on three
629
- // sources, in DESCENDING precedence — a reference is an IDENTITY, not
630
- // a copy the caller must fully courier:
631
- // 1. The explicit `referenceManifest` couriered in THIS build call.
632
- // 2. The package's BOUND manifest (`manifestLocation`) — the set the
633
- // orchestrator distributed to this worker. This is what makes the
634
- // cross-worker flow work: a worker that never built the upstream
635
- // still holds its full entry via the refreshed manifest, so a
636
- // downstream can reuse the upstream's materialized table.
637
- // 3. This worker's own most-recent local manifest (skip-if-unchanged
638
- // cache) — same-worker reuse.
639
- // Each fills the storage fields (sourceName, storageConnectionName,
640
- // schema) a thin reference can't carry — required for a `storage=`
641
- // upstream's stack-on-the-parent rebind. Higher-precedence
642
- // fields win; each step is best-effort (a fetch/read failure leaves
643
- // the entries as they are).
644
- await this.seedFromBoundManifest(carried, pkg, instructions);
645
- if (Object.keys(carried).length > 0) {
646
- await this.resolveReferencesFromStore(
647
- carried,
648
- environmentId,
649
- packageName,
650
- id,
651
- );
652
- }
653
- } else {
654
- // Skip-if-unchanged: reuse tables from the most recent successful
655
- // manifest for sources whose sourceEntityId is unchanged, unless
656
- // forceRefresh.
657
- const priorEntries = opts.forceRefresh
658
- ? {}
659
- : await this.getMostRecentManifestEntries(
660
- environmentId,
661
- packageName,
662
- id,
663
- );
664
- ({ instructions, carried } = this.deriveSelfInstructions(
665
- compiled,
666
- opts.sourceNames,
667
- priorEntries,
668
- ));
669
- }
670
-
671
- const entries = await this.executeInstructedBuild(
672
- compiled,
673
- environment,
674
- instructions,
675
- carried,
676
- signal,
677
- opts.strictUpstreams ?? false,
678
- // Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
679
- // reclaimStorageTablesFromFailedRun.
680
- orchestrated ? { environmentId, packageName } : undefined,
681
- {
682
- // Optional for the same reason build_plan reads it optionally:
683
- // callers that build from a lighter package surface still resolve,
684
- // just without a package-level layer.
685
- packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
686
- context: {
687
- queryClass: "materialize",
688
- environment: environmentName,
689
- package: packageName,
690
- // The caller's trigger wins because it can express a publish,
691
- // which the publisher's own two-value trigger cannot.
692
- trigger:
693
- opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
694
- // Default to the materialization id: the publisher always has a
695
- // run id, so a build's statements group in the backend's query
696
- // history whether or not the caller supplied one.
697
- runId: opts.runContext?.runId ?? id,
698
- },
699
- },
700
- );
701
-
702
- const sourcesBuilt = instructions.length;
703
- const sourcesReused = Object.keys(carried).length;
704
- const durationMs = Date.now() - startedAt;
705
- await this.commitManifest(id, entries, {
706
- forceRefresh: opts.forceRefresh,
707
- sourceNames: opts.sourceNames ?? null,
708
- mode,
709
- trigger: opts.trigger,
710
- sourcesBuilt,
711
- sourcesReused,
712
- durationMs,
713
- });
714
-
715
- // Auto-run owns distribution: load the fresh manifest into the package
716
- // models so subsequent queries resolve to the materialized tables.
717
- // Orchestrated leaves distribution to the caller (manifestLocation).
718
- if (!orchestrated) {
719
- await this.autoLoadManifest(environment, packageName, entries);
720
- }
721
-
722
- recordSourcesOutcome("built", sourcesBuilt);
723
- recordSourcesOutcome("reused", sourcesReused);
724
- this.recordRun(mode, "success", startedAt);
725
- logger.info("Materialization build complete", {
726
- materializationId: id,
727
- packageName,
728
- mode,
729
- sourcesBuilt,
730
- sourcesReused,
731
- durationMs,
732
- });
733
- } catch (err) {
734
- this.recordRun(mode, outcomeFor(err, signal), startedAt);
735
- throw err;
736
- }
737
- }
738
-
739
- /**
740
- * Derive the publisher's own build instructions for auto-run. Each persist
741
- * source (respecting the optional sourceNames filter) gets a self-assigned
742
- * physical table name and COPY realization, unless its sourceEntityId is unchanged
743
- * since `priorEntries` — those are carried forward (reused) instead of
744
- * rebuilt.
745
- */
746
- private deriveSelfInstructions(
747
- compiled: CompiledBuildPlan,
748
- sourceNames: string[] | undefined,
749
- priorEntries: Record<string, ManifestEntry>,
750
- ): {
751
- instructions: BuildInstruction[];
752
- carried: Record<string, ManifestEntry>;
753
- } {
754
- const include = sourceNames ? new Set(sourceNames) : null;
755
- const instructions: BuildInstruction[] = [];
756
- const carried: Record<string, ManifestEntry> = {};
757
- const seen = new Set<string>();
758
-
759
- for (const graph of compiled.graphs) {
760
- for (const persistSource of iterGraphSources(
761
- graph,
762
- compiled.sources,
763
- )) {
764
- if (include && !include.has(persistSource.name)) continue;
765
-
766
- // Safety: a source that DECLARES `storage=` must never silently
767
- // downgrade to a colocated build when the external tier is disabled
768
- // (PERSIST_STORAGE_MODE=off). A colocated build writes a CTAS into the
769
- // source's OWN warehouse — which the author did not intend (they asked
770
- // for external storage; production grants this server read-only
771
- // warehouse access) and which could fail or land in an unexpected
772
- // schema. Skip it: the source is not materialized and serves LIVE, and
773
- // the mode warning (Package.storageWarnings) surfaces the degraded
774
- // state. A plain `#@ persist` (no `storage=`) is unaffected —
775
- // colocated IS its author's intent (the v0 path, ungated by the
776
- // storage kill switch).
777
- if (
778
- getPersistStorageMode() === "off" &&
779
- declaredStorage(persistSource)
780
- ) {
781
- continue;
782
- }
783
-
784
- const destination = resolveStorageDestination(persistSource);
785
- if (destination) {
786
- // Gate BEFORE computeSourceEntityId: an unbound parameter or a
787
- // given makes getSQL() (called inside computeSourceEntityId)
788
- // throw opaquely, so the eligibility refusal must fire first to
789
- // give a clean, actionable 422.
790
- assertMaterializationEligible(persistSource);
791
- }
792
-
793
- const sourceEntityId = computeSourceEntityId(
794
- persistSource,
795
- compiled.connectionDigests,
796
- );
797
- if (seen.has(sourceEntityId)) continue;
798
- seen.add(sourceEntityId);
799
-
800
- const prior = priorEntries[sourceEntityId];
801
- // Destination-scoped reuse: carry a prior table forward only when it
802
- // landed in the SAME destination. sourceEntityId is a pure content
803
- // address and does NOT encode the destination, so a source that adds,
804
- // drops, or switches `storage=` must rebuild — otherwise a
805
- // warehouse-landed (colocated) table would be silently reused for a
806
- // DuckLake serve that cannot resolve it.
807
- if (
808
- prior &&
809
- prior.physicalTableName &&
810
- (prior.storageConnectionName ?? undefined) === destination
811
- ) {
812
- carried[sourceEntityId] = prior;
813
- continue;
814
- }
815
-
816
- // Self-assign the physical name from `name=` (or the source name)
817
- // verbatim for BOTH the colocated and storage
818
- // destinations — the only difference between the two is which
819
- // connection the table lands in. A storage build replaces the table
820
- // atomically (`CREATE OR REPLACE`), so no generational decoration is
821
- // needed to make a rebuild safe. An orchestrated build ignores this
822
- // and trusts the host-supplied `physicalTableName`; the host owns any
823
- // generational, ownership-scoped naming.
824
- const logicalName = selfAssignTableName(persistSource);
825
- instructions.push({
826
- sourceEntityId,
827
- materializedTableId: `local-${sourceEntityId.substring(
828
- 0,
829
- STAGING_ID_LEN,
830
- )}`,
831
- physicalTableName: logicalName,
832
- realization: "COPY",
833
- ...(destination ? { destination } : {}),
834
- });
835
- }
836
- }
837
-
838
- return { instructions, carried };
839
- }
840
-
841
- /**
842
- * Project the caller-supplied upstream reference manifest into the seed
843
- * entry map `executeInstructedBuild` consumes. Each reference is keyed by the
844
- * compiler's manifest-lookup sourceEntityId and carries the physical table
845
- * name plus the connection it lives on — enough for the build Manifest to
846
- * resolve a downstream persist reference to the existing table, and to quote
847
- * that reference for the connection's dialect so it resolves on a
848
- * case-folding engine (see {@link quoteSeedTablePath}). `connectionName` is
849
- * optional on the wire: an older control plane that omits it seeds unquoted,
850
- * exactly as before.
851
- */
852
- private referenceManifestToEntries(
853
- referenceManifest: ManifestReference[] | undefined,
854
- ): Record<string, ManifestEntry> {
855
- const entries: Record<string, ManifestEntry> = {};
856
- for (const ref of referenceManifest ?? []) {
857
- entries[ref.sourceEntityId] = {
858
- sourceEntityId: ref.sourceEntityId,
859
- physicalTableName: ref.physicalTableName,
860
- connectionName: ref.connectionName,
861
- };
862
- }
863
- return entries;
864
- }
865
-
866
- /**
867
- * Resolve upstream references against this publisher's OWN most-recent
868
- * manifest, keyed by sourceEntityId (the same cache skip-if-unchanged reads).
869
- * A reference is fundamentally an IDENTITY, not a copy: for each one that
870
- * matches a locally persisted entry, fill in the fields a thin reference
871
- * can't carry (sourceName, storageConnectionName, schema — needed for a
872
- * `storage=` upstream's stack-on-the-parent rebind) from the local entry, while
873
- * letting any caller-supplied field WIN (the orchestrator is authoritative across a
874
- * stateless fleet). Mutates `carried` in place. Best-effort: a lookup failure
875
- * leaves the references exactly as supplied, so the cross-worker courier path
876
- * (a worker that never built the upstream, fed the full entry) is unaffected.
877
- */
878
- private async resolveReferencesFromStore(
879
- carried: Record<string, ManifestEntry>,
880
- environmentId: string,
881
- packageName: string,
882
- excludeId: string,
883
- ): Promise<void> {
884
- let cached: Record<string, ManifestEntry>;
885
- try {
886
- cached = await this.getMostRecentManifestEntries(
887
- environmentId,
888
- packageName,
889
- excludeId,
890
- );
891
- } catch (err) {
892
- logger.warn(
893
- "Reference resolve-local lookup failed; using references as supplied",
894
- {
895
- packageName,
896
- error: err instanceof Error ? err.message : String(err),
897
- },
898
- );
899
- return;
900
- }
901
- for (const [sourceEntityId, ref] of Object.entries(carried)) {
902
- const local = cached[sourceEntityId];
903
- if (!local) continue;
904
- // Local entry is the base; caller-supplied (defined) fields override it.
905
- const merged: ManifestEntry = { ...local };
906
- for (const [key, value] of Object.entries(ref)) {
907
- if (value !== undefined) {
908
- (merged as Record<string, unknown>)[key] = value;
909
- }
910
- }
911
- carried[sourceEntityId] = merged;
912
- }
913
- }
914
-
915
- /**
916
- * Seed upstream reuse from the package's BOUND manifest — the set the
917
- * orchestrator distributed to this worker via `manifestLocation`. This is the
918
- * cross-worker path: a worker that never built an upstream still holds its full
919
- * entry here (`storageConnectionName` + `schema` + `sourceName`), so a
920
- * downstream can reuse the upstream's materialized table instead of recomputing
921
- * it from raw. Adds any bound upstream not already carried (so a build needs no
922
- * explicit reference when the manifest is refreshed), and fills gaps in a
923
- * thin explicit reference — but an explicit `referenceManifest` field always
924
- * wins (it targets THIS build). Sources this build is producing are skipped
925
- * (built fresh, not reused). Best-effort: a fetch failure is ignored (the build
926
- * falls back to the local store / inline recompute). Mutates `carried`.
927
- */
928
- private async seedFromBoundManifest(
929
- carried: Record<string, ManifestEntry>,
930
- pkg: { getPackageMetadata(): { manifestLocation?: string | null } },
931
- instructions: BuildInstruction[],
932
- ): Promise<void> {
933
- const manifestLocation = pkg.getPackageMetadata().manifestLocation;
934
- if (!manifestLocation) return;
935
- let fetched;
936
- try {
937
- fetched = await fetchManifestEntries(manifestLocation);
938
- } catch (err) {
939
- logger.warn(
940
- "Build upstream resolution: bound manifest fetch failed; ignoring",
941
- {
942
- manifestLocation,
943
- error: err instanceof Error ? err.message : String(err),
944
- },
945
- );
946
- return;
947
- }
948
- // Reconstruct a ManifestEntry map from both tiers of the fetched manifest:
949
- // storage entries carry their full shape; colocated entries reconstruct from
950
- // the tableName manifest.
951
- const bound: Record<string, ManifestEntry> = {
952
- ...fetched.storageEntries,
953
- };
954
- for (const [eid, e] of Object.entries(fetched.tableNameManifest)) {
955
- if (!bound[eid]) {
956
- bound[eid] = {
957
- sourceEntityId: eid,
958
- physicalTableName: e.tableName,
959
- connectionName: e.connectionName,
960
- };
961
- }
962
- }
963
- const building = new Set(instructions.map((i) => i.sourceEntityId));
964
- for (const [eid, entry] of Object.entries(bound)) {
965
- if (building.has(eid)) continue;
966
- const existing = carried[eid];
967
- if (!existing) {
968
- carried[eid] = entry;
969
- continue;
970
- }
971
- // Explicit reference wins; the bound entry fills the gaps it left.
972
- const merged: ManifestEntry = { ...entry };
973
- for (const [key, value] of Object.entries(existing)) {
974
- if (value !== undefined) {
975
- (merged as Record<string, unknown>)[key] = value;
976
- }
977
- }
978
- carried[eid] = merged;
979
- }
980
- }
981
-
982
- /**
983
- * Entries of the most recent successful (MANIFEST_FILE_READY) materialization
984
- * for this package, used for skip-if-unchanged. Excludes the in-flight run.
985
- */
986
- private async getMostRecentManifestEntries(
987
- environmentId: string,
988
- packageName: string,
989
- excludeId: string,
990
- ): Promise<Record<string, ManifestEntry>> {
991
- const list =
992
- (await this.repository.listMaterializations(
993
- environmentId,
994
- packageName,
995
- )) ?? [];
996
- for (const m of list) {
997
- if (m.id === excludeId) continue;
998
- if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
999
- return m.manifest.entries;
1000
- }
1001
- }
1002
- return {};
1003
- }
1004
-
1005
- /**
1006
- * Load a freshly produced manifest into the package's models so persist
1007
- * references resolve to the materialized tables. Best-effort: a load failure
1008
- * is logged, not fatal (the run already reached MANIFEST_FILE_READY).
1009
- */
1010
- private async autoLoadManifest(
1011
- environment: {
1012
- reloadAllModelsForPackage(
1013
- packageName: string,
1014
- manifest: FreshnessManifest,
1015
- ): Promise<void>;
1016
- bindPackageStorageServeBindings(
1017
- packageName: string,
1018
- entries: Record<string, ManifestEntry>,
1019
- ): Promise<void>;
1020
- },
1021
- packageName: string,
1022
- entries: Record<string, ManifestEntry>,
1023
- ): Promise<void> {
1024
- // The post-build auto-load binds tableName-only entries: the control plane
1025
- // stamps freshness (dataAsOf/window/fallback) on the wire manifest it
1026
- // distributes, not on this in-memory post-build load, so these sources are
1027
- // bound un-gated (always serve the freshly-built table).
1028
- const manifestEntries: FreshnessManifest = {};
1029
- for (const [sourceEntityId, entry] of Object.entries(entries)) {
1030
- // Storage entries serve cross-connection via the virtual-source
1031
- // bindings (below), NOT the same-connection manifest substitution —
1032
- // putting one here would make the original model try to substitute the
1033
- // source with a table on its OWN (source) connection, which doesn't
1034
- // exist there. Only colocated entries go into the tableName manifest.
1035
- if (entry.physicalTableName && !entry.storageConnectionName) {
1036
- manifestEntries[sourceEntityId] = {
1037
- tableName: entry.physicalTableName,
1038
- // Carried so the bind step can quote the physical path for the
1039
- // connection's dialect (Package.quoteBoundTableNames) — the
1040
- // build CREATEd it quoted, so an unquoted read would miss on a
1041
- // case-folding engine in the window before the control plane's
1042
- // wire-manifest rebind.
1043
- connectionName: entry.connectionName,
1044
- };
1045
- }
1046
- }
1047
- try {
1048
- await environment.reloadAllModelsForPackage(
1049
- packageName,
1050
- manifestEntries,
1051
- );
1052
- // Separately bind the FULL entries as storage serve bindings — sources
1053
- // materialized into a storage destination serve cross-connection via
1054
- // the virtual-source transform, not the tableName manifest above. No-op
1055
- // for a package with no storage= sources (deriveServeBindings → []).
1056
- await environment.bindPackageStorageServeBindings(
1057
- packageName,
1058
- entries,
1059
- );
1060
- recordAutoLoadOutcome("success");
1061
- logger.info("Auto-run: loaded manifest into package models", {
1062
- packageName,
1063
- entryCount: Object.keys(manifestEntries).length,
1064
- });
1065
- } catch (err) {
1066
- recordAutoLoadOutcome("failure");
1067
- logger.warn("Auto-run: failed to load manifest into package models", {
1068
- packageName,
1069
- error: err instanceof Error ? err.message : String(err),
1070
- });
1071
- }
1072
- }
1073
-
1074
- /**
1075
- * Validate caller-supplied build instructions against the package's compiled
1076
- * build plan: every instructed sourceEntityId must be a planned source, and only
1077
- * COPY realization is supported. Throws when the package declares no persist
1078
- * source (no plan to build against).
1079
- */
1080
- private validateInstructions(
1081
- plan: BuildPlan | null,
1082
- instructions: BuildInstruction[],
1083
- ): void {
1084
- if (!plan) {
1085
- throw new BadRequestError(
1086
- "Package has no persist sources; buildInstructions cannot be applied",
1087
- );
1088
- }
1089
- const plannedSourceEntityIds = new Set<string>();
1090
- for (const source of Object.values(plan.sources)) {
1091
- plannedSourceEntityIds.add(source.sourceEntityId);
1092
- }
1093
-
1094
- for (const instruction of instructions) {
1095
- if (!plannedSourceEntityIds.has(instruction.sourceEntityId)) {
1096
- throw new BadRequestError(
1097
- `Instruction references unknown sourceEntityId '${instruction.sourceEntityId}'`,
1098
- );
1099
- }
1100
- // COPY-only for now; SNAPSHOT lands once clone semantics are defined.
1101
- if (instruction.realization === "SNAPSHOT") {
1102
- throw new BadRequestError(
1103
- "realization=SNAPSHOT is not supported (COPY only)",
1104
- );
1105
- }
1106
- }
1107
- }
1108
-
1109
- /**
1110
- * Quote a seeded upstream's physical path for the in-memory build Manifest,
1111
- * mirroring the CREATE side ({@link quoteManifestTablePath}) so a downstream
1112
- * persist source resolves the reference on a case-folding engine. The seed's
1113
- * own connection (carried on the entry) supplies the dialect. If it can't be
1114
- * resolved — an older control plane omitted `connectionName`, or the named
1115
- * connection isn't part of this build — the path binds unquoted: the build
1116
- * then fails loudly at the downstream CREATE on a case-folding engine (a
1117
- * self-signaling miss, unlike the serve path), and is unaffected elsewhere.
1118
- */
1119
- private quoteSeedTablePath(
1120
- sourceEntityId: string,
1121
- physicalTableName: string,
1122
- connectionName: string | undefined,
1123
- connections: Map<string, MalloyConnection>,
1124
- ): string {
1125
- const connection = connectionName
1126
- ? connections.get(connectionName)
1127
- : undefined;
1128
- if (!connection) {
1129
- // A named-but-absent connection is a real gap (the seed can't be
1130
- // quoted); a missing name is the benign older-CP default. Only the
1131
- // former is worth a signal.
1132
- if (connectionName) {
1133
- recordManifestBindDegraded();
1134
- logger.warn(
1135
- "Seeded upstream names a connection not present in this build; " +
1136
- "leaving its manifest path unquoted (a downstream build will " +
1137
- "fail on a case-folding engine)",
1138
- { sourceEntityId, connectionName },
1139
- );
1140
- }
1141
- return physicalTableName;
1142
- }
1143
- return quoteManifestTablePath(physicalTableName, connection.dialectName);
1144
- }
1145
-
1146
- /**
1147
- * Shared build loop for both auto-run and orchestrated builds. Seeds the
1148
- * manifest with carried-forward (reused) upstream entries so downstream
1149
- * references resolve, then builds each instructed source in dependency
1150
- * order. Returns the full entry map (carried + freshly built). The package
1151
- * was compiled by the caller; this runs outside the package lock.
1152
- */
1153
- private async executeInstructedBuild(
1154
- compiled: CompiledBuildPlan,
1155
- environment: BuildEnvironment,
1156
- instructions: BuildInstruction[],
1157
- seedEntries: Record<string, ManifestEntry>,
1158
- signal: AbortSignal,
1159
- strict = false,
1160
- // Identity of the run, used only to reclaim storage tables this run created
1161
- // if it fails part-way (see reclaimStorageTablesFromFailedRun).
1162
- owner?: { environmentId: string; packageName: string },
1163
- buildMetadata?: BuildQueryMetadata,
1164
- ): Promise<Record<string, ManifestEntry>> {
1165
- const { graphs, sources, connectionDigests, connections } = compiled;
1166
-
1167
- // Index instructions by sourceID (the stable per-source handle) so the
1168
- // build no longer recomputes the sourceEntityId to find an instruction.
1169
- // Recomputing it here forced a caller's sourceEntityId to equal the publisher's
1170
- // content hash, so a caller that derives sourceEntityIds by any other scheme
1171
- // would have its sources silently skipped (the recomputed sourceEntityId would
1172
- // not match the instruction). sourceEntityId is treated as opaque, caller-assigned
1173
- // identity. A sourceEntityId index is kept as a fallback for instructions without
1174
- // a sourceID (e.g. standalone auto-run).
1175
- const bySourceID = new Map<string, BuildInstruction>();
1176
- const bySourceEntityId = new Map<string, BuildInstruction>();
1177
- for (const instruction of instructions) {
1178
- if (instruction.sourceID) {
1179
- bySourceID.set(instruction.sourceID, instruction);
1180
- }
1181
- bySourceEntityId.set(instruction.sourceEntityId, instruction);
1182
- }
1183
-
1184
- // Accumulates physical names as sources are built so downstream sources
1185
- // resolve their upstream references to the freshly-assigned tables. Seed
1186
- // it with carried-forward entries so reused upstreams resolve too. In
1187
- // strict mode, an upstream persist reference that is neither built here
1188
- // nor seeded fails the compile (runtime-manifest-strict-miss) instead of
1189
- // silently recomputing live.
1190
- const manifest = new Manifest();
1191
- manifest.strict = strict;
1192
- const entries: Record<string, ManifestEntry> = {};
1193
- for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
1194
- if (entry.physicalTableName) {
1195
- // The build Manifest feeds a downstream persist's `FROM` verbatim,
1196
- // so a seeded upstream must carry the SAME quoting the builder
1197
- // CREATEd it with — else the downstream CREATE misses the
1198
- // case-preserved table on a case-folding engine. The seed keeps its
1199
- // logical (unquoted) name in `entries` (the committed manifest, in
1200
- // logical-name space); only the in-memory build Manifest is quoted.
1201
- manifest.update(sourceEntityId, {
1202
- tableName: this.quoteSeedTablePath(
1203
- sourceEntityId,
1204
- entry.physicalTableName,
1205
- entry.connectionName,
1206
- connections,
1207
- ),
1208
- });
1209
- }
1210
- entries[sourceEntityId] = entry;
1211
- }
1212
-
1213
- // Entries this run actually CREATED, as opposed to the seeded/carried ones
1214
- // above. Only these are eligible for failure-path reclaim: a carried entry
1215
- // names a table an earlier successful run built and a live manifest may still
1216
- // serve, so dropping one would be data loss rather than cleanup.
1217
- const builtThisRun: ManifestEntry[] = [];
1218
- try {
1219
- for (const graph of graphs) {
1220
- const connection = connections.get(graph.connectionName);
1221
- if (!connection) {
1222
- throw new BadRequestError(
1223
- `Connection '${graph.connectionName}' not found`,
1224
- );
1225
- }
1226
- for (const persistSource of iterGraphSources(graph, sources)) {
1227
- if (signal.aborted) throw new Error("Build cancelled");
1228
-
1229
- // Prefer sourceID matching (so the caller's sourceEntityId scheme
1230
- // stays opaque to the build); the sourceEntityId lookup below is the
1231
- // fallback for instructions without a sourceID (auto-run). Resolved
1232
- // before computeSourceEntityId so the eligibility gate wins: that
1233
- // call invokes getSQL(), which throws opaquely for a free-parameter
1234
- // or given source, losing the clean 422.
1235
- const orchestratedInstruction = bySourceID.get(
1236
- persistSource.sourceID,
1237
- );
1238
-
1239
- // Enforce the eligibility gate for any storage-targeted build,
1240
- // including orchestrated (host-supplied) instructions — the publisher
1241
- // refuses an ineligible source into the tier itself, not on trust.
1242
- // Skipped when the mode is off: the kill switch ignores a
1243
- // host-supplied destination too and does a colocated build.
1244
- if (
1245
- orchestratedInstruction?.destination &&
1246
- getPersistStorageMode() !== "off"
1247
- ) {
1248
- assertMaterializationEligible(persistSource);
1249
- }
1250
-
1251
- // The manifest is keyed by the content sourceEntityId — what Malloy
1252
- // recomputes to resolve upstream persist references during SQL
1253
- // generation — independent of the instruction's identity sourceEntityId.
1254
- const sourceEntityId = computeSourceEntityId(
1255
- persistSource,
1256
- connectionDigests,
1257
- );
1258
- const instruction =
1259
- orchestratedInstruction ??
1260
- bySourceEntityId.get(sourceEntityId);
1261
- if (!instruction) continue;
1262
-
1263
- // Auto-run already gated pre-getSQL in deriveSelfInstructions;
1264
- // re-assert (idempotent) so no path into a storage build is ungated.
1265
- if (
1266
- !orchestratedInstruction &&
1267
- instruction.destination &&
1268
- getPersistStorageMode() !== "off"
1269
- ) {
1270
- assertMaterializationEligible(persistSource);
1271
- }
1272
-
1273
- const entry = await this.buildOneSource(
1274
- persistSource,
1275
- instruction,
1276
- connection,
1277
- connectionDigests,
1278
- manifest,
1279
- environment,
1280
- entries,
1281
- buildMetadata,
1282
- );
1283
- entries[sourceEntityId] = entry;
1284
- if (entry.storageConnectionName) builtThisRun.push(entry);
1285
- }
1286
- }
1287
- } catch (err) {
1288
- // A run that fails part-way commits NO manifest, and manifest-driven GC
1289
- // only drops names a manifest records — so a table an earlier source in
1290
- // this run already wrote would be unreachable forever. Reclaim those
1291
- // before rethrowing. Best-effort and non-fatal: the build's own failure is
1292
- // what the caller needs to see.
1293
- if (owner) {
1294
- await this.reclaimStorageTablesFromFailedRun(
1295
- builtThisRun,
1296
- environment,
1297
- owner,
1298
- );
1299
- }
1300
- throw err;
1301
- }
1302
-
1303
- return entries;
1304
- }
1305
-
1306
- /**
1307
- * Drop the storage tables a FAILED run created, so a partial build does not
1308
- * leak an unreferenced table. For DuckLake that is data plus Parquet files at
1309
- * rest, and nothing else will ever name them: the run commits no manifest, and
1310
- * GC reclaims only what a manifest records.
1311
- *
1312
- * Three guards make this safe, and each closes a real way to destroy live data:
1313
- *
1314
- * - ORCHESTRATED runs only (the caller passes no `owner` for auto-run). Those
1315
- * names are host-assigned and generational, so unique by construction — which
1316
- * is both where the leak actually bites and the only case where a drop cannot
1317
- * hit something another run owns. Auto-run's STABLE names are overwritten in
1318
- * place by the next build, so skipping them forgoes little.
1319
- *
1320
- * This gate is what bounds the cross-environment hazard. The
1321
- * still-referenced check below reads THIS environment and package only, and a
1322
- * BuildID carries no environment input, so two environments sharing a
1323
- * destination can resolve a source to the SAME physical name. A reclaim that
1324
- * trusted a per-environment check could then drop a table another environment
1325
- * is actively serving — the failure mode behind a real cross-environment
1326
- * data-loss incident on the hosted side. Generational names remove the
1327
- * collision rather than racing it. The durable fix is refusing a colliding
1328
- * persist target at validation time; until then, do not widen this.
1329
- *
1330
- * - Only entries this run CREATED (never a carried-forward one).
1331
- * - Only names no other MANIFEST_FILE_READY run references, the same
1332
- * destination-and-name check {@link dropMaterializedTables} applies.
1333
- *
1334
- * Scoped to `storage=` entries. A colocated failure is left alone: those names
1335
- * are stable and in the customer's own warehouse, so the next successful build
1336
- * overwrites in place and a failure-path DROP there would be a far larger
1337
- * blast radius for no reclaim.
1338
- */
1339
- private async reclaimStorageTablesFromFailedRun(
1340
- builtThisRun: ManifestEntry[],
1341
- environment: BuildEnvironment,
1342
- owner: { environmentId: string; packageName: string },
1343
- ): Promise<void> {
1344
- if (builtThisRun.length === 0) return;
1345
- try {
1346
- const tableKey = (dest: string, table: string) => `${dest}:${table}`;
1347
- const stillReferenced = new Set<string>();
1348
- const others =
1349
- (await this.repository.listMaterializations(
1350
- owner.environmentId,
1351
- owner.packageName,
1352
- )) ?? [];
1353
- for (const other of others) {
1354
- if (other.status !== "MANIFEST_FILE_READY") continue;
1355
- for (const e of Object.values(other.manifest?.entries ?? {})) {
1356
- const dest = e.storageConnectionName ?? e.connectionName;
1357
- if (dest && e.physicalTableName) {
1358
- stillReferenced.add(tableKey(dest, e.physicalTableName));
1359
- }
1360
- }
1361
- }
1362
-
1363
- for (const entry of builtThisRun) {
1364
- const dest = entry.storageConnectionName;
1365
- const table = entry.physicalTableName;
1366
- if (!dest || !table) continue;
1367
- if (stillReferenced.has(tableKey(dest, table))) {
1368
- logger.info(
1369
- "Keeping a table from a failed run: a live manifest still serves it",
1370
- { destinationName: dest, physicalTableName: table },
1371
- );
1372
- continue;
1373
- }
1374
- try {
1375
- await dropStorageTable({
1376
- destinationName: dest,
1377
- destinationConnection: environment.getApiConnection(dest),
1378
- physicalTableName: table,
1379
- environmentPath: environment.getEnvironmentPath(),
1380
- });
1381
- recordDropTables("success", "storage");
1382
- logger.info("Reclaimed a table stranded by a failed build", {
1383
- destinationName: dest,
1384
- physicalTableName: table,
1385
- });
1386
- } catch (dropErr) {
1387
- recordDropTables("failure", "storage");
1388
- logger.warn(
1389
- "Failed to reclaim a table stranded by a failed build",
1390
- {
1391
- destinationName: dest,
1392
- physicalTableName: table,
1393
- error: redactConnectionSecrets(
1394
- errMessage(dropErr),
1395
- environment.getApiConnection(dest),
1396
- ),
1397
- },
1398
- );
1399
- }
1400
- }
1401
- } catch (err) {
1402
- // Never let cleanup mask the build failure that triggered it.
1403
- logger.warn("Failed-run table reclaim did not complete", {
1404
- error: errMessage(err),
1405
- });
1406
- }
1407
- }
1408
-
1409
- /**
1410
- * The `RunSQLOptions` for one source's build statements: its resolved
1411
- * per-query metadata, merged under this run's context.
1412
- *
1413
- * Layers, least specific first: the executing connection's default, then what
1414
- * the model side declared for this source (package → model-file → `#@ persist`,
1415
- * already resolved by {@link resolveQueryMetadata}), then the run's context,
1416
- * which names the source. There is no request layer — a build has no request.
1417
- *
1418
- * Fails OPEN: a build must not fail because metadata could not be assembled,
1419
- * so an unresolvable connection just contributes no default, and a dropped
1420
- * property is logged and metered rather than thrown.
1421
- */
1422
- private buildRunSQLOptions(
1423
- persistSource: PersistSource,
1424
- environment: BuildEnvironment,
1425
- buildMetadata: BuildQueryMetadata | undefined,
1426
- ): { queryMetadata?: QueryMetadata } {
1427
- if (!buildMetadata) return {};
1428
- const connectionLayers = connectionMetadataLayers(
1429
- environment,
1430
- persistSource.connectionName,
1431
- );
1432
- const resolved = mergeQueryMetadata({
1433
- connection: connectionLayers.default,
1434
- enforced: connectionLayers.enforced,
1435
- model: resolveQueryMetadata(
1436
- persistSource,
1437
- buildMetadata.packageMaterialization,
1438
- ),
1439
- context: { ...buildMetadata.context, source: persistSource.name },
1440
- });
1441
- if (resolved.drops.length > 0) {
1442
- logger.warn("Dropped query-metadata properties for a build", {
1443
- sourceName: persistSource.name,
1444
- drops: resolved.drops,
1445
- });
1446
- }
1447
- return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
1448
- }
1449
-
1450
- /**
1451
- * The `RunSQLOptions` for the drops that retire a materialization's tables.
1452
- * No model layer: the source's declaration described how to BUILD it, and the
1453
- * source may no longer exist by the time its table is retired.
1454
- */
1455
- private dropRunSQLOptions(
1456
- environment: BuildEnvironment,
1457
- connectionName: string,
1458
- environmentName: string,
1459
- packageName: string,
1460
- materializationId: string,
1461
- ): { queryMetadata?: QueryMetadata } {
1462
- const connectionLayers = connectionMetadataLayers(
1463
- environment,
1464
- connectionName,
1465
- );
1466
- const resolved = mergeQueryMetadata({
1467
- connection: connectionLayers.default,
1468
- enforced: connectionLayers.enforced,
1469
- context: {
1470
- queryClass: "ops",
1471
- environment: environmentName,
1472
- package: packageName,
1473
- runId: materializationId,
1474
- },
1475
- });
1476
- return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
1477
- }
1478
-
1479
- /**
1480
- * Build a single instructed source into its assigned physical table.
1481
- * COPY uses a staging table + atomic rename for crash-safety; the staging
1482
- * name derives from the sourceEntityId. Records and returns the manifest entry.
1483
- */
1484
- private async buildOneSource(
1485
- persistSource: PersistSource,
1486
- instruction: BuildInstruction,
1487
- connection: MalloyConnection,
1488
- connectionDigests: Record<string, string>,
1489
- manifest: Manifest,
1490
- environment: BuildEnvironment,
1491
- builtEntries: Record<string, ManifestEntry>,
1492
- buildMetadata?: BuildQueryMetadata,
1493
- ): Promise<ManifestEntry> {
1494
- const sourceEntityId = instruction.sourceEntityId;
1495
- const physicalTableName = instruction.physicalTableName;
1496
- const isStorageBuild =
1497
- !!instruction.destination && getPersistStorageMode() !== "off";
1498
- // ANY warehouse-executed build SQL — a colocated CTAS or a storage build's
1499
- // native passthrough — runs against the SOURCE warehouse, which cannot see
1500
- // a storage-materialized upstream's DuckDB/DuckLake table (a different
1501
- // engine). Substituting that upstream's lake table name into warehouse SQL
1502
- // would reference a table the warehouse can't resolve (a confusing "table
1503
- // not found"), whether the downstream is a storage build OR a colocated
1504
- // source reading a storage upstream. So exclude storage-materialized
1505
- // upstreams from the build manifest in BOTH cases: non-strict, they INLINE
1506
- // (recompute from raw against the warehouse) so a chained source still
1507
- // materializes; under `strictUpstreams` the excluded reference becomes a
1508
- // clean strict-miss error (the orchestrated contract — don't silently
1509
- // recompute). A stack-on-the-parent build reads the parent's lake table
1510
- // instead, but via a separate DuckDB recompile that does NOT use this
1511
- // warehouse buildSQL — this remains its recompute-from-raw fallback.
1512
- const buildManifest = manifestExcludingStorage(manifest, builtEntries);
1513
- const buildSQL = persistSource.getSQL({
1514
- buildManifest,
1515
- connectionDigests,
1516
- });
1517
-
1518
- // `storage=` build: materialize into a DuckDB/DuckLake destination via a
1519
- // build-scoped session (never on the source or serve connection). Diverges
1520
- // fully from the in-warehouse CTAS below — different engine, credential
1521
- // federation, and a captured authoritative schema for the serve transform.
1522
- // Gated by the kill switch: when off, ignore a destination and do a colocated build.
1523
- if (isStorageBuild) {
1524
- // Stack-on-the-parent detection: does the source's SQL change when storage upstreams
1525
- // are PRESENT in the manifest (mapped to their lake tables) vs EXCLUDED
1526
- // (inlined, the buildSQL above)? If so it reads a storage-materialized
1527
- // upstream, so it can be built by reading the parent's lake table
1528
- // ("stack on the parent") instead of recomputing from raw. The compare
1529
- // is graph-free and self-contained; a single-source build's two SQLs are
1530
- // identical, so it skips straight to the passthrough below.
1531
- const dependsOnStorageUpstream =
1532
- persistSource.getSQL({
1533
- buildManifest: manifest.buildManifest,
1534
- connectionDigests,
1535
- }) !== buildSQL;
1536
- // Materialize ONLY the source's PUBLIC columns. `getSQL` projects every
1537
- // underlying column, including ones the source hides (`except:`, non-public
1538
- // access modifiers). Query reachability is bounded by the declared
1539
- // ::Shape, which the serve transform narrows to the public surface
1540
- // (proven by the shape-bounds-physical-columns scenario) — so what this
1541
- // prevents is the hidden column's VALUES sitting at rest in the
1542
- // destination store, reachable by direct catalog access and possibly
1543
- // across a trust boundary the source's visibility was meant to hold.
1544
- // Refuses the build (422) if the public surface can't be determined.
1545
- const publicBuildSQL = projectToPublicColumns(persistSource, buildSQL);
1546
- return this.buildOneSourceIntoStorage(
1547
- persistSource,
1548
- instruction,
1549
- manifest,
1550
- environment,
1551
- publicBuildSQL,
1552
- builtEntries,
1553
- dependsOnStorageUpstream,
1554
- );
1555
- }
1556
-
1557
- // Every statement of this source's build carries the same metadata, so the
1558
- // warehouse's query history shows the staging CTAS, the drop and the rename
1559
- // as one attributable unit of work.
1560
- const runOptions = this.buildRunSQLOptions(
1561
- persistSource,
1562
- environment,
1563
- buildMetadata,
1564
- );
1565
-
1566
- const bareName = bareTableName(physicalTableName);
1567
- const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
1568
- // The control plane sends the logical (unquoted) physical name; dialect-
1569
- // quote each identifier here so a container path or quote-requiring name
1570
- // (e.g. a hyphenated BigQuery project id) produces valid DDL. The manifest
1571
- // echoes the logical name (below) so the CP stays in logical-name space.
1572
- const dialect = persistSource.dialectName;
1573
- const quotedStaging = quoteTablePath(stagingTableName, dialect);
1574
- const quotedPhysical = quoteTablePath(physicalTableName, dialect);
1575
- const quotedBareName = quoteIdentifier(bareName, dialect);
1576
-
1577
- const startTime = performance.now();
1578
- await connection.runSQL(
1579
- `DROP TABLE IF EXISTS ${quotedStaging}`,
1580
- runOptions,
1581
- );
1582
- try {
1583
- await connection.runSQL(
1584
- `CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
1585
- runOptions,
1586
- );
1587
- await connection.runSQL(
1588
- `DROP TABLE IF EXISTS ${quotedPhysical}`,
1589
- runOptions,
1590
- );
1591
- await connection.runSQL(
1592
- `ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
1593
- runOptions,
1594
- );
1595
- } catch (err) {
1596
- try {
1597
- await connection.runSQL(
1598
- `DROP TABLE IF EXISTS ${quotedStaging}`,
1599
- runOptions,
1600
- );
1601
- } catch (cleanupErr) {
1602
- logger.warn(
1603
- "Failed to clean up staging table after a failed build; physical leak",
1604
- {
1605
- stagingTableName,
1606
- connectionName: persistSource.connectionName,
1607
- cleanupError: errMessage(cleanupErr),
1608
- },
1609
- );
1610
- }
1611
- throw err;
1612
- }
1613
-
1614
- // Make this table visible to downstream sources built later in this run.
1615
- // Record the SAME quoted path the CREATE used (not the logical name): the
1616
- // build Manifest is pasted into a downstream `FROM` verbatim, so on a
1617
- // case-folding engine an unquoted name would miss the case-preserved table
1618
- // just written. The returned entry (below) keeps the logical name for the
1619
- // committed manifest; only this in-memory build Manifest is quoted.
1620
- manifest.update(sourceEntityId, { tableName: quotedPhysical });
1621
-
1622
- const durationMs = Math.round(performance.now() - startTime);
1623
- recordSourceBuildDuration(durationMs, "in_warehouse");
1624
- logger.info(`Built materialized source ${persistSource.name}`, {
1625
- physicalTableName,
1626
- durationMs,
1627
- });
1628
-
1629
- return {
1630
- sourceEntityId,
1631
- sourceName: persistSource.name,
1632
- materializedTableId: instruction.materializedTableId,
1633
- physicalTableName,
1634
- connectionName: persistSource.connectionName,
1635
- realization: instruction.realization,
1636
- rowCount: null,
1637
- };
1638
- }
1639
-
1640
- /**
1641
- * Materialize a source into a `storage=` destination (a DuckDB/DuckLake
1642
- * connection) via a native query-passthrough CTAS on a build-scoped session.
1643
- * Records the destination connection and the captured authoritative DuckDB
1644
- * schema on the manifest entry so the source can later be served
1645
- * cross-dialect from the destination (the serve transform declares that
1646
- * schema). `connectionName` still names the SOURCE warehouse (where data is
1647
- * read from); `storageConnectionName` names where the table now lives.
1648
- */
1649
- private async buildOneSourceIntoStorage(
1650
- persistSource: PersistSource,
1651
- instruction: BuildInstruction,
1652
- manifest: Manifest,
1653
- environment: BuildEnvironment,
1654
- buildSQL: string,
1655
- builtEntries: Record<string, ManifestEntry>,
1656
- dependsOnStorageUpstream: boolean,
1657
- ): Promise<ManifestEntry> {
1658
- const sourceEntityId = instruction.sourceEntityId;
1659
- const physicalTableName = instruction.physicalTableName;
1660
- const destinationName = instruction.destination!;
1661
- const sourceConnection = environment.getApiConnection(
1662
- persistSource.connectionName,
1663
- );
1664
- const destinationConnection =
1665
- environment.getApiConnection(destinationName);
1666
-
1667
- const startTime = performance.now();
1668
- let result;
1669
-
1670
- // Stack on the parent: a source that reads a storage-materialized
1671
- // upstream is built by reading the parent's STORED lake table instead of
1672
- // recomputing it from raw against the warehouse. This reuses the
1673
- // parent's work and is consistent-by-construction (the downstream is a pure
1674
- // function of the parent's stored rows). Attempted only when the source
1675
- // actually reads a storage upstream; on any ineligibility (a parent
1676
- // refinement not carried into the rebind, a live-warehouse join, a
1677
- // cross-catalog parent) it throws and we fall back: recompute-from-raw when
1678
- // non-strict, or a loud refusal under `strictUpstreams` (the orchestrated
1679
- // contract — never silently recompute).
1680
- if (dependsOnStorageUpstream) {
1681
- try {
1682
- result = await this.buildDownstreamViaParents(
1683
- persistSource,
1684
- destinationName,
1685
- destinationConnection,
1686
- builtEntries,
1687
- environment,
1688
- physicalTableName,
1689
- );
1690
- recordChainedStorageBuild("parent_reuse");
1691
- } catch (err) {
1692
- // Same redaction contract as the recompute-from-raw path below: this
1693
- // branch's read-write ATTACH or CTAS can fail with the offending SQL
1694
- // echoed back, catalog `password=` included. Redact before the
1695
- // message reaches the thrown run `error` or the log.
1696
- const safeDetail = redactConnectionSecrets(
1697
- errMessage(err),
1698
- sourceConnection,
1699
- destinationConnection,
1700
- );
1701
- // Only a SHAPE failure justifies recomputing from raw: the downstream
1702
- // could not be expressed over its rebound parents, so building it the
1703
- // other way is a genuinely different attempt. An INFRA failure (the
1704
- // read-write attach, the CTAS, the destination being down) is not —
1705
- // recompute-from-raw writes to the SAME destination and fails the same
1706
- // way, and metering it as `inline_fallback` files an outage in the same
1707
- // bucket as a legitimate shape miss.
1708
- if (!(err instanceof MaterializationEligibilityError)) {
1709
- recordChainedStorageBuild("infra_failure");
1710
- recordStorageBuildFailure(destinationName);
1711
- throw new Error(
1712
- `Failed to materialize chained source '${persistSource.name}' ` +
1713
- `into storage destination '${destinationName}': ${safeDetail}`,
1714
- );
1715
- }
1716
- if (manifest.strict) {
1717
- recordChainedStorageBuild("strict_refused");
1718
- recordStorageBuildFailure(destinationName);
1719
- throw new Error(
1720
- `Failed to materialize chained source '${persistSource.name}' ` +
1721
- `into storage destination '${destinationName}' by reading ` +
1722
- `its materialized upstream, and strict upstreams forbid ` +
1723
- `recomputing it from raw: ${safeDetail}`,
1724
- );
1725
- }
1726
- recordChainedStorageBuild("inline_fallback");
1727
- logger.warn(
1728
- "Chained storage build could not reuse the parent table; " +
1729
- "recomputing the upstream from raw",
1730
- {
1731
- sourceName: persistSource.name,
1732
- destinationName,
1733
- reason: safeDetail,
1734
- },
1735
- );
1736
- }
1737
- }
1738
-
1739
- // Recompute from raw (the single-source passthrough): materialize `buildSQL`
1740
- // (with storage upstreams inlined) in the source warehouse and CTAS the result
1741
- // into the destination. Skipped when stacking on the parent already produced
1742
- // the table above.
1743
- if (!result) {
1744
- try {
1745
- result = await buildSourceIntoStorage({
1746
- destinationName,
1747
- destinationConnection,
1748
- sourceConnection,
1749
- buildSQL,
1750
- physicalTableName,
1751
- environmentPath: environment.getEnvironmentPath(),
1752
- });
1753
- } catch (err) {
1754
- // Redaction: a failed federation / passthrough / attach
1755
- // error can echo source- or catalog-connection detail (connstrings,
1756
- // account names, service-account JSON) from the DuckDB engine. Strip
1757
- // the actual credential VALUES but keep the message, so an operator
1758
- // sees a legible, actionable error (e.g. "schema 'analytics' not
1759
- // found" when a `name=schema.table` target's schema wasn't
1760
- // provisioned) rather than an opaque failure — without leaking
1761
- // secrets into the user-visible run `error` column.
1762
- const safeDetail = redactConnectionSecrets(
1763
- errMessage(err),
1764
- sourceConnection,
1765
- destinationConnection,
1766
- );
1767
- recordStorageBuildFailure(destinationName);
1768
- logger.warn("Storage materialization build failed", {
1769
- sourceName: persistSource.name,
1770
- destinationName,
1771
- error: safeDetail,
1772
- });
1773
- throw new Error(
1774
- `Failed to materialize source '${persistSource.name}' into ` +
1775
- `storage destination '${destinationName}': ${safeDetail}`,
1776
- );
1777
- }
1778
- }
1779
-
1780
- // Build-time servability gate: the serve-shape must compile in DuckDB
1781
- // against the authoritative post-build schema, or the build is refused
1782
- // (HTTP 422) — a serve-time execution error turned into a fail-loud
1783
- // build-time refusal. Outside the redaction try above so the eligibility
1784
- // error surfaces as-is (it carries no connection secrets). Runs here, in
1785
- // stage→validate, because it needs the captured schema.
1786
- try {
1787
- await assertStorageServeShapeCompiles({
1788
- destinationName,
1789
- sourceName: persistSource.name,
1790
- virtualHandle: sourceEntityId,
1791
- physicalTableName,
1792
- schema: result.schema,
1793
- });
1794
- } catch (gateErr) {
1795
- // The table was already CTAS'd before this post-build gate, and no
1796
- // manifest entry records it yet — so a refusal would strand it where
1797
- // manifest-driven GC (which only drops names it recorded building) can
1798
- // never see it. Best-effort drop of the just-built table so a refused
1799
- // build doesn't leak an orphaned table.
1800
- //
1801
- // Note (in-place naming): the CTAS above already replaced any prior
1802
- // generation at this name, so a failed-gate rebuild has no earlier
1803
- // table to fall back to — the source reverts to serving live until a
1804
- // subsequent successful build. Rollback-safe regeneration (keep the
1805
- // prior generation until the new one is validated) requires the
1806
- // host-generational orchestrated path, not the auto-run server.
1807
- try {
1808
- await dropStorageTable({
1809
- destinationName,
1810
- destinationConnection,
1811
- physicalTableName,
1812
- environmentPath: environment.getEnvironmentPath(),
1813
- });
1814
- } catch (dropErr) {
1815
- logger.warn(
1816
- "Failed to drop a storage table stranded by a serve-shape gate " +
1817
- "refusal (physical leak)",
1818
- {
1819
- sourceName: persistSource.name,
1820
- destinationName,
1821
- physicalTableName,
1822
- // The drop runs on a read-write attach, so a failure can echo
1823
- // the catalog connstring.
1824
- error: redactConnectionSecrets(
1825
- errMessage(dropErr),
1826
- sourceConnection,
1827
- destinationConnection,
1828
- ),
1829
- },
1830
- );
1831
- }
1832
- throw gateErr;
1833
- }
1834
-
1835
- // Make this table visible to downstream sources built later in this run,
1836
- // so a chained storage source can stack on it (above).
1837
- manifest.update(sourceEntityId, { tableName: physicalTableName });
1838
-
1839
- const durationMs = Math.round(performance.now() - startTime);
1840
- recordSourceBuildDuration(durationMs, "storage");
1841
- logger.info(
1842
- `Built materialized source ${persistSource.name} into storage`,
1843
- {
1844
- physicalTableName,
1845
- storageConnectionName: result.storageConnectionName,
1846
- columns: result.schema.length,
1847
- durationMs,
1848
- },
1849
- );
1850
-
1851
- return {
1852
- sourceEntityId,
1853
- sourceName: persistSource.name,
1854
- materializedTableId: instruction.materializedTableId,
1855
- physicalTableName,
1856
- connectionName: persistSource.connectionName,
1857
- storageConnectionName: result.storageConnectionName,
1858
- schema: result.schema,
1859
- realization: instruction.realization,
1860
- rowCount: null,
1861
- };
1862
- }
1863
-
1864
- /**
1865
- * Stack on the parent: materialize a chained storage source by reading its
1866
- * already-materialized upstream(s) from the SAME destination store. Rebinds
1867
- * every same-destination materialized upstream to a virtual source (base-only,
1868
- * the captured schema), re-declares the downstream over them (its definition
1869
- * text lifted from the author's model), and hands the assembled transient
1870
- * model to {@link buildDownstreamIntoStorage}, which compiles it against the
1871
- * build session and CTASes the downstream's SQL — now reading the parents'
1872
- * lake tables — into the destination.
1873
- *
1874
- * Throws (⇒ the caller falls back to recompute-from-raw) when there is no
1875
- * same-destination upstream to build on, the definition text can't be lifted,
1876
- * or the transient model doesn't compile (a parent refinement not carried, a
1877
- * live-warehouse join, a cross-catalog parent).
1878
- */
1879
- private async buildDownstreamViaParents(
1880
- persistSource: PersistSource,
1881
- destinationName: string,
1882
- destinationConnection: ApiConnection,
1883
- builtEntries: Record<string, ManifestEntry>,
1884
- environment: BuildEnvironment,
1885
- physicalTableName: string,
1886
- ): Promise<StorageBuildResult> {
1887
- // Rebind every upstream materialized into THIS destination. A parent in a
1888
- // DIFFERENT destination is absent here, so the downstream def fails to
1889
- // compile against the rebind model and the caller falls back — cross-catalog
1890
- // parent reuse is out of scope for the spike.
1891
- const upstreams: ServeBinding[] = deriveServeBindings(
1892
- builtEntries,
1893
- ).filter((b) => b.connectionName === destinationName);
1894
- if (upstreams.length === 0) {
1895
- throw new MaterializationEligibilityError({
1896
- message:
1897
- "no materialized upstream is available in this destination to build on",
1898
- });
1899
- }
1900
- const downstreamDefText = this.liftDownstreamDefText(persistSource);
1901
- if (!downstreamDefText) {
1902
- throw new MaterializationEligibilityError({
1903
- message:
1904
- "could not recover the downstream source definition text from the model",
1905
- });
1906
- }
1907
- const transientModel = buildChainedStorageBuildModel({
1908
- upstreams,
1909
- downstreamName: persistSource.name,
1910
- downstreamDefText,
1911
- destinationName,
1912
- });
1913
- return buildDownstreamIntoStorage({
1914
- destinationName,
1915
- destinationConnection,
1916
- transientModel,
1917
- downstreamName: persistSource.name,
1918
- virtualMap: buildVirtualMap(upstreams),
1919
- physicalTableName,
1920
- environmentPath: environment.getEnvironmentPath(),
1921
- });
1922
- }
1923
-
1924
- /**
1925
- * Lift the verbatim RHS of a persist source's `source: <name> is …`
1926
- * declaration from the author's model file (same technique as the serve
1927
- * transform's join/view lift): read the file named by the source's compiled
1928
- * `location`, slice the covered range. A top-level source's range starts just
1929
- * after the `source: ` keyword, so the result is `<name> is <def>` and the
1930
- * transient-model assembler prepends `source: `. Returns undefined (⇒ fall
1931
- * back) when there is no file-backed location or the file can't be read/sliced.
1932
- */
1933
- private liftDownstreamDefText(
1934
- persistSource: PersistSource,
1935
- ): string | undefined {
1936
- const location = (
1937
- persistSource._explore as unknown as { location?: SourceLocation }
1938
- ).location;
1939
- if (!location?.url?.startsWith("file:")) return undefined;
1940
- let text: string;
1941
- try {
1942
- text = readFileSync(fileURLToPath(location.url), "utf8");
1943
- } catch {
1944
- return undefined;
1945
- }
1946
- return sliceSourceRange(text, location.range);
1947
- }
1948
-
1949
- // ==================== CANCELLATION ====================
1950
-
1951
- /** Cancel a non-terminal materialization. */
1952
- async stopMaterialization(
1953
- environmentName: string,
1954
- packageName: string,
1955
- id: string,
1956
- ): Promise<Materialization> {
1957
- const m = await this.getMaterialization(environmentName, packageName, id);
1958
-
1959
- const cancellable: MaterializationStatus[] = [
1960
- "PENDING",
1961
- "MANIFEST_ROWS_READY",
1962
- ];
1963
- if (!cancellable.includes(m.status)) {
1964
- throw new InvalidStateTransitionError(
1965
- `Materialization ${id} is ${m.status}, cannot stop`,
1966
- );
1967
- }
1968
-
1969
- const abortController = this.runningAbortControllers.get(id);
1970
- if (abortController) {
1971
- abortController.abort();
1972
- return m;
1973
- }
1974
- return this.transition(id, "CANCELLED", {
1975
- completedAt: new Date(),
1976
- error: "Cancelled",
1977
- });
1978
- }
1979
-
1980
- /**
1981
- * Delete a materialization record. Only terminal materializations
1982
- * (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
1983
- * be stopped first.
1984
- *
1985
- * By default this removes the publisher's record only — physical-table GC is
1986
- * the caller's responsibility. When `dropTables` is set, the publisher
1987
- * additionally drops the physical tables recorded in this run's manifest as a
1988
- * best-effort cleanup (a drop failure is logged, not fatal, so the record
1989
- * still deletes).
1990
- */
1991
- async deleteMaterialization(
1992
- environmentName: string,
1993
- packageName: string,
1994
- id: string,
1995
- options: { dropTables?: boolean } = {},
1996
- ): Promise<void> {
1997
- const m = await this.getMaterialization(environmentName, packageName, id);
1998
-
1999
- const terminal: MaterializationStatus[] = [
2000
- "MANIFEST_FILE_READY",
2001
- "FAILED",
2002
- "CANCELLED",
2003
- ];
2004
- if (!terminal.includes(m.status)) {
2005
- throw new InvalidStateTransitionError(
2006
- `Cannot delete materialization ${id} while it is ${m.status}`,
2007
- );
2008
- }
2009
-
2010
- if (options.dropTables) {
2011
- await this.dropMaterializedTables(environmentName, packageName, m);
2012
- }
2013
-
2014
- await this.repository.deleteMaterialization(id);
2015
-
2016
- // Re-derive the package's serve routing from the latest REMAINING
2017
- // successful materialization — BOTH tiers. The deleted run may have been
2018
- // the one bound for serving — and with `dropTables` its tables are now gone
2019
- // — so without this a query would keep routing (schema-on-faith, no
2020
- // run-time fallback) to a deleted/dropped table and error until the next
2021
- // reload or build. Rebinding picks the next-latest generation, or CLEARS
2022
- // the bindings when none remain (empty ⇒ serve live).
2023
- await this.rebindServeBindingsAfterDelete(environmentName, packageName);
2024
- }
2025
-
2026
- /**
2027
- * Re-derive a package's serve routing from its latest remaining successful
2028
- * materialization and push it onto the loaded models — BOTH tiers, mirroring
2029
- * the load-time {@link Environment.rebindServeBindingsFromLocalStore}. Called
2030
- * after a delete so serving never points at a removed table; picks the
2031
- * next-latest generation, or clears the bindings when none remain.
2032
- * Best-effort (a failure logs and leaves the current bindings — a later
2033
- * reload/build re-derives).
2034
- *
2035
- * The manifest is split by tier:
2036
- * - **colocated** (same-connection) → re-derived regardless of
2037
- * `PERSIST_STORAGE_MODE`: colocated is the v0 path and is not gated by the
2038
- * storage kill switch, so a reclaimed colocated table must not be left
2039
- * routed even when the tier is off.
2040
- * - **storage=** (cross-connection) → re-derived only when the tier is not
2041
- * `off` (its serve routing requires the tier on; an off deployment does no
2042
- * extra work here).
2043
- */
2044
- private async rebindServeBindingsAfterDelete(
2045
- environmentName: string,
2046
- packageName: string,
2047
- ): Promise<void> {
2048
- try {
2049
- const environmentId = await this.resolveEnvironmentId(environmentName);
2050
- // "" excludes nothing — the deleted record is already gone from the repo.
2051
- const entries = await this.getMostRecentManifestEntries(
2052
- environmentId,
2053
- packageName,
2054
- "",
2055
- );
2056
- const environment = await this.environmentStore.getEnvironment(
2057
- environmentName,
2058
- false,
2059
- );
2060
- const { tableNameManifest, storageEntries } = splitManifestEntries(
2061
- entries,
2062
- `post-delete rebind (package ${packageName})`,
2063
- );
2064
- // Colocated: re-derive (or clear) regardless of mode.
2065
- await environment.bindPackageColocatedServeManifest(
2066
- packageName,
2067
- tableNameManifest,
2068
- );
2069
- // Storage=: only meaningful when the tier is not off.
2070
- if (getPersistStorageMode() !== "off") {
2071
- await environment.bindPackageStorageServeBindings(
2072
- packageName,
2073
- storageEntries,
2074
- );
2075
- }
2076
- } catch (err) {
2077
- logger.warn(
2078
- "Failed to rebind serve bindings after delete (leaving current " +
2079
- "bindings; a reload/build will re-derive)",
2080
- { packageName, error: errMessage(err) },
2081
- );
2082
- }
2083
- }
2084
-
2085
- /**
2086
- * Best-effort drop of every physical table this run produced, read from the
2087
- * materialization's manifest. Resolves each entry's connection by name and
2088
- * issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
2089
- * leftover staging table. Failures are logged and swallowed so a partial
2090
- * cleanup never blocks deletion of the record.
2091
- *
2092
- * A physical name is dropped only when NO other remaining MANIFEST_FILE_READY
2093
- * run references it. Physical names are the source's `name=` verbatim (or a
2094
- * host-assigned name), so multiple generations of a source share one physical
2095
- * name — dropping a superseded record's table would otherwise take out the
2096
- * table the current generation still serves. The skip incidentally also
2097
- * protects the colocated case, where generations likewise share a
2098
- * name.
2099
- */
2100
- private async dropMaterializedTables(
2101
- environmentName: string,
2102
- packageName: string,
2103
- m: Materialization,
2104
- ): Promise<void> {
2105
- const entries = m.manifest?.entries;
2106
- if (!entries || Object.keys(entries).length === 0) {
2107
- return;
2108
- }
2109
-
2110
- const environment = await this.environmentStore.getEnvironment(
2111
- environmentName,
2112
- false,
2113
- );
2114
- const pkg = await environment.getPackage(packageName, false);
2115
- const connectionCache = new Map<string, MalloyConnection>();
2116
-
2117
- // Physical names still referenced by ANOTHER MANIFEST_FILE_READY run for
2118
- // this package (keyed destination-and-name), so a shared name is never
2119
- // dropped out from under a live generation. `m` is still in the repo at
2120
- // this point (deletion happens after this sweep), so exclude it by id.
2121
- const tableKey = (dest: string, table: string) => `${dest}:${table}`;
2122
- const stillReferenced = new Set<string>();
2123
- const environmentId = await this.resolveEnvironmentId(environmentName);
2124
- const others =
2125
- (await this.repository.listMaterializations(
2126
- environmentId,
2127
- packageName,
2128
- )) ?? [];
2129
- for (const other of others) {
2130
- if (other.id === m.id) continue;
2131
- if (other.status !== "MANIFEST_FILE_READY") continue;
2132
- for (const e of Object.values(other.manifest?.entries ?? {})) {
2133
- const dest = e.storageConnectionName ?? e.connectionName;
2134
- if (dest && e.physicalTableName) {
2135
- stillReferenced.add(tableKey(dest, e.physicalTableName));
2136
- }
2137
- }
2138
- }
2139
-
2140
- for (const entry of Object.values(entries)) {
2141
- const connectionName = entry.connectionName;
2142
- const physicalTableName = entry.physicalTableName;
2143
- if (!connectionName || !physicalTableName) {
2144
- logger.warn("Skipping manifest entry with no connection/table", {
2145
- materializationId: m.id,
2146
- sourceEntityId: entry.sourceEntityId,
2147
- });
2148
- continue;
2149
- }
2150
-
2151
- // Do not drop a table another live generation still serves (shared
2152
- // physical name — see the method doc).
2153
- const destForKey = entry.storageConnectionName ?? connectionName;
2154
- if (stillReferenced.has(tableKey(destForKey, physicalTableName))) {
2155
- logger.info(
2156
- "Skipping drop: table still referenced by another materialization",
2157
- {
2158
- materializationId: m.id,
2159
- physicalTableName,
2160
- destination: destForKey,
2161
- },
2162
- );
2163
- continue;
2164
- }
2165
-
2166
- // A storage= table lives in `storageConnectionName` (a DuckDB/DuckLake
2167
- // destination), not in `connectionName` (the source warehouse), and
2168
- // dropping it needs a build-scoped READ-WRITE attach — the serve attach
2169
- // is read-only — so it is dropped on its own RW session rather than on
2170
- // the (wrong-engine, read-only) source connection. Best-effort: a
2171
- // failure is logged and the sweep continues, so one unreachable
2172
- // destination never blocks reclaiming the rest.
2173
- if (entry.storageConnectionName) {
2174
- const destinationConnection = environment.getApiConnection(
2175
- entry.storageConnectionName,
2176
- );
2177
- try {
2178
- await dropStorageTable({
2179
- destinationName: entry.storageConnectionName,
2180
- destinationConnection,
2181
- physicalTableName,
2182
- environmentPath: environment.getEnvironmentPath(),
2183
- });
2184
- recordDropTables("success", "storage");
2185
- logger.info("Dropped materialized storage table on delete", {
2186
- materializationId: m.id,
2187
- physicalTableName,
2188
- storageConnectionName: entry.storageConnectionName,
2189
- });
2190
- } catch (err) {
2191
- recordDropTables("failure", "storage");
2192
- logger.warn("Failed to drop a storage-materialized table", {
2193
- materializationId: m.id,
2194
- physicalTableName,
2195
- storageConnectionName: entry.storageConnectionName,
2196
- // The drop attaches read-write, so a failure can echo the
2197
- // catalog connstring.
2198
- error: redactConnectionSecrets(
2199
- errMessage(err),
2200
- destinationConnection,
2201
- ),
2202
- });
2203
- }
2204
- continue;
2205
- }
2206
-
2207
- try {
2208
- let connection = connectionCache.get(connectionName);
2209
- if (!connection) {
2210
- connection = await pkg.getMalloyConnection(connectionName);
2211
- connectionCache.set(connectionName, connection);
2212
- }
2213
- // Dialect-quote from the live connection, the same way
2214
- // buildOneSource quoted at build time, so a name that built
2215
- // successfully also drops successfully (container paths, hyphenated
2216
- // BigQuery project ids, etc.).
2217
- const dialect = connection.dialectName;
2218
- // Dropping a materialized table is warehouse work someone will have
2219
- // to account for later, so it is tagged like the build that created
2220
- // it — `ops` rather than `materialize`, because this is the
2221
- // lifecycle operation, not a build.
2222
- const dropOptions = this.dropRunSQLOptions(
2223
- environment,
2224
- connectionName,
2225
- environmentName,
2226
- packageName,
2227
- m.id,
2228
- );
2229
- await connection.runSQL(
2230
- `DROP TABLE IF EXISTS ${quoteTablePath(
2231
- physicalTableName,
2232
- dialect,
2233
- )}`,
2234
- dropOptions,
2235
- );
2236
- // A crash between staging-create and rename can leave the staging
2237
- // table behind; clean it up too while we hold the connection.
2238
- await connection.runSQL(
2239
- `DROP TABLE IF EXISTS ${quoteTablePath(
2240
- `${physicalTableName}${stagingSuffix(entry.sourceEntityId)}`,
2241
- dialect,
2242
- )}`,
2243
- dropOptions,
2244
- );
2245
- recordDropTables("success", "in_warehouse");
2246
- logger.info("Dropped materialized table on delete", {
2247
- materializationId: m.id,
2248
- physicalTableName,
2249
- connectionName,
2250
- });
2251
- } catch (err) {
2252
- recordDropTables("failure", "in_warehouse");
2253
- logger.warn("Failed to drop materialized table on delete", {
2254
- materializationId: m.id,
2255
- physicalTableName,
2256
- connectionName,
2257
- error: errMessage(err),
2258
- });
2259
- }
2260
- }
2261
- }
2262
-
2263
- /**
2264
- * Finalize a successful build: advance to MANIFEST_ROWS_READY then
2265
- * MANIFEST_FILE_READY and persist the assembled manifest. The caller
2266
- * supplies the per-run metadata. The build itself happens before this.
2267
- */
2268
- private async commitManifest(
2269
- id: string,
2270
- entries: Record<string, ManifestEntry>,
2271
- metadata: Record<string, unknown>,
2272
- ): Promise<void> {
2273
- await this.transition(id, "MANIFEST_ROWS_READY");
2274
- const manifest: BuildManifestResult = {
2275
- builtAt: new Date().toISOString(),
2276
- entries,
2277
- strict: false,
2278
- };
2279
- await this.transition(id, "MANIFEST_FILE_READY", {
2280
- completedAt: new Date(),
2281
- manifest,
2282
- metadata,
2283
- });
2284
- }
2285
-
2286
- // ==================== HELPERS ====================
2287
-
2288
- /** The single-active conflict error, with the winning run's id when known. */
2289
- private activeConflict(
2290
- packageName: string,
2291
- activeId?: string,
2292
- ): MaterializationConflictError {
2293
- const suffix = activeId ? ` (${activeId})` : "";
2294
- return new MaterializationConflictError(
2295
- `Package ${packageName} already has an active materialization${suffix}`,
2296
- );
2297
- }
2298
-
2299
- private recordRun(
2300
- mode: MaterializationMode,
2301
- outcome: "success" | "failed" | "cancelled",
2302
- startedAtMs: number,
2303
- ): void {
2304
- recordMaterializationRun(mode, outcome, Date.now() - startedAtMs);
2305
- }
2306
-
2307
- private runInBackground(
2308
- id: string,
2309
- run: (signal: AbortSignal) => Promise<void>,
2310
- ): void {
2311
- const abortController = new AbortController();
2312
- this.runningAbortControllers.set(id, abortController);
2313
-
2314
- run(abortController.signal)
2315
- .catch(async (err) => {
2316
- const message = errMessage(err);
2317
- const next = abortController.signal.aborted
2318
- ? "CANCELLED"
2319
- : "FAILED";
2320
- try {
2321
- await this.repository.updateMaterialization(id, {
2322
- status: next,
2323
- completedAt: new Date(),
2324
- error: abortController.signal.aborted ? "Cancelled" : message,
2325
- });
2326
- } catch (transitionErr) {
2327
- logger.error("Failed to record materialization failure", {
2328
- materializationId: id,
2329
- originalError: message,
2330
- transitionError: errMessage(transitionErr),
2331
- });
2332
- }
2333
- })
2334
- .finally(() => {
2335
- this.runningAbortControllers.delete(id);
2336
- });
2337
- }
2338
-
2339
- private resolveEnvironmentId(environmentName: string): Promise<string> {
2340
- return resolveEnvironmentId(this.repository, environmentName);
2341
- }
2342
- }