@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.
- package/README.docker.md +1 -0
- package/dist/app/api-doc.yaml +208 -0
- package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
- package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
- package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
- package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
- package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
- package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
- package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
- package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
- package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
- package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
- package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
- package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
- package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +53 -3
- package/dist/server.mjs +835 -97
- package/package.json +12 -12
- package/src/config.ts +35 -1
- package/src/controller/connection.controller.spec.ts +46 -0
- package/src/controller/connection.controller.ts +105 -2
- package/src/controller/materialization.controller.spec.ts +25 -0
- package/src/controller/materialization.controller.ts +60 -0
- package/src/controller/model.controller.ts +24 -0
- package/src/controller/query.controller.ts +83 -15
- package/src/mcp/handler_utils.ts +10 -2
- package/src/mcp/query_envelope.ts +10 -0
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
- package/src/mcp/tools/execute_query_tool.ts +62 -25
- package/src/mcp_config.spec.ts +919 -0
- package/src/mcp_config.ts +425 -0
- package/src/oom_guards.integration.spec.ts +11 -3
- package/src/package_load/package_load_pool.ts +2 -0
- package/src/package_load/package_load_worker.ts +17 -5
- package/src/package_load/protocol.ts +6 -0
- package/src/query_metadata_metrics.ts +49 -0
- package/src/server.ts +99 -3
- package/src/service/build_plan.spec.ts +125 -0
- package/src/service/build_plan.ts +108 -7
- package/src/service/connection_config.ts +49 -0
- package/src/service/environment.ts +57 -3
- package/src/service/materialization_config_validation.spec.ts +99 -0
- package/src/service/materialization_config_validation.ts +120 -0
- package/src/service/materialization_schedule_surface.spec.ts +124 -0
- package/src/service/materialization_service.spec.ts +119 -0
- package/src/service/materialization_service.ts +186 -3
- package/src/service/materialization_test_fixtures.ts +86 -21
- package/src/service/model.spec.ts +45 -1
- package/src/service/model.ts +145 -19
- package/src/service/package.ts +24 -1
- package/src/service/package_manifest.spec.ts +137 -4
- package/src/service/package_manifest.ts +140 -5
- package/src/service/persist_annotation_validation.spec.ts +12 -0
- package/src/service/persist_annotation_validation.ts +9 -4
- package/src/service/query_metadata.spec.ts +408 -0
- package/src/service/query_metadata.ts +492 -0
- package/src/service/query_metadata_identity.spec.ts +149 -0
package/src/service/model.ts
CHANGED
|
@@ -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
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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;
|
package/src/service/package.ts
CHANGED
|
@@ -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
|
-
`
|
|
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({
|
|
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({
|
|
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
|
});
|