@olivervea/pipelines 2.0.0-beta.27 → 2.0.0-beta.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.27",
3
+ "version": "2.0.0-beta.28",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,98 @@
1
+ import { SECRET_REF_KIND } from '../core/config.ts';
2
+ import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from '../core/config-refs.ts';
3
+ import type { JsonSchema } from './schemas.ts';
4
+
5
+ /**
6
+ * The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
7
+ * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
8
+ */
9
+ export function subSchemas(
10
+ schema: unknown,
11
+ path: readonly string[],
12
+ kase: string | undefined,
13
+ entry: string,
14
+ ): unknown[] {
15
+ let narrow: { at: string; discriminator: string; value: string } | undefined;
16
+ if (kase) {
17
+ const eq = kase.lastIndexOf('=');
18
+ const left = kase.slice(0, eq);
19
+ const dot = left.lastIndexOf('.');
20
+ narrow = {
21
+ at: left.slice(0, dot),
22
+ discriminator: left.slice(dot + 1),
23
+ value: kase.slice(eq + 1),
24
+ };
25
+ }
26
+ const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
27
+ if (node === null || typeof node !== 'object') return [];
28
+ const o = node as JsonSchema;
29
+ const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
30
+ if (Array.isArray(branches)) {
31
+ const chosen =
32
+ narrow && narrow.at === here
33
+ ? branches.filter(
34
+ (b) =>
35
+ (
36
+ (b.properties as Record<string, JsonSchema> | undefined)?.[
37
+ narrow!.discriminator
38
+ ] as JsonSchema | undefined
39
+ )?.const === narrow!.value,
40
+ )
41
+ : branches;
42
+ return chosen.flatMap((b) => walk(b, rest, here));
43
+ }
44
+ if (rest.length === 0) return [node];
45
+ const props = o.properties as Record<string, JsonSchema> | undefined;
46
+ if (props && Object.hasOwn(props, rest[0]!))
47
+ return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
48
+ if (o.additionalProperties && typeof o.additionalProperties === 'object')
49
+ return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
50
+ return [];
51
+ };
52
+ return walk(schema, path, entry);
53
+ }
54
+
55
+ /** Secret names of every secretRef inside a config entry's JSON Schema. */
56
+ export function secretRefsOf(schema: unknown): string[] {
57
+ const out = new Set<string>();
58
+ const walk = (v: unknown) => {
59
+ if (Array.isArray(v)) return v.forEach(walk);
60
+ if (v === null || typeof v !== 'object') return;
61
+ const o = v as JsonSchema;
62
+ if (o['x-olve-kind'] === SECRET_REF_KIND) {
63
+ const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret
64
+ ?.const;
65
+ if (typeof name === 'string') out.add(name);
66
+ }
67
+ Object.values(o).forEach(walk);
68
+ };
69
+ walk(schema);
70
+ return [...out].sort();
71
+ }
72
+
73
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
74
+ export function recordedConfigRefs(
75
+ v: unknown,
76
+ out: { ref: string; case?: string }[] = [],
77
+ ): { ref: string; case?: string }[] {
78
+ if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
79
+ else if (v !== null && typeof v === 'object') {
80
+ const o = v as Record<string, unknown>;
81
+ const keys = Object.keys(o).sort();
82
+ const isRef =
83
+ typeof o[CONFIG_REF_KEY] === 'string' &&
84
+ (keys.length === 1
85
+ ? keys[0] === CONFIG_REF_KEY
86
+ : keys.length === 2 &&
87
+ keys[0] === CONFIG_CASE_KEY &&
88
+ keys[1] === CONFIG_REF_KEY &&
89
+ typeof o[CONFIG_CASE_KEY] === 'string');
90
+ if (isRef)
91
+ out.push({
92
+ ref: o[CONFIG_REF_KEY] as string,
93
+ ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}),
94
+ });
95
+ else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
96
+ }
97
+ return out;
98
+ }
@@ -0,0 +1,182 @@
1
+ import { z } from 'zod';
2
+ import { CONFIG_NAME, isConfigEntry } from '../core/config.ts';
3
+ import { MODEL_NAME, type AnyModel } from '../core/models.ts';
4
+ import { SECRET_NAME, type Secret } from '../core/secrets.ts';
5
+ import { RESERVED_STEP_NAMES, STEP_NAME, type AnyStep } from '../core/steps.ts';
6
+ import { canonicalize } from '../util/canonical.ts';
7
+ import { secretRefsOf } from './config-schema.ts';
8
+ import type { ManifestT, ModelSchemasT } from './manifest-schema.ts';
9
+ import { schemaViews, type JsonSchema } from './schemas.ts';
10
+
11
+ /** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
12
+ export const FAILURE_ROOT = 'failure';
13
+ const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
14
+
15
+ /** What a step can read at a point of the pipeline: its output model, and whether it may have been skipped. */
16
+ export interface Avail {
17
+ model: string | null;
18
+ maybe: boolean;
19
+ }
20
+
21
+ /** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
22
+ export interface CaseOf {
23
+ select: number;
24
+ value: string;
25
+ cond: string;
26
+ }
27
+
28
+ interface ConfigUse {
29
+ description: string;
30
+ required: boolean;
31
+ schema: JsonSchema;
32
+ key: string;
33
+ secretRefs: string[];
34
+ }
35
+
36
+ // Two places are mutually exclusive when they sit in different cases of the same select.
37
+ const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) =>
38
+ a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
39
+
40
+ const byName = ([a]: [string, unknown], [b]: [string, unknown]) =>
41
+ a < b ? -1 : a > b ? 1 : 0;
42
+
43
+ /** The problems found so far and the models, secrets, config entries and step names the pipeline uses. */
44
+ export class DescribeContext {
45
+ readonly problems: string[];
46
+ readonly models = new Map<string, { schemas: ModelSchemasT; key: string }>();
47
+ readonly secrets = new Map<string, string>();
48
+ readonly configs = new Map<string, ConfigUse>();
49
+ readonly steps = new Map<string, AnyStep>();
50
+ private readonly placements = new Map<
51
+ string,
52
+ { step: AnyStep; cases: readonly CaseOf[] }[]
53
+ >();
54
+ private selects = 0;
55
+
56
+ constructor(problems: readonly string[]) {
57
+ this.problems = [...problems];
58
+ }
59
+
60
+ nextSelectId(): number {
61
+ return ++this.selects;
62
+ }
63
+
64
+ useModel(m: AnyModel, at: string): string {
65
+ const problems = this.problems;
66
+ if (!MODEL_NAME.test(m.name))
67
+ problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
68
+ let schemas: ModelSchemasT;
69
+ try {
70
+ schemas = schemaViews(m.schema);
71
+ } catch (e) {
72
+ problems.push(
73
+ `${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`,
74
+ );
75
+ return m.name;
76
+ }
77
+ const key = JSON.stringify(schemas);
78
+ const prev = this.models.get(m.name);
79
+ if (!prev) this.models.set(m.name, { schemas, key });
80
+ else if (prev.key !== key)
81
+ problems.push(
82
+ `two different schemas are registered under the model name '${m.name}' (one schema per model name)`,
83
+ );
84
+ return m.name;
85
+ }
86
+
87
+ outputViewOf(modelName: string | null): JsonSchema | undefined {
88
+ return modelName ? this.models.get(modelName)?.schemas.output : undefined;
89
+ }
90
+
91
+ useSecret(s: Secret, at: string): string {
92
+ if (!SECRET_NAME.test(s.name))
93
+ this.problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
94
+ const prev = this.secrets.get(s.name);
95
+ if (prev === undefined) this.secrets.set(s.name, s.description);
96
+ else if (prev !== s.description)
97
+ this.problems.push(
98
+ `secret '${s.name}' is declared twice with different descriptions`,
99
+ );
100
+ return s.name;
101
+ }
102
+
103
+ useConfig(e: unknown, at: string): string | undefined {
104
+ const problems = this.problems;
105
+ if (!isConfigEntry(e))
106
+ return void problems.push(
107
+ `${at}: config entries must be config("name", "description", schema)`,
108
+ );
109
+ if (!CONFIG_NAME.test(e.name))
110
+ problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
111
+ let schema: JsonSchema;
112
+ try {
113
+ schema = z.toJSONSchema(e.schema, {
114
+ io: 'input',
115
+ target: 'draft-2020-12',
116
+ }) as JsonSchema;
117
+ delete schema.$schema;
118
+ schema = canonicalize(schema) as JsonSchema;
119
+ } catch (err) {
120
+ problems.push(
121
+ `${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`,
122
+ );
123
+ return e.name;
124
+ }
125
+ const key = JSON.stringify([e.description, schema]);
126
+ const prev = this.configs.get(e.name);
127
+ if (!prev)
128
+ this.configs.set(e.name, {
129
+ description: e.description,
130
+ required: !e.schema.safeParse(undefined).success,
131
+ schema,
132
+ key,
133
+ secretRefs: secretRefsOf(schema),
134
+ });
135
+ else if (prev.key !== key)
136
+ problems.push(
137
+ `two different config entries are named '${e.name}' (one description and schema per config name)`,
138
+ );
139
+ return e.name;
140
+ }
141
+
142
+ registerStep(s: AnyStep, where: string, cases: readonly CaseOf[] = []): void {
143
+ const problems = this.problems;
144
+ if (typeof s.name !== 'string' || !STEP_NAME.test(s.name))
145
+ problems.push(
146
+ `${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`,
147
+ );
148
+ if (RESERVED.has(s.name))
149
+ problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
150
+ const prev = this.placements.get(s.name) ?? [];
151
+ if (prev.some((p) => !exclusive(p.cases, cases)))
152
+ problems.push(`duplicate step name '${s.name}'`);
153
+ else if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
154
+ problems.push(
155
+ `step '${s.name}': its select cases must give it the same output model`,
156
+ );
157
+ this.placements.set(s.name, [...prev, { step: s, cases }]);
158
+ if (!this.steps.has(s.name)) this.steps.set(s.name, s);
159
+ }
160
+
161
+ modelsSection(): ManifestT['models'] {
162
+ return Object.fromEntries([...this.models].map(([k, v]) => [k, v.schemas]));
163
+ }
164
+
165
+ secretsSection(): ManifestT['secrets'] {
166
+ return [...this.secrets]
167
+ .sort(byName)
168
+ .map(([name, description]) => ({ name, description }));
169
+ }
170
+
171
+ configSection(): Pick<ManifestT, 'config'> {
172
+ if (!this.configs.size) return {};
173
+ return {
174
+ config: [...this.configs].sort(byName).map(([name, c]) => ({
175
+ name,
176
+ description: c.description,
177
+ required: c.required,
178
+ schema: c.schema,
179
+ })),
180
+ };
181
+ }
182
+ }