@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,233 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve, isAbsolute } from "node:path";
|
|
3
|
+
import { parseDocument, LineCounter } from "yaml";
|
|
4
|
+
import { check, Problem, digest, isMap } from "./errors.js";
|
|
5
|
+
export class Configuration {
|
|
6
|
+
environmentVariables;
|
|
7
|
+
root;
|
|
8
|
+
files = {};
|
|
9
|
+
locations = new WeakMap();
|
|
10
|
+
inputs = {};
|
|
11
|
+
pending = [];
|
|
12
|
+
bytes = 0;
|
|
13
|
+
nodes = 0;
|
|
14
|
+
documents = new Map();
|
|
15
|
+
constructor(root, environmentVariables = process.env) {
|
|
16
|
+
this.environmentVariables = environmentVariables;
|
|
17
|
+
this.root = realpathSync(root);
|
|
18
|
+
}
|
|
19
|
+
path(name, from = resolve(this.root, "project.yaml")) {
|
|
20
|
+
let file;
|
|
21
|
+
try {
|
|
22
|
+
file = realpathSync(resolve(dirname(from), name));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Problem("FILE", `Missing file: ${name}`, from);
|
|
26
|
+
}
|
|
27
|
+
const rel = relative(this.root, file);
|
|
28
|
+
check(rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel), "PATH", "Reference leaves the project", from);
|
|
29
|
+
check(statSync(file).isFile(), "FILE", "Expected a regular file", file);
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
text(name, from) {
|
|
33
|
+
const file = this.path(name, from), size = statSync(file).size;
|
|
34
|
+
check(size <= 10 * 1024 * 1024, "LIMIT", "A source file must be at most 10 MiB", file);
|
|
35
|
+
if (!Object.hasOwn(this.files, relative(this.root, file)))
|
|
36
|
+
this.bytes += size;
|
|
37
|
+
check(this.bytes <= 50 * 1024 * 1024, "LIMIT", "Project inputs exceed 50 MiB", file);
|
|
38
|
+
const value = readFileSync(file, "utf8");
|
|
39
|
+
this.files[relative(this.root, file)] = digest(value);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
document(file) {
|
|
43
|
+
if (!this.documents.has(file)) {
|
|
44
|
+
const lines = new LineCounter(), doc = parseDocument(this.text(file), {
|
|
45
|
+
uniqueKeys: true,
|
|
46
|
+
lineCounter: lines,
|
|
47
|
+
strict: true,
|
|
48
|
+
});
|
|
49
|
+
check(!doc.errors.length && !doc.warnings.length, "YAML", `${doc.errors[0]?.message ?? doc.warnings[0]?.message}`, file);
|
|
50
|
+
let value;
|
|
51
|
+
try {
|
|
52
|
+
value = doc.toJS({ maxAliasCount: 50 });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Problem("LIMIT", "YAML aliases exceed the limit", file);
|
|
56
|
+
}
|
|
57
|
+
this.documents.set(file, { doc, lines, value });
|
|
58
|
+
}
|
|
59
|
+
return this.documents.get(file);
|
|
60
|
+
}
|
|
61
|
+
load(name = "project.yaml", from = resolve(this.root, "project.yaml"), stack = [], depth = 0) {
|
|
62
|
+
check(depth < 64, "LIMIT", "Includes exceed 64 levels", from);
|
|
63
|
+
check(!/^[a-z]+:/i.test(name), "REFERENCE", "Only local configuration includes are allowed", from);
|
|
64
|
+
const index = name.indexOf("#"), path = index < 0 ? name : name.slice(0, index);
|
|
65
|
+
let pointer = "";
|
|
66
|
+
try {
|
|
67
|
+
pointer = index < 0 ? "" : decodeURIComponent(name.slice(index + 1));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Problem("POINTER", "Invalid fragment encoding", from);
|
|
71
|
+
}
|
|
72
|
+
check(!pointer || pointer.startsWith("/"), "POINTER", "Expected a JSON Pointer fragment", from);
|
|
73
|
+
const file = path ? this.path(path, from) : from, key = file + "#" + pointer;
|
|
74
|
+
check(!stack.includes(key), "CYCLE", `Reference cycle: ${[...stack, key].join(" → ")}`, file);
|
|
75
|
+
let value = this.document(file).value;
|
|
76
|
+
for (const token of pointer ? pointer.slice(1).split("/") : []) {
|
|
77
|
+
check(!/~(?![01])/.test(token), "POINTER", "Invalid pointer escape", file);
|
|
78
|
+
const part = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
79
|
+
check(!["__proto__", "constructor", "prototype"].includes(part) &&
|
|
80
|
+
value != null &&
|
|
81
|
+
typeof value === "object" &&
|
|
82
|
+
Object.hasOwn(value, part) &&
|
|
83
|
+
(!Array.isArray(value) || /^(0|[1-9]\d*)$/.test(part)), "POINTER", `Missing pointer ${pointer}`, file);
|
|
84
|
+
value = value[part];
|
|
85
|
+
}
|
|
86
|
+
return this.walk(value, file, pointer, [...stack, key], depth + 1);
|
|
87
|
+
}
|
|
88
|
+
walk(value, file, pointer, stack, depth) {
|
|
89
|
+
check(++this.nodes <= 200000 && depth <= 64, "LIMIT", "Configuration exceeds structural limits", file);
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return value.map((v, i) => this.walk(v, file, `${pointer}/${i}`, stack, depth + 1));
|
|
92
|
+
if (!isMap(value))
|
|
93
|
+
return value;
|
|
94
|
+
for (const key of Object.keys(value))
|
|
95
|
+
check(!["__proto__", "constructor", "prototype"].includes(key), "KEY", "Unsafe mapping key", file);
|
|
96
|
+
if (Object.hasOwn(value, "$resolve")) {
|
|
97
|
+
check(Object.keys(value).length === 1 && typeof value.$resolve === "string", "REFERENCE", "$resolve must be the only key", file);
|
|
98
|
+
return this.load(value.$resolve, file, stack, depth);
|
|
99
|
+
}
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [key, child] of Object.entries(value)) {
|
|
102
|
+
if (["sqlFile", "pythonFile", "moduleFile"].includes(key) &&
|
|
103
|
+
typeof child === "string") {
|
|
104
|
+
const target = this.path(child, file);
|
|
105
|
+
this.text(target);
|
|
106
|
+
out[key] = relative(this.root, target);
|
|
107
|
+
}
|
|
108
|
+
else if (key === "projectDirectory" && typeof child === "string") {
|
|
109
|
+
const target = realpathSync(resolve(dirname(file), child)), rel = relative(this.root, target);
|
|
110
|
+
check(rel !== ".." &&
|
|
111
|
+
!rel.startsWith("../") &&
|
|
112
|
+
!isAbsolute(rel) &&
|
|
113
|
+
statSync(target).isDirectory(), "PATH", "Project directory must stay inside the project", file);
|
|
114
|
+
out[key] = rel;
|
|
115
|
+
}
|
|
116
|
+
else if (key === "uses" &&
|
|
117
|
+
typeof child === "string" &&
|
|
118
|
+
child.startsWith(".")) {
|
|
119
|
+
const target = this.path(child, file);
|
|
120
|
+
this.text(target);
|
|
121
|
+
out[key] = "./" + relative(this.root, target);
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
out[key] = this.walk(child, file, pointer + "/" + key.replaceAll("~", "~0").replaceAll("/", "~1"), stack, depth + 1);
|
|
125
|
+
}
|
|
126
|
+
const entry = this.document(file), keys = pointer
|
|
127
|
+
? pointer
|
|
128
|
+
.slice(1)
|
|
129
|
+
.split("/")
|
|
130
|
+
.map((k) => k.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
131
|
+
: [];
|
|
132
|
+
const node = keys.length ? entry.doc.getIn(keys, true) : entry.doc.contents;
|
|
133
|
+
this.locations.set(out, {
|
|
134
|
+
file: relative(this.root, file),
|
|
135
|
+
pointer,
|
|
136
|
+
...entry.lines.linePos(node?.range?.[0] ?? 0),
|
|
137
|
+
column: entry.lines.linePos(node?.range?.[0] ?? 0).col,
|
|
138
|
+
});
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
parse(schema, value) {
|
|
142
|
+
const result = schema.safeParse(value);
|
|
143
|
+
if (result.success)
|
|
144
|
+
return result.data;
|
|
145
|
+
const issue = result.error.issues[0];
|
|
146
|
+
let owner = value;
|
|
147
|
+
for (const key of issue.path.slice(0, -1))
|
|
148
|
+
if (owner && typeof owner === "object")
|
|
149
|
+
owner = owner[key];
|
|
150
|
+
const where = this.locations.get(owner) ??
|
|
151
|
+
(isMap(value) ? this.locations.get(value) : undefined);
|
|
152
|
+
throw new Problem("SCHEMA", `${issue.path.join(".")}: ${issue.message}`, where?.file, where?.pointer, "Use config_explain to inspect the originating value");
|
|
153
|
+
}
|
|
154
|
+
values(value, context, options = {}) {
|
|
155
|
+
const resolving = new Set();
|
|
156
|
+
const lookup = (name) => {
|
|
157
|
+
check(!resolving.has(name), "CYCLE", `Value cycle at ${name}`);
|
|
158
|
+
const parts = name.split(".");
|
|
159
|
+
let current = context;
|
|
160
|
+
for (const key of parts) {
|
|
161
|
+
check(!["__proto__", "constructor", "prototype"].includes(key), "KEY", "Unsafe value reference");
|
|
162
|
+
current =
|
|
163
|
+
isMap(current) && Object.hasOwn(current, key)
|
|
164
|
+
? current[key]
|
|
165
|
+
: undefined;
|
|
166
|
+
}
|
|
167
|
+
check(current !== undefined, "VALUE", `Unknown value {{${name}}}`);
|
|
168
|
+
resolving.add(name);
|
|
169
|
+
const result = visit(current);
|
|
170
|
+
resolving.delete(name);
|
|
171
|
+
return result;
|
|
172
|
+
};
|
|
173
|
+
const visit = (v) => {
|
|
174
|
+
if (Array.isArray(v))
|
|
175
|
+
return v.map(visit);
|
|
176
|
+
if (isMap(v)) {
|
|
177
|
+
if (Object.hasOwn(v, "$env")) {
|
|
178
|
+
check(Object.keys(v).length === 1 &&
|
|
179
|
+
typeof v.$env === "string" &&
|
|
180
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.$env), "ENV", "$env requires one environment variable name");
|
|
181
|
+
const supplied = this.environmentVariables[v.$env];
|
|
182
|
+
if (supplied === undefined || supplied === "") {
|
|
183
|
+
const location = this.locations.get(v);
|
|
184
|
+
this.pending.push({
|
|
185
|
+
name: v.$env,
|
|
186
|
+
file: location?.file,
|
|
187
|
+
pointer: location?.pointer,
|
|
188
|
+
});
|
|
189
|
+
check(options.draft, "INPUT", `Missing environment variable ${v.$env}`, location?.file);
|
|
190
|
+
return { $env: v.$env };
|
|
191
|
+
}
|
|
192
|
+
this.inputs[v.$env] = digest(supplied);
|
|
193
|
+
return supplied;
|
|
194
|
+
}
|
|
195
|
+
if (Object.hasOwn(v, "$secret")) {
|
|
196
|
+
check(Object.keys(v).length === 1 && isMap(v.$secret), "SECRET", "$secret must describe a runtime lookup");
|
|
197
|
+
return structuredClone(v);
|
|
198
|
+
}
|
|
199
|
+
return Object.fromEntries(Object.entries(v).map(([k, child]) => [
|
|
200
|
+
k,
|
|
201
|
+
["sql", "query", "python", "script"].includes(k)
|
|
202
|
+
? child
|
|
203
|
+
: visit(child),
|
|
204
|
+
]));
|
|
205
|
+
}
|
|
206
|
+
if (typeof v !== "string")
|
|
207
|
+
return v;
|
|
208
|
+
const all = [...v.matchAll(/{{\s*([A-Za-z_][\w.]*)\s*}}/g)];
|
|
209
|
+
if (!all.length)
|
|
210
|
+
return v;
|
|
211
|
+
const tokenValue = (name) => {
|
|
212
|
+
if (name.startsWith("run.") && options.runtime) {
|
|
213
|
+
check(["run.id", "run.date"].includes(name), "VALUE", `Unknown runtime placeholder ${name}; use run.id or run.date`);
|
|
214
|
+
return `{{${name}}}`;
|
|
215
|
+
}
|
|
216
|
+
if ((name.startsWith("table.") || name.startsWith("step.")) &&
|
|
217
|
+
options.table)
|
|
218
|
+
return `{{${name}}}`;
|
|
219
|
+
return lookup(name);
|
|
220
|
+
};
|
|
221
|
+
if (all.length === 1 && all[0][0] === v)
|
|
222
|
+
return tokenValue(all[0][1]);
|
|
223
|
+
return v.replace(/{{\s*([A-Za-z_][\w.]*)\s*}}/g, (_, name) => {
|
|
224
|
+
const resolved = tokenValue(name);
|
|
225
|
+
if (options.draft && isMap(resolved) && resolved.$env)
|
|
226
|
+
return `{{${name}}}`;
|
|
227
|
+
check(["string", "number", "boolean"].includes(typeof resolved), "VALUE", `Cannot embed non-scalar ${name} in text`);
|
|
228
|
+
return String(resolved);
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
return visit(value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function prepareConnection(root: string, environment: string, flowId: string, validateOnly?: boolean, reviewFile?: string, configureExecution?: (execution: Record<string, any>) => Record<string, any>): {
|
|
2
|
+
project: string;
|
|
3
|
+
environment: string;
|
|
4
|
+
configuration: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
version: string;
|
|
7
|
+
commit: string;
|
|
8
|
+
apiVersion: string;
|
|
9
|
+
command: string;
|
|
10
|
+
execution: string;
|
|
11
|
+
inputDigest: string;
|
|
12
|
+
reportLock?: Record<string, unknown> | undefined;
|
|
13
|
+
result: import("../plugins/provider-renderer.js").ProviderAssets;
|
|
14
|
+
} | {
|
|
15
|
+
valid: boolean;
|
|
16
|
+
specificationSha256: string;
|
|
17
|
+
connection: string;
|
|
18
|
+
flow: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fence } from "./packages.js";
|
|
3
|
+
import { connectorPackageSchema } from "./connector-package.js";
|
|
4
|
+
import { contractColumns } from "./contracts.js";
|
|
5
|
+
/** Project-owned configuration; schemas and runtime assets remain plugin-owned. */
|
|
6
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
7
|
+
import { Configuration } from "./config.js";
|
|
8
|
+
import { projectSchema, flowSchema } from "./schema.js";
|
|
9
|
+
import { canonical, check, digest, isMap } from "./errors.js";
|
|
10
|
+
import { canonicalPackageReference, resolvePackage, packageYaml, providerPackageSchema, } from "./packages.js";
|
|
11
|
+
import { checkProviderCompatibility } from "../plugins/compatibility.js";
|
|
12
|
+
import { runProviderCommand, validateSchemaShape, } from "./provider-commands.js";
|
|
13
|
+
function validate(schema, value, label) {
|
|
14
|
+
validateSchemaShape(schema);
|
|
15
|
+
const ajv = new Ajv2020({ strict: true, validateFormats: false });
|
|
16
|
+
const fn = ajv.compile(schema);
|
|
17
|
+
check(fn(value), "CONNECTION", `${label}: ${ajv.errorsText(fn.errors)}`);
|
|
18
|
+
}
|
|
19
|
+
export function prepareConnection(root, environment, flowId, validateOnly = false, reviewFile, configureExecution) {
|
|
20
|
+
// Do not interpolate $env here: connection credentials must never enter compiler memory/output.
|
|
21
|
+
const reader = new Configuration(root, {});
|
|
22
|
+
const project = reader.parse(projectSchema, reader.load());
|
|
23
|
+
const profile = project.environments[environment];
|
|
24
|
+
check(profile, "ENVIRONMENT", `Unknown environment ${environment}`);
|
|
25
|
+
check(!profile.environment || profile.environment === environment, "ENVIRONMENT", "Environment name differs from selected profile");
|
|
26
|
+
const flows = project.flows.map((f) => reader.parse(flowSchema, f));
|
|
27
|
+
check(new Set(flows.map((f) => f.id)).size === flows.length, "FLOW", "Duplicate flow IDs");
|
|
28
|
+
const flow = flows.find((f) => f.id === flowId);
|
|
29
|
+
check(flow && flow.kind === "ingestion" && !flow.steps, "FLOW", "Select an ingestion flow");
|
|
30
|
+
const ingestion = flow.ingestion;
|
|
31
|
+
check(isMap(ingestion) &&
|
|
32
|
+
Object.keys(ingestion).every((k) => ["connection", "execution", "timeoutSeconds"].includes(k)), "CONNECTION", "Connection ingestion accepts connection, execution and timeoutSeconds only");
|
|
33
|
+
check(!Object.keys(flow.requires).length && !Object.keys(flow.publishes).length, "CONNECTION", "Downstream dataset handovers require reviewed contracts");
|
|
34
|
+
const connection = project.connections[String(ingestion.connection)];
|
|
35
|
+
check(connection, "CONNECTION", "Unknown ingestion connection");
|
|
36
|
+
const configured = project.providers.configurations[flow.provider ?? project.defaults.provider ?? ""];
|
|
37
|
+
check(configured, "PROVIDER", "Select the execution provider configuration");
|
|
38
|
+
const owner = project.providers.packages[connection.package];
|
|
39
|
+
const executor = project.providers.packages[configured.package];
|
|
40
|
+
check(owner && executor, "PACKAGE", "Unknown connection/execution package");
|
|
41
|
+
const load = (pkg) => {
|
|
42
|
+
const ref = canonicalPackageReference(`${pkg.source}@${pkg.version}`);
|
|
43
|
+
const locked = resolvePackage(root, ref);
|
|
44
|
+
const manifest = providerPackageSchema.parse(packageYaml(locked.file));
|
|
45
|
+
checkProviderCompatibility(manifest);
|
|
46
|
+
return { ref, locked, manifest };
|
|
47
|
+
};
|
|
48
|
+
const sourceRef = canonicalPackageReference(`${owner.source}@${owner.version}`);
|
|
49
|
+
const sourceLock = resolvePackage(root, sourceRef);
|
|
50
|
+
const sourceRaw = packageYaml(sourceLock.file);
|
|
51
|
+
check(sourceRaw.apiVersion === "ingestron.connector/v1", "CONNECTION", "Select an independently installed source connector package");
|
|
52
|
+
const connectorPackage = connectorPackageSchema.parse(sourceRaw);
|
|
53
|
+
const source = { ref: sourceRef, locked: sourceLock };
|
|
54
|
+
const target = load(executor);
|
|
55
|
+
const connector = connectorPackage.connector;
|
|
56
|
+
check(!connection.connector || connection.connector === connector, "CONNECTION", "Connection must use the exact installed connector identity");
|
|
57
|
+
check(connectorPackage.upstream.licenceStatus === "evidenced", "LICENCE", "Connector licence evidence is unresolved");
|
|
58
|
+
const descriptor = connectorPackage.definition;
|
|
59
|
+
const runtimeContract = connectorPackage.runtime.contract;
|
|
60
|
+
const adapter = target.manifest.connectorRuntimes?.[runtimeContract];
|
|
61
|
+
check(adapter &&
|
|
62
|
+
adapter.executionPlatforms.includes(target.manifest.platform) &&
|
|
63
|
+
Object.hasOwn(connectorPackage.execution, target.manifest.platform), "CONNECTION", "Selected execution provider does not support this connector runtime");
|
|
64
|
+
const binding = connection.binding
|
|
65
|
+
? profile.bindings[connection.binding]
|
|
66
|
+
: {};
|
|
67
|
+
check(binding, "CONNECTION", "Missing connection environment binding");
|
|
68
|
+
let settings = { ...connection.settings, ...binding };
|
|
69
|
+
const rejectEnvironment = (v) => {
|
|
70
|
+
if (Array.isArray(v))
|
|
71
|
+
return v.forEach(rejectEnvironment);
|
|
72
|
+
if (!isMap(v))
|
|
73
|
+
return;
|
|
74
|
+
check(!Object.hasOwn(v, "$env"), "SECRET", "Use runtime $secret references, not compiler $env values, in connections");
|
|
75
|
+
Object.values(v).forEach(rejectEnvironment);
|
|
76
|
+
};
|
|
77
|
+
rejectEnvironment(settings);
|
|
78
|
+
const context = {
|
|
79
|
+
env: environment,
|
|
80
|
+
project: { id: project.id },
|
|
81
|
+
values: profile.values,
|
|
82
|
+
};
|
|
83
|
+
settings = reader.values(settings, context);
|
|
84
|
+
rejectEnvironment(settings);
|
|
85
|
+
validate(descriptor.settingsSchema, settings, "Connection settings");
|
|
86
|
+
check(flow.tables && Object.keys(flow.tables).length > 0, "CONNECTION", "Select at least one table with an ODCS contract");
|
|
87
|
+
const streams = new Set();
|
|
88
|
+
const tables = Object.fromEntries(Object.entries(flow.tables).map(([name, table]) => {
|
|
89
|
+
check(Object.keys(table.source).length === 1 &&
|
|
90
|
+
typeof table.source.stream === "string", "CONNECTION", `${name}: source must identify one connector stream`);
|
|
91
|
+
check(!streams.has(table.source.stream), "CONNECTION", "Duplicate source stream");
|
|
92
|
+
streams.add(table.source.stream);
|
|
93
|
+
check(!table.ingestion && !Object.keys(table.steps).length, "CONNECTION", "Connection tables do not accept ingestion/step overrides");
|
|
94
|
+
return [
|
|
95
|
+
name,
|
|
96
|
+
{
|
|
97
|
+
source: table.source,
|
|
98
|
+
contract: table.contract,
|
|
99
|
+
columns: contractColumns(table.contract),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}));
|
|
103
|
+
rejectEnvironment(ingestion.execution);
|
|
104
|
+
let execution = reader.values(ingestion.execution ?? { mode: "local" }, context);
|
|
105
|
+
rejectEnvironment(execution);
|
|
106
|
+
if (configureExecution)
|
|
107
|
+
execution = configureExecution(execution);
|
|
108
|
+
else if (target.manifest.projectAssembly) {
|
|
109
|
+
const configuration = flow.provider ?? project.defaults.provider;
|
|
110
|
+
const configured = project.providers.configurations[configuration];
|
|
111
|
+
const result = runProviderCommand(root, environment, {
|
|
112
|
+
configuration,
|
|
113
|
+
command: target.manifest.projectAssembly.command,
|
|
114
|
+
input: {
|
|
115
|
+
phase: "configure",
|
|
116
|
+
project: project.id,
|
|
117
|
+
environment,
|
|
118
|
+
configuration,
|
|
119
|
+
flow: flowId,
|
|
120
|
+
scope: { id: "full", partial: false },
|
|
121
|
+
execution,
|
|
122
|
+
binding: profile.bindings[configured.binding],
|
|
123
|
+
options: configured.options,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
check(isMap(result.result?.execution), "PROVIDER", "Invalid project execution configuration");
|
|
127
|
+
execution = result.result.execution;
|
|
128
|
+
}
|
|
129
|
+
rejectEnvironment(execution);
|
|
130
|
+
validate(adapter.executionSchema, execution, "Execution settings");
|
|
131
|
+
check(connectorPackage.execution[target.manifest.platform]?.modes.includes(execution.mode), "CONNECTION", "Connector package does not support the selected execution mode");
|
|
132
|
+
const timeoutSeconds = ingestion.timeoutSeconds ?? 600;
|
|
133
|
+
check(Number.isInteger(timeoutSeconds) &&
|
|
134
|
+
Number(timeoutSeconds) >= 1 &&
|
|
135
|
+
Number(timeoutSeconds) <= 604800, "CONNECTION", "Timeout must be 1–604800 seconds");
|
|
136
|
+
const asset = connectorPackage.runtime;
|
|
137
|
+
check(Object.hasOwn(sourceLock.entry.files, asset.path), "PACKAGE", "Runtime asset is not locked");
|
|
138
|
+
const content = readFileSync(fence(sourceLock.root, asset.path), "utf8");
|
|
139
|
+
check(digest(content) === asset.sha256, "PACKAGE", "Runtime asset digest mismatch");
|
|
140
|
+
const parsed = JSON.parse(content);
|
|
141
|
+
check(isMap(parsed) &&
|
|
142
|
+
Object.keys(parsed).length <= 100 &&
|
|
143
|
+
Object.entries(parsed).every(([name, value]) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) &&
|
|
144
|
+
typeof value === "string"), "PACKAGE", "Invalid runtime asset map");
|
|
145
|
+
const runtimeAssets = parsed;
|
|
146
|
+
const specification = {
|
|
147
|
+
runtimeContract,
|
|
148
|
+
runtimeAssetSha256: connectorPackage.runtime.sha256,
|
|
149
|
+
apiVersion: "ingestron.connection-request/v1",
|
|
150
|
+
project: project.id,
|
|
151
|
+
environment,
|
|
152
|
+
flow: flowId,
|
|
153
|
+
connection: String(ingestion.connection),
|
|
154
|
+
connector,
|
|
155
|
+
sourceId: connection.sourceId,
|
|
156
|
+
tenantId: connection.tenantId,
|
|
157
|
+
settings,
|
|
158
|
+
tables,
|
|
159
|
+
execution,
|
|
160
|
+
timeoutSeconds,
|
|
161
|
+
sourcePackage: {
|
|
162
|
+
reference: source.ref,
|
|
163
|
+
commit: source.locked.entry.commit,
|
|
164
|
+
},
|
|
165
|
+
executionPackage: {
|
|
166
|
+
reference: target.ref,
|
|
167
|
+
commit: target.locked.entry.commit,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const input = {
|
|
171
|
+
...specification,
|
|
172
|
+
specificationSha256: digest(canonical(specification)),
|
|
173
|
+
};
|
|
174
|
+
if (reviewFile) {
|
|
175
|
+
const review = JSON.parse(reader.text(reviewFile));
|
|
176
|
+
check(review.status === "approved" &&
|
|
177
|
+
review.identity?.projectSpecSha256 === input.specificationSha256, "CONNECTION", "Review does not match the current project specification");
|
|
178
|
+
check(canonical(review.contracts) ===
|
|
179
|
+
canonical(Object.fromEntries(Object.entries(flow.tables).map(([name, table]) => [
|
|
180
|
+
name,
|
|
181
|
+
table.contract,
|
|
182
|
+
]))), "CONNECTION", "Review selection differs from project");
|
|
183
|
+
check(adapter.contractsCommand, "CONNECTION", "Provider does not declare contract export");
|
|
184
|
+
return runProviderCommand(root, environment, {
|
|
185
|
+
configuration: flow.provider ?? project.defaults.provider,
|
|
186
|
+
command: adapter.contractsCommand,
|
|
187
|
+
input: { review },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const result = runProviderCommand(root, environment, {
|
|
191
|
+
configuration: flow.provider ?? project.defaults.provider,
|
|
192
|
+
command: adapter.prepareCommand,
|
|
193
|
+
input: { ...input, runtimeAssets },
|
|
194
|
+
});
|
|
195
|
+
return validateOnly
|
|
196
|
+
? {
|
|
197
|
+
valid: true,
|
|
198
|
+
specificationSha256: input.specificationSha256,
|
|
199
|
+
connection: input.connection,
|
|
200
|
+
flow: flowId,
|
|
201
|
+
}
|
|
202
|
+
: result;
|
|
203
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Source definitions are data-only; execution remains with a selected provider. */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const connectorPackageSchema: z.ZodObject<{
|
|
4
|
+
apiVersion: z.ZodLiteral<"ingestron.connector/v1">;
|
|
5
|
+
id: z.ZodString;
|
|
6
|
+
version: z.ZodString;
|
|
7
|
+
description: z.ZodString;
|
|
8
|
+
connector: z.ZodString;
|
|
9
|
+
documentation: z.ZodString;
|
|
10
|
+
upstream: z.ZodObject<{
|
|
11
|
+
ecosystem: z.ZodString;
|
|
12
|
+
variant: z.ZodString;
|
|
13
|
+
package: z.ZodString;
|
|
14
|
+
version: z.ZodString;
|
|
15
|
+
repository: z.ZodString;
|
|
16
|
+
licence: z.ZodString;
|
|
17
|
+
licenceFile: z.ZodString;
|
|
18
|
+
licenceStatus: z.ZodEnum<{
|
|
19
|
+
conflict: "conflict";
|
|
20
|
+
evidenced: "evidenced";
|
|
21
|
+
unknown: "unknown";
|
|
22
|
+
}>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
runtime: z.ZodObject<{
|
|
25
|
+
contract: z.ZodString;
|
|
26
|
+
path: z.ZodString;
|
|
27
|
+
sha256: z.ZodString;
|
|
28
|
+
}, z.core.$strict>;
|
|
29
|
+
definition: z.ZodObject<{
|
|
30
|
+
settingsSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
31
|
+
selectionSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
32
|
+
}, z.core.$strict>;
|
|
33
|
+
execution: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
34
|
+
modes: z.ZodArray<z.ZodString>;
|
|
35
|
+
evidence: z.ZodString;
|
|
36
|
+
}, z.core.$strict>>;
|
|
37
|
+
}, z.core.$strict>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Source definitions are data-only; execution remains with a selected provider. */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const schema = z.record(z.string(), z.unknown());
|
|
4
|
+
export const connectorPackageSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
apiVersion: z.literal("ingestron.connector/v1"),
|
|
7
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
8
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
9
|
+
description: z.string().min(1),
|
|
10
|
+
connector: z
|
|
11
|
+
.string()
|
|
12
|
+
.regex(/^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*@\d+\.\d+\.\d+$/),
|
|
13
|
+
documentation: z.string().url(),
|
|
14
|
+
upstream: z
|
|
15
|
+
.object({
|
|
16
|
+
ecosystem: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
17
|
+
variant: z.string().min(1),
|
|
18
|
+
package: z.string().min(1),
|
|
19
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
20
|
+
repository: z.string().url(),
|
|
21
|
+
licence: z.string().min(1),
|
|
22
|
+
licenceFile: z.string().min(1),
|
|
23
|
+
licenceStatus: z.enum(["evidenced", "unknown", "conflict"]),
|
|
24
|
+
})
|
|
25
|
+
.strict(),
|
|
26
|
+
runtime: z
|
|
27
|
+
.object({
|
|
28
|
+
contract: z.string().min(1),
|
|
29
|
+
path: z.string().min(1),
|
|
30
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
31
|
+
})
|
|
32
|
+
.strict(),
|
|
33
|
+
definition: z
|
|
34
|
+
.object({ settingsSchema: schema, selectionSchema: schema })
|
|
35
|
+
.strict(),
|
|
36
|
+
execution: z.record(z.string(), z
|
|
37
|
+
.object({
|
|
38
|
+
modes: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/)).min(1),
|
|
39
|
+
evidence: z.string().min(1),
|
|
40
|
+
})
|
|
41
|
+
.strict()),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function validateContract(contract: Record<string, any>, file?: string): void;
|
|
2
|
+
export interface Column {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
required: boolean;
|
|
6
|
+
key: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function contractColumns(contract: Record<string, any>, file?: string): Column[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { assetPath } from "./installation.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Ajv2019 } from "ajv/dist/2019.js";
|
|
4
|
+
import addFormats from "ajv-formats";
|
|
5
|
+
import { check } from "./errors.js";
|
|
6
|
+
const ajv = new Ajv2019({ allErrors: true, strict: false });
|
|
7
|
+
addFormats(ajv);
|
|
8
|
+
const validator = ajv.compile(JSON.parse(readFileSync(assetPath("schemas/odcs-3.1.0.json"), "utf8")));
|
|
9
|
+
export function validateContract(contract, file) {
|
|
10
|
+
check(contract.apiVersion === "v3.1.0", "ODCS", "Use pinned ODCS v3.1.0; other versions are not silently converted", file);
|
|
11
|
+
check(validator(contract), "ODCS", ajv.errorsText(validator.errors, { separator: "; " }), file);
|
|
12
|
+
}
|
|
13
|
+
export function contractColumns(contract, file) {
|
|
14
|
+
validateContract(contract, file);
|
|
15
|
+
check(contract.schema?.length === 1 && contract.schema[0].properties?.length, "ODCS", "A dataset contract must contain one table schema with columns", file);
|
|
16
|
+
const names = new Set();
|
|
17
|
+
return contract.schema[0].properties.map((p) => {
|
|
18
|
+
const name = p.physicalName ?? p.name;
|
|
19
|
+
check(typeof name === "string" &&
|
|
20
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
21
|
+
!names.has(name), "ODCS", `Invalid or duplicate column ${name}`, file);
|
|
22
|
+
names.add(name);
|
|
23
|
+
const type = (p.physicalType ??
|
|
24
|
+
{
|
|
25
|
+
string: "STRING",
|
|
26
|
+
integer: "BIGINT",
|
|
27
|
+
number: "DOUBLE",
|
|
28
|
+
boolean: "BOOLEAN",
|
|
29
|
+
date: "DATE",
|
|
30
|
+
timestamp: "TIMESTAMP",
|
|
31
|
+
}[p.logicalType])?.toUpperCase();
|
|
32
|
+
check(typeof type === "string" &&
|
|
33
|
+
/^(STRING|BIGINT|INT|INTEGER|SMALLINT|DOUBLE|FLOAT|BOOLEAN|DATE|TIMESTAMP|BINARY|DECIMAL\(\d{1,2},\s*\d{1,2}\))$/.test(type), "ODCS", `Unsupported native type ${type} for ${name}`, file);
|
|
34
|
+
if (type.startsWith("DECIMAL")) {
|
|
35
|
+
const [precision, scale] = type.match(/\d+/g).map(Number);
|
|
36
|
+
check(precision >= 1 && precision <= 38 && scale <= precision, "ODCS", `Invalid decimal type ${type}`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
type,
|
|
41
|
+
required: p.required === true,
|
|
42
|
+
key: p.primaryKey === true,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Producer/consumer boundary; not an assertion that an upstream delivery exists. */
|
|
3
|
+
export declare const deliveryIndexSchema: z.ZodObject<{
|
|
4
|
+
apiVersion: z.ZodLiteral<"ingestron.delivery-index/v1">;
|
|
5
|
+
dataset: z.ZodString;
|
|
6
|
+
deliveries: z.ZodArray<z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
version: z.ZodNumber;
|
|
9
|
+
capturedAt: z.ZodISODateTime;
|
|
10
|
+
contractVersion: z.ZodString;
|
|
11
|
+
complete: z.ZodLiteral<true>;
|
|
12
|
+
scope: z.ZodLiteral<"full-table">;
|
|
13
|
+
rowCount: z.ZodNumber;
|
|
14
|
+
path: z.ZodString;
|
|
15
|
+
}, z.core.$strict>>;
|
|
16
|
+
}, z.core.$strict>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Producer/consumer boundary; not an assertion that an upstream delivery exists. */
|
|
3
|
+
export const deliveryIndexSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
apiVersion: z.literal("ingestron.delivery-index/v1"),
|
|
6
|
+
dataset: z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^[A-Za-z_][\w-]*\.[A-Za-z_][\w-]*\.[A-Za-z_][\w-]*$/),
|
|
9
|
+
deliveries: z
|
|
10
|
+
.array(z
|
|
11
|
+
.object({
|
|
12
|
+
id: z.string().min(1),
|
|
13
|
+
version: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
14
|
+
capturedAt: z.iso.datetime({ offset: true }),
|
|
15
|
+
contractVersion: z.string().min(1),
|
|
16
|
+
complete: z.literal(true),
|
|
17
|
+
scope: z.literal("full-table"),
|
|
18
|
+
rowCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
19
|
+
path: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(4096)
|
|
23
|
+
.refine((value) => !value.includes("\0"), "Delivery location cannot contain NUL"),
|
|
24
|
+
})
|
|
25
|
+
.strict())
|
|
26
|
+
.min(1),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Plan } from "./schema.js";
|
|
2
|
+
/** Partition compilation only. This never schedules jobs or asserts data exists. */
|
|
3
|
+
export declare function deliveryExports(plan: Plan): {
|
|
4
|
+
id: string;
|
|
5
|
+
directory: string;
|
|
6
|
+
needs: string[];
|
|
7
|
+
imports: {
|
|
8
|
+
dataset: string;
|
|
9
|
+
producer: string;
|
|
10
|
+
kind: "files" | "relation";
|
|
11
|
+
location: Record<string, unknown>;
|
|
12
|
+
contract: Record<string, unknown>;
|
|
13
|
+
}[];
|
|
14
|
+
resources: {
|
|
15
|
+
scope: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}[];
|
|
19
|
+
plan: Plan;
|
|
20
|
+
}[];
|