@malloy-publisher/server 0.0.224 → 0.0.225
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/dist/app/api-doc.yaml +58 -0
- package/dist/server.mjs +148 -16
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/controller/materialization.controller.spec.ts +72 -0
- package/src/controller/materialization.controller.ts +75 -11
- package/src/service/build_plan.spec.ts +119 -0
- package/src/service/build_plan.ts +143 -0
- package/src/service/materialization_cron_gate.spec.ts +60 -21
- package/src/service/materialization_service.spec.ts +42 -0
- package/src/service/materialization_service.ts +40 -2
- package/src/service/materialization_test_fixtures.ts +47 -14
- package/src/service/package.ts +64 -19
- package/src/storage/DatabaseInterface.ts +1 -0
- package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
- package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
- package/tests/fixtures/persist-multi-level/publisher.json +5 -0
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +251 -0
package/dist/app/api-doc.yaml
CHANGED
|
@@ -4124,6 +4124,25 @@ components:
|
|
|
4124
4124
|
The source's declared `#@ persist ... refresh=...` value
|
|
4125
4125
|
("full" | "incremental"), reported verbatim; null = unset.
|
|
4126
4126
|
Metadata pass-through — inert to the publisher today.
|
|
4127
|
+
freshness:
|
|
4128
|
+
oneOf:
|
|
4129
|
+
- $ref: "#/components/schemas/Freshness"
|
|
4130
|
+
- type: "null"
|
|
4131
|
+
description: >-
|
|
4132
|
+
The source's EFFECTIVE freshness objective after most-specific-wins
|
|
4133
|
+
resolution (source > model-file > package). Null = unset at every
|
|
4134
|
+
level; the control plane applies the platform default. Reported
|
|
4135
|
+
verbatim (invalid fields dropped, never defaulted).
|
|
4136
|
+
schedule:
|
|
4137
|
+
type: ["string", "null"]
|
|
4138
|
+
description: >-
|
|
4139
|
+
The source's EFFECTIVE power-tier cron after most-specific-wins
|
|
4140
|
+
resolution of the source's OWN declaration (source > model-file).
|
|
4141
|
+
The package-level cron (PackageMaterializationConfig.schedule) is a
|
|
4142
|
+
distinct package-grain concept and is NOT folded in here. Null =
|
|
4143
|
+
unset. Valid only when the source resolves to sharing="private" (a
|
|
4144
|
+
cron on a shared or unset source is rejected at publish; declare
|
|
4145
|
+
freshness.window instead).
|
|
4127
4146
|
columns:
|
|
4128
4147
|
type: array
|
|
4129
4148
|
description: Output schema of the source.
|
|
@@ -4162,6 +4181,45 @@ components:
|
|
|
4162
4181
|
type: array
|
|
4163
4182
|
items:
|
|
4164
4183
|
$ref: "#/components/schemas/BuildInstruction"
|
|
4184
|
+
referenceManifest:
|
|
4185
|
+
type: array
|
|
4186
|
+
description: >-
|
|
4187
|
+
Already-materialized persist upstreams the built sources may
|
|
4188
|
+
reference but which are NOT rebuilt in this run. The publisher seeds
|
|
4189
|
+
the build Manifest with these so a downstream source's upstream
|
|
4190
|
+
persist reference resolves to the existing physical table instead of
|
|
4191
|
+
recomputing it live. Only consumed on the orchestrated
|
|
4192
|
+
(buildInstructions) path; auto-run seeds its own reference set from
|
|
4193
|
+
the most-recent manifest.
|
|
4194
|
+
items:
|
|
4195
|
+
$ref: "#/components/schemas/ManifestReference"
|
|
4196
|
+
strictUpstreams:
|
|
4197
|
+
type: boolean
|
|
4198
|
+
default: false
|
|
4199
|
+
description: >-
|
|
4200
|
+
When true, a persist upstream that is neither built here nor present
|
|
4201
|
+
in referenceManifest fails the build (compiler strict mode) instead
|
|
4202
|
+
of silently recomputing it live. Per-unit dispatch should set this
|
|
4203
|
+
true.
|
|
4204
|
+
|
|
4205
|
+
ManifestReference:
|
|
4206
|
+
type: object
|
|
4207
|
+
description: >-
|
|
4208
|
+
A reference to an already-materialized persist upstream that the built
|
|
4209
|
+
sources may read but which is not rebuilt in this run.
|
|
4210
|
+
required: [sourceEntityId, physicalTableName]
|
|
4211
|
+
properties:
|
|
4212
|
+
sourceEntityId:
|
|
4213
|
+
type: string
|
|
4214
|
+
description: >-
|
|
4215
|
+
The upstream's content id AS REPORTED BY THE PUBLISHER in
|
|
4216
|
+
PersistSourcePlan.sourceEntityId. It MUST equal what the compiler
|
|
4217
|
+
recomputes for the manifest lookup (mkBuildID over the upstream's
|
|
4218
|
+
manifest-ignorant SQL); do not substitute any other identity here.
|
|
4219
|
+
physicalTableName:
|
|
4220
|
+
type: string
|
|
4221
|
+
description: >-
|
|
4222
|
+
Fully-qualified physical table the upstream currently serves.
|
|
4165
4223
|
|
|
4166
4224
|
BuildInstruction:
|
|
4167
4225
|
type: object
|
package/dist/server.mjs
CHANGED
|
@@ -158407,6 +158407,11 @@ var require_utils74 = __commonJS((exports, module) => {
|
|
|
158407
158407
|
};
|
|
158408
158408
|
});
|
|
158409
158409
|
|
|
158410
|
+
// ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
158411
|
+
var require_sshcrypto = __commonJS((exports, module) => {
|
|
158412
|
+
module.exports = __require("./sshcrypto-8m50vnmb.node");
|
|
158413
|
+
});
|
|
158414
|
+
|
|
158410
158415
|
// ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
158411
158416
|
var require_poly1305 = __commonJS((exports, module) => {
|
|
158412
158417
|
var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
|
|
@@ -158893,7 +158898,7 @@ var require_crypto = __commonJS((exports, module) => {
|
|
|
158893
158898
|
var ChaChaPolyDecipher;
|
|
158894
158899
|
var GenericDecipher;
|
|
158895
158900
|
try {
|
|
158896
|
-
binding = (
|
|
158901
|
+
binding = require_sshcrypto();
|
|
158897
158902
|
({
|
|
158898
158903
|
AESGCMCipher,
|
|
158899
158904
|
ChaChaPolyCipher,
|
|
@@ -258421,6 +258426,7 @@ function hydrateMarkdownOnlyCells(notebookCells) {
|
|
|
258421
258426
|
}
|
|
258422
258427
|
|
|
258423
258428
|
// src/service/build_plan.ts
|
|
258429
|
+
var FRESHNESS_FALLBACKS = ["live", "stale_ok", "fail"];
|
|
258424
258430
|
function deriveColumns(persistSource) {
|
|
258425
258431
|
try {
|
|
258426
258432
|
return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
|
|
@@ -258447,6 +258453,65 @@ function deriveAnnotationFields(persistSource) {
|
|
|
258447
258453
|
} catch {}
|
|
258448
258454
|
return out;
|
|
258449
258455
|
}
|
|
258456
|
+
function tagFreshnessScheduleLayer(tag) {
|
|
258457
|
+
if (!tag || typeof tag.text !== "function")
|
|
258458
|
+
return {};
|
|
258459
|
+
const layer = {};
|
|
258460
|
+
const window2 = tag.text("freshness", "window");
|
|
258461
|
+
if (typeof window2 === "string")
|
|
258462
|
+
layer.window = window2;
|
|
258463
|
+
const fallback = tag.text("freshness", "fallback");
|
|
258464
|
+
if (typeof fallback === "string" && FRESHNESS_FALLBACKS.includes(fallback)) {
|
|
258465
|
+
layer.fallback = fallback;
|
|
258466
|
+
}
|
|
258467
|
+
const schedule = tag.text("schedule");
|
|
258468
|
+
if (typeof schedule === "string")
|
|
258469
|
+
layer.schedule = schedule;
|
|
258470
|
+
return layer;
|
|
258471
|
+
}
|
|
258472
|
+
function packageFreshnessLayer(cfg) {
|
|
258473
|
+
if (!cfg)
|
|
258474
|
+
return {};
|
|
258475
|
+
const layer = {};
|
|
258476
|
+
if (cfg.freshness?.window)
|
|
258477
|
+
layer.window = cfg.freshness.window;
|
|
258478
|
+
const fallback = cfg.freshness?.fallback;
|
|
258479
|
+
if (typeof fallback === "string" && FRESHNESS_FALLBACKS.includes(fallback)) {
|
|
258480
|
+
layer.fallback = fallback;
|
|
258481
|
+
}
|
|
258482
|
+
return layer;
|
|
258483
|
+
}
|
|
258484
|
+
function safeSourceTag(source) {
|
|
258485
|
+
try {
|
|
258486
|
+
return source.annotations.parseAsTag("@").tag;
|
|
258487
|
+
} catch {
|
|
258488
|
+
return;
|
|
258489
|
+
}
|
|
258490
|
+
}
|
|
258491
|
+
function safeModelTag(source) {
|
|
258492
|
+
try {
|
|
258493
|
+
return source.modelAnnotations.parseAsTag().tag;
|
|
258494
|
+
} catch {
|
|
258495
|
+
return;
|
|
258496
|
+
}
|
|
258497
|
+
}
|
|
258498
|
+
function resolveFreshnessSchedule(source, packageMaterialization) {
|
|
258499
|
+
const sourceLayer = tagFreshnessScheduleLayer(safeSourceTag(source));
|
|
258500
|
+
const modelLayer = tagFreshnessScheduleLayer(safeModelTag(source));
|
|
258501
|
+
const pkgLayer = packageFreshnessLayer(packageMaterialization);
|
|
258502
|
+
const window2 = sourceLayer.window ?? modelLayer.window ?? pkgLayer.window;
|
|
258503
|
+
const fallback = sourceLayer.fallback ?? modelLayer.fallback ?? pkgLayer.fallback;
|
|
258504
|
+
const schedule = sourceLayer.schedule ?? modelLayer.schedule ?? null;
|
|
258505
|
+
let freshness = null;
|
|
258506
|
+
if (window2 !== undefined || fallback !== undefined) {
|
|
258507
|
+
freshness = {};
|
|
258508
|
+
if (window2 !== undefined)
|
|
258509
|
+
freshness.window = window2;
|
|
258510
|
+
if (fallback !== undefined)
|
|
258511
|
+
freshness.fallback = fallback;
|
|
258512
|
+
}
|
|
258513
|
+
return { freshness, schedule };
|
|
258514
|
+
}
|
|
258450
258515
|
function flattenDependsOn(node) {
|
|
258451
258516
|
return node.dependsOn.map((d) => d.sourceID);
|
|
258452
258517
|
}
|
|
@@ -258539,7 +258604,7 @@ async function compilePackageBuildPlan(pkg, signal) {
|
|
|
258539
258604
|
sourceModelPaths
|
|
258540
258605
|
};
|
|
258541
258606
|
}
|
|
258542
|
-
function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, sourceModelPaths) {
|
|
258607
|
+
function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, sourceModelPaths, packageMaterialization) {
|
|
258543
258608
|
const include = sourceNames ? new Set(sourceNames) : null;
|
|
258544
258609
|
const wireGraphs = graphs.map((graph) => ({
|
|
258545
258610
|
connectionName: graph.connectionName,
|
|
@@ -258553,6 +258618,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
258553
258618
|
if (include && !include.has(source.name))
|
|
258554
258619
|
continue;
|
|
258555
258620
|
const annotationFields = deriveAnnotationFields(source);
|
|
258621
|
+
const { freshness, schedule } = resolveFreshnessSchedule(source, packageMaterialization);
|
|
258556
258622
|
wireSources[sourceID] = {
|
|
258557
258623
|
name: source.name,
|
|
258558
258624
|
sourceID: source.sourceID,
|
|
@@ -258562,6 +258628,8 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
258562
258628
|
sql: source.getSQL(),
|
|
258563
258629
|
sharing: annotationFields.sharing ?? null,
|
|
258564
258630
|
refresh: annotationFields.refresh ?? null,
|
|
258631
|
+
freshness,
|
|
258632
|
+
schedule,
|
|
258565
258633
|
columns: deriveColumns(source),
|
|
258566
258634
|
annotationFields,
|
|
258567
258635
|
modelPath: sourceModelPaths?.[sourceID]
|
|
@@ -258574,7 +258642,7 @@ async function computePackageBuildPlan(pkg, signal) {
|
|
|
258574
258642
|
if (compiled.graphs.length === 0) {
|
|
258575
258643
|
return null;
|
|
258576
258644
|
}
|
|
258577
|
-
return deriveBuildPlan(compiled.graphs, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths);
|
|
258645
|
+
return deriveBuildPlan(compiled.graphs, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths, pkg.getMaterializationConfig?.() ?? null);
|
|
258578
258646
|
}
|
|
258579
258647
|
|
|
258580
258648
|
// src/service/freshness.ts
|
|
@@ -258851,6 +258919,9 @@ class Package {
|
|
|
258851
258919
|
getBuildPlan() {
|
|
258852
258920
|
return this.buildPlan;
|
|
258853
258921
|
}
|
|
258922
|
+
getMaterializationConfig() {
|
|
258923
|
+
return this.packageMetadata.materialization ?? null;
|
|
258924
|
+
}
|
|
258854
258925
|
getPackageMetadata() {
|
|
258855
258926
|
const metadata = {
|
|
258856
258927
|
...this.packageMetadata,
|
|
@@ -258936,12 +259007,22 @@ class Package {
|
|
|
258936
259007
|
`);
|
|
258937
259008
|
}
|
|
258938
259009
|
scheduleWarnings() {
|
|
258939
|
-
const
|
|
258940
|
-
|
|
258941
|
-
|
|
258942
|
-
|
|
258943
|
-
|
|
258944
|
-
|
|
259010
|
+
const warnings = [];
|
|
259011
|
+
const sources = this.buildPlan?.sources ? Object.values(this.buildPlan.sources) : [];
|
|
259012
|
+
for (const source of sources) {
|
|
259013
|
+
if (source.schedule && source.sharing !== "private") {
|
|
259014
|
+
warnings.push(`#@ persist source "${source.name}" declares a schedule (cron) but ` + `resolves to sharing="${source.sharing ?? "shared (unset default)"}": a per-source cron is valid only for sharing="private". ` + `Declare 'freshness.window' instead (the control plane schedules ` + `refreshes from it).`);
|
|
259015
|
+
}
|
|
259016
|
+
}
|
|
259017
|
+
const packageSchedule = this.packageMetadata.materialization?.schedule;
|
|
259018
|
+
if (packageSchedule) {
|
|
259019
|
+
const nonPrivate = sources.filter((s) => s.sharing !== "private");
|
|
259020
|
+
if (sources.length === 0 || nonPrivate.length > 0) {
|
|
259021
|
+
const detail = sources.length === 0 ? "the package declares no persist source for it to govern" : `sources [${nonPrivate.map((s) => s.name).join(", ")}] resolve to shared/unset`;
|
|
259022
|
+
warnings.push(`materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is valid ` + `only when every persist source resolves to sharing="private": ` + `${detail}. Declare 'materialization.freshness.window' instead ` + `(the control plane schedules refreshes from it).`);
|
|
259023
|
+
}
|
|
259024
|
+
}
|
|
259025
|
+
return warnings;
|
|
258945
259026
|
}
|
|
258946
259027
|
formatInvalidSchedule() {
|
|
258947
259028
|
return this.scheduleWarnings().join(`
|
|
@@ -261261,7 +261342,14 @@ class MaterializationController {
|
|
|
261261
261342
|
validateCreateBody(body) {
|
|
261262
261343
|
const result = {};
|
|
261263
261344
|
if (body.buildInstructions !== undefined && body.buildInstructions !== null) {
|
|
261264
|
-
|
|
261345
|
+
const parsed = this.validateBuildInstructions(body.buildInstructions);
|
|
261346
|
+
result.buildInstructions = parsed.sources;
|
|
261347
|
+
if (parsed.referenceManifest !== undefined) {
|
|
261348
|
+
result.referenceManifest = parsed.referenceManifest;
|
|
261349
|
+
}
|
|
261350
|
+
if (parsed.strictUpstreams !== undefined) {
|
|
261351
|
+
result.strictUpstreams = parsed.strictUpstreams;
|
|
261352
|
+
}
|
|
261265
261353
|
}
|
|
261266
261354
|
if (body.forceRefresh !== undefined) {
|
|
261267
261355
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -261281,11 +261369,42 @@ class MaterializationController {
|
|
|
261281
261369
|
if (typeof raw !== "object" || raw === null) {
|
|
261282
261370
|
throw new BadRequestError("buildInstructions must be an object");
|
|
261283
261371
|
}
|
|
261284
|
-
const
|
|
261372
|
+
const obj = raw;
|
|
261373
|
+
const sources = obj.sources;
|
|
261285
261374
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
261286
261375
|
throw new BadRequestError("buildInstructions requires a non-empty 'sources' array of BuildInstruction");
|
|
261287
261376
|
}
|
|
261288
|
-
|
|
261377
|
+
const result = {
|
|
261378
|
+
sources: sources.map((instruction) => this.validateInstruction(instruction))
|
|
261379
|
+
};
|
|
261380
|
+
if (obj.referenceManifest !== undefined && obj.referenceManifest !== null) {
|
|
261381
|
+
if (!Array.isArray(obj.referenceManifest)) {
|
|
261382
|
+
throw new BadRequestError("buildInstructions.referenceManifest must be an array of ManifestReference");
|
|
261383
|
+
}
|
|
261384
|
+
result.referenceManifest = obj.referenceManifest.map((ref) => this.validateManifestReference(ref));
|
|
261385
|
+
}
|
|
261386
|
+
if (obj.strictUpstreams !== undefined) {
|
|
261387
|
+
if (typeof obj.strictUpstreams !== "boolean") {
|
|
261388
|
+
throw new BadRequestError("buildInstructions.strictUpstreams must be a boolean");
|
|
261389
|
+
}
|
|
261390
|
+
result.strictUpstreams = obj.strictUpstreams;
|
|
261391
|
+
}
|
|
261392
|
+
return result;
|
|
261393
|
+
}
|
|
261394
|
+
validateManifestReference(raw) {
|
|
261395
|
+
if (typeof raw !== "object" || raw === null) {
|
|
261396
|
+
throw new BadRequestError("Each manifest reference must be an object");
|
|
261397
|
+
}
|
|
261398
|
+
const ref = raw;
|
|
261399
|
+
for (const field of ["sourceEntityId", "physicalTableName"]) {
|
|
261400
|
+
if (typeof ref[field] !== "string") {
|
|
261401
|
+
throw new BadRequestError(`Manifest reference is missing required string field '${field}'`);
|
|
261402
|
+
}
|
|
261403
|
+
}
|
|
261404
|
+
return {
|
|
261405
|
+
sourceEntityId: ref.sourceEntityId,
|
|
261406
|
+
physicalTableName: ref.physicalTableName
|
|
261407
|
+
};
|
|
261289
261408
|
}
|
|
261290
261409
|
validateInstruction(raw) {
|
|
261291
261410
|
if (typeof raw !== "object" || raw === null) {
|
|
@@ -268174,7 +268293,9 @@ class MaterializationService {
|
|
|
268174
268293
|
this.runInBackground(created.id, (signal) => this.runBuild(created.id, environmentName, packageName, {
|
|
268175
268294
|
sourceNames: options.sourceNames,
|
|
268176
268295
|
forceRefresh,
|
|
268177
|
-
buildInstructions
|
|
268296
|
+
buildInstructions,
|
|
268297
|
+
referenceManifest: options.referenceManifest,
|
|
268298
|
+
strictUpstreams: options.strictUpstreams
|
|
268178
268299
|
}, signal));
|
|
268179
268300
|
return created;
|
|
268180
268301
|
}
|
|
@@ -268199,12 +268320,12 @@ class MaterializationService {
|
|
|
268199
268320
|
let carried;
|
|
268200
268321
|
if (orchestrated) {
|
|
268201
268322
|
instructions = opts.buildInstructions;
|
|
268202
|
-
carried =
|
|
268323
|
+
carried = this.referenceManifestToEntries(opts.referenceManifest);
|
|
268203
268324
|
} else {
|
|
268204
268325
|
const priorEntries = opts.forceRefresh ? {} : await this.getMostRecentManifestEntries(environmentId, packageName, id);
|
|
268205
268326
|
({ instructions, carried } = this.deriveSelfInstructions(compiled, opts.sourceNames, priorEntries));
|
|
268206
268327
|
}
|
|
268207
|
-
const entries = await this.executeInstructedBuild(compiled, instructions, carried, signal);
|
|
268328
|
+
const entries = await this.executeInstructedBuild(compiled, instructions, carried, signal, opts.strictUpstreams ?? false);
|
|
268208
268329
|
const sourcesBuilt = instructions.length;
|
|
268209
268330
|
const sourcesReused = Object.keys(carried).length;
|
|
268210
268331
|
const durationMs = Date.now() - startedAt;
|
|
@@ -268263,6 +268384,16 @@ class MaterializationService {
|
|
|
268263
268384
|
}
|
|
268264
268385
|
return { instructions, carried };
|
|
268265
268386
|
}
|
|
268387
|
+
referenceManifestToEntries(referenceManifest) {
|
|
268388
|
+
const entries = {};
|
|
268389
|
+
for (const ref of referenceManifest ?? []) {
|
|
268390
|
+
entries[ref.sourceEntityId] = {
|
|
268391
|
+
sourceEntityId: ref.sourceEntityId,
|
|
268392
|
+
physicalTableName: ref.physicalTableName
|
|
268393
|
+
};
|
|
268394
|
+
}
|
|
268395
|
+
return entries;
|
|
268396
|
+
}
|
|
268266
268397
|
async getMostRecentManifestEntries(environmentId, packageName, excludeId) {
|
|
268267
268398
|
const list = await this.repository.listMaterializations(environmentId, packageName);
|
|
268268
268399
|
for (const m of list) {
|
|
@@ -268315,7 +268446,7 @@ class MaterializationService {
|
|
|
268315
268446
|
}
|
|
268316
268447
|
}
|
|
268317
268448
|
}
|
|
268318
|
-
async executeInstructedBuild(compiled, instructions, seedEntries, signal) {
|
|
268449
|
+
async executeInstructedBuild(compiled, instructions, seedEntries, signal, strict = false) {
|
|
268319
268450
|
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
268320
268451
|
const bySourceID = new Map;
|
|
268321
268452
|
const bySourceEntityId = new Map;
|
|
@@ -268326,6 +268457,7 @@ class MaterializationService {
|
|
|
268326
268457
|
bySourceEntityId.set(instruction.sourceEntityId, instruction);
|
|
268327
268458
|
}
|
|
268328
268459
|
const manifest = new Manifest;
|
|
268460
|
+
manifest.strict = strict;
|
|
268329
268461
|
const entries = {};
|
|
268330
268462
|
for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
|
|
268331
268463
|
if (entry.physicalTableName) {
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -85,6 +85,78 @@ describe("MaterializationController.createMaterialization validation", () => {
|
|
|
85
85
|
expect(await parse({ buildInstructions: null })).toEqual({});
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it("parses referenceManifest and strictUpstreams alongside sources", async () => {
|
|
89
|
+
const parsed = await parse({
|
|
90
|
+
buildInstructions: {
|
|
91
|
+
sources: [
|
|
92
|
+
{
|
|
93
|
+
sourceEntityId: "b2",
|
|
94
|
+
materializedTableId: "mt-2",
|
|
95
|
+
physicalTableName: "downstream_v1",
|
|
96
|
+
realization: "COPY",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
referenceManifest: [
|
|
100
|
+
{ sourceEntityId: "b1", physicalTableName: "upstream_table" },
|
|
101
|
+
],
|
|
102
|
+
strictUpstreams: true,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(parsed).toEqual({
|
|
106
|
+
buildInstructions: [
|
|
107
|
+
{
|
|
108
|
+
sourceEntityId: "b2",
|
|
109
|
+
sourceID: undefined,
|
|
110
|
+
materializedTableId: "mt-2",
|
|
111
|
+
physicalTableName: "downstream_v1",
|
|
112
|
+
realization: "COPY",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
referenceManifest: [
|
|
116
|
+
{ sourceEntityId: "b1", physicalTableName: "upstream_table" },
|
|
117
|
+
],
|
|
118
|
+
strictUpstreams: true,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects a referenceManifest entry missing a required field", async () => {
|
|
123
|
+
const { controller } = build();
|
|
124
|
+
await expect(
|
|
125
|
+
controller.createMaterialization("env", "pkg", {
|
|
126
|
+
buildInstructions: {
|
|
127
|
+
sources: [
|
|
128
|
+
{
|
|
129
|
+
sourceEntityId: "b2",
|
|
130
|
+
materializedTableId: "mt-2",
|
|
131
|
+
physicalTableName: "downstream_v1",
|
|
132
|
+
realization: "COPY",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
referenceManifest: [{ sourceEntityId: "b1" }],
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
).rejects.toThrow(BadRequestError);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects a non-boolean strictUpstreams", async () => {
|
|
142
|
+
const { controller } = build();
|
|
143
|
+
await expect(
|
|
144
|
+
controller.createMaterialization("env", "pkg", {
|
|
145
|
+
buildInstructions: {
|
|
146
|
+
sources: [
|
|
147
|
+
{
|
|
148
|
+
sourceEntityId: "b2",
|
|
149
|
+
materializedTableId: "mt-2",
|
|
150
|
+
physicalTableName: "downstream_v1",
|
|
151
|
+
realization: "COPY",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
strictUpstreams: "yes",
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toThrow(BadRequestError);
|
|
158
|
+
});
|
|
159
|
+
|
|
88
160
|
it("rejects buildInstructions without a non-empty sources array", async () => {
|
|
89
161
|
const { controller } = build();
|
|
90
162
|
await expect(
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { BadRequestError } from "../errors";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BuildInstruction,
|
|
4
|
+
ManifestReference,
|
|
5
|
+
} from "../storage/DatabaseInterface";
|
|
3
6
|
import { MaterializationService } from "../service/materialization_service";
|
|
4
7
|
|
|
5
8
|
export class MaterializationController {
|
|
@@ -21,19 +24,28 @@ export class MaterializationController {
|
|
|
21
24
|
forceRefresh?: boolean;
|
|
22
25
|
sourceNames?: string[];
|
|
23
26
|
buildInstructions?: BuildInstruction[];
|
|
27
|
+
referenceManifest?: ManifestReference[];
|
|
28
|
+
strictUpstreams?: boolean;
|
|
24
29
|
} {
|
|
25
30
|
const result: {
|
|
26
31
|
forceRefresh?: boolean;
|
|
27
32
|
sourceNames?: string[];
|
|
28
33
|
buildInstructions?: BuildInstruction[];
|
|
34
|
+
referenceManifest?: ManifestReference[];
|
|
35
|
+
strictUpstreams?: boolean;
|
|
29
36
|
} = {};
|
|
30
37
|
if (
|
|
31
38
|
body.buildInstructions !== undefined &&
|
|
32
39
|
body.buildInstructions !== null
|
|
33
40
|
) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
41
|
+
const parsed = this.validateBuildInstructions(body.buildInstructions);
|
|
42
|
+
result.buildInstructions = parsed.sources;
|
|
43
|
+
if (parsed.referenceManifest !== undefined) {
|
|
44
|
+
result.referenceManifest = parsed.referenceManifest;
|
|
45
|
+
}
|
|
46
|
+
if (parsed.strictUpstreams !== undefined) {
|
|
47
|
+
result.strictUpstreams = parsed.strictUpstreams;
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
if (body.forceRefresh !== undefined) {
|
|
39
51
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -57,22 +69,74 @@ export class MaterializationController {
|
|
|
57
69
|
|
|
58
70
|
/**
|
|
59
71
|
* Validate the orchestrated `buildInstructions` payload (BuildInstructions:
|
|
60
|
-
* `{ sources: BuildInstruction[] }`)
|
|
61
|
-
* the service consumes
|
|
72
|
+
* `{ sources: BuildInstruction[], referenceManifest?, strictUpstreams? }`)
|
|
73
|
+
* into the parts the service consumes: the flattened instruction list, the
|
|
74
|
+
* optional upstream reference manifest, and the strict flag.
|
|
62
75
|
*/
|
|
63
|
-
private validateBuildInstructions(raw: unknown):
|
|
76
|
+
private validateBuildInstructions(raw: unknown): {
|
|
77
|
+
sources: BuildInstruction[];
|
|
78
|
+
referenceManifest?: ManifestReference[];
|
|
79
|
+
strictUpstreams?: boolean;
|
|
80
|
+
} {
|
|
64
81
|
if (typeof raw !== "object" || raw === null) {
|
|
65
82
|
throw new BadRequestError("buildInstructions must be an object");
|
|
66
83
|
}
|
|
67
|
-
const
|
|
84
|
+
const obj = raw as Record<string, unknown>;
|
|
85
|
+
const sources = obj.sources;
|
|
68
86
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
69
87
|
throw new BadRequestError(
|
|
70
88
|
"buildInstructions requires a non-empty 'sources' array of BuildInstruction",
|
|
71
89
|
);
|
|
72
90
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
const result: {
|
|
92
|
+
sources: BuildInstruction[];
|
|
93
|
+
referenceManifest?: ManifestReference[];
|
|
94
|
+
strictUpstreams?: boolean;
|
|
95
|
+
} = {
|
|
96
|
+
sources: sources.map((instruction) =>
|
|
97
|
+
this.validateInstruction(instruction),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
if (
|
|
101
|
+
obj.referenceManifest !== undefined &&
|
|
102
|
+
obj.referenceManifest !== null
|
|
103
|
+
) {
|
|
104
|
+
if (!Array.isArray(obj.referenceManifest)) {
|
|
105
|
+
throw new BadRequestError(
|
|
106
|
+
"buildInstructions.referenceManifest must be an array of ManifestReference",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
result.referenceManifest = obj.referenceManifest.map((ref) =>
|
|
110
|
+
this.validateManifestReference(ref),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (obj.strictUpstreams !== undefined) {
|
|
114
|
+
if (typeof obj.strictUpstreams !== "boolean") {
|
|
115
|
+
throw new BadRequestError(
|
|
116
|
+
"buildInstructions.strictUpstreams must be a boolean",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
result.strictUpstreams = obj.strictUpstreams;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private validateManifestReference(raw: unknown): ManifestReference {
|
|
125
|
+
if (typeof raw !== "object" || raw === null) {
|
|
126
|
+
throw new BadRequestError("Each manifest reference must be an object");
|
|
127
|
+
}
|
|
128
|
+
const ref = raw as Record<string, unknown>;
|
|
129
|
+
for (const field of ["sourceEntityId", "physicalTableName"] as const) {
|
|
130
|
+
if (typeof ref[field] !== "string") {
|
|
131
|
+
throw new BadRequestError(
|
|
132
|
+
`Manifest reference is missing required string field '${field}'`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
sourceEntityId: ref.sourceEntityId as string,
|
|
138
|
+
physicalTableName: ref.physicalTableName as string,
|
|
139
|
+
};
|
|
76
140
|
}
|
|
77
141
|
|
|
78
142
|
private validateInstruction(raw: unknown): BuildInstruction {
|