@malloy-publisher/server 0.0.231 → 0.0.233

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 (101) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +135 -13
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  14. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  15. package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
  16. package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
  17. package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
  18. package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +22627 -1260
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/controller/query.controller.ts +26 -21
  32. package/src/errors.ts +19 -0
  33. package/src/json_utils.spec.ts +51 -0
  34. package/src/json_utils.ts +33 -0
  35. package/src/logger.spec.ts +18 -1
  36. package/src/logger.ts +3 -1
  37. package/src/materialization_metrics.spec.ts +89 -4
  38. package/src/materialization_metrics.ts +155 -5
  39. package/src/mcp/query_envelope.spec.ts +229 -0
  40. package/src/mcp/query_envelope.ts +230 -0
  41. package/src/mcp/server.protocol.spec.ts +128 -16
  42. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  43. package/src/mcp/skills/skills_bundle.json +1 -1
  44. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  45. package/src/mcp/tool_response.spec.ts +108 -0
  46. package/src/mcp/tool_response.ts +138 -0
  47. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  48. package/src/mcp/tools/compile_tool.ts +61 -30
  49. package/src/mcp/tools/docs_search_tool.ts +6 -16
  50. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  51. package/src/mcp/tools/embedding_index.ts +808 -0
  52. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  53. package/src/mcp/tools/execute_query_tool.ts +90 -151
  54. package/src/mcp/tools/get_context_eval.ts +194 -45
  55. package/src/mcp/tools/get_context_tool.spec.ts +358 -5
  56. package/src/mcp/tools/get_context_tool.ts +202 -56
  57. package/src/mcp/tools/reload_package_tool.ts +3 -29
  58. package/src/pg_helpers.spec.ts +201 -0
  59. package/src/pg_helpers.ts +44 -5
  60. package/src/server.ts +24 -0
  61. package/src/service/build_plan.spec.ts +128 -2
  62. package/src/service/build_plan.ts +239 -17
  63. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  64. package/src/service/connection.spec.ts +371 -1
  65. package/src/service/connection.ts +339 -20
  66. package/src/service/connection_config.spec.ts +108 -0
  67. package/src/service/connection_config.ts +47 -8
  68. package/src/service/connection_federation.spec.ts +184 -0
  69. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  70. package/src/service/embedding_provider.spec.ts +329 -0
  71. package/src/service/embedding_provider.ts +236 -0
  72. package/src/service/environment.ts +274 -12
  73. package/src/service/environment_store.spec.ts +678 -3
  74. package/src/service/environment_store.ts +449 -33
  75. package/src/service/environment_store_clone.spec.ts +350 -0
  76. package/src/service/manifest_loader.spec.ts +68 -13
  77. package/src/service/manifest_loader.ts +67 -19
  78. package/src/service/materialization_build_session.spec.ts +435 -0
  79. package/src/service/materialization_build_session.ts +681 -0
  80. package/src/service/materialization_eligibility.spec.ts +158 -0
  81. package/src/service/materialization_eligibility.ts +305 -0
  82. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  83. package/src/service/materialization_serve_transform.ts +779 -0
  84. package/src/service/materialization_service.spec.ts +774 -7
  85. package/src/service/materialization_service.ts +1107 -42
  86. package/src/service/materialization_test_fixtures.ts +7 -0
  87. package/src/service/model.spec.ts +207 -0
  88. package/src/service/model.ts +569 -59
  89. package/src/service/model_limits.spec.ts +28 -0
  90. package/src/service/model_limits.ts +21 -0
  91. package/src/service/model_storage_serve.spec.ts +193 -0
  92. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  93. package/src/service/package.spec.ts +196 -0
  94. package/src/service/package.ts +385 -17
  95. package/src/service/persistence_policy.spec.ts +109 -0
  96. package/src/storage/duckdb/schema.ts +37 -0
  97. package/tests/fixtures/xlsx/database.xlsx +0 -0
  98. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  99. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  100. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
  101. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -4,6 +4,7 @@ import {
4
4
  Connection,
5
5
  FixedConnectionMap,
6
6
  GivenValue,
7
+ InMemoryURLReader,
7
8
  isBasicArray,
8
9
  isJoined,
9
10
  isRepeatedRecord,
@@ -19,6 +20,7 @@ import {
19
20
  Runtime,
20
21
  type FieldDef,
21
22
  type SourceDef,
23
+ type VirtualMap,
22
24
  } from "@malloydata/malloy";
23
25
  import * as Malloy from "@malloydata/malloy-interfaces";
24
26
  import {
@@ -27,9 +29,15 @@ import {
27
29
  } from "@malloydata/malloy-sql";
28
30
  import { DataStyles } from "@malloydata/render";
29
31
  import { publisherMeter } from "../telemetry";
32
+ import {
33
+ recordServeShapeTierDrop,
34
+ recordStorageServeRouting,
35
+ } from "../materialization_metrics";
30
36
  import * as fs from "fs/promises";
37
+ import { readFileSync } from "fs";
31
38
  import { createRequire } from "module";
32
39
  import * as path from "path";
40
+ import { fileURLToPath } from "url";
33
41
  import { components } from "../api";
34
42
  import {
35
43
  getDefaultQueryRowLimit,
@@ -46,7 +54,20 @@ import {
46
54
  NotQueryableError,
47
55
  PayloadTooLargeError,
48
56
  } from "../errors";
57
+ import { getPersistStorageMode } from "../config";
49
58
  import { logger } from "../logger";
59
+ import {
60
+ buildServeShapeModelForBindings,
61
+ buildVirtualMap,
62
+ extractJoins,
63
+ extractRefinements,
64
+ extractViews,
65
+ narrowSchemaToPublic,
66
+ sliceSourceRange,
67
+ type ServeBinding,
68
+ type SourceLocation,
69
+ } from "./materialization_serve_transform";
70
+ import { evaluateManifestFreshness } from "./freshness";
50
71
  import { deserializeError } from "../package_load/package_load_pool";
51
72
  import type {
52
73
  SerializedModel,
@@ -71,6 +92,8 @@ import {
71
92
  import { malloyGivenToApi, type MalloyGiven } from "./given";
72
93
  import {
73
94
  assertWithinModelResponseLimits,
95
+ type QueryRowLimitSource,
96
+ queryRowLimitSource,
74
97
  resolveModelQueryRowLimit,
75
98
  } from "./model_limits";
76
99
  import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
@@ -164,6 +187,20 @@ export class Model {
164
187
  private modelMaterializer: ModelMaterializer | undefined;
165
188
  private modelDef: ModelDef | undefined;
166
189
  private modelInfo: Malloy.ModelInfo | undefined;
190
+ /**
191
+ * Connection config to compile a transient serve-shape model against, when a
192
+ * query is routed through the `storage=` virtual-source transform. Captured
193
+ * at hydration (fromSerialized) so serve can build a fresh Runtime.
194
+ */
195
+ private serveMalloyConfig?: ModelConnectionInput;
196
+ /**
197
+ * The package's `storage=` serve bindings, set by the owning Package when a
198
+ * build/manifest binds materialized-into-storage sources. Empty ⇒ no serve
199
+ * routing (the common case; the serve path is unchanged).
200
+ */
201
+ private serveBindings: ServeBinding[] = [];
202
+ /** Memoized serve-shape materializer, keyed by the bound source set. */
203
+ private serveShapeCache?: { key: string; materializer: ModelMaterializer };
167
204
  private sources: ApiSource[] | undefined;
168
205
  private queries: ApiQuery[] | undefined;
169
206
  private sourceInfos: Malloy.SourceInfo[] | undefined;
@@ -1275,7 +1312,7 @@ export class Model {
1275
1312
  ? hydrateNotebookCells(runtime, data.notebookCells)
1276
1313
  : undefined;
1277
1314
 
1278
- return new Model(
1315
+ const model = new Model(
1279
1316
  packageName,
1280
1317
  data.modelPath,
1281
1318
  dataStyles,
@@ -1291,6 +1328,10 @@ export class Model {
1291
1328
  givens,
1292
1329
  modelInfo,
1293
1330
  );
1331
+ // Capture the config so a storage= serve query can compile a transient
1332
+ // serve-shape model against the same connections.
1333
+ model.setServeMalloyConfig(malloyConfig);
1334
+ return model;
1294
1335
  }
1295
1336
 
1296
1337
  /**
@@ -1724,6 +1765,282 @@ export class Model {
1724
1765
  }
1725
1766
  }
1726
1767
 
1768
+ /** Capture the config used to compile a transient serve-shape model. */
1769
+ public setServeMalloyConfig(config: ModelConnectionInput): void {
1770
+ this.serveMalloyConfig = config;
1771
+ }
1772
+
1773
+ /**
1774
+ * Set (or clear) this model's `storage=` serve bindings. Invalidates the
1775
+ * memoized serve-shape materializer so the next routed query recompiles
1776
+ * against the new binding set.
1777
+ */
1778
+ public setServeBindings(bindings: ServeBinding[]): void {
1779
+ this.serveBindings = bindings;
1780
+ this.serveShapeCache = undefined;
1781
+ }
1782
+
1783
+ /**
1784
+ * Compile an untrusted query against the transient serve-shape model that
1785
+ * rebinds this package's materialized-into-storage sources to virtual sources
1786
+ * on their storage connection. Returns the runnable and the `virtualMap` that
1787
+ * binds each virtual handle to its physical table.
1788
+ *
1789
+ * Throwing is the eligibility signal: a query that references a refinement
1790
+ * the serve shape does not reproduce (a measure/dim/join defined on the
1791
+ * source in the author's model, or a source with no binding) fails to compile
1792
+ * here, and {@link getQueryResults} falls back to serving it live. The
1793
+ * compiled materializer is memoized per binding set (the serve-variant cache).
1794
+ */
1795
+ /**
1796
+ * The serve bindings that may serve from their materialized table right NOW:
1797
+ * fresh, un-gated, or stale-under-`stale_ok`. A stale binding whose fallback is
1798
+ * `live`/`fail` evaluates to `serve_live` and is dropped, so the serve shape
1799
+ * omits that source and any query touching it falls through to live — the SAME
1800
+ * freshness gate the colocated serve applies (`getFreshBuildManifest`
1801
+ * → `evaluateManifestFreshness`). Placement (`storage=`) is orthogonal to
1802
+ * freshness: flip a colocated build ↔ `storage=lake` and this behaves identically.
1803
+ */
1804
+ private freshServeBindings(now: number): ServeBinding[] {
1805
+ const at = new Date(now);
1806
+ return this.serveBindings.filter(
1807
+ (b) =>
1808
+ evaluateManifestFreshness(
1809
+ {
1810
+ tableName: b.tablePath,
1811
+ dataAsOf: b.freshAsOf,
1812
+ freshnessWindowSeconds: b.freshnessWindowSeconds,
1813
+ freshnessFallback: b.freshnessFallback,
1814
+ },
1815
+ at,
1816
+ ) === "serve_table",
1817
+ );
1818
+ }
1819
+
1820
+ private async loadServeShapeQuery(queryString: string): Promise<{
1821
+ runnable: QueryMaterializer;
1822
+ virtualMap: VirtualMap;
1823
+ /**
1824
+ * The fresh bindings the shape was built from. Returned so a caller making
1825
+ * a per-binding decision reads the set that actually produced this shape
1826
+ * rather than the package-wide field.
1827
+ */
1828
+ bindings: ServeBinding[];
1829
+ }> {
1830
+ // Gate by freshness first: only bindings that should serve their table now
1831
+ // enter the shape. Keying the cache on the FRESH subset means it recompiles
1832
+ // when a binding crosses its window (drops out) — the storage analogue of
1833
+ // the colocated path's memoized getFreshBuildManifest.
1834
+ const freshBindings = this.freshServeBindings(Date.now());
1835
+ if (freshBindings.length === 0) {
1836
+ // Every bound source is stale past its window with a live/fail fallback —
1837
+ // nothing to serve from storage; fall through to live (caller's catch).
1838
+ throw new Error("no fresh storage serve bindings for this query");
1839
+ }
1840
+ const key = freshBindings
1841
+ .map((b) => `${b.sourceName}@${b.connectionName}/${b.virtualHandle}`)
1842
+ .sort()
1843
+ .join("|");
1844
+ if (!this.serveShapeCache || this.serveShapeCache.key !== key) {
1845
+ this.serveShapeCache = {
1846
+ key,
1847
+ materializer: await this.compileServeShape(
1848
+ this.serveBindingsWithRefinements(freshBindings),
1849
+ ),
1850
+ };
1851
+ }
1852
+ const virtualMap = buildVirtualMap(freshBindings);
1853
+ const runnable =
1854
+ this.serveShapeCache.materializer.loadRestrictedQuery(queryString);
1855
+ // Compile eagerly so ineligibility (a refinement the serve shape lacks, an
1856
+ // unbound source, a bad connection) surfaces HERE — Malloy compiles lazily,
1857
+ // so without this the error would escape at prepare/run instead of at the
1858
+ // caller's try, defeating the safe fallback. Cheap relative to the run. The
1859
+ // serve shape is pure virtual sources, so no buildManifest is needed.
1860
+ await runnable.getSQL({ virtualMap });
1861
+ return { runnable, virtualMap, bindings: freshBindings };
1862
+ }
1863
+
1864
+ /**
1865
+ * Compile the serve-shape materializer for a binding set, degrading
1866
+ * gracefully if the richest shape does not compile. A single un-carriable
1867
+ * refinement (a join or view that reaches a non-materialized source, a
1868
+ * non-portable expression) would otherwise fail the WHOLE shape's compile and
1869
+ * disable storage serving for every source in the package. So the shape is
1870
+ * validated once (per binding set — the result is cached) and, on failure,
1871
+ * the riskiest refinement category is dropped and it retries: full → drop
1872
+ * views → drop views + joins → base-only. Base-only is pure virtual sources
1873
+ * and always compiles, so it is the guaranteed floor. Each surviving tier
1874
+ * still serves everything it can; the per-query eager compile in
1875
+ * {@link loadServeShapeQuery} remains the final net for query-specific
1876
+ * ineligibility.
1877
+ */
1878
+ private async compileServeShape(
1879
+ enriched: ServeBinding[],
1880
+ ): Promise<ModelMaterializer> {
1881
+ // Richest first; each predicate keeps fewer refinement kinds than the last.
1882
+ const keepKinds: Array<ReadonlySet<string>> = [
1883
+ new Set(["join", "dimension", "measure", "view"]),
1884
+ new Set(["join", "dimension", "measure"]),
1885
+ new Set(["dimension", "measure"]),
1886
+ new Set(),
1887
+ ];
1888
+ // Skip escalation entirely when nothing beyond the base is carried.
1889
+ const hasRefinements = enriched.some(
1890
+ (b) => (b.refinements ?? []).length > 0,
1891
+ );
1892
+ const lastTier = keepKinds.length - 1;
1893
+ for (let tier = 0; tier <= lastTier; tier++) {
1894
+ const keep = keepKinds[tier];
1895
+ const shaped =
1896
+ tier === 0
1897
+ ? enriched
1898
+ : enriched.map((b) =>
1899
+ b.refinements
1900
+ ? {
1901
+ ...b,
1902
+ refinements: b.refinements.filter((r) =>
1903
+ keep.has(r.kind),
1904
+ ),
1905
+ }
1906
+ : b,
1907
+ );
1908
+ const materializer = this.buildServeShapeMaterializer(shaped);
1909
+ // Base-only (last tier) always compiles; trust it without a probe. And
1910
+ // when there are no refinements at all, tier 0 IS the base — skip too.
1911
+ if (tier === lastTier || (tier === 0 && !hasRefinements)) {
1912
+ return materializer;
1913
+ }
1914
+ try {
1915
+ await materializer.getModel();
1916
+ return materializer;
1917
+ } catch (err) {
1918
+ recordServeShapeTierDrop(tier);
1919
+ logger.warn(
1920
+ "Storage serve shape failed to compile; dropping the riskiest refinement category and retrying",
1921
+ {
1922
+ model: this.modelPath,
1923
+ tier,
1924
+ error: err instanceof Error ? err.message : String(err),
1925
+ },
1926
+ );
1927
+ }
1928
+ }
1929
+ // Unreachable: the last tier returns above. Satisfy the type checker.
1930
+ return this.buildServeShapeMaterializer(
1931
+ enriched.map((b) => ({ ...b, refinements: [] })),
1932
+ );
1933
+ }
1934
+
1935
+ /** Build the transient serve-shape materializer for a set of bindings. */
1936
+ private buildServeShapeMaterializer(
1937
+ bindings: ServeBinding[],
1938
+ ): ModelMaterializer {
1939
+ const { modelText } = buildServeShapeModelForBindings(bindings);
1940
+ const root = "file:///storage-serve-shape/";
1941
+ const url = `${root}shape.malloy`;
1942
+ const runtime = new Runtime({
1943
+ urlReader: new InMemoryURLReader(new Map([[url, modelText]])),
1944
+ config: Model.toMalloyConfig(this.serveMalloyConfig!),
1945
+ });
1946
+ return runtime.loadModel(new URL(url), {
1947
+ importBaseURL: new URL(root),
1948
+ });
1949
+ }
1950
+
1951
+ /**
1952
+ * The serve bindings enriched with the refinements to re-declare on each
1953
+ * virtual base — the source's dimensions/measures (computed from the stored
1954
+ * columns), its joins whose target source is ALSO materialized (the join runs
1955
+ * over the stored tables), and its views (turtles). All are read from this
1956
+ * model's compiled definition. Analytic source-fields are not carried; a
1957
+ * query using one falls back to live.
1958
+ *
1959
+ * Join and view declaration text is lifted verbatim from the author's source
1960
+ * files by location (read once per file, best-effort — an unreadable file
1961
+ * just drops that source's joins/views, which fall back). The join
1962
+ * materialization gate is a `sourceID`-keyed lookup, so it never depends on
1963
+ * parsing that text; views are emitted optimistically and pruned by the
1964
+ * shape-compile escalation in {@link compileServeShape} if they don't hold.
1965
+ */
1966
+ private serveBindingsWithRefinements(
1967
+ bindings: ServeBinding[] = this.serveBindings,
1968
+ ): ServeBinding[] {
1969
+ const contents = (
1970
+ this.modelDef as
1971
+ | {
1972
+ contents?: Record<
1973
+ string,
1974
+ { sourceID?: unknown; fields?: unknown[] }
1975
+ >;
1976
+ }
1977
+ | undefined
1978
+ )?.contents;
1979
+ // sourceID -> author source name, for the join materialization gate.
1980
+ const sourceNameById = new Map<string, string>();
1981
+ for (const [name, def] of Object.entries(contents ?? {})) {
1982
+ if (typeof def?.sourceID === "string") {
1983
+ sourceNameById.set(def.sourceID, name);
1984
+ }
1985
+ }
1986
+ const materializedSourceNames = new Set(
1987
+ bindings.map((b) => b.sourceName),
1988
+ );
1989
+ // Cache each source file's text (or null when unreadable) across bindings.
1990
+ const fileCache = new Map<string, string | null>();
1991
+ const liftText = (location: SourceLocation): string | undefined => {
1992
+ if (!location?.url?.startsWith("file:")) return undefined;
1993
+ if (!fileCache.has(location.url)) {
1994
+ try {
1995
+ fileCache.set(
1996
+ location.url,
1997
+ readFileSync(fileURLToPath(location.url), "utf8"),
1998
+ );
1999
+ } catch {
2000
+ fileCache.set(location.url, null);
2001
+ }
2002
+ }
2003
+ const text = fileCache.get(location.url);
2004
+ return text ? sliceSourceRange(text, location.range) : undefined;
2005
+ };
2006
+ return (
2007
+ bindings
2008
+ .map((b) => {
2009
+ const fields = contents?.[b.sourceName]?.fields;
2010
+ // Narrow the declared ::Shape to the source's PUBLIC columns: the
2011
+ // build materializes every projected column (incl. `except:`-ed /
2012
+ // access-restricted ones), so the captured schema can be wider
2013
+ // than the source's public surface. Declaring a hidden column
2014
+ // would expose it over storage when live hides it — always applied
2015
+ // (even with no refinements), so the serve surface never widens
2016
+ // the source's.
2017
+ const schema = narrowSchemaToPublic(b.schema, fields);
2018
+ const refinements = [
2019
+ ...extractJoins(fields, {
2020
+ sourceNameById,
2021
+ materializedSourceNames,
2022
+ liftText,
2023
+ }),
2024
+ ...extractRefinements(fields),
2025
+ ...extractViews(fields, liftText),
2026
+ ];
2027
+ return { ...b, schema, refinements };
2028
+ })
2029
+ // Drop any binding whose public schema is empty. Bindings are pushed
2030
+ // to EVERY model in the package, so a model receives bindings for
2031
+ // sources it doesn't define (defined in a sibling model) — those have
2032
+ // no field list here, hence an empty narrowed schema. An empty
2033
+ // `type: X__shape is {}` is a Malloy parse error that would fail the
2034
+ // ENTIRE serve-shape model (breaking the base-only-always-compiles
2035
+ // fallback invariant) and silently drop storage serving for this
2036
+ // model's own sources too. Omitting them sends queries on an
2037
+ // undefined source to live (where this model refuses them anyway) and
2038
+ // keeps a source from being served through a model that doesn't
2039
+ // declare it.
2040
+ .filter((b) => b.schema.length > 0)
2041
+ );
2042
+ }
2043
+
1727
2044
  public async getQueryResults(
1728
2045
  sourceName?: string,
1729
2046
  queryName?: string,
@@ -1744,6 +2061,10 @@ export class Model {
1744
2061
  compactResult: QueryData;
1745
2062
  modelInfo: Malloy.ModelInfo;
1746
2063
  dataStyles: DataStyles;
2064
+ /** Row cap pushed into the SQL: the query's own LIMIT, else the default. */
2065
+ rowLimit: number;
2066
+ /** Which of those two the cap came from. */
2067
+ rowLimitSource: QueryRowLimitSource;
1747
2068
  }> {
1748
2069
  const startTime = performance.now();
1749
2070
  if (this.compilationError) {
@@ -1761,6 +2082,23 @@ export class Model {
1761
2082
  }
1762
2083
 
1763
2084
  let runnable: QueryMaterializer;
2085
+ let liveRunnable: QueryMaterializer | undefined;
2086
+ // Set when this query is routed through the `storage=` serve-shape
2087
+ // transform; threaded into prepare + run so the virtual sources resolve to
2088
+ // their physical tables. Undefined ⇒ served live (the default path).
2089
+ let serveVirtualMap: VirtualMap | undefined;
2090
+ // The bindings that actually PRODUCED the serve shape — the fresh subset,
2091
+ // not the package's whole set. Per-binding decisions (freshnessFallback)
2092
+ // must read this: `this.serveBindings` is pushed package-wide, so a
2093
+ // sibling source's value would otherwise decide this query's behavior.
2094
+ let serveShapeBindings: ServeBinding[] = [];
2095
+ // How the answer was ultimately produced, for the query histogram. Absent
2096
+ // when the query never routed (an off/live deployment's histogram is
2097
+ // unchanged); "storage" when served from a materialized table;
2098
+ // "live_fallback" when it routed but a run-time store failure degraded it
2099
+ // to live — which must NOT count as a storage hit, since the hit rate is
2100
+ // the tier's headline KPI and would otherwise rise while the tier is down.
2101
+ let servedFrom: "storage" | "live_fallback" | undefined;
1764
2102
  if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
1765
2103
  throw new BadRequestError("Model has no queryable entities.");
1766
2104
 
@@ -1852,6 +2190,51 @@ export class Model {
1852
2190
  // `sourceName`/`queryName` pair are untrusted, so both compile here;
1853
2191
  // only author-curated notebook cells use the unrestricted `loadQuery`.
1854
2192
  runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
2193
+ // Kept so a routed query can still be answered live if the store fails
2194
+ // underneath it at RUN time (see the freshnessFallback retry below).
2195
+ liveRunnable = runnable;
2196
+
2197
+ // storage= serve routing: when enabled and this package has sources
2198
+ // materialized into a storage destination, try compiling the query
2199
+ // against the transient serve-shape model (materialized sources rebound
2200
+ // to virtual sources on their storage connection). If it compiles, we
2201
+ // serve from the materialized tables via the virtualMap; if it does not
2202
+ // (a refinement the shape lacks, or an unbound source), we keep the
2203
+ // original runnable and serve live — safe fallback, no behavior change
2204
+ // for anything the transform can't yet reproduce. Off / write-only and
2205
+ // packages with no storage bindings skip this entirely.
2206
+ if (
2207
+ getPersistStorageMode() === "on" &&
2208
+ this.serveBindings.length > 0 &&
2209
+ this.serveMalloyConfig
2210
+ ) {
2211
+ try {
2212
+ const shaped = await this.loadServeShapeQuery(queryString);
2213
+ runnable = shaped.runnable;
2214
+ serveVirtualMap = shaped.virtualMap;
2215
+ serveShapeBindings = shaped.bindings;
2216
+ servedFrom = "storage";
2217
+ recordStorageServeRouting("storage");
2218
+ logger.info("Serving query from storage tier (virtual-source)", {
2219
+ modelPath: this.modelPath,
2220
+ // The sources in the SHAPE, not the package's whole binding set:
2221
+ // a stale binding is dropped before the shape is built.
2222
+ storageSources: shaped.bindings.map((b) => b.sourceName),
2223
+ });
2224
+ } catch (shapeErr) {
2225
+ recordStorageServeRouting("live_fallback");
2226
+ logger.debug(
2227
+ "storage serve-shape ineligible for this query; serving live",
2228
+ {
2229
+ modelPath: this.modelPath,
2230
+ error:
2231
+ shapeErr instanceof Error
2232
+ ? shapeErr.message
2233
+ : String(shapeErr),
2234
+ },
2235
+ );
2236
+ }
2237
+ }
1855
2238
  } catch (error) {
1856
2239
  // Re-throw BadRequestError as-is
1857
2240
  if (error instanceof BadRequestError) {
@@ -1926,6 +2309,14 @@ export class Model {
1926
2309
  // (for the row limit) and the run so a stale persist source falls back per
1927
2310
  // its declared policy — and prep/run agree on the same substitution.
1928
2311
  const buildManifest = this.resolveFreshBuildManifest();
2312
+ // The serve-shape runnable resolves its tables through `virtualMap`, not
2313
+ // the same-connection build manifest, and its transient model carries no
2314
+ // `##! experimental.persistence` — so passing a non-empty buildManifest to
2315
+ // it errors. When routing through the shape, suppress the manifest; the
2316
+ // original (live) runnable still gets it.
2317
+ const effectiveBuildManifest = serveVirtualMap
2318
+ ? undefined
2319
+ : buildManifest;
1929
2320
 
1930
2321
  // Prepare INSIDE the run try/catch: a bad-given / value-type throw at
1931
2322
  // prepare time (getPreparedResult binds the givens) gets the same
@@ -1933,6 +2324,7 @@ export class Model {
1933
2324
  // a 500. `executionTime` is still captured after prepare and before run,
1934
2325
  // preserving the pre-existing timing recorded by the success histogram.
1935
2326
  let rowLimit = 0;
2327
+ let rowLimitSource: QueryRowLimitSource = "server_default";
1936
2328
  let executionTime = 0;
1937
2329
  let queryResults;
1938
2330
  // Givens supplied only so a joined source's authorize gate could see
@@ -1940,79 +2332,180 @@ export class Model {
1940
2332
  // the real query if this model doesn't itself surface them — see
1941
2333
  // filterGivensToModelSurface.
1942
2334
  const querySurfaceGivens = this.filterGivensToModelSurface(givens);
2335
+ // Same reason as effectiveBuildManifest: the serve shape is built from
2336
+ // given-FREE sources, so it surfaces no `given:` and Malloy rejects any
2337
+ // supplied name with "unknown given" — a spurious 400, past the routing
2338
+ // fallback, on a query that should just serve from storage. Nothing in the
2339
+ // shape can read them; the authorize gate above already saw the full set.
2340
+ const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
1943
2341
  try {
1944
- rowLimit = resolveModelQueryRowLimit(
1945
- (
1946
- await runnable.getPreparedResult({
1947
- givens: querySurfaceGivens,
1948
- buildManifest,
1949
- })
1950
- ).resultExplore.limit,
1951
- { defaultLimit: getDefaultQueryRowLimit(), maxRows },
1952
- );
2342
+ const preparedLimit = (
2343
+ await runnable.getPreparedResult({
2344
+ givens: effectiveGivens,
2345
+ buildManifest: effectiveBuildManifest,
2346
+ virtualMap: serveVirtualMap,
2347
+ })
2348
+ ).resultExplore.limit;
2349
+ rowLimitSource = queryRowLimitSource(preparedLimit);
2350
+ rowLimit = resolveModelQueryRowLimit(preparedLimit, {
2351
+ defaultLimit: getDefaultQueryRowLimit(),
2352
+ maxRows,
2353
+ });
1953
2354
  executionTime = performance.now() - startTime;
1954
2355
 
1955
2356
  queryResults = await runnable.run({
1956
2357
  rowLimit,
1957
- givens: querySurfaceGivens,
2358
+ givens: effectiveGivens,
1958
2359
  abortSignal,
1959
- buildManifest,
2360
+ buildManifest: effectiveBuildManifest,
2361
+ virtualMap: serveVirtualMap,
1960
2362
  });
1961
2363
  } catch (error) {
1962
- // Record error metrics
1963
- const errorEndTime = performance.now();
1964
- const errorExecutionTime = errorEndTime - startTime;
1965
- this.queryExecutionHistogram.record(errorExecutionTime, {
1966
- "malloy.model.path": this.modelPath,
1967
- "malloy.model.query.name": queryName,
1968
- "malloy.model.query.source": sourceName,
1969
- "malloy.model.query.query": query,
1970
- "malloy.model.query.status": "error",
1971
- });
2364
+ // A binding that declares `freshnessFallback=live` is saying the tier is
2365
+ // a performance optimisation, not a dependency — so a store that fails
2366
+ // UNDER a routed query should degrade to serving live, the same answer
2367
+ // the compile-time ladder already gives when the shape can't be built.
2368
+ // Without this the store is a hard dependency the moment a query routes:
2369
+ // a not-yet-converged rebind or an over-eager GC turns into a user-facing
2370
+ // error for a source explicitly marked as safe to serve live.
2371
+ //
2372
+ // Deliberately narrow:
2373
+ // - only when the query actually routed to storage (`serveVirtualMap`);
2374
+ // - only when every binding THAT PRODUCED THIS SHAPE says `live` —
2375
+ // `fail` (and the `stale_ok` default) keep surfacing, so one
2376
+ // fail-closed source is not degraded by a permissive neighbour.
2377
+ // Read off the shape's own bindings, not the package's: bindings are
2378
+ // pushed package-wide and `freshnessFallback` is per entry, so a
2379
+ // mixed set is normal and a sibling must not decide this query;
2380
+ // - never for a client error (a bad given) or an abort, where a retry
2381
+ // would just reproduce it or defy the caller;
2382
+ // - the retry re-supplies the REAL givens. The storage path suppresses
2383
+ // them because the shape is built from given-free sources; the live
2384
+ // source may filter on them, and running it without them would serve
2385
+ // unfiltered rows.
2386
+ const canDegradeToLive =
2387
+ !!serveVirtualMap &&
2388
+ !!liveRunnable &&
2389
+ !abortSignal?.aborted &&
2390
+ !String((error as { code?: string })?.code ?? "").startsWith(
2391
+ "runtime-given-",
2392
+ ) &&
2393
+ serveShapeBindings.length > 0 &&
2394
+ serveShapeBindings.every((b) => b.freshnessFallback === "live");
2395
+ // Both the original failure and a failure OF THE RETRY end here: record
2396
+ // the error metric, then map the error. A broad outage takes the source
2397
+ // warehouse down alongside the store, so the retry failing is ordinary —
2398
+ // and without this it would escape the given-mapping, the MalloyError
2399
+ // rethrow and the error metric, turning a clean 400 into an untracked 500.
2400
+ // Annotated on the CONST, not just the arrow: control-flow analysis only
2401
+ // treats a call as never-returning when the callee has an explicit type
2402
+ // annotation, and the fall-through below depends on that narrowing.
2403
+ const failQuery: (err: unknown) => never = (err) => {
2404
+ // Record error metrics
2405
+ const errorEndTime = performance.now();
2406
+ const errorExecutionTime = errorEndTime - startTime;
2407
+ this.queryExecutionHistogram.record(errorExecutionTime, {
2408
+ "malloy.model.path": this.modelPath,
2409
+ "malloy.model.query.name": queryName,
2410
+ "malloy.model.query.source": sourceName,
2411
+ "malloy.model.query.query": query,
2412
+ "malloy.model.query.status": "error",
2413
+ // Ships dark: only tag queries that routed. A live/off query gets
2414
+ // no new attribute, so an off deployment's histogram is unchanged.
2415
+ ...(servedFrom
2416
+ ? { "malloy.model.query.served_from": servedFrom }
2417
+ : {}),
2418
+ });
1972
2419
 
1973
- // Bad client-supplied givens (unknown name, wrong-typed value, an
1974
- // operator-finalized override, ...) all surface as a Malloy
1975
- // `runtime-given-*` error. Malloy is the single validator; the publisher
1976
- // just maps its rejection to a clean 400. Duck-type on `.code`
1977
- // (MalloyCompileError extends Error, not MalloyError, and isn't
1978
- // root-exported). The `runtime-given-` prefix is a pinned coupling to
1979
- // Malloy's error codes (@malloydata/malloy given_binding.ts / runtime.ts);
1980
- // if they're renamed upstream, update it here (and in environment.ts) —
1981
- // otherwise these fall through to the generic 400 below with a worse
1982
- // message, and the /compile path silently omits `sql`.
1983
- const givenCode = (error as { code?: string })?.code;
1984
- if (
1985
- typeof givenCode === "string" &&
1986
- givenCode.startsWith("runtime-given-")
1987
- ) {
1988
- logger.debug("Rejected client-supplied given", {
2420
+ // Bad client-supplied givens (unknown name, wrong-typed value, an
2421
+ // operator-finalized override, ...) all surface as a Malloy
2422
+ // `runtime-given-*` error. Malloy is the single validator; the publisher
2423
+ // just maps its rejection to a clean 400. Duck-type on `.code`
2424
+ // (MalloyCompileError extends Error, not MalloyError, and isn't
2425
+ // root-exported). The `runtime-given-` prefix is a pinned coupling to
2426
+ // Malloy's error codes (@malloydata/malloy given_binding.ts / runtime.ts);
2427
+ // if they're renamed upstream, update it here (and in environment.ts) —
2428
+ // otherwise these fall through to the generic 400 below with a worse
2429
+ // message, and the /compile path silently omits `sql`.
2430
+ const givenCode = (err as { code?: string })?.code;
2431
+ if (
2432
+ typeof givenCode === "string" &&
2433
+ givenCode.startsWith("runtime-given-")
2434
+ ) {
2435
+ logger.debug("Rejected client-supplied given", {
2436
+ environmentName: this.packageName,
2437
+ modelPath: this.modelPath,
2438
+ error: err instanceof Error ? err.message : String(err),
2439
+ });
2440
+ throw new BadRequestError(
2441
+ err instanceof Error ? err.message : String(err),
2442
+ );
2443
+ }
2444
+
2445
+ // Re-throw Malloy errors as-is (they will be handled by error handler)
2446
+ if (err instanceof MalloyError) {
2447
+ throw err;
2448
+ }
2449
+
2450
+ // For other runtime errors (like divide by zero), throw as BadRequestError
2451
+ const errorMessage =
2452
+ err instanceof Error ? err.message : String(err);
2453
+ logger.error("Query execution error", {
2454
+ error: err,
2455
+ errorMessage,
1989
2456
  environmentName: this.packageName,
1990
2457
  modelPath: this.modelPath,
1991
- error: error instanceof Error ? error.message : String(error),
2458
+ query,
2459
+ queryName,
2460
+ sourceName,
1992
2461
  });
1993
2462
  throw new BadRequestError(
1994
- error instanceof Error ? error.message : String(error),
2463
+ `Query execution failed: ${errorMessage}`,
1995
2464
  );
2465
+ };
2466
+ if (!canDegradeToLive) failQuery(error);
2467
+ logger.warn(
2468
+ "Storage-served query failed at run time; falling back to live (freshnessFallback=live)",
2469
+ {
2470
+ modelPath: this.modelPath,
2471
+ error: error instanceof Error ? error.message : String(error),
2472
+ },
2473
+ );
2474
+ recordStorageServeRouting("runtime_live_fallback");
2475
+ try {
2476
+ // Re-derive the row cap from the LIVE shape. `rowLimit` is assigned
2477
+ // inside the try above, from the storage prepare — so a store failure
2478
+ // that surfaces AT PREPARE (a manifest naming a table that is missing
2479
+ // or malformed resolves through `virtualMap` there) leaves it 0, which
2480
+ // the connector reads as a hard cap and stops before the first row: a
2481
+ // successful, EMPTY answer. Asking the live shape is also the honest
2482
+ // limit, since the live shape is what runs.
2483
+ const livePreparedLimit = (
2484
+ await liveRunnable!.getPreparedResult({
2485
+ givens: querySurfaceGivens,
2486
+ buildManifest,
2487
+ })
2488
+ ).resultExplore.limit;
2489
+ rowLimitSource = queryRowLimitSource(livePreparedLimit);
2490
+ rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
2491
+ defaultLimit: getDefaultQueryRowLimit(),
2492
+ maxRows,
2493
+ });
2494
+ queryResults = await liveRunnable!.run({
2495
+ rowLimit,
2496
+ givens: querySurfaceGivens,
2497
+ abortSignal,
2498
+ buildManifest,
2499
+ });
2500
+ } catch (retryError) {
2501
+ failQuery(retryError);
1996
2502
  }
1997
-
1998
- // Re-throw Malloy errors as-is (they will be handled by error handler)
1999
- if (error instanceof MalloyError) {
2000
- throw error;
2001
- }
2002
-
2003
- // For other runtime errors (like divide by zero), throw as BadRequestError
2004
- const errorMessage =
2005
- error instanceof Error ? error.message : String(error);
2006
- logger.error("Query execution error", {
2007
- error,
2008
- errorMessage,
2009
- environmentName: this.packageName,
2010
- modelPath: this.modelPath,
2011
- query,
2012
- queryName,
2013
- sourceName,
2014
- });
2015
- throw new BadRequestError(`Query execution failed: ${errorMessage}`);
2503
+ // The answer came from the live warehouse, so it is NOT a storage hit —
2504
+ // `runtime_live_fallback` above is the signal that the tier is degraded.
2505
+ servedFrom = "live_fallback";
2506
+ executionTime = performance.now() - startTime;
2507
+ // Fall through: `queryResults` is set, so the normal post-run path
2508
+ // wraps and returns it exactly as a live query would.
2016
2509
  }
2017
2510
 
2018
2511
  const wrappedResult = API.util.wrapResult(queryResults);
@@ -2042,12 +2535,29 @@ export class Model {
2042
2535
  "malloy.model.query.rows_total": queryResults.totalRows,
2043
2536
  "malloy.model.query.connection": queryResults.connectionName,
2044
2537
  "malloy.model.query.status": "success",
2538
+ // Ships dark: only tag queries that routed (see the error path).
2539
+ // "live_fallback" here is a SUCCESS answered by the live warehouse, so
2540
+ // it must not inflate the storage hit rate.
2541
+ ...(servedFrom
2542
+ ? { "malloy.model.query.served_from": servedFrom }
2543
+ : {}),
2045
2544
  });
2046
2545
  return {
2047
2546
  result: wrappedResult,
2048
2547
  compactResult: queryResults.data.value,
2049
2548
  modelInfo: this.modelInfo,
2050
2549
  dataStyles: this.dataStyles,
2550
+ // The cap actually pushed into the SQL. A caller cannot otherwise tell
2551
+ // a complete result from one the row limit cut off: with no LIMIT of
2552
+ // its own a query silently gets DEFAULT_QUERY_ROW_LIMIT rows, and that
2553
+ // is under maxRows, so assertWithinModelResponseLimits raises nothing.
2554
+ // Returning the number lets a caller compare it against the row count
2555
+ // and say so, with no extra query.
2556
+ rowLimit,
2557
+ // Whether that cap was the author's own limit:/top: or the silent
2558
+ // default. Only the second means rows were probably left behind; a
2559
+ // deliberate `top: 10` returning 10 rows is a complete answer.
2560
+ rowLimitSource,
2051
2561
  };
2052
2562
  }
2053
2563