@ingestron/core 0.12.0
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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +56 -0
- package/SECURITY.md +44 -0
- package/THIRD_PARTY_NOTICES.md +693 -0
- package/dist/assets/schemas/ODCS-LICENSE +201 -0
- package/dist/assets/schemas/odcs-3.1.0.json +2929 -0
- package/dist/assets/schemas/provenance.json +16 -0
- package/dist/compiler-fingerprint.json +84 -0
- package/dist/core/authoring.d.ts +55 -0
- package/dist/core/authoring.js +315 -0
- package/dist/core/config.d.ts +34 -0
- package/dist/core/config.js +233 -0
- package/dist/core/connections.d.ts +19 -0
- package/dist/core/connections.js +203 -0
- package/dist/core/connector-package.d.ts +37 -0
- package/dist/core/connector-package.js +43 -0
- package/dist/core/contracts.d.ts +8 -0
- package/dist/core/contracts.js +45 -0
- package/dist/core/deliveries.d.ts +16 -0
- package/dist/core/deliveries.js +28 -0
- package/dist/core/delivery-exports.d.ts +20 -0
- package/dist/core/delivery-exports.js +119 -0
- package/dist/core/development.d.ts +50 -0
- package/dist/core/development.js +94 -0
- package/dist/core/ecosystem-authoring.d.ts +13 -0
- package/dist/core/ecosystem-authoring.js +82 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +43 -0
- package/dist/core/execution.d.ts +28 -0
- package/dist/core/execution.js +297 -0
- package/dist/core/extension-packs.d.ts +8 -0
- package/dist/core/extension-packs.js +34 -0
- package/dist/core/fingerprint.d.ts +1 -0
- package/dist/core/fingerprint.js +16 -0
- package/dist/core/generate.d.ts +16 -0
- package/dist/core/generate.js +198 -0
- package/dist/core/installation.d.ts +3 -0
- package/dist/core/installation.js +14 -0
- package/dist/core/installed-plugins.d.ts +41 -0
- package/dist/core/installed-plugins.js +92 -0
- package/dist/core/model-pack-schema.d.ts +46 -0
- package/dist/core/model-pack-schema.js +81 -0
- package/dist/core/model-packs.d.ts +4 -0
- package/dist/core/model-packs.js +33 -0
- package/dist/core/operations.d.ts +346 -0
- package/dist/core/operations.js +760 -0
- package/dist/core/package-references.d.ts +36 -0
- package/dist/core/package-references.js +103 -0
- package/dist/core/packages.d.ts +340 -0
- package/dist/core/packages.js +579 -0
- package/dist/core/planner.d.ts +114 -0
- package/dist/core/planner.js +821 -0
- package/dist/core/project-build.d.ts +13 -0
- package/dist/core/project-build.js +266 -0
- package/dist/core/provider-commands.d.ts +83 -0
- package/dist/core/provider-commands.js +252 -0
- package/dist/core/provider-starter.d.ts +5 -0
- package/dist/core/provider-starter.js +155 -0
- package/dist/core/report-pack-schema.d.ts +13 -0
- package/dist/core/report-pack-schema.js +15 -0
- package/dist/core/schema.d.ts +257 -0
- package/dist/core/schema.js +126 -0
- package/dist/core/workflows.d.ts +91 -0
- package/dist/core/workflows.js +438 -0
- package/dist/plugins/authoring.d.ts +88 -0
- package/dist/plugins/authoring.js +34 -0
- package/dist/plugins/compatibility.d.ts +23 -0
- package/dist/plugins/compatibility.js +55 -0
- package/dist/plugins/generation.d.ts +5 -0
- package/dist/plugins/generation.js +105 -0
- package/dist/plugins/provider-renderer.d.ts +7 -0
- package/dist/plugins/provider-renderer.js +37 -0
- package/dist/plugins/provider-worker.d.ts +1 -0
- package/dist/plugins/provider-worker.js +74 -0
- package/dist/sdk/adapter.d.ts +15 -0
- package/dist/sdk/adapter.js +15 -0
- package/dist/sdk/index.d.ts +4 -0
- package/dist/sdk/index.js +3 -0
- package/dist/sdk/schemas.d.ts +34 -0
- package/dist/sdk/schemas.js +74 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +7 -0
- package/package.json +72 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
import { resolveModelContracts } from "./model-packs.js";
|
|
2
|
+
import { extensionActivities } from "./extension-packs.js";
|
|
3
|
+
import { expandProvider } from "../plugins/provider-renderer.js";
|
|
4
|
+
import { validateProviderPlan } from "../plugins/generation.js";
|
|
5
|
+
import addFormats from "ajv-formats";
|
|
6
|
+
import { resolve, relative, dirname } from "node:path";
|
|
7
|
+
import { Configuration } from "./config.js";
|
|
8
|
+
import { projectSchema, flowSchema, stepSchema, } from "./schema.js";
|
|
9
|
+
import { check, canonical, digest, isMap, merge, Problem } from "./errors.js";
|
|
10
|
+
import { executeProvider } from "../plugins/provider-renderer.js";
|
|
11
|
+
import { contractColumns } from "./contracts.js";
|
|
12
|
+
import { resolvePackage, providerPackageSchema, externalActivitySchema, fence, packageYaml, } from "./packages.js";
|
|
13
|
+
import { Ajv2020 as Ajv } from "ajv/dist/2020.js";
|
|
14
|
+
import { compilerFingerprint } from "./fingerprint.js";
|
|
15
|
+
import { version } from "../version.js";
|
|
16
|
+
import { checkProviderCompatibility } from "../plugins/compatibility.js";
|
|
17
|
+
const secretFields = new Set([
|
|
18
|
+
"password",
|
|
19
|
+
"auth_token",
|
|
20
|
+
"token",
|
|
21
|
+
"accessToken",
|
|
22
|
+
"clientSecret",
|
|
23
|
+
"accountKey",
|
|
24
|
+
"sasToken",
|
|
25
|
+
]);
|
|
26
|
+
const auditSecrets = (value, path) => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
value.forEach((child, index) => auditSecrets(child, `${path}[${index}]`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!isMap(value) || value.$secret)
|
|
32
|
+
return;
|
|
33
|
+
for (const [key, child] of Object.entries(value)) {
|
|
34
|
+
if (secretFields.has(key))
|
|
35
|
+
check(isMap(child) && !!child.$secret, "SECRET", `${path}.${key}: use a runtime $secret reference`);
|
|
36
|
+
if (typeof child === "string" && key === "jdbcUrl")
|
|
37
|
+
check(!/(password|pwd|token|accesskey)\s*=/i.test(child), "SECRET", "Keep credentials out of JDBC URLs; use runtime secret options");
|
|
38
|
+
auditSecrets(child, path + "." + key);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
export function inspectProject(root, environment = "dev", options = {}) {
|
|
42
|
+
const reader = new Configuration(root, options.environmentVariables);
|
|
43
|
+
const raw = reader.load();
|
|
44
|
+
check(raw.apiVersion === "ingestron.project/v1", "VERSION", "Core accepts ingestron.project/v1 only; legacy project formats are not supported");
|
|
45
|
+
const initial = reader.parse(projectSchema, raw);
|
|
46
|
+
const profile = initial.environments[environment];
|
|
47
|
+
check(profile, "ENVIRONMENT", `Unknown environment ${environment}; available: ${Object.keys(initial.environments).join(", ")}`);
|
|
48
|
+
check(!profile.environment || profile.environment === environment, "ENVIRONMENT", `Profile name ${environment} differs from environment: ${profile.environment}`);
|
|
49
|
+
auditSecrets(profile.bindings, "bindings");
|
|
50
|
+
auditSecrets(initial.connections, "connections");
|
|
51
|
+
const context = {
|
|
52
|
+
env: environment,
|
|
53
|
+
project: { id: initial.id },
|
|
54
|
+
values: profile.values,
|
|
55
|
+
};
|
|
56
|
+
const resolvedProfile = reader.values(profile, context, { draft: true });
|
|
57
|
+
const project = {
|
|
58
|
+
...initial,
|
|
59
|
+
providers: {
|
|
60
|
+
...initial.providers,
|
|
61
|
+
configurations: Object.fromEntries(Object.entries(initial.providers.configurations).map(([name, config]) => [
|
|
62
|
+
name,
|
|
63
|
+
{
|
|
64
|
+
...config,
|
|
65
|
+
options: reader.values(config.options, { ...context, values: resolvedProfile.values }, { draft: true }),
|
|
66
|
+
},
|
|
67
|
+
])),
|
|
68
|
+
},
|
|
69
|
+
environments: { [environment]: resolvedProfile },
|
|
70
|
+
};
|
|
71
|
+
const flows = initial.flows.map((rawFlow) => reader.parse(flowSchema, reader.values(rawFlow, {
|
|
72
|
+
...context,
|
|
73
|
+
values: resolvedProfile.values,
|
|
74
|
+
flow: { id: rawFlow.id },
|
|
75
|
+
}, { draft: true, table: true, runtime: true })));
|
|
76
|
+
resolveModelContracts(reader, project, flows);
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
for (const flow of flows) {
|
|
79
|
+
check(!ids.has(flow.id), "OWNER", `Duplicate flow ${flow.id}`);
|
|
80
|
+
ids.add(flow.id);
|
|
81
|
+
check([flow.steps, flow.ingestion].filter(Boolean).length === 1, "FLOW", `${flow.id}: select an ingestion standard or transformation steps`);
|
|
82
|
+
check(options.draft ||
|
|
83
|
+
flow.kind !== "ingestion" ||
|
|
84
|
+
!!flow.ingestion?.connection ||
|
|
85
|
+
(!!flow.tables && Object.keys(flow.tables).length > 0), "FLOW", `${flow.id}: ingestion requires tables`);
|
|
86
|
+
check(flow.kind !== "transformation" || !flow.tables, "FLOW", `${flow.id}: transformation uses named requires/publishes, not a table loop`);
|
|
87
|
+
if (flow.kind === "transformation" && !options.draft)
|
|
88
|
+
check(Object.keys(flow.publishes).length, "FLOW", `${flow.id}: declare published outputs`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
reader,
|
|
92
|
+
project,
|
|
93
|
+
flows,
|
|
94
|
+
profile: resolvedProfile,
|
|
95
|
+
context: { ...context, values: resolvedProfile.values },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function noPending(value, label) {
|
|
99
|
+
if (Array.isArray(value))
|
|
100
|
+
value.forEach((v) => noPending(v, label));
|
|
101
|
+
else if (isMap(value)) {
|
|
102
|
+
if (value.$secret)
|
|
103
|
+
return;
|
|
104
|
+
check(!Object.hasOwn(value, "$env"), "INPUT", `${label}: supply environment variable ${value.$env}`);
|
|
105
|
+
Object.values(value).forEach((v) => noPending(v, label));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
check(typeof value !== "string" ||
|
|
109
|
+
!/{{\s*(?!run\.)[A-Za-z_][\w.]*\s*}}/.test(value), "INPUT", `${label}: unresolved value ${value}`);
|
|
110
|
+
check(value !== "SPECIFY", "INPUT", `${label}: replace SPECIFY with a value or a declared $env input`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function provider(project, bindings, reader, selected) {
|
|
114
|
+
const name = selected ?? project.defaults.provider;
|
|
115
|
+
check(name, "PROVIDER", "Select a provider or set defaults.provider");
|
|
116
|
+
const configured = project.providers.configurations[name];
|
|
117
|
+
check(configured, "PROVIDER", `Unknown provider configuration ${name}`);
|
|
118
|
+
const pkg = project.providers.packages[configured.package];
|
|
119
|
+
check(pkg, "PROVIDER", `Unknown provider package ${configured.package}`);
|
|
120
|
+
let rendererCode;
|
|
121
|
+
let plannerCode;
|
|
122
|
+
let providerVersion;
|
|
123
|
+
let lifecycleCode;
|
|
124
|
+
let outputSchemas = {};
|
|
125
|
+
let platform;
|
|
126
|
+
let packageSource;
|
|
127
|
+
let exports;
|
|
128
|
+
let packageOptions;
|
|
129
|
+
let capabilities;
|
|
130
|
+
let packs = {};
|
|
131
|
+
check(!pkg.source.startsWith("builtin:"), "MIGRATION", "Bundled providers have retired. Install an exact provider version and use plugin migrate --from <package> --to <reference> to review the project change.");
|
|
132
|
+
{
|
|
133
|
+
packageSource = pkg.source.startsWith(".")
|
|
134
|
+
? { file: reader.path(pkg.source), root: reader.root }
|
|
135
|
+
: resolvePackage(reader.root, `${pkg.source}@${pkg.version}`);
|
|
136
|
+
const descriptor = providerPackageSchema.parse(packageYaml(packageSource.file));
|
|
137
|
+
check(pkg.version === "v1" ||
|
|
138
|
+
pkg.version === descriptor.version ||
|
|
139
|
+
/^[a-f0-9]{40}$/.test(pkg.version), "PACKAGE", "Provider manifest version differs from its selector");
|
|
140
|
+
checkProviderCompatibility(descriptor);
|
|
141
|
+
if (descriptor.outputSchemas)
|
|
142
|
+
outputSchemas = Object.fromEntries(Object.entries(descriptor.outputSchemas).map(([key, path]) => [
|
|
143
|
+
key,
|
|
144
|
+
reader.text(fence(packageSource.root, relative(packageSource.root, resolve(dirname(packageSource.file), path)))),
|
|
145
|
+
]));
|
|
146
|
+
if (descriptor.lifecycle)
|
|
147
|
+
lifecycleCode = reader.text(fence(packageSource.root, relative(packageSource.root, resolve(dirname(packageSource.file), descriptor.lifecycle.module))));
|
|
148
|
+
if (descriptor.planner) {
|
|
149
|
+
plannerCode = reader.text(fence(packageSource.root, relative(packageSource.root, resolve(dirname(packageSource.file), descriptor.planner.module))));
|
|
150
|
+
}
|
|
151
|
+
if (descriptor.configurationSchema) {
|
|
152
|
+
const configAjv = new Ajv({
|
|
153
|
+
allErrors: true,
|
|
154
|
+
strict: false,
|
|
155
|
+
useDefaults: true,
|
|
156
|
+
});
|
|
157
|
+
addFormats(configAjv);
|
|
158
|
+
const configSchema = typeof descriptor.configurationSchema === "string"
|
|
159
|
+
? JSON.parse(reader.text(fence(packageSource.root, relative(packageSource.root, resolve(dirname(packageSource.file), descriptor.configurationSchema)))))
|
|
160
|
+
: descriptor.configurationSchema;
|
|
161
|
+
const validateConfig = configAjv.compile(configSchema);
|
|
162
|
+
packageOptions = structuredClone(configured.options);
|
|
163
|
+
check(validateConfig(packageOptions), "PROVIDER", `Provider options: ${configAjv.errorsText(validateConfig.errors)}`);
|
|
164
|
+
}
|
|
165
|
+
platform = descriptor.platform;
|
|
166
|
+
capabilities = descriptor.capabilities;
|
|
167
|
+
packs = extensionActivities(reader, project, configured.packs, descriptor);
|
|
168
|
+
if (descriptor.renderer) {
|
|
169
|
+
check(descriptor.configurationSchema, "PROVIDER", "A renderer provider must declare its own configurationSchema");
|
|
170
|
+
rendererCode = reader.text(fence(packageSource.root, relative(packageSource.root, resolve(dirname(packageSource.file), descriptor.renderer.module))));
|
|
171
|
+
providerVersion = descriptor.version;
|
|
172
|
+
if (Object.keys(descriptor.activities).length)
|
|
173
|
+
exports = descriptor.activities;
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
throw new Problem("PROVIDER", "Extension providers must declare a renderer");
|
|
177
|
+
reader.text(packageSource.file);
|
|
178
|
+
if (packageSource.entry)
|
|
179
|
+
reader.text("packages.lock.yaml");
|
|
180
|
+
}
|
|
181
|
+
check(bindings[configured.binding], "BINDING", `Missing existing binding ${configured.binding}`);
|
|
182
|
+
check(bindings[configured.binding].kind === platform, "BINDING", `${configured.binding}.kind must be ${platform}`);
|
|
183
|
+
return {
|
|
184
|
+
platform,
|
|
185
|
+
plannerCode,
|
|
186
|
+
binding: configured.binding,
|
|
187
|
+
packageSource,
|
|
188
|
+
exports,
|
|
189
|
+
packs,
|
|
190
|
+
runtime: {
|
|
191
|
+
implementation: "native",
|
|
192
|
+
configuration: name,
|
|
193
|
+
...(capabilities ? { capabilities } : {}),
|
|
194
|
+
options: packageOptions ?? configured.options,
|
|
195
|
+
version: providerVersion,
|
|
196
|
+
rendererCode,
|
|
197
|
+
lifecycleCode,
|
|
198
|
+
outputSchemas,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function refs(value, found = []) {
|
|
203
|
+
if (isMap(value)) {
|
|
204
|
+
if (typeof value.from === "string")
|
|
205
|
+
found.push(value.from);
|
|
206
|
+
for (const v of Object.values(value))
|
|
207
|
+
refs(v, found);
|
|
208
|
+
}
|
|
209
|
+
else if (Array.isArray(value))
|
|
210
|
+
value.forEach((v) => refs(v, found));
|
|
211
|
+
return found;
|
|
212
|
+
}
|
|
213
|
+
export function planProject(root, environment = "dev", options = {}) {
|
|
214
|
+
const inspected = inspectProject(root, environment, {
|
|
215
|
+
...options,
|
|
216
|
+
draft: !!options.flow || options.draft,
|
|
217
|
+
});
|
|
218
|
+
const { reader, project, profile, context } = inspected;
|
|
219
|
+
let flows = options.nativeOnly
|
|
220
|
+
? inspected.flows.filter((f) => !f.ingestion?.connection)
|
|
221
|
+
: inspected.flows;
|
|
222
|
+
if (options.flowIds)
|
|
223
|
+
flows = flows.filter((f) => options.flowIds.includes(f.id));
|
|
224
|
+
if (options.flow) {
|
|
225
|
+
const wanted = new Set();
|
|
226
|
+
const include = (id) => {
|
|
227
|
+
if (wanted.has(id))
|
|
228
|
+
return;
|
|
229
|
+
wanted.add(id);
|
|
230
|
+
const flow = inspected.flows.find((f) => f.id === id);
|
|
231
|
+
check(flow, "SELECT", `Unknown flow ${id}`);
|
|
232
|
+
if (options.delivery && flow.export)
|
|
233
|
+
for (const peer of inspected.flows)
|
|
234
|
+
if (peer.export === flow.export &&
|
|
235
|
+
(peer.provider ?? project.defaults.provider) ===
|
|
236
|
+
(flow.provider ?? project.defaults.provider))
|
|
237
|
+
include(peer.id);
|
|
238
|
+
for (const input of Object.values(flow.requires)) {
|
|
239
|
+
const parts = input.dataset.split(".");
|
|
240
|
+
check(parts[0] === project.id && parts.length >= 3, "DATASET", `Unknown project dataset ${input.dataset}`);
|
|
241
|
+
include(parts[1]);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
include(options.flow);
|
|
245
|
+
flows = flows.filter((f) => wanted.has(f.id));
|
|
246
|
+
}
|
|
247
|
+
check(!options.delivery || (!options.table && !options.step), "SELECT", "Delivery builds require complete export groups; table/step selection is unsafe");
|
|
248
|
+
check(!options.table || options.flow, "SELECT", "Supply flow with table");
|
|
249
|
+
check(!options.step || options.flow, "SELECT", "Supply flow with step");
|
|
250
|
+
const nodes = [], datasets = {}, recovery = [], targets = new Map(), byFlow = new Map();
|
|
251
|
+
const selectedFlow = options.flow
|
|
252
|
+
? flows.find((f) => f.id === options.flow)
|
|
253
|
+
: undefined;
|
|
254
|
+
check(!options.flow || selectedFlow, "SELECT", `Unknown flow ${options.flow}`);
|
|
255
|
+
// Model producers before consumers regardless of file declaration order.
|
|
256
|
+
const sorted = [], visiting = new Set(), visitedFlows = new Set();
|
|
257
|
+
const visitFlow = (flow) => {
|
|
258
|
+
check(!visiting.has(flow.id), "CYCLE", `Flow dependency cycle at ${flow.id}`);
|
|
259
|
+
if (visitedFlows.has(flow.id))
|
|
260
|
+
return;
|
|
261
|
+
visiting.add(flow.id);
|
|
262
|
+
for (const required of Object.values(flow.requires)) {
|
|
263
|
+
const parts = required.dataset.split(".");
|
|
264
|
+
check(parts[0] === project.id, "DATASET", `Unknown project dataset ${required.dataset}`);
|
|
265
|
+
const parent = flows.find((f) => f.id === parts[1]);
|
|
266
|
+
check(parent, "DATASET", `Unknown producer flow for ${required.dataset}`);
|
|
267
|
+
visitFlow(parent);
|
|
268
|
+
}
|
|
269
|
+
visiting.delete(flow.id);
|
|
270
|
+
visitedFlows.add(flow.id);
|
|
271
|
+
sorted.push(flow);
|
|
272
|
+
};
|
|
273
|
+
flows.forEach(visitFlow);
|
|
274
|
+
flows = sorted;
|
|
275
|
+
for (const flow of flows) {
|
|
276
|
+
check(!flow.ingestion?.connection, "CONNECTION", `${flow.id}: prepare reviewed connector assets with connections prepare ${flow.id}; native build does not execute source discovery`);
|
|
277
|
+
check(flow.kind !== "ingestion" || Object.keys(flow.tables ?? {}).length > 0, "FLOW", `${flow.id}: add ingestion tables before planning`);
|
|
278
|
+
check(flow.kind !== "transformation" || Object.keys(flow.publishes).length > 0, "FLOW", `${flow.id}: declare published outputs`);
|
|
279
|
+
// Global ownership is checked for all declared datasets; incomplete unrelated
|
|
280
|
+
// flows remain draft. Their nodes are excluded after dependency selection.
|
|
281
|
+
let steps = flow.steps ?? [];
|
|
282
|
+
if (flow.ingestion) {
|
|
283
|
+
const configuration = project.providers.configurations[flow.provider ?? project.defaults.provider ?? ""];
|
|
284
|
+
check(configuration, "PROVIDER", "Select a provider for the ingestion flow");
|
|
285
|
+
const selectedProvider = provider(project, profile.bindings, reader, flow.provider);
|
|
286
|
+
const providerSource = project.providers.packages[configuration.package]?.source;
|
|
287
|
+
check(selectedProvider.plannerCode, "PROVIDER", "Ingestion requires a provider planner");
|
|
288
|
+
const preset = flow.ingestion.preset
|
|
289
|
+
? selectedProvider.packs[String(flow.ingestion.preset)]
|
|
290
|
+
: undefined;
|
|
291
|
+
check(!flow.ingestion.preset || preset, "PACK", `Unknown ingestion preset ${flow.ingestion.preset}`);
|
|
292
|
+
const ingestion = { ...flow.ingestion };
|
|
293
|
+
delete ingestion.preset;
|
|
294
|
+
const planningFlow = preset
|
|
295
|
+
? { ...flow, ingestion: merge(preset.with, ingestion) }
|
|
296
|
+
: flow;
|
|
297
|
+
const built = expandProvider(selectedProvider.plannerCode, {
|
|
298
|
+
apiVersion: "ingestron.provider-planning/v1",
|
|
299
|
+
flow: planningFlow,
|
|
300
|
+
providerSource,
|
|
301
|
+
columns: Object.fromEntries(Object.entries(flow.tables ?? {}).map(([id, table]) => [
|
|
302
|
+
id,
|
|
303
|
+
contractColumns(table.contract),
|
|
304
|
+
])),
|
|
305
|
+
});
|
|
306
|
+
if (preset)
|
|
307
|
+
check(built.steps.every((step) => step.uses.replace(/@.*$/, "") === preset.uses), "PACK", "Preset standard expanded an activity outside its allowed base activity");
|
|
308
|
+
steps = built.steps;
|
|
309
|
+
recovery.push({ ...built.recovery, flow: flow.id });
|
|
310
|
+
}
|
|
311
|
+
steps = steps.map((s) => reader.parse(stepSchema, s));
|
|
312
|
+
const stepIds = new Set();
|
|
313
|
+
for (const step of steps) {
|
|
314
|
+
check(!stepIds.has(step.id), "STEP", `Duplicate step ${flow.id}/${step.id}`);
|
|
315
|
+
stepIds.add(step.id);
|
|
316
|
+
}
|
|
317
|
+
const tableEntries = flow.kind === "ingestion"
|
|
318
|
+
? Object.entries(flow.tables)
|
|
319
|
+
: [["", undefined]];
|
|
320
|
+
const flowNodes = [];
|
|
321
|
+
byFlow.set(flow.id, flowNodes);
|
|
322
|
+
for (const [tableId, table] of tableEntries) {
|
|
323
|
+
if (table) {
|
|
324
|
+
contractColumns(table.contract);
|
|
325
|
+
for (const name of Object.keys(table.steps))
|
|
326
|
+
check(stepIds.has(name), "STEP", `Unknown table override ${flow.id}/${tableId}/${name}`);
|
|
327
|
+
}
|
|
328
|
+
let previous;
|
|
329
|
+
const previousById = new Map();
|
|
330
|
+
for (const step of steps) {
|
|
331
|
+
if (step.select) {
|
|
332
|
+
check(flow.kind === "ingestion", "SELECT", "Table selection is only valid in an ingestion flow");
|
|
333
|
+
for (const selected of step.select)
|
|
334
|
+
check(Object.hasOwn(flow.tables, selected), "SELECT", `Unknown selected table ${selected}`);
|
|
335
|
+
if (!step.select.includes(tableId))
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const chosen = provider(project, profile.bindings, reader, step.provider ?? flow.provider);
|
|
339
|
+
const preset = chosen.packs[step.uses];
|
|
340
|
+
check(!step.uses.includes(":") || preset, "PACK", `Unknown pack activity ${step.uses}`);
|
|
341
|
+
const activityUse = preset?.uses ?? step.uses;
|
|
342
|
+
let descriptor;
|
|
343
|
+
let implementation;
|
|
344
|
+
let generation;
|
|
345
|
+
let external;
|
|
346
|
+
let packageRoot;
|
|
347
|
+
let activityFile;
|
|
348
|
+
if (activityUse.startsWith(".")) {
|
|
349
|
+
activityFile = reader.path(activityUse);
|
|
350
|
+
packageRoot = reader.root;
|
|
351
|
+
}
|
|
352
|
+
else if (chosen.exports &&
|
|
353
|
+
chosen.packageSource &&
|
|
354
|
+
!activityUse.includes("/")) {
|
|
355
|
+
const name = activityUse.replace(/@(v1|[1-9]\d*\.\d+\.\d+)$/, "");
|
|
356
|
+
const exported = chosen.exports[name];
|
|
357
|
+
check(exported, "ACTIVITY", `Provider does not export ${name}`);
|
|
358
|
+
activityFile = fence(chosen.packageSource.root, relative(chosen.packageSource.root, resolve(dirname(chosen.packageSource.file), exported)));
|
|
359
|
+
packageRoot = chosen.packageSource.root;
|
|
360
|
+
}
|
|
361
|
+
else if (activityUse.includes("/")) {
|
|
362
|
+
const resolved = resolvePackage(reader.root, activityUse);
|
|
363
|
+
activityFile = resolved.file;
|
|
364
|
+
packageRoot = resolved.root;
|
|
365
|
+
reader.text("packages.lock.yaml");
|
|
366
|
+
}
|
|
367
|
+
if (activityFile && packageRoot) {
|
|
368
|
+
// Activity sources remain project-fenced and enter the source inventory.
|
|
369
|
+
const readActivity = (file) => reader.text(file);
|
|
370
|
+
external = externalActivitySchema.parse(packageYaml(activityFile));
|
|
371
|
+
const selectedVersion = /@([1-9]\d*\.\d+\.\d+)$/.exec(step.uses)?.[1];
|
|
372
|
+
check(!selectedVersion || selectedVersion === external.version, "PACKAGE", "Activity manifest version differs from its exact selector");
|
|
373
|
+
if (external.generator?.kind === "native-pipeline" &&
|
|
374
|
+
external.templates.some((t) => t.format === "sql"))
|
|
375
|
+
check(external.input === "relation" && external.output === "relation", "INTERFACE", "SQL query activities require relation input and output");
|
|
376
|
+
check(external.platform === chosen.platform, "CAPABILITY", `${external.id} targets ${external.platform}, not ${chosen.platform}`);
|
|
377
|
+
readActivity(activityFile);
|
|
378
|
+
descriptor = {
|
|
379
|
+
id: external.id,
|
|
380
|
+
description: external.description,
|
|
381
|
+
platforms: [external.platform],
|
|
382
|
+
input: external.input,
|
|
383
|
+
output: external.output,
|
|
384
|
+
effect: external.effect,
|
|
385
|
+
evidence: "offline",
|
|
386
|
+
};
|
|
387
|
+
if (external.generator) {
|
|
388
|
+
generation = {
|
|
389
|
+
...external.generator,
|
|
390
|
+
requirements: external.requirements,
|
|
391
|
+
};
|
|
392
|
+
if (external.generator.code)
|
|
393
|
+
generation.code = readActivity(fence(packageRoot, relative(packageRoot, resolve(dirname(activityFile), external.generator.code))));
|
|
394
|
+
if (external.generator.runtime)
|
|
395
|
+
generation.runtime = readActivity(fence(packageRoot, relative(packageRoot, resolve(dirname(activityFile), external.generator.runtime))));
|
|
396
|
+
}
|
|
397
|
+
implementation = {
|
|
398
|
+
dependencies: external.dependencies,
|
|
399
|
+
templates: external.templates.map((template) => {
|
|
400
|
+
const absolute = resolve(dirname(activityFile), template.source);
|
|
401
|
+
const rel = relative(packageRoot, absolute);
|
|
402
|
+
const file = fence(packageRoot, rel);
|
|
403
|
+
return {
|
|
404
|
+
content: readActivity(file),
|
|
405
|
+
...(template.source.endsWith(".j2")
|
|
406
|
+
? { engine: "jinja" }
|
|
407
|
+
: {}),
|
|
408
|
+
output: template.output,
|
|
409
|
+
format: template.format,
|
|
410
|
+
};
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
else
|
|
415
|
+
throw new Problem("ACTIVITY", `No activity definition for ${step.uses}`);
|
|
416
|
+
check(generation &&
|
|
417
|
+
["native-pipeline", "native-notebook", "native-project"].includes(generation.kind), "CAPABILITY", "Activities require a provider-native definition");
|
|
418
|
+
if (generation?.kind === "native-project")
|
|
419
|
+
check(generation.artifactKind &&
|
|
420
|
+
chosen.runtime.capabilities?.artifactKinds.includes(generation.artifactKind), "CAPABILITY", "native-project requires a provider-declared artifactKind");
|
|
421
|
+
let settings = merge(merge(merge(merge(preset?.with ?? {}, project.defaults.with), flow.defaults.with), step.with), table?.steps[step.id]?.with ?? {});
|
|
422
|
+
auditSecrets(settings, step.id);
|
|
423
|
+
settings = reader.values(settings, {
|
|
424
|
+
...context,
|
|
425
|
+
table: { id: tableId },
|
|
426
|
+
flow: { id: flow.id },
|
|
427
|
+
step: { id: step.id },
|
|
428
|
+
}, { draft: true, runtime: true });
|
|
429
|
+
if (external) {
|
|
430
|
+
const optionsAjv = new Ajv({
|
|
431
|
+
allErrors: true,
|
|
432
|
+
strict: false,
|
|
433
|
+
useDefaults: true,
|
|
434
|
+
});
|
|
435
|
+
addFormats(optionsAjv);
|
|
436
|
+
const validate = optionsAjv.compile(external.optionsSchema);
|
|
437
|
+
check(validate(settings), "ACTIVITY_INPUT", new Ajv().errorsText(validate.errors));
|
|
438
|
+
}
|
|
439
|
+
const explicit = refs(settings), needs = [];
|
|
440
|
+
for (const ref of explicit) {
|
|
441
|
+
if (ref.startsWith("requires.")) {
|
|
442
|
+
check(Object.hasOwn(flow.requires, ref.slice(9)), "REFERENCE", `Unknown input ${ref}`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const m = /^steps\.([A-Za-z_][\w-]*)\.outputs\.([A-Za-z_][\w-]*)$/.exec(ref);
|
|
446
|
+
check(m && previousById.has(m[1]), "REFERENCE", `Unavailable output ${ref}`);
|
|
447
|
+
check(["result", "table", "files", "receipt"].includes(m[2]), "REFERENCE", `Unknown primary output ${ref}`);
|
|
448
|
+
needs.push(previousById.get(m[1]).id);
|
|
449
|
+
}
|
|
450
|
+
if (!explicit.length && previous) {
|
|
451
|
+
check(descriptor.input === previous.interface.output ||
|
|
452
|
+
(descriptor.input === "relation" &&
|
|
453
|
+
previous.interface.output === "committed"), "INTERFACE", `${previous.uses} produces ${previous.interface.output}; ${step.uses} requires ${descriptor.input}`);
|
|
454
|
+
}
|
|
455
|
+
if (previous)
|
|
456
|
+
needs.push(previous.id);
|
|
457
|
+
if (!previous && flow.kind === "ingestion")
|
|
458
|
+
check(descriptor.input === "source" ||
|
|
459
|
+
(descriptor.input === "files" &&
|
|
460
|
+
typeof (table?.source?.from ?? flow.defaults.source?.from) ===
|
|
461
|
+
"string" &&
|
|
462
|
+
!!flow.requires[String(table?.source?.from ?? flow.defaults.source?.from).replace(/^requires\./, "")]?.handover), "INTERFACE", `${step.uses} cannot start an ingestion flow without a source`);
|
|
463
|
+
const node = {
|
|
464
|
+
id: [flow.id, tableId, step.id].filter(Boolean).join("/"),
|
|
465
|
+
flow: flow.id,
|
|
466
|
+
...(tableId ? { table: tableId } : {}),
|
|
467
|
+
step: step.id,
|
|
468
|
+
...(flow.export ? { exportGroup: flow.export } : {}),
|
|
469
|
+
uses: descriptor.id + "@v1",
|
|
470
|
+
platform: chosen.platform,
|
|
471
|
+
runtime: chosen.runtime,
|
|
472
|
+
binding: chosen.binding,
|
|
473
|
+
...(implementation ? { implementation } : {}),
|
|
474
|
+
...(generation ? { generation } : {}),
|
|
475
|
+
with: settings,
|
|
476
|
+
needs: [...new Set(needs)],
|
|
477
|
+
...(table
|
|
478
|
+
? {
|
|
479
|
+
source: reader.values(merge(flow.defaults.source, table.source), {
|
|
480
|
+
...context,
|
|
481
|
+
flow: { id: flow.id },
|
|
482
|
+
table: { id: tableId },
|
|
483
|
+
step: { id: step.id },
|
|
484
|
+
}, { draft: true, runtime: true }),
|
|
485
|
+
contract: settings.outputContract ??
|
|
486
|
+
previous?.contract ??
|
|
487
|
+
table.contract,
|
|
488
|
+
}
|
|
489
|
+
: {}),
|
|
490
|
+
output: [project.id, flow.id, tableId, step.id]
|
|
491
|
+
.filter(Boolean)
|
|
492
|
+
.join("."),
|
|
493
|
+
interface: {
|
|
494
|
+
input: descriptor.input,
|
|
495
|
+
output: descriptor.output,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
for (const field of ["sqlFile", "pythonFile", "moduleFile"])
|
|
499
|
+
if (typeof settings[field] === "string")
|
|
500
|
+
reader.text(settings[field]);
|
|
501
|
+
flowNodes.push(node);
|
|
502
|
+
nodes.push(node);
|
|
503
|
+
previousById.set(step.id, node);
|
|
504
|
+
previous = node;
|
|
505
|
+
if (chosen.runtime.lifecycleCode) {
|
|
506
|
+
const modeled = executeProvider(chosen.runtime.lifecycleCode, {
|
|
507
|
+
apiVersion: "ingestron.provider-model/v1",
|
|
508
|
+
node: {
|
|
509
|
+
...node,
|
|
510
|
+
runtime: {
|
|
511
|
+
implementation: node.runtime.implementation,
|
|
512
|
+
version: node.runtime.version,
|
|
513
|
+
options: node.runtime.options,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
bindings: profile.bindings,
|
|
517
|
+
outputs: Object.fromEntries(Object.entries(flow.publishes).filter(([, output]) => output.from.startsWith(`steps.${node.step}.outputs.`))),
|
|
518
|
+
inputs: Object.fromEntries(Object.entries(flow.requires).map(([alias, requirement]) => {
|
|
519
|
+
const dataset = datasets[requirement.dataset];
|
|
520
|
+
check(dataset, "DATASET", `Missing upstream dataset ${requirement.dataset}`);
|
|
521
|
+
return [
|
|
522
|
+
alias,
|
|
523
|
+
{
|
|
524
|
+
...dataset,
|
|
525
|
+
dataset: requirement.dataset,
|
|
526
|
+
handover: requirement.handover,
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
})),
|
|
530
|
+
tableName: reader.values(project.defaults.naming.table ?? "{{table.id}}", {
|
|
531
|
+
...context,
|
|
532
|
+
table: { id: tableId },
|
|
533
|
+
flow: { id: flow.id },
|
|
534
|
+
step: { id: step.id },
|
|
535
|
+
}),
|
|
536
|
+
project: project.id,
|
|
537
|
+
environment,
|
|
538
|
+
draft: !!options.draft,
|
|
539
|
+
}, false, "model");
|
|
540
|
+
check(isMap(modeled.with) &&
|
|
541
|
+
isMap(modeled.datasets) &&
|
|
542
|
+
Array.isArray(modeled.targets), "PROVIDER", "Invalid provider model response");
|
|
543
|
+
node.with = modeled.with;
|
|
544
|
+
if (modeled.source !== undefined) {
|
|
545
|
+
check(isMap(modeled.source), "PROVIDER", "Invalid modelled source");
|
|
546
|
+
auditSecrets(modeled.source, node.id);
|
|
547
|
+
node.source = modeled.source;
|
|
548
|
+
}
|
|
549
|
+
if (modeled.resources !== undefined) {
|
|
550
|
+
check(Array.isArray(modeled.resources) &&
|
|
551
|
+
modeled.resources.length <= 100 &&
|
|
552
|
+
modeled.resources.every((r) => isMap(r) &&
|
|
553
|
+
Object.keys(r).every((k) => ["scope", "kind", "name"].includes(k)) &&
|
|
554
|
+
[r.scope, r.kind, r.name].every((v) => typeof v === "string" &&
|
|
555
|
+
v.length > 0 &&
|
|
556
|
+
v.length <= 1024)), "PROVIDER", "Invalid native resource claims");
|
|
557
|
+
node.resources = modeled.resources;
|
|
558
|
+
}
|
|
559
|
+
for (const target of modeled.targets) {
|
|
560
|
+
check(typeof target === "string" && target.length <= 1024, "PROVIDER", "Invalid provider target identity");
|
|
561
|
+
const owner = node.binding + ":" + target;
|
|
562
|
+
check(!targets.has(owner), "OWNER", `Target collision ${owner}: ${targets.get(owner)} and ${node.id}`);
|
|
563
|
+
targets.set(owner, node.id);
|
|
564
|
+
}
|
|
565
|
+
for (const [suffix, dataset] of Object.entries(modeled.datasets)) {
|
|
566
|
+
check(/^[A-Za-z_][\w-]*$/.test(suffix) && isMap(dataset), "PROVIDER", "Invalid published dataset");
|
|
567
|
+
contractColumns(dataset.contract);
|
|
568
|
+
const id = [project.id, flow.id, tableId, suffix]
|
|
569
|
+
.filter(Boolean)
|
|
570
|
+
.join(".");
|
|
571
|
+
check(!datasets[id], "OWNER", `Duplicate dataset ${id}`);
|
|
572
|
+
datasets[id] = {
|
|
573
|
+
...dataset,
|
|
574
|
+
contract: dataset.contract,
|
|
575
|
+
producer: node.id,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (generation?.requirements?.sourceKinds && node.source)
|
|
580
|
+
check(generation.requirements.sourceKinds.includes(node.source.kind ?? profile.bindings[node.source.binding]?.kind), "CAPABILITY", `Activity ${descriptor.id} supports sources: ${generation.requirements.sourceKinds.join(", ")}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const [name, publish] of Object.entries(flow.publishes)) {
|
|
584
|
+
contractColumns(publish.contract);
|
|
585
|
+
const match = /^steps\.([A-Za-z_][\w-]*)\.outputs\.([A-Za-z_][\w-]*)$/.exec(publish.from);
|
|
586
|
+
check(match, "REFERENCE", `Invalid published output ${publish.from}`);
|
|
587
|
+
const producer = flowNodes.find((n) => n.step === match[1]);
|
|
588
|
+
check(producer && ["result", "table"].includes(match[2]), "REFERENCE", `Unknown published port ${publish.from}`);
|
|
589
|
+
datasets[`${project.id}.${flow.id}.${name}`] = {
|
|
590
|
+
producer: producer.id,
|
|
591
|
+
contract: publish.contract,
|
|
592
|
+
location: publish.location ??
|
|
593
|
+
datasets[`${project.id}.${flow.id}.${name}`]?.location,
|
|
594
|
+
};
|
|
595
|
+
producer.contract ??= publish.contract;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (const flow of flows) {
|
|
599
|
+
if (flow.kind === "transformation") {
|
|
600
|
+
const own = byFlow.get(flow.id) ?? [];
|
|
601
|
+
let current;
|
|
602
|
+
for (const node of own) {
|
|
603
|
+
current = node.with.outputContract ?? node.contract ?? current;
|
|
604
|
+
if (!current) {
|
|
605
|
+
const downstream = own
|
|
606
|
+
.slice(own.indexOf(node) + 1)
|
|
607
|
+
.find((n) => n.contract);
|
|
608
|
+
current = downstream?.contract;
|
|
609
|
+
}
|
|
610
|
+
node.contract ??= current;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const inputNodes = Object.values(flow.requires).map((input) => {
|
|
614
|
+
const found = datasets[input.dataset];
|
|
615
|
+
check(found, "DATASET", `${flow.id}: unknown dataset ${input.dataset}`);
|
|
616
|
+
return found.producer;
|
|
617
|
+
});
|
|
618
|
+
const localNodes = byFlow.get(flow.id) ?? [];
|
|
619
|
+
const localIds = new Set(localNodes.map((node) => node.id));
|
|
620
|
+
for (const node of localNodes) {
|
|
621
|
+
const required = refs(node.with)
|
|
622
|
+
.filter((ref) => ref.startsWith("requires."))
|
|
623
|
+
.map((ref) => datasets[flow.requires[ref.slice(9)].dataset].producer);
|
|
624
|
+
const first = !node.needs.some((id) => localIds.has(id));
|
|
625
|
+
node.needs = [
|
|
626
|
+
...new Set([...required, ...node.needs, ...(first ? inputNodes : [])]),
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (const node of nodes) {
|
|
631
|
+
if (node.contract)
|
|
632
|
+
contractColumns(node.contract);
|
|
633
|
+
}
|
|
634
|
+
const byId = new Map(nodes.map((n) => [n.id, n])), visited = new Set(), active = new Set();
|
|
635
|
+
const visit = (id) => {
|
|
636
|
+
check(!active.has(id), "CYCLE", `Flow dependency cycle at ${id}`);
|
|
637
|
+
if (visited.has(id))
|
|
638
|
+
return;
|
|
639
|
+
active.add(id);
|
|
640
|
+
const node = byId.get(id);
|
|
641
|
+
check(node, "REFERENCE", `Missing dependency ${id}`);
|
|
642
|
+
node.needs.forEach(visit);
|
|
643
|
+
active.delete(id);
|
|
644
|
+
visited.add(id);
|
|
645
|
+
};
|
|
646
|
+
for (const node of nodes)
|
|
647
|
+
for (const parentId of node.needs) {
|
|
648
|
+
const parent = byId.get(parentId);
|
|
649
|
+
const consumerFlow = flows.find((f) => f.id === node.flow);
|
|
650
|
+
const requirements = Object.values(consumerFlow.requires).filter((r) => datasets[r.dataset]?.producer === parent.id);
|
|
651
|
+
for (const input of requirements)
|
|
652
|
+
if (input.handover) {
|
|
653
|
+
check(parent.runtime?.capabilities?.handovers.includes(input.handover) &&
|
|
654
|
+
node.runtime?.capabilities?.handovers.includes(input.handover), "CAPABILITY", `Both providers must support ${input.handover} handovers`);
|
|
655
|
+
check(input.handover === "files"
|
|
656
|
+
? parent.interface.output === "files"
|
|
657
|
+
: ["relation", "committed"].includes(parent.interface.output), "INTERFACE", "Handover kind differs from producer output");
|
|
658
|
+
const location = datasets[input.dataset].location;
|
|
659
|
+
check(location?.kind === input.handover &&
|
|
660
|
+
typeof location.binding === "string" &&
|
|
661
|
+
profile.bindings[location.binding] &&
|
|
662
|
+
typeof location.name === "string" &&
|
|
663
|
+
location.name.length > 0, "HANDOVER", `Dataset ${input.dataset} requires a ${input.handover} location with an existing binding and name`);
|
|
664
|
+
auditSecrets(location, input.dataset);
|
|
665
|
+
if (!options.draft)
|
|
666
|
+
noPending(location, input.dataset);
|
|
667
|
+
}
|
|
668
|
+
if (parent.platform !== node.platform)
|
|
669
|
+
check(parent.interface.output === "files" ||
|
|
670
|
+
(requirements.length > 0 &&
|
|
671
|
+
requirements.every((input) => input.handover === "relation")), "CAPABILITY", `Cross-platform edge ${parent.id} → ${node.id} requires an explicit file export/import boundary or declared relation handover`);
|
|
672
|
+
if (parent.flow !== node.flow)
|
|
673
|
+
check(node.interface.input === parent.interface.output ||
|
|
674
|
+
(node.interface.input === "relation" &&
|
|
675
|
+
parent.interface.output === "committed"), "INTERFACE", `${node.id}: required dataset has incompatible ${parent.interface.output} interface`);
|
|
676
|
+
}
|
|
677
|
+
nodes.forEach((n) => visit(n.id));
|
|
678
|
+
check(nodes.length <= 10000, "LIMIT", "Plan exceeds 10,000 expanded nodes");
|
|
679
|
+
let selected = nodes;
|
|
680
|
+
if (options.flow && !options.delivery) {
|
|
681
|
+
const matches = nodes.filter((n) => n.flow === options.flow &&
|
|
682
|
+
(!options.table || n.table === options.table) &&
|
|
683
|
+
(!options.step || n.step === options.step));
|
|
684
|
+
check(matches.length, "SELECT", "Selection contains no activity nodes");
|
|
685
|
+
const closure = new Set();
|
|
686
|
+
const add = (id) => {
|
|
687
|
+
if (closure.has(id))
|
|
688
|
+
return;
|
|
689
|
+
closure.add(id);
|
|
690
|
+
byId.get(id).needs.forEach(add);
|
|
691
|
+
};
|
|
692
|
+
matches.forEach((n) => add(n.id));
|
|
693
|
+
selected = nodes.filter((n) => closure.has(n.id));
|
|
694
|
+
}
|
|
695
|
+
const selectedBindings = new Set();
|
|
696
|
+
for (const node of selected) {
|
|
697
|
+
selectedBindings.add(node.binding);
|
|
698
|
+
if (node.source?.binding)
|
|
699
|
+
selectedBindings.add(node.source.binding);
|
|
700
|
+
const flow = flows.find((f) => f.id === node.flow);
|
|
701
|
+
for (const input of Object.values(flow.requires))
|
|
702
|
+
if (input.handover)
|
|
703
|
+
selectedBindings.add(datasets[input.dataset].location.binding);
|
|
704
|
+
}
|
|
705
|
+
for (const dataset of Object.values(datasets))
|
|
706
|
+
if (selected.some((n) => n.id === dataset.producer) &&
|
|
707
|
+
["files", "relation"].includes(dataset.location?.kind) &&
|
|
708
|
+
typeof dataset.location?.binding === "string")
|
|
709
|
+
selectedBindings.add(dataset.location.binding);
|
|
710
|
+
const bindings = Object.fromEntries([...selectedBindings].map((name) => {
|
|
711
|
+
check(profile.bindings[name], "BINDING", `Missing binding ${name}`);
|
|
712
|
+
return [name, profile.bindings[name]];
|
|
713
|
+
}));
|
|
714
|
+
auditSecrets(bindings, "bindings");
|
|
715
|
+
if (!options.draft)
|
|
716
|
+
for (const [name, binding] of Object.entries(bindings))
|
|
717
|
+
noPending(binding, name);
|
|
718
|
+
if (!options.draft)
|
|
719
|
+
for (const node of selected) {
|
|
720
|
+
const { implementation: _templates, generation: _generation, ...configuration } = node;
|
|
721
|
+
const { rendererCode: _rendererSource, lifecycleCode: _lifecycle, outputSchemas: _schemas, ...runtimeOptions } = node.runtime ?? {};
|
|
722
|
+
noPending({ ...configuration, runtime: runtimeOptions }, node.id);
|
|
723
|
+
noPending(profile.bindings[node.binding], node.binding);
|
|
724
|
+
if (node.source?.binding) {
|
|
725
|
+
check(profile.bindings[node.source.binding], "BINDING", `Missing source binding ${node.source.binding}`);
|
|
726
|
+
noPending(profile.bindings[node.source.binding], node.source.binding);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const names = {
|
|
730
|
+
jobs: {},
|
|
731
|
+
nodes: {},
|
|
732
|
+
};
|
|
733
|
+
const nameOwners = new Set();
|
|
734
|
+
for (const node of selected) {
|
|
735
|
+
const ctx = {
|
|
736
|
+
...context,
|
|
737
|
+
flow: { id: node.flow },
|
|
738
|
+
table: { id: node.table ?? "flow" },
|
|
739
|
+
step: { id: node.step },
|
|
740
|
+
};
|
|
741
|
+
const kind = "asset";
|
|
742
|
+
const name = node.id.replace(/[^A-Za-z0-9_]/g, "_");
|
|
743
|
+
check(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name), "NAMING", `Invalid ${kind} name ${name}; use letters, digits and underscores, starting with a letter`);
|
|
744
|
+
const owner = node.binding + ":" + kind + ":" + name;
|
|
745
|
+
check(!nameOwners.has(owner), "NAMING", `Duplicate resource name ${name}`);
|
|
746
|
+
nameOwners.add(owner);
|
|
747
|
+
names.nodes[node.id] = name;
|
|
748
|
+
names.jobs[node.flow] = reader.values(project.defaults.naming.job ?? "{{env}}_{{project.id}}_{{flow.id}}", ctx);
|
|
749
|
+
check(/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/.test(names.jobs[node.flow]), "NAMING", "Invalid job name");
|
|
750
|
+
}
|
|
751
|
+
const activitySources = {};
|
|
752
|
+
for (const node of selected)
|
|
753
|
+
if (node.runtime?.rendererCode) {
|
|
754
|
+
const ref = digest(node.runtime.rendererCode);
|
|
755
|
+
activitySources[ref] = node.runtime.rendererCode;
|
|
756
|
+
node.runtime.rendererRef = ref;
|
|
757
|
+
delete node.runtime.rendererCode;
|
|
758
|
+
}
|
|
759
|
+
for (const node of selected)
|
|
760
|
+
if (node.runtime?.outputSchemas) {
|
|
761
|
+
const ref = digest(canonical(node.runtime.outputSchemas));
|
|
762
|
+
activitySources[ref] = canonical(node.runtime.outputSchemas);
|
|
763
|
+
node.runtime.outputSchemasRef = ref;
|
|
764
|
+
delete node.runtime.outputSchemas;
|
|
765
|
+
}
|
|
766
|
+
for (const node of selected)
|
|
767
|
+
if (node.runtime?.lifecycleCode) {
|
|
768
|
+
const ref = digest(node.runtime.lifecycleCode);
|
|
769
|
+
activitySources[ref] = node.runtime.lifecycleCode;
|
|
770
|
+
node.runtime.lifecycleRef = ref;
|
|
771
|
+
delete node.runtime.lifecycleCode;
|
|
772
|
+
}
|
|
773
|
+
for (const node of selected)
|
|
774
|
+
if (node.generation?.runtime) {
|
|
775
|
+
const ref = digest(node.generation.runtime);
|
|
776
|
+
activitySources[ref] = node.generation.runtime;
|
|
777
|
+
node.generation.runtimeRef = ref;
|
|
778
|
+
delete node.generation.runtime;
|
|
779
|
+
}
|
|
780
|
+
for (const node of selected)
|
|
781
|
+
if (node.generation?.code) {
|
|
782
|
+
const ref = digest(node.generation.code);
|
|
783
|
+
activitySources[ref] = node.generation.code;
|
|
784
|
+
node.generation.codeRef = ref;
|
|
785
|
+
delete node.generation.code;
|
|
786
|
+
}
|
|
787
|
+
const body = {
|
|
788
|
+
activitySources,
|
|
789
|
+
apiVersion: "ingestron.plan/v1",
|
|
790
|
+
compilerVersion: version,
|
|
791
|
+
compilerDigest: compilerFingerprint(),
|
|
792
|
+
names,
|
|
793
|
+
inputDigests: reader.inputs,
|
|
794
|
+
selection: {
|
|
795
|
+
...(options.flow ? { flow: options.flow } : {}),
|
|
796
|
+
...(options.table ? { table: options.table } : {}),
|
|
797
|
+
...(options.step ? { step: options.step } : {}),
|
|
798
|
+
...(options.delivery ? { delivery: true } : {}),
|
|
799
|
+
...(options.projectBuild ? { projectBuild: true } : {}),
|
|
800
|
+
},
|
|
801
|
+
project: project.id,
|
|
802
|
+
environment,
|
|
803
|
+
bindings,
|
|
804
|
+
nodes: selected,
|
|
805
|
+
flows: flows.filter((f) => selected.some((n) => n.flow === f.id)),
|
|
806
|
+
files: reader.files,
|
|
807
|
+
datasets: Object.fromEntries(Object.entries(datasets).filter(([, d]) => selected.some((n) => n.id === d.producer))),
|
|
808
|
+
recovery: recovery.filter((r) => selected.some((n) => n.flow === r.flow)),
|
|
809
|
+
pending: options.draft ? reader.pending : [],
|
|
810
|
+
};
|
|
811
|
+
const plan = { ...body, digest: digest(canonical(body)) };
|
|
812
|
+
for (const flow of plan.flows) {
|
|
813
|
+
if (Object.values(flow.requires).some((input) => input.select.mode === "same-run"))
|
|
814
|
+
check(plan.nodes
|
|
815
|
+
.filter((n) => n.flow === flow.id)
|
|
816
|
+
.every((n) => n.runtime?.implementation === "native"), "CAPABILITY", "same-run dataset selection requires native provider generation");
|
|
817
|
+
}
|
|
818
|
+
if (!options.draft)
|
|
819
|
+
validateProviderPlan(plan);
|
|
820
|
+
return plan;
|
|
821
|
+
}
|