@malloy-publisher/server 0.0.232 → 0.0.234

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 (83) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +269 -10
  3. package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DTZQ4Gxc.js} +1 -1
  4. package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-C5mlDPXK.js} +1 -1
  5. package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-DGNmhG0u.js} +1 -1
  6. package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-CVL_wmP4.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-DmzMBCpy.js} +1 -1
  8. package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-Dbvf4QbB.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-DxdHc2Qs.js} +1 -1
  10. package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-OJdT4tCd.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-Bk7s0KXY.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-j_vCWdN3.js} +1 -1
  13. package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-Rj_4rRnA.es-DoIfLxDJ.js} +1 -1
  14. package/dist/app/assets/{index-B33zGctF.js → index-B_jKMR35.js} +4 -4
  15. package/dist/app/assets/{index-CmkW1MiE.js → index-D-rDyK11.js} +1 -1
  16. package/dist/app/assets/{index-tXJXwdyj.js → index-DWIe_hK0.js} +1 -1
  17. package/dist/app/assets/{index-BkiWKaAF.js → index-hw-xn0X7.js} +1 -1
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +53 -3
  20. package/dist/server.mjs +20277 -925
  21. package/package.json +1 -1
  22. package/src/config.ts +35 -1
  23. package/src/controller/connection.controller.spec.ts +46 -0
  24. package/src/controller/connection.controller.ts +105 -2
  25. package/src/controller/materialization.controller.spec.ts +25 -0
  26. package/src/controller/materialization.controller.ts +60 -0
  27. package/src/controller/model.controller.ts +24 -0
  28. package/src/controller/query.controller.ts +83 -10
  29. package/src/json_utils.spec.ts +51 -0
  30. package/src/json_utils.ts +33 -0
  31. package/src/mcp/handler_utils.ts +10 -2
  32. package/src/mcp/query_envelope.spec.ts +229 -0
  33. package/src/mcp/query_envelope.ts +240 -0
  34. package/src/mcp/server.protocol.spec.ts +128 -16
  35. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  38. package/src/mcp/tool_response.spec.ts +108 -0
  39. package/src/mcp/tool_response.ts +138 -0
  40. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  41. package/src/mcp/tools/compile_tool.ts +61 -30
  42. package/src/mcp/tools/docs_search_tool.ts +6 -16
  43. package/src/mcp/tools/execute_query_tool.spec.ts +154 -3
  44. package/src/mcp/tools/execute_query_tool.ts +131 -155
  45. package/src/mcp/tools/get_context_tool.spec.ts +63 -3
  46. package/src/mcp/tools/get_context_tool.ts +43 -46
  47. package/src/mcp/tools/reload_package_tool.ts +3 -29
  48. package/src/mcp_config.spec.ts +919 -0
  49. package/src/mcp_config.ts +425 -0
  50. package/src/oom_guards.integration.spec.ts +11 -3
  51. package/src/package_load/package_load_pool.ts +2 -0
  52. package/src/package_load/package_load_worker.ts +17 -5
  53. package/src/package_load/protocol.ts +6 -0
  54. package/src/query_metadata_metrics.ts +49 -0
  55. package/src/server.ts +99 -3
  56. package/src/service/build_plan.spec.ts +125 -0
  57. package/src/service/build_plan.ts +108 -7
  58. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  59. package/src/service/connection.spec.ts +371 -1
  60. package/src/service/connection.ts +77 -14
  61. package/src/service/connection_config.spec.ts +60 -0
  62. package/src/service/connection_config.ts +75 -0
  63. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  64. package/src/service/environment.ts +57 -3
  65. package/src/service/materialization_config_validation.spec.ts +99 -0
  66. package/src/service/materialization_config_validation.ts +120 -0
  67. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  68. package/src/service/materialization_service.spec.ts +119 -0
  69. package/src/service/materialization_service.ts +186 -3
  70. package/src/service/materialization_test_fixtures.ts +86 -21
  71. package/src/service/model.spec.ts +45 -1
  72. package/src/service/model.ts +171 -23
  73. package/src/service/model_limits.spec.ts +28 -0
  74. package/src/service/model_limits.ts +21 -0
  75. package/src/service/package.ts +24 -1
  76. package/src/service/package_manifest.spec.ts +137 -4
  77. package/src/service/package_manifest.ts +140 -5
  78. package/src/service/persist_annotation_validation.spec.ts +12 -0
  79. package/src/service/persist_annotation_validation.ts +9 -4
  80. package/src/service/query_metadata.spec.ts +408 -0
  81. package/src/service/query_metadata.ts +492 -0
  82. package/src/service/query_metadata_identity.spec.ts +149 -0
  83. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
@@ -895,12 +895,14 @@ describe("service/model", () => {
895
895
  describe("runtime storage failure → freshnessFallback=live", () => {
896
896
  const originalMode = process.env.PERSIST_STORAGE_MODE;
897
897
  const originalDefaultRows = process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT;
898
+ const originalMetadata = process.env.PUBLISHER_QUERY_METADATA;
898
899
 
899
900
  afterEach(() => {
900
901
  sinon.restore();
901
902
  for (const [name, original] of [
902
903
  ["PERSIST_STORAGE_MODE", originalMode],
903
904
  ["PUBLISHER_DEFAULT_QUERY_ROW_LIMIT", originalDefaultRows],
905
+ ["PUBLISHER_QUERY_METADATA", originalMetadata],
904
906
  ] as const) {
905
907
  if (original === undefined) delete process.env[name];
906
908
  else process.env[name] = original;
@@ -936,7 +938,10 @@ describe("service/model", () => {
936
938
  getPreparedResult:
937
939
  opts.storageFailsAt === "prepare"
938
940
  ? sinon.stub().rejects(storageErr)
939
- : sinon.stub().resolves({ resultExplore: { limit: 0 } }),
941
+ : sinon.stub().resolves({
942
+ resultExplore: { limit: 0 },
943
+ connectionName: "lake",
944
+ }),
940
945
  run:
941
946
  opts.storageFailsAt === "run"
942
947
  ? sinon.stub().rejects(storageErr)
@@ -954,6 +959,7 @@ describe("service/model", () => {
954
959
  const liveRunnable = {
955
960
  getPreparedResult: sinon.stub().resolves({
956
961
  resultExplore: { limit: opts.livePreparedLimit ?? 0 },
962
+ connectionName: "live_pg",
957
963
  }),
958
964
  run: liveRun,
959
965
  };
@@ -1020,6 +1026,44 @@ describe("service/model", () => {
1020
1026
  expect(liveRun.firstCall.args[0].rowLimit).toBe(250);
1021
1027
  });
1022
1028
 
1029
+ it("tags the live retry, with the LIVE connection's layers and the same id", async () => {
1030
+ // Two failures in one: the statement that actually answered the query
1031
+ // carried no attribution at all, so the full-cost fallback — the one an
1032
+ // operator goes looking for on a bill — landed in the untagged bucket;
1033
+ // and the response still returned the id from the bag resolved before
1034
+ // the storage attempt, pointing a caller at the statement that FAILED.
1035
+ // The layers must come from the live connection: on this tier the store
1036
+ // is routinely a different connection, with different enforced
1037
+ // properties.
1038
+ process.env.PUBLISHER_QUERY_METADATA = "on";
1039
+ const { model, liveRun } = routedModel({
1040
+ shapeBindings: [binding("daily", "live")],
1041
+ storageFailsAt: "run",
1042
+ });
1043
+
1044
+ const result = await model.getQueryResults(
1045
+ undefined,
1046
+ undefined,
1047
+ "run: daily -> x",
1048
+ undefined,
1049
+ undefined,
1050
+ undefined,
1051
+ undefined,
1052
+ {
1053
+ correlationId: "corr-1",
1054
+ connectionMetadata: (connectionName: string) =>
1055
+ connectionName === "live_pg"
1056
+ ? { default: null, enforced: { tenant: "acme" } }
1057
+ : { default: null, enforced: { tenant: "wrong" } },
1058
+ },
1059
+ );
1060
+
1061
+ const attached = liveRun.firstCall.args[0].queryMetadata;
1062
+ expect(attached.tenant).toBe("acme");
1063
+ expect(attached.query_id).toBe("corr-1");
1064
+ expect(result.queryCorrelationId).toBe("corr-1");
1065
+ });
1066
+
1023
1067
  it("does not record a live-served answer as a storage hit", async () => {
1024
1068
  // The hit rate is the tier's headline KPI; counting a fallback as a hit
1025
1069
  // makes it RISE while the tier is broken.
@@ -92,14 +92,49 @@ import {
92
92
  import { malloyGivenToApi, type MalloyGiven } from "./given";
93
93
  import {
94
94
  assertWithinModelResponseLimits,
95
+ type QueryRowLimitSource,
96
+ queryRowLimitSource,
95
97
  resolveModelQueryRowLimit,
96
98
  } from "./model_limits";
97
99
  import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
100
+ import {
101
+ mergeQueryMetadata,
102
+ type QueryClass,
103
+ type QueryMetadata,
104
+ } from "./query_metadata";
98
105
  import {
99
106
  extractQueriesFromModelDef,
100
107
  extractSourcesFromModelDef,
101
108
  } from "./source_extraction";
102
109
 
110
+ /**
111
+ * What a request boundary contributes to a model query's per-query metadata: the
112
+ * caller's own properties and class, the environment the query runs in, and a
113
+ * reader for the executing connection's default (the controller owns the
114
+ * environment, so it supplies the lookup rather than the model reaching for it).
115
+ */
116
+ export interface ModelQueryMetadataInput {
117
+ request?: QueryMetadata;
118
+ queryClass?: QueryClass;
119
+ environment?: string;
120
+ version?: string;
121
+ /**
122
+ * The id this query is correlated by, minted by the boundary that returns it
123
+ * (see `mintCorrelationId`). Omitted by callers with no response field to
124
+ * carry it, which is why it is not minted here.
125
+ */
126
+ correlationId?: string;
127
+ /**
128
+ * The executing connection's two metadata layers: its overridable default and
129
+ * the properties the deployment enforces. Supplied by the controller, which
130
+ * owns the environment; the model only knows the connection by name.
131
+ */
132
+ connectionMetadata?: (connectionName: string) => {
133
+ default?: QueryMetadata | null;
134
+ enforced?: QueryMetadata | null;
135
+ } | null;
136
+ }
137
+
103
138
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
104
139
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
105
140
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
@@ -2054,11 +2089,28 @@ export class Model {
2054
2089
  // background callers (materialization, tests) that own their
2055
2090
  // own deadline.
2056
2091
  abortSignal?: AbortSignal,
2092
+ /**
2093
+ * Per-query metadata inputs from the request boundary (see
2094
+ * {@link ModelQueryMetadataInput}). Omitting it still tags the query with
2095
+ * the server context; what is lost is the caller's own properties, the
2096
+ * connection default, and the correlation id.
2097
+ */
2098
+ queryMetadataInput?: ModelQueryMetadataInput,
2057
2099
  ): Promise<{
2058
2100
  result: Malloy.Result;
2059
2101
  compactResult: QueryData;
2060
2102
  modelInfo: Malloy.ModelInfo;
2061
2103
  dataStyles: DataStyles;
2104
+ /** Row cap pushed into the SQL: the query's own LIMIT, else the default. */
2105
+ rowLimit: number;
2106
+ /** Which of those two the cap came from. */
2107
+ rowLimitSource: QueryRowLimitSource;
2108
+ /**
2109
+ * The `query_id` property attached to this query's statements, which is the
2110
+ * caller's join key into the backend's own query record. Null when no
2111
+ * metadata was attached.
2112
+ */
2113
+ queryCorrelationId: string | null;
2062
2114
  }> {
2063
2115
  const startTime = performance.now();
2064
2116
  if (this.compilationError) {
@@ -2318,8 +2370,10 @@ export class Model {
2318
2370
  // a 500. `executionTime` is still captured after prepare and before run,
2319
2371
  // preserving the pre-existing timing recorded by the success histogram.
2320
2372
  let rowLimit = 0;
2373
+ let rowLimitSource: QueryRowLimitSource = "server_default";
2321
2374
  let executionTime = 0;
2322
2375
  let queryResults;
2376
+ let appliedQueryMetadata: QueryMetadata | undefined;
2323
2377
  // Givens supplied only so a joined source's authorize gate could see
2324
2378
  // them (checked above, against the full unfiltered set) must not reach
2325
2379
  // the real query if this model doesn't itself surface them — see
@@ -2332,15 +2386,23 @@ export class Model {
2332
2386
  // shape can read them; the authorize gate above already saw the full set.
2333
2387
  const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
2334
2388
  try {
2335
- rowLimit = resolveModelQueryRowLimit(
2336
- (
2337
- await runnable.getPreparedResult({
2338
- givens: effectiveGivens,
2339
- buildManifest: effectiveBuildManifest,
2340
- virtualMap: serveVirtualMap,
2341
- })
2342
- ).resultExplore.limit,
2343
- { defaultLimit: getDefaultQueryRowLimit(), maxRows },
2389
+ // The prepared result is also where the executing connection's name
2390
+ // comes from, which is what makes the connection's default metadata
2391
+ // layer resolvable BEFORE the statement is issued.
2392
+ const preparedResult = await runnable.getPreparedResult({
2393
+ givens: effectiveGivens,
2394
+ buildManifest: effectiveBuildManifest,
2395
+ virtualMap: serveVirtualMap,
2396
+ });
2397
+ const preparedLimit = preparedResult.resultExplore.limit;
2398
+ rowLimitSource = queryRowLimitSource(preparedLimit);
2399
+ rowLimit = resolveModelQueryRowLimit(preparedLimit, {
2400
+ defaultLimit: getDefaultQueryRowLimit(),
2401
+ maxRows,
2402
+ });
2403
+ appliedQueryMetadata = this.resolveQueryMetadata(
2404
+ queryMetadataInput,
2405
+ preparedResult.connectionName,
2344
2406
  );
2345
2407
  executionTime = performance.now() - startTime;
2346
2408
 
@@ -2350,6 +2412,7 @@ export class Model {
2350
2412
  abortSignal,
2351
2413
  buildManifest: effectiveBuildManifest,
2352
2414
  virtualMap: serveVirtualMap,
2415
+ queryMetadata: appliedQueryMetadata,
2353
2416
  });
2354
2417
  } catch (error) {
2355
2418
  // A binding that declares `freshnessFallback=live` is saying the tier is
@@ -2471,20 +2534,32 @@ export class Model {
2471
2534
  // the connector reads as a hard cap and stops before the first row: a
2472
2535
  // successful, EMPTY answer. Asking the live shape is also the honest
2473
2536
  // limit, since the live shape is what runs.
2474
- rowLimit = resolveModelQueryRowLimit(
2475
- (
2476
- await liveRunnable!.getPreparedResult({
2477
- givens: querySurfaceGivens,
2478
- buildManifest,
2479
- })
2480
- ).resultExplore.limit,
2481
- { defaultLimit: getDefaultQueryRowLimit(), maxRows },
2537
+ const livePrepared = await liveRunnable!.getPreparedResult({
2538
+ givens: querySurfaceGivens,
2539
+ buildManifest,
2540
+ });
2541
+ const livePreparedLimit = livePrepared.resultExplore.limit;
2542
+ rowLimitSource = queryRowLimitSource(livePreparedLimit);
2543
+ rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
2544
+ defaultLimit: getDefaultQueryRowLimit(),
2545
+ maxRows,
2546
+ });
2547
+ // Re-resolve rather than reuse: the bag above was resolved against
2548
+ // the STORAGE connection, which on this tier is routinely not the
2549
+ // one the live shape runs on, so reusing it would stamp another
2550
+ // connection's default and enforced layers on this statement. The
2551
+ // correlation id rides in the input, so the retry keeps the id the
2552
+ // response returns — one API call, one join key, two statements.
2553
+ appliedQueryMetadata = this.resolveQueryMetadata(
2554
+ queryMetadataInput,
2555
+ livePrepared.connectionName,
2482
2556
  );
2483
2557
  queryResults = await liveRunnable!.run({
2484
2558
  rowLimit,
2485
2559
  givens: querySurfaceGivens,
2486
2560
  abortSignal,
2487
2561
  buildManifest,
2562
+ queryMetadata: appliedQueryMetadata,
2488
2563
  });
2489
2564
  } catch (retryError) {
2490
2565
  failQuery(retryError);
@@ -2536,9 +2611,74 @@ export class Model {
2536
2611
  compactResult: queryResults.data.value,
2537
2612
  modelInfo: this.modelInfo,
2538
2613
  dataStyles: this.dataStyles,
2614
+ // The cap actually pushed into the SQL. A caller cannot otherwise tell
2615
+ // a complete result from one the row limit cut off: with no LIMIT of
2616
+ // its own a query silently gets DEFAULT_QUERY_ROW_LIMIT rows, and that
2617
+ // is under maxRows, so assertWithinModelResponseLimits raises nothing.
2618
+ // Returning the number lets a caller compare it against the row count
2619
+ // and say so, with no extra query.
2620
+ rowLimit,
2621
+ // Whether that cap was the author's own limit:/top: or the silent
2622
+ // default. Only the second means rows were probably left behind; a
2623
+ // deliberate `top: 10` returning 10 rows is a complete answer.
2624
+ rowLimitSource,
2625
+ // The id from the bag that was actually attached, not the one supplied:
2626
+ // a bag shed under budget pressure sheds context last, but a caller
2627
+ // should be told the truth about what it can look up.
2628
+ queryCorrelationId: appliedQueryMetadata?.query_id ?? null,
2539
2629
  };
2540
2630
  }
2541
2631
 
2632
+ /**
2633
+ * The per-query metadata for one model query: the executing connection's
2634
+ * default, the caller's request override, and the server's context (which
2635
+ * package, which model, which class of work), merged most-specific-wins.
2636
+ *
2637
+ * There is no model-side layer here. `materialization.queryMetadata` describes
2638
+ * how a persist source is BUILT; a live query against the model is a different
2639
+ * unit of work, and inheriting a build's tags would attribute interactive
2640
+ * traffic to the build that happens to share the source.
2641
+ *
2642
+ * Fails open, like every other metadata path: a connection whose config can't
2643
+ * be read contributes no default rather than failing the query.
2644
+ */
2645
+ private resolveQueryMetadata(
2646
+ input: ModelQueryMetadataInput | undefined,
2647
+ connectionName: string | undefined,
2648
+ ): QueryMetadata | undefined {
2649
+ let connectionLayers: {
2650
+ default?: QueryMetadata | null;
2651
+ enforced?: QueryMetadata | null;
2652
+ } | null = null;
2653
+ if (connectionName && input?.connectionMetadata) {
2654
+ try {
2655
+ connectionLayers = input.connectionMetadata(connectionName);
2656
+ } catch {
2657
+ connectionLayers = null;
2658
+ }
2659
+ }
2660
+ const resolved = mergeQueryMetadata({
2661
+ connection: connectionLayers?.default,
2662
+ enforced: connectionLayers?.enforced,
2663
+ request: input?.request,
2664
+ context: {
2665
+ queryClass: input?.queryClass ?? "interactive",
2666
+ environment: input?.environment,
2667
+ package: this.packageName,
2668
+ model: this.modelPath,
2669
+ version: input?.version,
2670
+ correlationId: input?.correlationId,
2671
+ },
2672
+ });
2673
+ if (resolved.drops.length > 0) {
2674
+ logger.warn("Dropped query-metadata properties for a query", {
2675
+ modelPath: this.modelPath,
2676
+ drops: resolved.drops,
2677
+ });
2678
+ }
2679
+ return resolved.metadata;
2680
+ }
2681
+
2542
2682
  private getStandardModel(): ApiCompiledModel {
2543
2683
  return {
2544
2684
  type: "source",
@@ -2633,6 +2773,11 @@ export class Model {
2633
2773
  // See `getQueryResults`: forwarded into `runnable.run` so the
2634
2774
  // publisher's wall-clock timeout actually cancels the query.
2635
2775
  abortSignal?: AbortSignal,
2776
+ // A notebook cell issues real backend SQL on an interactive path, so it is
2777
+ // tagged like any other query. No correlation id: the cell response has no
2778
+ // field to hand one back on, and an id nobody can read costs the result
2779
+ // cache for nothing.
2780
+ queryMetadataInput?: ModelQueryMetadataInput,
2636
2781
  ): Promise<{
2637
2782
  type: "code" | "markdown";
2638
2783
  text: string;
@@ -2712,13 +2857,12 @@ export class Model {
2712
2857
  // See getQueryResults / filterGivensToModelSurface: the gate
2713
2858
  // above already saw the full unfiltered givens.
2714
2859
  const cellSurfaceGivens = this.filterGivensToModelSurface(givens);
2860
+ const preparedCell = await runnableToExecute.getPreparedResult({
2861
+ givens: cellSurfaceGivens,
2862
+ buildManifest,
2863
+ });
2715
2864
  const rowLimit = resolveModelQueryRowLimit(
2716
- (
2717
- await runnableToExecute.getPreparedResult({
2718
- givens: cellSurfaceGivens,
2719
- buildManifest,
2720
- })
2721
- ).resultExplore.limit,
2865
+ preparedCell.resultExplore.limit,
2722
2866
  {
2723
2867
  defaultLimit: getDefaultQueryRowLimit(),
2724
2868
  maxRows: cellMaxRows,
@@ -2729,6 +2873,10 @@ export class Model {
2729
2873
  givens: cellSurfaceGivens,
2730
2874
  abortSignal,
2731
2875
  buildManifest,
2876
+ queryMetadata: this.resolveQueryMetadata(
2877
+ queryMetadataInput,
2878
+ preparedCell.connectionName,
2879
+ ),
2732
2880
  });
2733
2881
  const query = (await runnableToExecute.getPreparedQuery())._query;
2734
2882
  queryName = (query as NamedQueryDef).as || query.name;
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
3
3
  import { PayloadTooLargeError } from "../errors";
4
4
  import {
5
5
  assertWithinModelResponseLimits,
6
+ queryRowLimitSource,
6
7
  resolveModelQueryRowLimit,
7
8
  } from "./model_limits";
8
9
 
@@ -179,3 +180,30 @@ describe("assertWithinModelResponseLimits", () => {
179
180
  ).not.toThrow();
180
181
  });
181
182
  });
183
+
184
+ describe("queryRowLimitSource", () => {
185
+ /**
186
+ * Must mirror resolveModelQueryRowLimit's own `requested` condition: if the
187
+ * two ever disagree, the reported source describes a different limit than
188
+ * the one actually pushed into the SQL.
189
+ */
190
+ it("reports the query when it carried its own positive limit", () => {
191
+ expect(queryRowLimitSource(10)).toBe("query");
192
+ expect(queryRowLimitSource(1)).toBe("query");
193
+ });
194
+
195
+ it("reports the server default when the query carried none", () => {
196
+ expect(queryRowLimitSource(undefined)).toBe("server_default");
197
+ expect(queryRowLimitSource(0)).toBe("server_default");
198
+ expect(queryRowLimitSource(-1)).toBe("server_default");
199
+ });
200
+
201
+ it("agrees with which limit resolveModelQueryRowLimit actually used", () => {
202
+ const config = { defaultLimit: 1000, maxRows: 0 };
203
+ for (const userLimit of [undefined, 0, -1, 1, 10, 5000]) {
204
+ const used = resolveModelQueryRowLimit(userLimit, config);
205
+ const cameFromQuery = queryRowLimitSource(userLimit) === "query";
206
+ expect(cameFromQuery).toBe(used !== config.defaultLimit);
207
+ }
208
+ });
209
+ });
@@ -67,6 +67,27 @@ export function resolveModelQueryRowLimit(
67
67
  return Math.min(requested, maxRows + 1);
68
68
  }
69
69
 
70
+ /** Whether the cap came from the query itself or from the server default. */
71
+ export type QueryRowLimitSource = "query" | "server_default";
72
+
73
+ /**
74
+ * Which of the two the cap in {@link resolveModelQueryRowLimit} came from.
75
+ *
76
+ * The distinction is the whole difference between "the database cut this off
77
+ * and you were not told" and "you asked for exactly this many". A `limit:` or
78
+ * `top:` the author wrote is deliberate and complete for what it asked; only the
79
+ * silently-applied default means rows were probably left behind.
80
+ *
81
+ * The condition deliberately mirrors `requested` above and must stay in step
82
+ * with it, so a change to which limit wins cannot leave the reported source
83
+ * describing the other one.
84
+ */
85
+ export function queryRowLimitSource(
86
+ userLimit: number | undefined,
87
+ ): QueryRowLimitSource {
88
+ return userLimit && userLimit > 0 ? "query" : "server_default";
89
+ }
90
+
70
91
  export interface ModelResponseLimitsConfig {
71
92
  /** Result of {@link getMaxQueryRows}. `0` disables the row cap. */
72
93
  maxRows: number;
@@ -50,6 +50,7 @@ import { errMessage, ignoreDotfiles } from "../utils";
50
50
  import { getPersistCollisionEnforce, getPersistStorageMode } from "../config";
51
51
  import { deriveServeBindings } from "./materialization_serve_transform";
52
52
  import { computePackageBuildPlan, SourceEligibility } from "./build_plan";
53
+ import { materializationConfigWarnings } from "./materialization_config_validation";
53
54
  import { CronEvaluator } from "./cron_evaluator";
54
55
  import { filterFreshManifest } from "./freshness";
55
56
  import { isQuotedIdentifierPath, quoteManifestTablePath } from "./quoting";
@@ -175,6 +176,12 @@ export class Package {
175
176
  // tag does not fail the load (see Model.validateRenderTags); this is the
176
177
  // response-level signal that a tag is misconfigured.
177
178
  private renderTagWarnings: ApiPackageWarning[] = [];
179
+ /**
180
+ * Manifest-shape deprecations the load tolerated (a root-level `scope`), kept
181
+ * so publish can report a still-parsing-but-outdated manifest. Not on the wire
182
+ * package: it is a property of the manifest text, not of the loaded package.
183
+ */
184
+ private manifestWarnings: string[] = [];
178
185
  private static meter = publisherMeter();
179
186
  private static packageLoadHistogram = this.meter.createHistogram(
180
187
  "malloy_package_load_duration",
@@ -550,6 +557,7 @@ export class Package {
550
557
  malloyConfig,
551
558
  );
552
559
  pkg.renderTagWarnings = renderTagWarnings;
560
+ pkg.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
553
561
  // Install the per-query freshness resolver on the freshly-built models.
554
562
  // At create time no manifest is bound yet, so the resolver returns
555
563
  // undefined (serve live) until a subsequent bindManifest → reloadAllModels.
@@ -688,6 +696,16 @@ export class Package {
688
696
  // (alongside the load-path log) so an operator can see it on the status
689
697
  // API like the other persist warnings — see persistenceCollisionWarnings.
690
698
  ...this.persistenceCollisionWarnings().map((message) => ({ message })),
699
+ // Materialization-config findings: a queryMetadata property that will
700
+ // not do what it says, and manifest shapes that still parse but are
701
+ // deprecated. Advisory by design — none of these blocks a publish.
702
+ ...materializationConfigWarnings({
703
+ packageMaterialization: this.packageMetadata.materialization,
704
+ sources: this.buildPlan?.sources
705
+ ? Object.values(this.buildPlan.sources)
706
+ : [],
707
+ manifestWarnings: this.manifestWarnings,
708
+ }),
691
709
  ];
692
710
  if (allWarnings.length > 0) {
693
711
  metadata.warnings = allWarnings;
@@ -1059,6 +1077,10 @@ export class Package {
1059
1077
  }
1060
1078
 
1061
1079
  public persistencePolicyWarnings(): string[] {
1080
+ // REJECTION rules only: a non-empty result fails a publish and disarms the
1081
+ // scheduler. Advisory findings (queryMetadata problems, a deprecated
1082
+ // manifest shape) go to the operator warnings array instead — see
1083
+ // materializationConfigWarnings.
1062
1084
  const warnings: string[] = [];
1063
1085
  const sources = this.buildPlan?.sources
1064
1086
  ? Object.values(this.buildPlan.sources)
@@ -1075,7 +1097,7 @@ export class Package {
1075
1097
  warnings.push(
1076
1098
  `#@ persist source "${source.name}" declares sharing=... which is ` +
1077
1099
  `no longer supported: scope is a single package-level mode. Set ` +
1078
- `the root-level "scope": "version" | "package" in ` +
1100
+ `"materialization": { "scope": "version" | "package" } in ` +
1079
1101
  `${PACKAGE_MANIFEST_NAME} instead.`,
1080
1102
  );
1081
1103
  }
@@ -1480,6 +1502,7 @@ export class Package {
1480
1502
  // package's current storage= bindings so a reload preserves serve routing.
1481
1503
  this.pushStorageServeBindingsToModels();
1482
1504
  this.renderTagWarnings = renderTagWarnings;
1505
+ this.manifestWarnings = outcome.packageMetadata.manifestWarnings ?? [];
1483
1506
  // A reload re-reads publisher.json in the worker; pick up any change to
1484
1507
  // the explore set and query-boundary mode so listModels()/the gate
1485
1508
  // reflect edited explores without a full Package.create.