@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.
@@ -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 = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
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 schedule = this.packageMetadata.materialization?.schedule;
258940
- if (!schedule)
258941
- return [];
258942
- return [
258943
- `materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is not accepted: ` + `package-level crons are rejected in Phase B. Declare ` + `'materialization.freshness.window' instead (the control plane schedules ` + `refreshes from it).`
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
- result.buildInstructions = this.validateBuildInstructions(body.buildInstructions);
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 sources = raw.sources;
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
- return sources.map((instruction) => this.validateInstruction(instruction));
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.224",
4
+ "version": "0.0.225",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -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 { BuildInstruction } from "../storage/DatabaseInterface";
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
- result.buildInstructions = this.validateBuildInstructions(
35
- body.buildInstructions,
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[] }`) and flatten it to the instruction list
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): BuildInstruction[] {
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 sources = (raw as Record<string, unknown>).sources;
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
- return sources.map((instruction) =>
74
- this.validateInstruction(instruction),
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 {