@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.31

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.
Files changed (65) hide show
  1. package/README.md +165 -78
  2. package/manifest.schema.json +300 -16
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +98 -64
  6. package/src/build/cli.ts +33 -23
  7. package/src/build/elf.ts +16 -7
  8. package/src/build/hashes.ts +40 -51
  9. package/src/build/project.ts +55 -52
  10. package/src/builder/pipeline.ts +348 -89
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +6 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +62 -15
  17. package/src/core/matrix.ts +150 -0
  18. package/src/core/models.ts +37 -15
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +113 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +119 -42
  24. package/src/ctx/index.ts +110 -39
  25. package/src/ctx/links.ts +30 -0
  26. package/src/ctx/sh.ts +106 -44
  27. package/src/ctx/types.ts +24 -4
  28. package/src/describe/config-schema.ts +98 -0
  29. package/src/describe/context.ts +187 -0
  30. package/src/describe/describe.ts +63 -355
  31. package/src/describe/failure-handlers.ts +34 -0
  32. package/src/describe/manifest-schema.ts +304 -61
  33. package/src/describe/mapper-source.ts +252 -105
  34. package/src/describe/record.ts +67 -37
  35. package/src/describe/schemas.ts +71 -0
  36. package/src/describe/stages.ts +166 -0
  37. package/src/describe/step.ts +544 -0
  38. package/src/describe/triggers.ts +72 -0
  39. package/src/dev/cli.ts +62 -35
  40. package/src/harness/env.ts +47 -25
  41. package/src/harness/execute.ts +74 -30
  42. package/src/harness/exit.ts +1 -1
  43. package/src/harness/index.ts +19 -8
  44. package/src/harness/main.ts +245 -99
  45. package/src/harness/mask.ts +98 -0
  46. package/src/harness/result.ts +45 -24
  47. package/src/harness/validate.ts +82 -15
  48. package/src/index.ts +139 -10
  49. package/src/library/checks.ts +11 -6
  50. package/src/library/compose-deploy.ts +115 -0
  51. package/src/library/github-source.ts +100 -47
  52. package/src/library/helm-deploy-ssh.ts +426 -112
  53. package/src/library/helm-deploy.ts +529 -0
  54. package/src/library/http-health.ts +7 -5
  55. package/src/library/image-build.ts +510 -162
  56. package/src/library/index.ts +48 -14
  57. package/src/library/models.ts +35 -14
  58. package/src/library/ssh.ts +44 -18
  59. package/src/testing/index.ts +100 -35
  60. package/src/util/canonical.ts +7 -4
  61. package/src/util/code-hash.ts +17 -0
  62. package/src/util/hash.ts +5 -5
  63. package/src/util/tar.ts +83 -38
  64. package/src/library/script.ts +0 -85
  65. package/src/util/handler-hash.ts +0 -22
@@ -1,6 +1,7 @@
1
- import type { RefBrand } from "../core/brands.ts";
2
- import type { AnyStep, InferEntry } from "../core/steps.ts";
3
- import type { Primitive } from "../core/types.ts";
1
+ import type { RefBrand } from '../core/brands.ts';
2
+ import type { AnyStep, InferEntry } from '../core/steps.ts';
3
+ import type { External } from '../core/external.ts';
4
+ import type { Primitive } from '../core/types.ts';
4
5
 
5
6
  /**
6
7
  * A typed reference into the run pool — what mappers see. `symbol &` makes template strings, `+`
@@ -12,7 +13,11 @@ type RefFields<T> = [NonNullable<T>] extends [Primitive]
12
13
  ? {}
13
14
  : NonNullable<T> extends readonly unknown[]
14
15
  ? {}
15
- : { readonly [K in keyof NonNullable<T> & string]-?: Ref<NonNullable<T>[K] | (undefined extends T ? undefined : never)> };
16
+ : {
17
+ readonly [K in keyof NonNullable<T> & string]-?: Ref<
18
+ NonNullable<T>[K] | (undefined extends T ? undefined : never)
19
+ >;
20
+ };
16
21
 
17
22
  /** What a mapper may put where a T is expected: a Ref<T>, a literal, or an object of those. */
18
23
  export type Mappable<T> =
@@ -23,4 +28,9 @@ export type Mappable<T> =
23
28
  ? never
24
29
  : { [K in keyof T]: Mappable<T[K]> } & { readonly [RefBrand]?: never }); // a Ref of another model can't sneak in structurally
25
30
 
26
- export type MapperResult<S extends AnyStep> = { [K in keyof S["input"]]: Mappable<InferEntry<S["input"][K]>> };
31
+ /** What a mapper provides: every input except the external ones, which come from outside the run. */
32
+ export type MapperResult<S extends AnyStep> = {
33
+ [K in keyof S['input'] as S['input'][K] extends External ? never : K]: Mappable<
34
+ InferEntry<S['input'][K]>
35
+ >;
36
+ };
@@ -1,13 +1,14 @@
1
1
  // Runtime markers. `Symbol.for` (not `Symbol()`) so that two copies of the SDK in one process — e.g.
2
2
  // a dev runner and the user's installed package — still recognise each other's objects.
3
- export const STEP = Symbol.for("olve.pipelines.step");
4
- export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
5
- export const SKIPPED = Symbol.for("olve.pipelines.skipped");
6
- export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
7
- export const REF_PATH = Symbol.for("olve.pipelines.refPath");
3
+ export const STEP = Symbol.for('olve.pipelines.step');
4
+ export const IMPLEMENTATION = Symbol.for('olve.pipelines.implementation');
5
+ export const SKIPPED = Symbol.for('olve.pipelines.skipped');
6
+ export const PIPELINE = Symbol.for('olve.pipelines.pipeline');
7
+ export const REF_PATH = Symbol.for('olve.pipelines.refPath');
8
8
 
9
9
  // Type-only brands (never exist at runtime).
10
10
  export declare const ModelBrand: unique symbol;
11
11
  export declare const RefBrand: unique symbol;
12
12
  export declare const StepBrand: unique symbol;
13
13
  export declare const SkippedBrand: unique symbol;
14
+ export declare const ImplementationBrand: unique symbol;
@@ -0,0 +1,103 @@
1
+ // Config references inside library-step definitions: collected (the step receives their entries),
2
+ // recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
3
+ import {
4
+ configRefPath,
5
+ configRefTarget,
6
+ isConfigRef,
7
+ type AnyConfigEntry,
8
+ type ConfigRef,
9
+ } from './config.ts';
10
+
11
+ /** `T` with every config reference replaced by the type of its value. */
12
+ export type Resolved<T> =
13
+ T extends ConfigRef<infer U>
14
+ ? U
15
+ : T extends readonly (infer E)[]
16
+ ? Resolved<E>[]
17
+ : T extends (...args: never[]) => unknown
18
+ ? T
19
+ : T extends object
20
+ ? { [K in keyof T]: Resolved<T[K]> }
21
+ : T;
22
+
23
+ /** Manifest key of a recorded config reference. */
24
+ export const CONFIG_REF_KEY = '$config';
25
+ /** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
26
+ export const CONFIG_CASE_KEY = '$case';
27
+
28
+ function isPlain(v: unknown): v is Record<string, unknown> {
29
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
30
+ const proto = Object.getPrototypeOf(v);
31
+ return proto === Object.prototype || proto === null;
32
+ }
33
+
34
+ /** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
35
+ export function configRefsIn(
36
+ v: unknown,
37
+ into = new Map<string, AnyConfigEntry>(),
38
+ ): AnyConfigEntry[] {
39
+ if (isConfigRef(v)) {
40
+ const { entry } = configRefTarget(v);
41
+ if (!into.has(entry.name)) into.set(entry.name, entry);
42
+ } else if (Array.isArray(v)) for (const x of v) configRefsIn(x, into);
43
+ else if (isPlain(v)) for (const x of Object.values(v)) configRefsIn(x, into);
44
+ return [...into.values()];
45
+ }
46
+
47
+ /** `v` as manifest JSON: references become `{"$config": "target.beta.host"}`; literal keys starting with `$` are reserved. */
48
+ export function recordConfigRefs(v: unknown): unknown {
49
+ if (isConfigRef(v)) {
50
+ const { path, case: kase } = configRefTarget(v);
51
+ for (const seg of path)
52
+ if (seg === '' || seg.includes('.'))
53
+ throw new Error(
54
+ `config reference '${configRefPath(v)}': field names must not be empty or contain '.'`,
55
+ );
56
+ return {
57
+ [CONFIG_REF_KEY]: configRefPath(v),
58
+ ...(kase ? { [CONFIG_CASE_KEY]: kase } : {}),
59
+ };
60
+ }
61
+ if (Array.isArray(v)) return v.map(recordConfigRefs);
62
+ if (isPlain(v)) {
63
+ const out: Record<string, unknown> = {};
64
+ for (const [k, x] of Object.entries(v)) {
65
+ if (k.startsWith('$'))
66
+ throw new Error(`key '${k}': keys starting with '$' are reserved`);
67
+ out[k] = recordConfigRefs(x);
68
+ }
69
+ return out;
70
+ }
71
+ return v;
72
+ }
73
+
74
+ /** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
75
+ export function resolveConfigRefs<T>(
76
+ v: T,
77
+ config: Readonly<Record<string, unknown>>,
78
+ ): Resolved<T> {
79
+ if (isConfigRef(v)) {
80
+ const { entry, path } = configRefTarget(v);
81
+ if (!Object.hasOwn(config, entry.name))
82
+ throw new Error(`config entry '${entry.name}' was not delivered to the step`);
83
+ let cur: unknown = config[entry.name];
84
+ for (const seg of path)
85
+ cur =
86
+ cur !== null && typeof cur === 'object'
87
+ ? (cur as Record<string, unknown>)[seg]
88
+ : undefined;
89
+ return cur as Resolved<T>;
90
+ }
91
+ if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
92
+ if (isPlain(v)) {
93
+ const out: Record<string, unknown> = {};
94
+ for (const [k, x] of Object.entries(v)) out[k] = resolveConfigRefs(x, config);
95
+ return out as Resolved<T>;
96
+ }
97
+ return v as Resolved<T>;
98
+ }
99
+
100
+ /** Literal `v` (no reference inside), so define-time checks can run on it. */
101
+ export function isLiteral(v: unknown): boolean {
102
+ return configRefsIn(v).length === 0;
103
+ }
@@ -0,0 +1,224 @@
1
+ import { z } from 'zod';
2
+ import type { Secret } from './secrets.ts';
3
+
4
+ const CONFIG_REF = Symbol.for('olve.configRef');
5
+ declare const VALUE: unique symbol;
6
+
7
+ interface RefTarget {
8
+ readonly entry: AnyConfigEntry;
9
+ readonly path: readonly string[];
10
+ /** Set on a reference narrowed by a `select` case: `<ref>.<discriminator>=<value>`. */
11
+ readonly case?: string;
12
+ }
13
+
14
+ /**
15
+ * A typed reference to a config value (`TARGET.beta.host`), accepted in a step's settings in place of a
16
+ * literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
17
+ * run's config when the step runs; the step receives the entry automatically.
18
+ */
19
+ export interface ConfigRef<T = unknown> {
20
+ readonly [CONFIG_REF]: RefTarget;
21
+ readonly [VALUE]?: T;
22
+ }
23
+
24
+ /** A literal or a reference to a config value of the same type. */
25
+ export type Configurable<T> = T | ConfigRef<T>;
26
+
27
+ type Fields<T> = T extends readonly unknown[] ? never : T extends object ? T : never;
28
+ type Children<T> = [Fields<NonNullable<T>>] extends [never]
29
+ ? {}
30
+ : {
31
+ readonly [K in keyof Fields<NonNullable<T>> & string]-?: ConfigNode<
32
+ Fields<NonNullable<T>>[K] | (undefined extends T ? undefined : never)
33
+ >;
34
+ };
35
+ /** A reference to a config value, and to each of its fields. */
36
+ export type ConfigNode<T> = ConfigRef<T> & Children<T>;
37
+
38
+ /** Fields of an entry that are its own properties; reach config fields with these names through `$`. */
39
+ export type ReservedConfigField = 'kind' | 'name' | 'description' | 'schema' | '$';
40
+
41
+ /** A named, typed value stored on the instance (one YAML document per pipeline), declared by the steps that use it. */
42
+ export interface ConfigEntryBase<
43
+ N extends string = string,
44
+ S extends z.ZodType = z.ZodType,
45
+ > {
46
+ readonly kind: 'config';
47
+ readonly name: N;
48
+ readonly description: string;
49
+ readonly schema: S;
50
+ }
51
+ export type ConfigEntry<
52
+ N extends string = string,
53
+ S extends z.ZodType = z.ZodType,
54
+ > = ConfigEntryBase<N, S> &
55
+ ConfigRef<z.output<S>> &
56
+ Omit<Children<z.output<S>>, ReservedConfigField> & {
57
+ readonly $: ConfigNode<z.output<S>>;
58
+ };
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ export type AnyConfigEntry = ConfigEntryBase<string, any>;
61
+
62
+ /** Config names are YAML keys: camelCase identifiers. */
63
+ export const CONFIG_NAME = /^[a-z][a-zA-Z0-9]*$/;
64
+
65
+ const NOT_REFS = new Set<PropertyKey>([
66
+ 'then',
67
+ 'toJSON',
68
+ 'constructor',
69
+ 'valueOf',
70
+ 'toString',
71
+ 'asymmetricMatch',
72
+ '$$typeof',
73
+ 'nodeType',
74
+ ]);
75
+
76
+ function node(entry: AnyConfigEntry, path: readonly string[], kase?: string): unknown {
77
+ const target = Object.freeze({
78
+ [CONFIG_REF]: Object.freeze({ entry, path, ...(kase ? { case: kase } : {}) }),
79
+ });
80
+ return new Proxy(target, {
81
+ get(t, prop) {
82
+ if (prop === CONFIG_REF) return t[CONFIG_REF];
83
+ if (typeof prop === 'symbol' || NOT_REFS.has(prop)) return undefined;
84
+ return node(entry, [...path, prop], kase);
85
+ },
86
+ });
87
+ }
88
+
89
+ /** `ref` narrowed to the union member whose `discriminator` is `value` (a `select` case). */
90
+ export function narrowConfigRef(
91
+ ref: ConfigRef,
92
+ discriminator: string,
93
+ value: string,
94
+ ): unknown {
95
+ const { entry, path } = ref[CONFIG_REF];
96
+ return node(
97
+ entry,
98
+ path,
99
+ `${[entry.name, ...path].join('.')}.${discriminator}=${value}`,
100
+ );
101
+ }
102
+
103
+ export function config<const N extends string, S extends z.ZodType>(
104
+ name: N,
105
+ description: string,
106
+ schema: S,
107
+ ): ConfigEntry<N, S> {
108
+ const ref = {
109
+ entry: undefined as unknown as AnyConfigEntry,
110
+ path: [] as readonly string[],
111
+ };
112
+ const target = Object.freeze({
113
+ kind: 'config' as const,
114
+ name,
115
+ description,
116
+ schema,
117
+ [CONFIG_REF]: ref,
118
+ });
119
+ const entry: unknown = new Proxy(target, {
120
+ get(t, prop) {
121
+ if (prop in t) return t[prop as keyof typeof t];
122
+ if (typeof prop === 'symbol' || NOT_REFS.has(prop)) return undefined;
123
+ if (prop === '$') return node(entry as AnyConfigEntry, []);
124
+ return node(entry as AnyConfigEntry, [prop]);
125
+ },
126
+ });
127
+ ref.entry = entry as AnyConfigEntry;
128
+ Object.freeze(ref);
129
+ return entry as ConfigEntry<N, S>;
130
+ }
131
+
132
+ export function isConfigEntry(v: unknown): v is AnyConfigEntry {
133
+ return (
134
+ typeof v === 'object' &&
135
+ v !== null &&
136
+ (v as { kind?: unknown }).kind === 'config' &&
137
+ (v as { schema?: unknown }).schema instanceof z.ZodType
138
+ );
139
+ }
140
+
141
+ export function isConfigRef(v: unknown): v is ConfigRef {
142
+ return (
143
+ typeof v === 'object' &&
144
+ v !== null &&
145
+ (v as Record<symbol, unknown>)[CONFIG_REF] !== undefined
146
+ );
147
+ }
148
+
149
+ /** The entry and field path a reference points at, and the `select` case it was narrowed by. */
150
+ export function configRefTarget(ref: ConfigRef): {
151
+ entry: AnyConfigEntry;
152
+ path: readonly string[];
153
+ case?: string;
154
+ } {
155
+ return ref[CONFIG_REF];
156
+ }
157
+
158
+ /** `target.beta.host` */
159
+ export function configRefPath(ref: ConfigRef): string {
160
+ const { entry, path } = configRefTarget(ref);
161
+ return [entry.name, ...path].join('.');
162
+ }
163
+
164
+ export const SECRET_REF_KIND = 'secretRef';
165
+
166
+ /** A config field that names a secret: `{ secret: NAME }` in YAML. The value never enters the config. */
167
+ export function secretRef<const N extends string>(s: Secret<N>) {
168
+ return z
169
+ .strictObject({ secret: z.literal(s.name) })
170
+ .meta({ 'x-olve-kind': SECRET_REF_KIND });
171
+ }
172
+
173
+ /** `ctx.config`: exactly the entries a step declares, after parsing (defaults applied). */
174
+ export type ConfigValues<C extends AnyConfigEntry> = {
175
+ readonly [E in C as E['name']]: z.output<E['schema']>;
176
+ };
177
+
178
+ // ─── select: branching on a config union ────────────────────────────────────────────────────
179
+
180
+ type IsLiteralString<T> = [T] extends [string]
181
+ ? string extends T
182
+ ? false
183
+ : true
184
+ : false;
185
+ /** The fields of union `U` whose type is a string literal in every member: its discriminator(s). */
186
+ export type DiscriminatorOf<U> = {
187
+ [K in keyof U & string]-?: IsLiteralString<U[K]> extends true ? K : never;
188
+ }[keyof U & string];
189
+ /** The member of `U` whose discriminator `D` is `V`. */
190
+ export type VariantOf<U, D extends string, V> = Extract<U, { readonly [K in D]: V }>;
191
+
192
+ function unwrap(schema: z.ZodType): z.ZodType {
193
+ let s = schema as unknown as { _zod: { def: { innerType?: z.ZodType } } };
194
+ for (let i = 0; i < 16 && s._zod.def.innerType; i++) s = s._zod.def.innerType as never;
195
+ return s as unknown as z.ZodType;
196
+ }
197
+
198
+ /**
199
+ * The discriminated union a reference points at: its discriminator and, per value, nothing else.
200
+ * Undefined when the referenced value is not a `z.discriminatedUnion`.
201
+ */
202
+ export function unionAt(
203
+ ref: ConfigRef,
204
+ ): { discriminator: string; values: string[] } | undefined {
205
+ const { entry, path } = configRefTarget(ref);
206
+ let s = unwrap(entry.schema as z.ZodType) as unknown as {
207
+ _zod: { def: Record<string, unknown> };
208
+ };
209
+ for (const seg of path) {
210
+ const shape = s._zod.def.shape as Record<string, z.ZodType> | undefined;
211
+ if (!shape || !(seg in shape)) return undefined;
212
+ s = unwrap(shape[seg]!) as never;
213
+ }
214
+ const def = s._zod.def as {
215
+ discriminator?: string;
216
+ options?: { _zod: { propValues?: Record<string, Set<unknown>> } }[];
217
+ };
218
+ if (typeof def.discriminator !== 'string' || !Array.isArray(def.options))
219
+ return undefined;
220
+ const values = def.options
221
+ .flatMap((o) => [...(o._zod.propValues?.[def.discriminator!] ?? [])])
222
+ .filter((v): v is string => typeof v === 'string');
223
+ return { discriminator: def.discriminator, values };
224
+ }
@@ -0,0 +1,86 @@
1
+ import { isModel, type AnyModel } from './models.ts';
2
+
3
+ /** Who or what may supply an external input's value. */
4
+ export type ExternalSource =
5
+ | { readonly kind: 'person' }
6
+ | { readonly kind: 'callback' }
7
+ | {
8
+ readonly kind: 'poll';
9
+ readonly url: string;
10
+ readonly valuePath: string;
11
+ readonly intervalSeconds: number;
12
+ };
13
+
14
+ /** A signed-in user answers, in the UI or with `pl input`. */
15
+ export function person(): ExternalSource {
16
+ return Object.freeze({ kind: 'person' as const });
17
+ }
18
+
19
+ /**
20
+ * Another system answers by POSTing to this run's callback URL for the input, which an earlier step hands it
21
+ * with `ctx.inputUrl(step, input)`. The body is the value as JSON, or `{"$decline": "reason"}`.
22
+ */
23
+ export function callback(): ExternalSource {
24
+ return Object.freeze({ kind: 'callback' as const });
25
+ }
26
+
27
+ export interface PollOptions {
28
+ /** GET while the step waits; `{run.id}` and `{run.number}` are filled in. */
29
+ url: string;
30
+ /** Dot path to the value in the JSON response; `.` is the whole body. */
31
+ valuePath: string;
32
+ /** Default 30; at least 10. */
33
+ intervalSeconds?: number;
34
+ }
35
+
36
+ /** The service polls `url` while the step waits and answers with the value at `valuePath` once it validates. */
37
+ export function poll(options: PollOptions): ExternalSource {
38
+ return Object.freeze({
39
+ kind: 'poll' as const,
40
+ url: options.url,
41
+ valuePath: options.valuePath,
42
+ intervalSeconds: options.intervalSeconds ?? 30,
43
+ });
44
+ }
45
+
46
+ /**
47
+ * A step input whose value comes from outside the run. The step waits until it is answered (the value is
48
+ * validated against `model`), then runs with the value in its input like any other.
49
+ */
50
+ export interface External<M extends AnyModel = AnyModel> {
51
+ readonly kind: 'external';
52
+ readonly model: M;
53
+ readonly title: string;
54
+ readonly description: string;
55
+ readonly from: readonly ExternalSource[];
56
+ }
57
+
58
+ export interface ExternalOptions {
59
+ /** What is asked, shown to whoever answers. */
60
+ title: string;
61
+ description?: string;
62
+ /** Who may answer (default: a person). */
63
+ from?: readonly ExternalSource[];
64
+ }
65
+
66
+ export function external<M extends AnyModel>(
67
+ model: M,
68
+ options: ExternalOptions,
69
+ ): External<M> {
70
+ return Object.freeze({
71
+ kind: 'external' as const,
72
+ model,
73
+ title: options.title,
74
+ description: options.description ?? '',
75
+ from: Object.freeze([...(options.from ?? [person()])]),
76
+ });
77
+ }
78
+
79
+ export function isExternal(v: unknown): v is External {
80
+ return (
81
+ typeof v === 'object' &&
82
+ v !== null &&
83
+ (v as { kind?: unknown }).kind === 'external' &&
84
+ isModel((v as { model?: unknown }).model)
85
+ );
86
+ }
@@ -1,7 +1,19 @@
1
- import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
2
- import type { Ctx } from "../ctx/types.ts";
3
- import type { AnyStep, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
4
- import type { Awaitable } from "./types.ts";
1
+ import {
2
+ IMPLEMENTATION,
3
+ SKIPPED,
4
+ type ImplementationBrand,
5
+ type SkippedBrand,
6
+ } from './brands.ts';
7
+ import type { StepCtx } from '../ctx/types.ts';
8
+ import type {
9
+ AnyStep,
10
+ CellOf,
11
+ ConfigOf,
12
+ OutputValue,
13
+ SecretsOf,
14
+ StepInput,
15
+ } from './steps.ts';
16
+ import type { Awaitable } from './types.ts';
5
17
 
6
18
  /** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
7
19
  export interface Skipped {
@@ -12,33 +24,68 @@ export function skipped(reason: string): Skipped {
12
24
  return Object.freeze({ [SKIPPED]: true, reason }) as unknown as Skipped;
13
25
  }
14
26
  export function isSkipped(v: unknown): v is Skipped {
15
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[SKIPPED] === true;
27
+ return (
28
+ typeof v === 'object' &&
29
+ v !== null &&
30
+ (v as Record<symbol, unknown>)[SKIPPED] === true
31
+ );
16
32
  }
17
33
 
18
34
  export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
19
- export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<HandlerResult<S>>;
35
+ /** The ctx a handler of `S` gets. */
36
+ export type CtxOf<S extends AnyStep> = StepCtx<SecretsOf<S>, ConfigOf<S>, CellOf<S>>;
37
+ export type Handler<S extends AnyStep> = (
38
+ input: StepInput<S['input']>,
39
+ ctx: CtxOf<S>,
40
+ ) => Awaitable<HandlerResult<S>>;
20
41
 
21
- export interface Implementation<S extends AnyStep = AnyStep> {
42
+ /** A step with its handler. It is itself a step: stage it like any value. */
43
+ export type Implementation<S extends AnyStep = AnyStep> = S & {
44
+ readonly [ImplementationBrand]: true;
45
+ /** The step as defined. */
22
46
  readonly step: S;
23
47
  readonly handler: Handler<S>;
24
- }
48
+ };
25
49
 
26
50
  /** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
27
51
  type NoExtraFields<R, T> = R extends Skipped | void
28
52
  ? unknown
29
- : { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
53
+ : {
54
+ [
55
+ K in Exclude<keyof R, keyof T>
56
+ ]: `❌ '${K & string}' is not a field of the output model`;
57
+ };
30
58
 
31
59
  /**
32
- * Implement a custom step. Input, ctx and return types come from the step definition alone.
33
- * Default-export the result from `handlers/<step-name>.ts`.
60
+ * Implement a step. Input, ctx and return types come from the step definition alone. Stage the
61
+ * result directly; a reusable step gives itself a `kind`.
34
62
  */
35
63
  export function implement<S extends AnyStep, R extends HandlerResult<S>>(
36
- step: S & (S extends LibraryStep ? "❌ library steps already carry their implementation" : unknown),
37
- handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
64
+ step: S &
65
+ (S extends { readonly [ImplementationBrand]: true }
66
+ ? '❌ this step already carries its implementation'
67
+ : unknown),
68
+ handler: (
69
+ input: StepInput<S['input']>,
70
+ ctx: CtxOf<S>,
71
+ ) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
38
72
  ): Implementation<S> {
39
- return Object.freeze({ [IMPLEMENTATION]: true, step, handler: handler as unknown as Handler<S> }) as Implementation<S>;
73
+ if (isImplementation(step))
74
+ throw new TypeError(
75
+ `step '${(step as AnyStep).name}' already carries its implementation`,
76
+ );
77
+ return Object.freeze({
78
+ ...step,
79
+ [IMPLEMENTATION]: true,
80
+ step,
81
+ handler: handler as unknown as Handler<S>,
82
+ }) as unknown as Implementation<S>;
40
83
  }
41
84
 
42
85
  export function isImplementation(v: unknown): v is Implementation {
43
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IMPLEMENTATION] === true;
86
+ return (
87
+ typeof v === 'object' &&
88
+ v !== null &&
89
+ (v as Record<symbol, unknown>)[IMPLEMENTATION] === true
90
+ );
44
91
  }