@malloy-publisher/server 0.0.233 → 0.0.235

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 (62) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +208 -0
  3. package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
  4. package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
  5. package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
  6. package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
  8. package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
  9. package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
  10. package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
  13. package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
  14. package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
  15. package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
  16. package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
  17. package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
  18. package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/package_load_worker.mjs +53 -3
  21. package/dist/server.mjs +835 -97
  22. package/package.json +12 -12
  23. package/src/config.ts +35 -1
  24. package/src/controller/connection.controller.spec.ts +46 -0
  25. package/src/controller/connection.controller.ts +105 -2
  26. package/src/controller/materialization.controller.spec.ts +25 -0
  27. package/src/controller/materialization.controller.ts +60 -0
  28. package/src/controller/model.controller.ts +24 -0
  29. package/src/controller/query.controller.ts +83 -15
  30. package/src/mcp/handler_utils.ts +10 -2
  31. package/src/mcp/query_envelope.ts +10 -0
  32. package/src/mcp/skills/skills_bundle.json +1 -1
  33. package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
  34. package/src/mcp/tools/execute_query_tool.ts +62 -25
  35. package/src/mcp_config.spec.ts +919 -0
  36. package/src/mcp_config.ts +425 -0
  37. package/src/oom_guards.integration.spec.ts +11 -3
  38. package/src/package_load/package_load_pool.ts +2 -0
  39. package/src/package_load/package_load_worker.ts +17 -5
  40. package/src/package_load/protocol.ts +6 -0
  41. package/src/query_metadata_metrics.ts +49 -0
  42. package/src/server.ts +99 -3
  43. package/src/service/build_plan.spec.ts +125 -0
  44. package/src/service/build_plan.ts +108 -7
  45. package/src/service/connection_config.ts +49 -0
  46. package/src/service/environment.ts +57 -3
  47. package/src/service/materialization_config_validation.spec.ts +99 -0
  48. package/src/service/materialization_config_validation.ts +120 -0
  49. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  50. package/src/service/materialization_service.spec.ts +119 -0
  51. package/src/service/materialization_service.ts +186 -3
  52. package/src/service/materialization_test_fixtures.ts +86 -21
  53. package/src/service/model.spec.ts +45 -1
  54. package/src/service/model.ts +145 -19
  55. package/src/service/package.ts +24 -1
  56. package/src/service/package_manifest.spec.ts +137 -4
  57. package/src/service/package_manifest.ts +140 -5
  58. package/src/service/persist_annotation_validation.spec.ts +12 -0
  59. package/src/service/persist_annotation_validation.ts +9 -4
  60. package/src/service/query_metadata.spec.ts +408 -0
  61. package/src/service/query_metadata.ts +492 -0
  62. package/src/service/query_metadata_identity.spec.ts +149 -0
@@ -97,11 +97,44 @@ import {
97
97
  resolveModelQueryRowLimit,
98
98
  } from "./model_limits";
99
99
  import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
100
+ import {
101
+ mergeQueryMetadata,
102
+ type QueryClass,
103
+ type QueryMetadata,
104
+ } from "./query_metadata";
100
105
  import {
101
106
  extractQueriesFromModelDef,
102
107
  extractSourcesFromModelDef,
103
108
  } from "./source_extraction";
104
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
+
105
138
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
106
139
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
107
140
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
@@ -2056,6 +2089,13 @@ export class Model {
2056
2089
  // background callers (materialization, tests) that own their
2057
2090
  // own deadline.
2058
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,
2059
2099
  ): Promise<{
2060
2100
  result: Malloy.Result;
2061
2101
  compactResult: QueryData;
@@ -2065,6 +2105,12 @@ export class Model {
2065
2105
  rowLimit: number;
2066
2106
  /** Which of those two the cap came from. */
2067
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;
2068
2114
  }> {
2069
2115
  const startTime = performance.now();
2070
2116
  if (this.compilationError) {
@@ -2327,6 +2373,7 @@ export class Model {
2327
2373
  let rowLimitSource: QueryRowLimitSource = "server_default";
2328
2374
  let executionTime = 0;
2329
2375
  let queryResults;
2376
+ let appliedQueryMetadata: QueryMetadata | undefined;
2330
2377
  // Givens supplied only so a joined source's authorize gate could see
2331
2378
  // them (checked above, against the full unfiltered set) must not reach
2332
2379
  // the real query if this model doesn't itself surface them — see
@@ -2339,18 +2386,24 @@ export class Model {
2339
2386
  // shape can read them; the authorize gate above already saw the full set.
2340
2387
  const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
2341
2388
  try {
2342
- const preparedLimit = (
2343
- await runnable.getPreparedResult({
2344
- givens: effectiveGivens,
2345
- buildManifest: effectiveBuildManifest,
2346
- virtualMap: serveVirtualMap,
2347
- })
2348
- ).resultExplore.limit;
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;
2349
2398
  rowLimitSource = queryRowLimitSource(preparedLimit);
2350
2399
  rowLimit = resolveModelQueryRowLimit(preparedLimit, {
2351
2400
  defaultLimit: getDefaultQueryRowLimit(),
2352
2401
  maxRows,
2353
2402
  });
2403
+ appliedQueryMetadata = this.resolveQueryMetadata(
2404
+ queryMetadataInput,
2405
+ preparedResult.connectionName,
2406
+ );
2354
2407
  executionTime = performance.now() - startTime;
2355
2408
 
2356
2409
  queryResults = await runnable.run({
@@ -2359,6 +2412,7 @@ export class Model {
2359
2412
  abortSignal,
2360
2413
  buildManifest: effectiveBuildManifest,
2361
2414
  virtualMap: serveVirtualMap,
2415
+ queryMetadata: appliedQueryMetadata,
2362
2416
  });
2363
2417
  } catch (error) {
2364
2418
  // A binding that declares `freshnessFallback=live` is saying the tier is
@@ -2480,22 +2534,32 @@ export class Model {
2480
2534
  // the connector reads as a hard cap and stops before the first row: a
2481
2535
  // successful, EMPTY answer. Asking the live shape is also the honest
2482
2536
  // limit, since the live shape is what runs.
2483
- const livePreparedLimit = (
2484
- await liveRunnable!.getPreparedResult({
2485
- givens: querySurfaceGivens,
2486
- buildManifest,
2487
- })
2488
- ).resultExplore.limit;
2537
+ const livePrepared = await liveRunnable!.getPreparedResult({
2538
+ givens: querySurfaceGivens,
2539
+ buildManifest,
2540
+ });
2541
+ const livePreparedLimit = livePrepared.resultExplore.limit;
2489
2542
  rowLimitSource = queryRowLimitSource(livePreparedLimit);
2490
2543
  rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
2491
2544
  defaultLimit: getDefaultQueryRowLimit(),
2492
2545
  maxRows,
2493
2546
  });
2547
+ // Re-resolve rather than reuse: the bag above was resolved against
2548
+ // the STORAGE connection, which on this tier is routinely not the
2549
+ // one the live shape runs on, so reusing it would stamp another
2550
+ // connection's default and enforced layers on this statement. The
2551
+ // correlation id rides in the input, so the retry keeps the id the
2552
+ // response returns — one API call, one join key, two statements.
2553
+ appliedQueryMetadata = this.resolveQueryMetadata(
2554
+ queryMetadataInput,
2555
+ livePrepared.connectionName,
2556
+ );
2494
2557
  queryResults = await liveRunnable!.run({
2495
2558
  rowLimit,
2496
2559
  givens: querySurfaceGivens,
2497
2560
  abortSignal,
2498
2561
  buildManifest,
2562
+ queryMetadata: appliedQueryMetadata,
2499
2563
  });
2500
2564
  } catch (retryError) {
2501
2565
  failQuery(retryError);
@@ -2558,9 +2622,63 @@ export class Model {
2558
2622
  // default. Only the second means rows were probably left behind; a
2559
2623
  // deliberate `top: 10` returning 10 rows is a complete answer.
2560
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,
2561
2629
  };
2562
2630
  }
2563
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
+
2564
2682
  private getStandardModel(): ApiCompiledModel {
2565
2683
  return {
2566
2684
  type: "source",
@@ -2655,6 +2773,11 @@ export class Model {
2655
2773
  // See `getQueryResults`: forwarded into `runnable.run` so the
2656
2774
  // publisher's wall-clock timeout actually cancels the query.
2657
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,
2658
2781
  ): Promise<{
2659
2782
  type: "code" | "markdown";
2660
2783
  text: string;
@@ -2734,13 +2857,12 @@ export class Model {
2734
2857
  // See getQueryResults / filterGivensToModelSurface: the gate
2735
2858
  // above already saw the full unfiltered givens.
2736
2859
  const cellSurfaceGivens = this.filterGivensToModelSurface(givens);
2860
+ const preparedCell = await runnableToExecute.getPreparedResult({
2861
+ givens: cellSurfaceGivens,
2862
+ buildManifest,
2863
+ });
2737
2864
  const rowLimit = resolveModelQueryRowLimit(
2738
- (
2739
- await runnableToExecute.getPreparedResult({
2740
- givens: cellSurfaceGivens,
2741
- buildManifest,
2742
- })
2743
- ).resultExplore.limit,
2865
+ preparedCell.resultExplore.limit,
2744
2866
  {
2745
2867
  defaultLimit: getDefaultQueryRowLimit(),
2746
2868
  maxRows: cellMaxRows,
@@ -2751,6 +2873,10 @@ export class Model {
2751
2873
  givens: cellSurfaceGivens,
2752
2874
  abortSignal,
2753
2875
  buildManifest,
2876
+ queryMetadata: this.resolveQueryMetadata(
2877
+ queryMetadataInput,
2878
+ preparedCell.connectionName,
2879
+ ),
2754
2880
  });
2755
2881
  const query = (await runnableToExecute.getPreparedQuery())._query;
2756
2882
  queryName = (query as NamedQueryDef).as || query.name;
@@ -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.
@@ -1,10 +1,63 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ packageMaterializationWarnings,
3
4
  parsePackageMaterialization,
4
5
  parsePackageScope,
6
+ resolvePackageScope,
5
7
  } from "./package_manifest";
6
8
 
7
9
  describe("service/package_manifest", () => {
10
+ describe("resolvePackageScope", () => {
11
+ it("reads the canonical materialization.scope with no warning", () => {
12
+ expect(resolvePackageScope(undefined, { scope: "version" })).toEqual({
13
+ scope: "version",
14
+ warnings: [],
15
+ });
16
+ });
17
+
18
+ it("defaults to 'package' when neither home declares it", () => {
19
+ expect(resolvePackageScope(undefined, undefined)).toEqual({
20
+ scope: "package",
21
+ warnings: [],
22
+ });
23
+ expect(resolvePackageScope(undefined, { freshness: {} })).toEqual({
24
+ scope: "package",
25
+ warnings: [],
26
+ });
27
+ });
28
+
29
+ it("still honors a root scope, with a deprecation warning", () => {
30
+ const resolved = resolvePackageScope("version", undefined);
31
+ expect(resolved.scope).toBe("version");
32
+ expect(resolved.warnings).toHaveLength(1);
33
+ expect(resolved.warnings[0]).toMatch(/deprecated/i);
34
+ });
35
+
36
+ it("accepts both homes agreeing without a warning", () => {
37
+ // The transition state the server writes: envelope for this build, root
38
+ // for an older publisher. Warning would blame the operator for it.
39
+ expect(resolvePackageScope("version", { scope: "version" })).toEqual({
40
+ scope: "version",
41
+ warnings: [],
42
+ });
43
+ });
44
+
45
+ it("throws when the two homes disagree rather than guessing", () => {
46
+ expect(() =>
47
+ resolvePackageScope("package", { scope: "version" }),
48
+ ).toThrow(/conflicting/i);
49
+ });
50
+
51
+ it("reports an invalid value as invalid, not as a conflict", () => {
52
+ expect(() =>
53
+ resolvePackageScope(undefined, { scope: "shared" }),
54
+ ).toThrow(/expected "version" or "package"/i);
55
+ expect(() =>
56
+ resolvePackageScope("shared", { scope: "version" }),
57
+ ).toThrow(/expected "version" or "package"/i);
58
+ });
59
+ });
60
+
8
61
  describe("parsePackageScope", () => {
9
62
  it("defaults to 'package' when absent", () => {
10
63
  expect(parsePackageScope(undefined)).toBe("package");
@@ -26,7 +79,7 @@ describe("service/package_manifest", () => {
26
79
  describe("parsePackageMaterialization", () => {
27
80
  it("extracts a string schedule", () => {
28
81
  expect(parsePackageMaterialization({ schedule: "0 6 * * *" })).toEqual(
29
- { schedule: "0 6 * * *", freshness: null },
82
+ { schedule: "0 6 * * *", freshness: null, queryMetadata: null },
30
83
  );
31
84
  });
32
85
 
@@ -41,17 +94,23 @@ describe("service/package_manifest", () => {
41
94
  schedule: "*/15 * * * *",
42
95
  retries: 3,
43
96
  }),
44
- ).toEqual({ schedule: "*/15 * * * *", freshness: null });
97
+ ).toEqual({
98
+ schedule: "*/15 * * * *",
99
+ freshness: null,
100
+ queryMetadata: null,
101
+ });
45
102
  });
46
103
 
47
104
  it("degrades a non-string schedule to null", () => {
48
105
  expect(parsePackageMaterialization({ schedule: 42 })).toEqual({
49
106
  schedule: null,
50
107
  freshness: null,
108
+ queryMetadata: null,
51
109
  });
52
110
  expect(parsePackageMaterialization({})).toEqual({
53
111
  schedule: null,
54
112
  freshness: null,
113
+ queryMetadata: null,
55
114
  });
56
115
  });
57
116
 
@@ -68,13 +127,18 @@ describe("service/package_manifest", () => {
68
127
  ).toEqual({
69
128
  schedule: null,
70
129
  freshness: { window: "24h", fallback: "stale_ok" },
130
+ queryMetadata: null,
71
131
  });
72
132
  });
73
133
 
74
134
  it("keeps a partial freshness block (window only)", () => {
75
135
  expect(
76
136
  parsePackageMaterialization({ freshness: { window: "1h" } }),
77
- ).toEqual({ schedule: null, freshness: { window: "1h" } });
137
+ ).toEqual({
138
+ schedule: null,
139
+ freshness: { window: "1h" },
140
+ queryMetadata: null,
141
+ });
78
142
  });
79
143
 
80
144
  it("drops invalid freshness fields rather than defaulting them", () => {
@@ -84,14 +148,83 @@ describe("service/package_manifest", () => {
84
148
  parsePackageMaterialization({
85
149
  freshness: { window: 24, fallback: "retry" },
86
150
  }),
87
- ).toEqual({ schedule: null, freshness: {} });
151
+ ).toEqual({ schedule: null, freshness: {}, queryMetadata: null });
88
152
  });
89
153
 
90
154
  it("degrades a non-object freshness to null", () => {
91
155
  expect(parsePackageMaterialization({ freshness: "24h" })).toEqual({
92
156
  schedule: null,
93
157
  freshness: null,
158
+ queryMetadata: null,
159
+ });
160
+ });
161
+
162
+ it("keeps queryMetadata properties verbatim", () => {
163
+ expect(
164
+ parsePackageMaterialization({
165
+ queryMetadata: { team: "finance", workload: "marts" },
166
+ }),
167
+ ).toEqual({
168
+ schedule: null,
169
+ freshness: null,
170
+ queryMetadata: { team: "finance", workload: "marts" },
171
+ });
172
+ });
173
+
174
+ it("keeps a contract-violating property so publish can report it", () => {
175
+ // Filtering here would make the author's typo disappear with nothing
176
+ // to point at; the validator warns and the runtime clamps instead.
177
+ expect(
178
+ parsePackageMaterialization({
179
+ queryMetadata: { "team.name": "finance" },
180
+ }),
181
+ ).toEqual({
182
+ schedule: null,
183
+ freshness: null,
184
+ queryMetadata: { "team.name": "finance" },
185
+ });
186
+ });
187
+
188
+ it("drops non-string values and empties to null", () => {
189
+ expect(
190
+ parsePackageMaterialization({
191
+ queryMetadata: { team: "finance", retries: 3 },
192
+ }),
193
+ ).toEqual({
194
+ schedule: null,
195
+ freshness: null,
196
+ queryMetadata: { team: "finance" },
94
197
  });
198
+ expect(
199
+ parsePackageMaterialization({ queryMetadata: { retries: 3 } }),
200
+ ).toEqual({ schedule: null, freshness: null, queryMetadata: null });
201
+ expect(
202
+ parsePackageMaterialization({ queryMetadata: "team=finance" }),
203
+ ).toEqual({ schedule: null, freshness: null, queryMetadata: null });
204
+ });
205
+ });
206
+
207
+ describe("packageMaterializationWarnings", () => {
208
+ it("names a dropped non-string value", () => {
209
+ // The drop happens before the config validation that would otherwise
210
+ // report it, so without this an unquoted `"team": 123` — a plausible
211
+ // hand-edit — vanishes with nothing anywhere to point at.
212
+ const warnings = packageMaterializationWarnings({
213
+ queryMetadata: { team: "finance", retries: 3 },
214
+ });
215
+ expect(warnings).toHaveLength(1);
216
+ expect(warnings[0]).toContain("'retries'");
217
+ expect(warnings[0]).toContain("got number");
218
+ });
219
+
220
+ it("says nothing about a block that parses cleanly", () => {
221
+ expect(
222
+ packageMaterializationWarnings({
223
+ schedule: "0 6 * * *",
224
+ queryMetadata: { team: "finance" },
225
+ }),
226
+ ).toEqual([]);
227
+ expect(packageMaterializationWarnings(undefined)).toEqual([]);
95
228
  });
96
229
  });
97
230
  });