@ingestron/pack-databricks 1.0.1 → 2.0.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.
@@ -0,0 +1,190 @@
1
+ import { bundleData, headerOf, scriptPath, written } from "./build.js";
2
+ import { prerequisites } from "./capabilities.js";
3
+ import { codeProblem, differ, listOf } from "./fold-problems.js";
4
+ import { pauseOf, targetSettings } from "./fold-target.js";
5
+ import { hasMarker, marker, markers } from "./fold-values.js";
6
+ import { configurationKey, pythonValues } from "./fold-python.js";
7
+ import { mapStrings } from "./walk.js";
8
+ /** The files for every target, folded from the plan's template. */
9
+ export function foldFiles(plan) {
10
+ const { files, origins } = bundleData(plan.template);
11
+ const { pipeline, job } = plan.template.names;
12
+ const used = { configuration: new Set(), parameters: new Set() };
13
+ for (const [path, file] of Object.entries(files)) {
14
+ if (hasMarker(path))
15
+ plan.problems.push(named(path));
16
+ const key = typeof file === "string" ? undefined : markedKey(file.data);
17
+ if (key !== undefined)
18
+ plan.problems.push(keyed(path, key));
19
+ if (path.startsWith("prerequisites/"))
20
+ delete files[path];
21
+ else if (typeof file === "string")
22
+ files[path] = foldCodeFile(plan, path, file, used, origins);
23
+ }
24
+ configure(files[`resources/${pipeline}.pipeline.yml`], pipeline, used.configuration);
25
+ const refresh = jobOf(files[`resources/${job}.job.yml`], job);
26
+ parameterise(refresh, used.parameters);
27
+ const targets = targetSettings(plan);
28
+ pause(plan, refresh, targets);
29
+ for (const file of Object.values(files))
30
+ if (typeof file !== "string")
31
+ file.data = variablesIn(file.data);
32
+ const declared = declare(plan, files, targets);
33
+ addPrerequisites(plan, files);
34
+ return { files: written(files), hosts: targets.hosts, declared };
35
+ }
36
+ function jobOf(file, job) {
37
+ if (!file || typeof file === "string")
38
+ return undefined;
39
+ return file.data.resources.jobs[job];
40
+ }
41
+ /** Each target's grants file, from its own model: an administrator runs it by hand. */
42
+ function addPrerequisites(plan, files) {
43
+ for (const target of plan.targets) {
44
+ const model = plan.models[target];
45
+ const needs = prerequisites(model, headerOf(model));
46
+ if (needs)
47
+ files[`prerequisites/${target}.sql`] = placeholders(plan, target, needs);
48
+ }
49
+ }
50
+ function named(path) {
51
+ return differ(`The generated file ${shown(path)} would be named after a value that differs between environments; names that decide files and resource keys must be the same in every environment.`, "project.yaml");
52
+ }
53
+ function keyed(path, key) {
54
+ return differ(`The resource key ${shown(key)} in ${path} would be named after a value that differs between environments; names that decide files and resource keys must be the same in every environment.`, "project.yaml");
55
+ }
56
+ /** A text with each marker shown as `<variable>`. */
57
+ function shown(text) {
58
+ return text.replaceAll(markers, (_, name) => `<${name}>`);
59
+ }
60
+ /** The first key in a YAML document that holds a marker. */
61
+ function markedKey(node) {
62
+ if (node === null || typeof node !== "object")
63
+ return undefined;
64
+ for (const [key, value] of Object.entries(node)) {
65
+ const found = hasMarker(key) ? key : markedKey(value);
66
+ if (found !== undefined)
67
+ return found;
68
+ }
69
+ return undefined;
70
+ }
71
+ /** Pipeline SQL takes `${ingestron.<name>}` from the pipeline's configuration; the script, from parameters. */
72
+ function foldCodeFile(plan, path, text, used, origins) {
73
+ if (!hasMarker(text))
74
+ return text;
75
+ const names = [...text.matchAll(markers)].map((match) => match[1]);
76
+ const kind = kindOf(plan, path);
77
+ const folded = kind && foldedCode(plan, path, text, kind);
78
+ if (!kind || folded === undefined) {
79
+ plan.problems.push(codeProblem(plan, path, names, origins[path] ?? "project.yaml"));
80
+ return text;
81
+ }
82
+ for (const name of names)
83
+ (kind === "script" ? used.parameters : used.configuration).add(name);
84
+ return folded;
85
+ }
86
+ /**
87
+ * SQL and the script take `${ingestron.<name>}`; a PySpark file reads spark.conf.get,
88
+ * except in the transform's own code. Undefined when the file cannot take the values.
89
+ */
90
+ function foldedCode(plan, path, text, kind) {
91
+ if (kind !== "python")
92
+ return text.replaceAll(markers, (_, name) => `\${${configurationKey(name)}}`);
93
+ return userCode(plan, path) ? undefined : pythonValues(text);
94
+ }
95
+ /** The after-refresh script, or a SQL or Python file of the pipeline that builds the tables. */
96
+ function kindOf(plan, path) {
97
+ const { pipeline } = plan.template.names;
98
+ if (path === scriptPath(pipeline))
99
+ return "script";
100
+ if (!path.startsWith(`src/${pipeline}/`))
101
+ return undefined;
102
+ if (path.endsWith(".sql"))
103
+ return "sql";
104
+ return path.endsWith(".py") ? "python" : undefined;
105
+ }
106
+ /** True when the values are in a PySpark transform's own code, which the pack copies as it is. */
107
+ function userCode(plan, path) {
108
+ const name = path.replace(/^.*\//, "").replace(/\.py$/, "");
109
+ const table = plan.template.tables.find((candidate) => candidate.name === name);
110
+ return hasMarker(table?.transform?.code ?? "");
111
+ }
112
+ function configure(file, pipeline, names) {
113
+ if (!file || typeof file === "string" || names.size === 0)
114
+ return;
115
+ const pipelines = file.data.resources.pipelines;
116
+ const configuration = Object.fromEntries([...names].map((name) => [configurationKey(name), marker(name)]));
117
+ pipelines[pipeline] = { ...pipelines[pipeline], configuration };
118
+ }
119
+ function parameterise(job, names) {
120
+ if (!job || names.size === 0)
121
+ return;
122
+ for (const task of job.tasks) {
123
+ const python = task.spark_python_task;
124
+ if (python)
125
+ python.parameters = [...names].map((name) => `${configurationKey(name)}=${marker(name)}`);
126
+ }
127
+ }
128
+ /** The refresh job's pause, when it differs: unset in the job and set per target, or a variable. */
129
+ function pause(plan, job, targets) {
130
+ const schedule = job?.schedule;
131
+ if (!schedule)
132
+ return;
133
+ const how = pauseOf(plan);
134
+ if (how.kind === "same")
135
+ return;
136
+ if (how.kind === "variable") {
137
+ schedule.pause_status = marker(how.variable.name);
138
+ return;
139
+ }
140
+ delete schedule.pause_status;
141
+ for (const [target, status] of Object.entries(how.presets)) {
142
+ const settings = targets.settings[target];
143
+ settings.presets = { ...settings.presets, trigger_pause_status: status };
144
+ }
145
+ }
146
+ /** Each marker in YAML becomes `${var.<name>}`. */
147
+ function variablesIn(node) {
148
+ return mapStrings(node, (text) => text.replaceAll(markers, (_, name) => `\${var.${name}}`));
149
+ }
150
+ /** Declares the variables the files use, sets them in each target, and writes the targets. */
151
+ function declare(plan, files, targets) {
152
+ const text = JSON.stringify([Object.values(files), targets.settings]);
153
+ const used = new Set([...text.matchAll(/\$\{var\.([a-z0-9_]+)\}/g)].map((match) => match[1]));
154
+ const declared = plan.variables.list.filter((variable) => used.has(variable.name));
155
+ const variables = Object.fromEntries(declared.map((variable) => [
156
+ variable.name,
157
+ { description: describe(plan, variable), ...(variable.complex && { type: "complex" }) },
158
+ ]));
159
+ for (const target of plan.targets) {
160
+ const set = declared.filter((variable) => setsVariable(target, variable));
161
+ const values = Object.fromEntries(set.map((variable) => [variable.name, variable.values[target]]));
162
+ if (set.length > 0)
163
+ targets.settings[target].variables = values;
164
+ }
165
+ const bundle = files["databricks.yml"];
166
+ const { bundle: name, include, sync } = bundle.data;
167
+ bundle.data = {
168
+ bundle: name,
169
+ include,
170
+ sync,
171
+ ...(declared.length > 0 && { variables }),
172
+ targets: targets.settings,
173
+ };
174
+ return declared;
175
+ }
176
+ /** A deploy-time value's variable says which targets leave it to the pipeline. */
177
+ function describe(plan, variable) {
178
+ if (!variable.deploy || variable.supplied.size === plan.targets.length)
179
+ return variable.description;
180
+ const supplied = plan.targets.filter((target) => variable.supplied.has(target));
181
+ return `Supplied when deploying to ${listOf(supplied)}, as BUNDLE_VAR_${variable.name} (${variable.deploy} in the definitions); the other targets set it.`;
182
+ }
183
+ /** Whether a target sets the variable, rather than leaving it to the deploying CI pipeline. */
184
+ function setsVariable(target, variable) {
185
+ return variable.values[target] !== undefined && !variable.supplied.has(target);
186
+ }
187
+ /** A file run by hand cannot take a value supplied when deploying: it names the variable to fill in. */
188
+ function placeholders(plan, target, text) {
189
+ return (plan.models[target].deployValues ?? []).reduce((result, value) => result.replaceAll(value.text, `<value of ${value.name}>`), text);
190
+ }
@@ -0,0 +1,16 @@
1
+ import type { ResolvedModel } from "@ingestron/definitions";
2
+ /** A variable's name and description, before it is made unique. */
3
+ export interface Place {
4
+ name: string;
5
+ description: string;
6
+ }
7
+ /** The segments of a JSON Pointer. */
8
+ export declare function segments(pointer: string): string[];
9
+ /** Lower case, with `_` for anything that is not a letter, digit or `_`; list positions left out. */
10
+ export declare function variableName(...parts: string[]): string;
11
+ /**
12
+ * The variable for the value at `pointer` in the first target's model. `pipeline` is
13
+ * true when the value is, in every target, the catalog of the pipeline that builds
14
+ * the tables.
15
+ */
16
+ export declare function placeOf(pointer: string, model: ResolvedModel, pipeline: boolean): Place;
@@ -0,0 +1,73 @@
1
+ /** The segments of a JSON Pointer. */
2
+ export function segments(pointer) {
3
+ return pointer
4
+ .split("/")
5
+ .slice(1)
6
+ .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
7
+ }
8
+ /** Lower case, with `_` for anything that is not a letter, digit or `_`; list positions left out. */
9
+ export function variableName(...parts) {
10
+ return parts
11
+ .filter((part) => !/^\d+$/.test(part))
12
+ .join("_")
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9_]+/g, "_")
15
+ .replace(/_+/g, "_")
16
+ .replace(/^_|_$/g, "");
17
+ }
18
+ /**
19
+ * The variable for the value at `pointer` in the first target's model. `pipeline` is
20
+ * true when the value is, in every target, the catalog of the pipeline that builds
21
+ * the tables.
22
+ */
23
+ export function placeOf(pointer, model, pipeline) {
24
+ const [area, index, ...rest] = segments(pointer);
25
+ if (area === "tables")
26
+ return tablePlace(model, Number(index), rest, pipeline);
27
+ const path = [index ?? "", ...rest].filter(Boolean);
28
+ const setting = path.filter((part) => !/^\d+$/.test(part)).join(".");
29
+ if (area === "platform")
30
+ return { name: variableName(...path), description: `Platform setting ${setting}.` };
31
+ return {
32
+ name: variableName(area ?? "", ...path),
33
+ description: `The value at ${pointer} of the resolved definitions.`,
34
+ };
35
+ }
36
+ function tablePlace(model, index, rest, pipeline) {
37
+ const table = model.tables[index];
38
+ const [part, key, ...more] = rest;
39
+ if (part === "fullName")
40
+ return namePlace(model, table.layer, table.name, key, pipeline);
41
+ if (part === "ingest" && key === "connection" && more[0] === "settings") {
42
+ const id = table.ingest.connection.id;
43
+ const setting = more.slice(1);
44
+ const description = setting.join(".") === "path"
45
+ ? `Folder the files of connection "${id}" are read from.`
46
+ : `Setting ${setting.join(".")} of connection "${id}".`;
47
+ return { name: variableName(id, ...setting), description };
48
+ }
49
+ if (key === "settings" && (more[0] === "landing" || more[0] === "published") && more.length === 1)
50
+ return { name: more[0], description: folderOf(more[0], part) };
51
+ const path = [part, key, ...more].filter((item) => Boolean(item));
52
+ return {
53
+ name: variableName(table.name, ...path.filter((item) => item !== "settings")),
54
+ description: `The value of ${path.join(".")} for table ${table.name}.`,
55
+ };
56
+ }
57
+ function folderOf(setting, step) {
58
+ return setting === "landing"
59
+ ? `Folder the landed files are read from (${step} setting landing).`
60
+ : `Folder the tables are published to (${step} setting published).`;
61
+ }
62
+ function namePlace(model, layer, table, key, pipeline) {
63
+ if (key === "catalog" && pipeline)
64
+ return { name: "catalog", description: `Catalog of the ${model.project.id} tables.` };
65
+ if (key === "catalog")
66
+ return {
67
+ name: `catalog_${variableName(layer)}`,
68
+ description: `Catalog of the ${layer} tables.`,
69
+ };
70
+ if (key === "schema")
71
+ return { name: `schema_${variableName(layer)}`, description: `Schema of the ${layer} tables.` };
72
+ return { name: variableName("table", table), description: `Name of the ${table} table.` };
73
+ }
@@ -0,0 +1,15 @@
1
+ import { type ResolvedModel, type TargetDifference } from "@ingestron/definitions";
2
+ import { type TextFold } from "./fold-text.js";
3
+ import { type Models } from "./fold-values.js";
4
+ /** A fold's template, variables and the problems found while planning it. */
5
+ export interface Plan extends TextFold {
6
+ template: ResolvedModel;
7
+ /** The tags that are not the same in every target, by target. */
8
+ tags: Record<string, Record<string, string>>;
9
+ /** Values that differ but are not text or lists, left as the first target has them. */
10
+ unfolded: TargetDifference[];
11
+ }
12
+ /** The template and variables for the targets' models, given where their values differ. */
13
+ export declare function planFold(models: Models, differing: readonly TargetDifference[]): Plan;
14
+ /** The catalog of the pipeline that builds tables: that of its first table. */
15
+ export declare function pipelineCatalog(model: ResolvedModel): string | undefined;
@@ -0,0 +1,156 @@
1
+ // Planning a fold: a template model, the first target's model with a marker
2
+ // for each value that differs between targets or is supplied when deploying,
3
+ // and the variables those markers stand for (spec/behaviour.md, section 2).
4
+ import { fullNameText, splitDeployText, } from "@ingestron/definitions";
5
+ import { placeOf, segments, variableName } from "./fold-names.js";
6
+ import { foldCode, foldText } from "./fold-text.js";
7
+ import { deployVariable, deployVariables, marker, placeVariable, supply, } from "./fold-values.js";
8
+ import { differ, listOf } from "./fold-problems.js";
9
+ import { mapStrings } from "./walk.js";
10
+ /** Values Databricks sets per target, written in each target as they are. */
11
+ const perTarget = /^\/(project\/environment|platform\/(mode|workspace|run_as|permissions|tags|schedule\/paused))(\/|$)/;
12
+ /** Transform inputs are full names, folded with the tables they name. */
13
+ const inputs = /^\/tables\/\d+\/transform\/inputs\//;
14
+ /** The template and variables for the targets' models, given where their values differ. */
15
+ export function planFold(models, differing) {
16
+ const targets = Object.keys(models);
17
+ const first = models[targets[0]];
18
+ const catalogs = targets.map((target) => pipelineCatalog(models[target]));
19
+ const plan = {
20
+ targets,
21
+ models,
22
+ variables: deployVariables(models),
23
+ problems: [],
24
+ template: structuredClone(first),
25
+ tags: splitTags(models, targets),
26
+ unfolded: [],
27
+ };
28
+ for (const difference of differing)
29
+ foldPlace(plan, difference, catalogs);
30
+ for (const [index, table] of plan.template.tables.entries())
31
+ if (table.transform)
32
+ table.transform.inputs = retarget(first, plan.template, index);
33
+ plan.template.platform.tags = commonTags(first, plan.tags);
34
+ markDeployValues(plan);
35
+ return plan;
36
+ }
37
+ /** The catalog of the pipeline that builds tables: that of its first table. */
38
+ export function pipelineCatalog(model) {
39
+ return (model.tables.find((table) => table.store) ?? model.tables[0])?.fullName.catalog;
40
+ }
41
+ function foldPlace(plan, difference, catalogs) {
42
+ const { pointer, values } = difference;
43
+ if (perTarget.test(pointer) || inputs.test(pointer))
44
+ return;
45
+ const path = segments(pointer);
46
+ if (path[0] === "names")
47
+ return plan.problems.push(renamed(plan, path[1], values));
48
+ if (!plan.targets.every((target) => values[target] !== undefined))
49
+ return plan.unfolded.push(difference);
50
+ if (path[2] === "transform" && path[3] === "code" && path.length === 4)
51
+ return foldTableCode(plan, Number(path[1]), values);
52
+ const pipeline = JSON.stringify(Object.values(values)) === JSON.stringify(catalogs);
53
+ if (!foldValue(plan, pointer, values, pipeline))
54
+ plan.unfolded.push(difference);
55
+ }
56
+ /**
57
+ * A text becomes a variable, or a variable for its deploy-time part; a list whose
58
+ * length differs becomes a complex variable. Other values stay as the first target
59
+ * has them, and the check against each target's own bundle finds what that changes.
60
+ */
61
+ function foldValue(plan, pointer, values, pipeline) {
62
+ const all = Object.values(values);
63
+ const first = plan.models[plan.targets[0]];
64
+ const path = segments(pointer);
65
+ if (all.every((value) => typeof value === "string")) {
66
+ const place = placeOf(pointer, first, pipeline);
67
+ setAt(plan.template, path, foldText(plan, values, place));
68
+ return true;
69
+ }
70
+ if (all.every((value) => Array.isArray(value)) && !JSON.stringify(all).includes("${envvar:")) {
71
+ const place = { ...placeOf(pointer, first, false), complex: true };
72
+ setAt(plan.template, path, marker(placeVariable(plan.variables, place, values).name));
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+ function renamed(plan, what, values) {
78
+ const [first, other] = plan.targets.filter((target) => values[target] !== undefined);
79
+ return differ(`The ${what} is named "${String(values[first])}" in ${first} and "${String(values[other])}" in ${other}; resource keys and folders are named after it, so it must be the same in every environment.`, "project.yaml");
80
+ }
81
+ function foldTableCode(plan, index, values) {
82
+ const table = plan.template.tables[index];
83
+ const code = foldCode(plan, values, (count) => ({
84
+ name: variableName(table.name, "value"),
85
+ description: `Part ${count} of the code of table ${table.name} that differs between environments.`,
86
+ }));
87
+ if (code === undefined) {
88
+ const message = `The code of table "${table.name}" differs between ${listOf(plan.targets)} in more than values.`;
89
+ plan.problems.push(differ(message, table.origin.file));
90
+ }
91
+ else
92
+ table.transform.code = code;
93
+ }
94
+ /** Sets the value at a path of segments. */
95
+ function setAt(model, path, value) {
96
+ let node = model;
97
+ for (const key of path.slice(0, -1))
98
+ node = node[key];
99
+ node[path[path.length - 1]] = value;
100
+ }
101
+ /** A transform's inputs, named as the template names the tables they are. */
102
+ function retarget(first, template, index) {
103
+ const entries = Object.entries(first.tables[index].transform.inputs).map(([name, full]) => {
104
+ const input = first.tables.findIndex((table) => fullNameText(table.fullName) === full);
105
+ return [name, input < 0 ? full : fullNameText(template.tables[input].fullName)];
106
+ });
107
+ return Object.fromEntries(entries);
108
+ }
109
+ /** The tags each target has that are not the same in every target. */
110
+ function splitTags(models, targets) {
111
+ const keys = [
112
+ ...new Set(targets.flatMap((target) => Object.keys(models[target].platform.tags))),
113
+ ];
114
+ const same = (key) => new Set(targets.map((target) => models[target].platform.tags[key])).size === 1;
115
+ return Object.fromEntries(targets.map((target) => {
116
+ const tags = models[target].platform.tags;
117
+ return [
118
+ target,
119
+ Object.fromEntries(Object.entries(tags).filter(([key]) => !same(key) && keys.includes(key))),
120
+ ];
121
+ }));
122
+ }
123
+ function commonTags(first, differing) {
124
+ const other = new Set(Object.values(differing).flatMap((tags) => Object.keys(tags)));
125
+ return Object.fromEntries(Object.entries(first.platform.tags).filter(([key]) => !other.has(key)));
126
+ }
127
+ /** Values Databricks takes per target, and the model's list of deploy-time values. */
128
+ const notWalked = new Set([
129
+ "/platform/workspace",
130
+ "/platform/run_as",
131
+ "/platform/permissions",
132
+ "/deployValues",
133
+ ]);
134
+ /** Each deploy-time value left in the template: the same in every target, so each supplies it. */
135
+ function markDeployValues(plan) {
136
+ const values = [
137
+ ...new Map(Object.values(plan.models).flatMap((model) => (model.deployValues ?? []).map((value) => [value.name, value]))).values(),
138
+ ];
139
+ const mark = (text) => marked(plan, text, values);
140
+ plan.template = mapStrings(plan.template, mark, (pointer) => notWalked.has(pointer));
141
+ }
142
+ function marked(plan, text, values) {
143
+ const parts = splitDeployText(text, values);
144
+ if (parts.every((part) => typeof part === "string"))
145
+ return text;
146
+ return parts
147
+ .map((part) => {
148
+ if (typeof part === "string")
149
+ return part;
150
+ const variable = deployVariable(plan.variables, part.name);
151
+ for (const target of plan.targets)
152
+ plan.problems.push(...supply(variable, target, plan.models[target]));
153
+ return marker(variable.name);
154
+ })
155
+ .join("");
156
+ }
@@ -0,0 +1,29 @@
1
+ import type { EnvironmentsProblem, ResolvedModel, TargetDifference } from "@ingestron/definitions";
2
+ import { type Models, type Variables } from "./fold-values.js";
3
+ /** An `ENVIRONMENTS_DIFFER` error at a definition. */
4
+ export declare function differ(message: string, file: string, hint?: string): EnvironmentsProblem;
5
+ /** Target names joined with `, `, the last two with ` and `. */
6
+ export declare function listOf(targets: string[]): string;
7
+ /** Each problem once. */
8
+ export declare function once(problems: EnvironmentsProblem[]): EnvironmentsProblem[];
9
+ /**
10
+ * A table the pipeline names `schema.table` in some targets, because it is in the
11
+ * pipeline's catalog there, and with three parts in others.
12
+ */
13
+ export declare function catalogProblems(models: Models, catalog: (model: ResolvedModel) => string | undefined): EnvironmentsProblem[];
14
+ /** A Kafka connection read continuously in some targets and by the refresh job in others. */
15
+ export declare function continuousProblems(models: Models): EnvironmentsProblem[];
16
+ /**
17
+ * A PySpark transform whose own code would need values that differ between targets, or
18
+ * that are supplied when deploying: the pack copies that code as it is.
19
+ */
20
+ export declare function codeProblem(fold: {
21
+ variables: Variables;
22
+ models: Models;
23
+ targets: string[];
24
+ }, path: string, names: string[], file: string): EnvironmentsProblem;
25
+ /**
26
+ * A file whose view in some targets is not that target's own file. When values that are
27
+ * not text differ, the message names each, with its value in every target.
28
+ */
29
+ export declare function unlikeProblem(models: Models, path: string, targets: string[], unfolded: TargetDifference[]): EnvironmentsProblem;
@@ -0,0 +1,113 @@
1
+ import { placeOf } from "./fold-names.js";
2
+ import { connectGroups } from "./connect.js";
3
+ import { deployProblem } from "./fold-values.js";
4
+ /** An `ENVIRONMENTS_DIFFER` error at a definition. */
5
+ export function differ(message, file, hint) {
6
+ return {
7
+ code: "ENVIRONMENTS_DIFFER",
8
+ severity: "error",
9
+ message,
10
+ file,
11
+ ...(hint && { hint }),
12
+ };
13
+ }
14
+ /** Target names joined with `, `, the last two with ` and `. */
15
+ export function listOf(targets) {
16
+ if (targets.length < 2)
17
+ return targets.join("");
18
+ return `${targets.slice(0, -1).join(", ")} and ${targets[targets.length - 1]}`;
19
+ }
20
+ /** Each problem once. */
21
+ export function once(problems) {
22
+ const seen = new Set();
23
+ return problems.filter((problem) => {
24
+ const key = JSON.stringify([problem.code, problem.message, problem.file, problem.line]);
25
+ if (seen.has(key))
26
+ return false;
27
+ seen.add(key);
28
+ return true;
29
+ });
30
+ }
31
+ /** The targets in which `test` holds, and those in which it does not. */
32
+ function split(targets, test) {
33
+ const yes = targets.filter(test);
34
+ return [yes, targets.filter((target) => !yes.includes(target))];
35
+ }
36
+ /**
37
+ * A table the pipeline names `schema.table` in some targets, because it is in the
38
+ * pipeline's catalog there, and with three parts in others.
39
+ */
40
+ export function catalogProblems(models, catalog) {
41
+ const targets = Object.keys(models);
42
+ const first = models[targets[0]];
43
+ return first.tables
44
+ .filter((table) => table.store)
45
+ .flatMap((table) => {
46
+ const inPipeline = (target) => {
47
+ const model = models[target];
48
+ const same = model.tables.find((other) => other.name === table.name);
49
+ return same?.fullName.catalog === catalog(model);
50
+ };
51
+ const [yes, no] = split(targets, inPipeline);
52
+ if (yes.length === 0 || no.length === 0)
53
+ return [];
54
+ return [
55
+ differ(`Table "${table.name}" is in the pipeline's catalog in ${listOf(yes)} but not in ${listOf(no)}, so the pipeline's code cannot name it the same way in every environment.`, table.origin.file, "Put the table in the pipeline's catalog in every environment, or in another catalog in every environment."),
56
+ ];
57
+ });
58
+ }
59
+ /** A Kafka connection read continuously in some targets and by the refresh job in others. */
60
+ export function continuousProblems(models) {
61
+ const targets = Object.keys(models);
62
+ const groups = targets.map((target) => connectGroups(models[target]));
63
+ return groups[0].flatMap((group) => {
64
+ const continuous = (target) => groups[targets.indexOf(target)].find((other) => other.connection.id === group.connection.id)
65
+ ?.continuous === true;
66
+ const [yes, no] = split(targets, continuous);
67
+ if (yes.length === 0 || no.length === 0)
68
+ return [];
69
+ return [
70
+ differ(`Connection "${group.connection.id}" is read continuously in ${listOf(yes)} and by the refresh job in ${listOf(no)}.`, group.tables[0].origin.file, "Set continuous in the flow's ingest settings, so that it is the same in every environment."),
71
+ ];
72
+ });
73
+ }
74
+ /**
75
+ * A PySpark transform whose own code would need values that differ between targets, or
76
+ * that are supplied when deploying: the pack copies that code as it is.
77
+ */
78
+ export function codeProblem(fold, path, names, file) {
79
+ const table = path.replace(/^.*\//, "").replace(/\.py$/, "");
80
+ const variables = fold.variables.list.filter((variable) => names.includes(variable.name));
81
+ const deploy = variables.find((variable) => variable.deploy);
82
+ if (deploy)
83
+ return deployProblem(`The code of PySpark transform "${table}" would need \${envvar:${deploy.deploy}}, which is supplied when deploying; a PySpark transform's own code cannot take it yet.`, fold.models[[...deploy.supplied][0] ?? fold.targets[0]], deploy.deploy);
84
+ return differ(`The code of PySpark transform "${table}" would need values that differ between environments (${variables.map((variable) => variable.name).join(", ")}); a PySpark transform's own code cannot take them yet.`, file, "Make these values the same in every environment, or write the transform in SQL.");
85
+ }
86
+ /**
87
+ * A file whose view in some targets is not that target's own file. When values that are
88
+ * not text differ, the message names each, with its value in every target.
89
+ */
90
+ export function unlikeProblem(models, path, targets, unfolded) {
91
+ const start = `${path} cannot be generated once for every environment: in ${listOf(targets)} it would not be what that environment gets on its own`;
92
+ if (unfolded.length === 0)
93
+ return differ(`${start}, because the environments differ there in more than values.`, "project.yaml");
94
+ const first = Object.values(models)[0];
95
+ const settings = unfolded.map((difference) => settingOf(difference.pointer, first));
96
+ const values = unfolded.map((difference, index) => `${settings[index]} is ${valuesOf(models, difference.values)}`);
97
+ return differ(`${start}, because a value that is not text differs between environments: ${values.join("; ")}.`, "project.yaml", `Make ${listOf(settings)} the same in every environment; only text values, such as names, paths and hosts, and lists can differ.`);
98
+ }
99
+ /** What a place in the model is, in words, such as "platform setting manage.schemas". */
100
+ function settingOf(pointer, model) {
101
+ const { description } = placeOf(pointer, model, false);
102
+ return `${description.charAt(0).toLowerCase()}${description.slice(1)}`.replace(/\.$/, "");
103
+ }
104
+ /** Each value with the targets that have it, such as "false in dev, true in test and prod". */
105
+ function valuesOf(models, values) {
106
+ const groups = new Map();
107
+ for (const target of Object.keys(models)) {
108
+ const value = values[target];
109
+ const text = value === undefined ? "not set" : JSON.stringify(value);
110
+ groups.set(text, [...(groups.get(text) ?? []), target]);
111
+ }
112
+ return [...groups].map(([value, targets]) => `${value} in ${listOf(targets)}`).join(", ");
113
+ }
@@ -0,0 +1,17 @@
1
+ /** The configuration key a variable is read under. */
2
+ export declare function configurationKey(name: string): string;
3
+ /**
4
+ * The file with each marker inside a double-quoted text read from the configuration:
5
+ * before the table's function, `"⟦schema_silver⟧.totals"` becomes
6
+ * `f"{ingestron_schema_silver}.totals"` with
7
+ * `ingestron_schema_silver = spark.conf.get("ingestron.schema_silver")` above the
8
+ * decorators; inside it, `spark.conf.get("ingestron.schema_bronze") + ".orders"`.
9
+ * Undefined when a marker is anywhere else, which no Python expression can stand for.
10
+ */
11
+ export declare function pythonValues(text: string): string | undefined;
12
+ /**
13
+ * The file as the pipeline runs it, as the pack would have written it with the values:
14
+ * the module-level reads removed and each f-string that uses them a plain text, then
15
+ * each spark.conf.get of an `ingestron.*` key in the body joined into the texts around it.
16
+ */
17
+ export declare function pythonWithValues(text: string, values: Record<string, string>): string;