@affordance/core 0.1.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 +21 -0
- package/README.md +52 -0
- package/dist/engine/compute.d.ts +147 -0
- package/dist/engine/compute.js +152 -0
- package/dist/engine/compute.js.map +1 -0
- package/dist/engine/engine.d.ts +162 -0
- package/dist/engine/engine.js +115 -0
- package/dist/engine/engine.js.map +1 -0
- package/dist/engine/errors.d.ts +6 -0
- package/dist/engine/errors.js +11 -0
- package/dist/engine/errors.js.map +1 -0
- package/dist/engine/index.d.ts +16 -0
- package/dist/engine/index.js +15 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/execution/delta.d.ts +42 -0
- package/dist/execution/delta.js +93 -0
- package/dist/execution/delta.js.map +1 -0
- package/dist/execution/errors.d.ts +94 -0
- package/dist/execution/errors.js +132 -0
- package/dist/execution/errors.js.map +1 -0
- package/dist/execution/execute.d.ts +179 -0
- package/dist/execution/execute.js +356 -0
- package/dist/execution/execute.js.map +1 -0
- package/dist/execution/index.d.ts +25 -0
- package/dist/execution/index.js +20 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/journal.d.ts +190 -0
- package/dist/execution/journal.js +192 -0
- package/dist/execution/journal.js.map +1 -0
- package/dist/execution/port.d.ts +81 -0
- package/dist/execution/port.js +101 -0
- package/dist/execution/port.js.map +1 -0
- package/dist/execution/replay.d.ts +58 -0
- package/dist/execution/replay.js +59 -0
- package/dist/execution/replay.js.map +1 -0
- package/dist/execution/timers.d.ts +21 -0
- package/dist/execution/timers.js +26 -0
- package/dist/execution/timers.js.map +1 -0
- package/dist/execution/transaction.d.ts +24 -0
- package/dist/execution/transaction.js +49 -0
- package/dist/execution/transaction.js.map +1 -0
- package/dist/guards/condition.d.ts +86 -0
- package/dist/guards/condition.js +11 -0
- package/dist/guards/condition.js.map +1 -0
- package/dist/guards/evaluate.d.ts +143 -0
- package/dist/guards/evaluate.js +167 -0
- package/dist/guards/evaluate.js.map +1 -0
- package/dist/guards/guard.d.ts +136 -0
- package/dist/guards/guard.js +118 -0
- package/dist/guards/guard.js.map +1 -0
- package/dist/guards/index.d.ts +20 -0
- package/dist/guards/index.js +17 -0
- package/dist/guards/index.js.map +1 -0
- package/dist/guards/time.d.ts +32 -0
- package/dist/guards/time.js +45 -0
- package/dist/guards/time.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/ingestion/correlation.d.ts +54 -0
- package/dist/ingestion/correlation.js +78 -0
- package/dist/ingestion/correlation.js.map +1 -0
- package/dist/ingestion/index.d.ts +16 -0
- package/dist/ingestion/index.js +15 -0
- package/dist/ingestion/index.js.map +1 -0
- package/dist/ingestion/ingest.d.ts +196 -0
- package/dist/ingestion/ingest.js +275 -0
- package/dist/ingestion/ingest.js.map +1 -0
- package/dist/migration/index.d.ts +10 -0
- package/dist/migration/index.js +10 -0
- package/dist/migration/index.js.map +1 -0
- package/dist/migration/migrate.d.ts +119 -0
- package/dist/migration/migrate.js +195 -0
- package/dist/migration/migrate.js.map +1 -0
- package/dist/model/casetype.d.ts +53 -0
- package/dist/model/casetype.js +51 -0
- package/dist/model/casetype.js.map +1 -0
- package/dist/model/errors.d.ts +28 -0
- package/dist/model/errors.js +39 -0
- package/dist/model/errors.js.map +1 -0
- package/dist/model/handler.d.ts +120 -0
- package/dist/model/handler.js +16 -0
- package/dist/model/handler.js.map +1 -0
- package/dist/model/index.d.ts +24 -0
- package/dist/model/index.js +19 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/retry.d.ts +40 -0
- package/dist/model/retry.js +51 -0
- package/dist/model/retry.js.map +1 -0
- package/dist/model/scope.d.ts +67 -0
- package/dist/model/scope.js +19 -0
- package/dist/model/scope.js.map +1 -0
- package/dist/model/step.d.ts +232 -0
- package/dist/model/step.js +210 -0
- package/dist/model/step.js.map +1 -0
- package/dist/model/target.d.ts +175 -0
- package/dist/model/target.js +236 -0
- package/dist/model/target.js.map +1 -0
- package/dist/store/bootstrap.d.ts +57 -0
- package/dist/store/bootstrap.js +268 -0
- package/dist/store/bootstrap.js.map +1 -0
- package/dist/store/errors.d.ts +18 -0
- package/dist/store/errors.js +26 -0
- package/dist/store/errors.js.map +1 -0
- package/dist/store/ids.d.ts +15 -0
- package/dist/store/ids.js +12 -0
- package/dist/store/ids.js.map +1 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/queryable.d.ts +60 -0
- package/dist/store/queryable.js +7 -0
- package/dist/store/queryable.js.map +1 -0
- package/dist/store/resolve.d.ts +66 -0
- package/dist/store/resolve.js +71 -0
- package/dist/store/resolve.js.map +1 -0
- package/dist/store/sql.d.ts +26 -0
- package/dist/store/sql.js +21 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/store/store.d.ts +66 -0
- package/dist/store/store.js +108 -0
- package/dist/store/store.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step retry policy.
|
|
3
|
+
*
|
|
4
|
+
* Handlers are short-lived, at-least-once and idempotent, so a
|
|
5
|
+
* failed attempt is retried in place: same Execution, same `ctx.executionId`
|
|
6
|
+
* (the idempotency key), same claim. Retries are bounded — "exhausted retries
|
|
7
|
+
* journal a *failed* execution and release the case" — so a permanently
|
|
8
|
+
* failing handler can never hold a case hostage.
|
|
9
|
+
*
|
|
10
|
+
* Only *indeterminate* failures are retried: a handler that threw. A
|
|
11
|
+
* deterministic failure (the returned Case State does not satisfy the case
|
|
12
|
+
* type's schema) fails the Execution immediately — retrying is guaranteed to
|
|
13
|
+
* produce the same defect.
|
|
14
|
+
*/
|
|
15
|
+
/** A normalized retry policy, as held on a {@link StepDefinition}. */
|
|
16
|
+
export interface RetryPolicy {
|
|
17
|
+
/** Total attempts, including the first — 1 disables retry. */
|
|
18
|
+
readonly maxAttempts: number;
|
|
19
|
+
/** Delay before the attempt *after* the given (1-based) failed attempt, in milliseconds. */
|
|
20
|
+
readonly delayMs: (attempt: number) => number;
|
|
21
|
+
}
|
|
22
|
+
/** Retry configuration as authored on a step; every field optional. */
|
|
23
|
+
export interface RetryOptions {
|
|
24
|
+
/** Total attempts, including the first. Integer ≥ 1; defaults to 3. */
|
|
25
|
+
readonly maxAttempts?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Delay between attempts: a fixed number of milliseconds, or a function of
|
|
28
|
+
* the (1-based) attempt that just failed. Defaults to exponential backoff
|
|
29
|
+
* (100ms, 200ms, 400ms …) capped at 5s.
|
|
30
|
+
*/
|
|
31
|
+
readonly delayMs?: number | ((attempt: number) => number);
|
|
32
|
+
}
|
|
33
|
+
/** The policy a step gets when it declares none: three attempts, exponential backoff. */
|
|
34
|
+
export declare const DEFAULT_RETRY: RetryPolicy;
|
|
35
|
+
/**
|
|
36
|
+
* Normalize authored retry options into a {@link RetryPolicy}, validating
|
|
37
|
+
* loudly (definition-time validation: a malformed policy should fail the
|
|
38
|
+
* deploy, not an execution).
|
|
39
|
+
*/
|
|
40
|
+
export declare const normalizeRetry: (stepName: string, options: RetryOptions | undefined) => RetryPolicy;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step retry policy.
|
|
3
|
+
*
|
|
4
|
+
* Handlers are short-lived, at-least-once and idempotent, so a
|
|
5
|
+
* failed attempt is retried in place: same Execution, same `ctx.executionId`
|
|
6
|
+
* (the idempotency key), same claim. Retries are bounded — "exhausted retries
|
|
7
|
+
* journal a *failed* execution and release the case" — so a permanently
|
|
8
|
+
* failing handler can never hold a case hostage.
|
|
9
|
+
*
|
|
10
|
+
* Only *indeterminate* failures are retried: a handler that threw. A
|
|
11
|
+
* deterministic failure (the returned Case State does not satisfy the case
|
|
12
|
+
* type's schema) fails the Execution immediately — retrying is guaranteed to
|
|
13
|
+
* produce the same defect.
|
|
14
|
+
*/
|
|
15
|
+
/** Exponential backoff from 100ms, capped at 5s. */
|
|
16
|
+
const defaultDelayMs = (attempt) => Math.min(5_000, 100 * 2 ** (attempt - 1));
|
|
17
|
+
/** The policy a step gets when it declares none: three attempts, exponential backoff. */
|
|
18
|
+
export const DEFAULT_RETRY = {
|
|
19
|
+
maxAttempts: 3,
|
|
20
|
+
delayMs: defaultDelayMs,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Normalize authored retry options into a {@link RetryPolicy}, validating
|
|
24
|
+
* loudly (definition-time validation: a malformed policy should fail the
|
|
25
|
+
* deploy, not an execution).
|
|
26
|
+
*/
|
|
27
|
+
export const normalizeRetry = (stepName, options) => {
|
|
28
|
+
if (options === undefined)
|
|
29
|
+
return DEFAULT_RETRY;
|
|
30
|
+
if (typeof options !== 'object' ||
|
|
31
|
+
options === null ||
|
|
32
|
+
Array.isArray(options)) {
|
|
33
|
+
throw new TypeError(`step '${stepName}': retry must be { maxAttempts?, delayMs? }`);
|
|
34
|
+
}
|
|
35
|
+
const { maxAttempts = DEFAULT_RETRY.maxAttempts, delayMs = defaultDelayMs } = options;
|
|
36
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
37
|
+
throw new TypeError(`step '${stepName}': retry.maxAttempts must be an integer >= 1`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof delayMs === 'number') {
|
|
40
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
41
|
+
throw new TypeError(`step '${stepName}': retry.delayMs must be a non-negative number or a function`);
|
|
42
|
+
}
|
|
43
|
+
const fixed = delayMs;
|
|
44
|
+
return { maxAttempts, delayMs: () => fixed };
|
|
45
|
+
}
|
|
46
|
+
if (typeof delayMs !== 'function') {
|
|
47
|
+
throw new TypeError(`step '${stepName}': retry.delayMs must be a non-negative number or a function`);
|
|
48
|
+
}
|
|
49
|
+
return { maxAttempts, delayMs };
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/model/retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAsBH,oDAAoD;AACpD,MAAM,cAAc,GAAG,CAAC,OAAe,EAAU,EAAE,CACjD,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAA;AAE3C,yFAAyF;AACzF,MAAM,CAAC,MAAM,aAAa,GAAgB;IACxC,WAAW,EAAE,CAAC;IACd,OAAO,EAAE,cAAc;CACxB,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,QAAgB,EAChB,OAAiC,EACpB,EAAE;IACf,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,aAAa,CAAA;IAC/C,IACE,OAAO,OAAO,KAAK,QAAQ;QAC3B,OAAO,KAAK,IAAI;QAChB,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EACtB,CAAC;QACD,MAAM,IAAI,SAAS,CACjB,SAAS,QAAQ,6CAA6C,CAC/D,CAAA;IACH,CAAC;IACD,MAAM,EAAE,WAAW,GAAG,aAAa,CAAC,WAAW,EAAE,OAAO,GAAG,cAAc,EAAE,GACzE,OAAO,CAAA;IACT,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,CAAC,IAAI,WAAW,GAAG,CAAC,EAAE,CAAC;QACtD,MAAM,IAAI,SAAS,CACjB,SAAS,QAAQ,8CAA8C,CAChE,CAAA;IACH,CAAC;IACD,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QAChC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;YAC7C,MAAM,IAAI,SAAS,CACjB,SAAS,QAAQ,8DAA8D,CAChF,CAAA;QACH,CAAC;QACD,MAAM,KAAK,GAAG,OAAO,CAAA;QACrB,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,CAAA;IAC9C,CAAC;IACD,IAAI,OAAO,OAAO,KAAK,UAAU,EAAE,CAAC;QAClC,MAAM,IAAI,SAAS,CACjB,SAAS,QAAQ,8DAA8D,CAChF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,CAAA;AACjC,CAAC,CAAA","sourcesContent":["/**\n * Per-step retry policy.\n *\n * Handlers are short-lived, at-least-once and idempotent, so a\n * failed attempt is retried in place: same Execution, same `ctx.executionId`\n * (the idempotency key), same claim. Retries are bounded — \"exhausted retries\n * journal a *failed* execution and release the case\" — so a permanently\n * failing handler can never hold a case hostage.\n *\n * Only *indeterminate* failures are retried: a handler that threw. A\n * deterministic failure (the returned Case State does not satisfy the case\n * type's schema) fails the Execution immediately — retrying is guaranteed to\n * produce the same defect.\n */\n\n/** A normalized retry policy, as held on a {@link StepDefinition}. */\nexport interface RetryPolicy {\n /** Total attempts, including the first — 1 disables retry. */\n readonly maxAttempts: number\n /** Delay before the attempt *after* the given (1-based) failed attempt, in milliseconds. */\n readonly delayMs: (attempt: number) => number\n}\n\n/** Retry configuration as authored on a step; every field optional. */\nexport interface RetryOptions {\n /** Total attempts, including the first. Integer ≥ 1; defaults to 3. */\n readonly maxAttempts?: number\n /**\n * Delay between attempts: a fixed number of milliseconds, or a function of\n * the (1-based) attempt that just failed. Defaults to exponential backoff\n * (100ms, 200ms, 400ms …) capped at 5s.\n */\n readonly delayMs?: number | ((attempt: number) => number)\n}\n\n/** Exponential backoff from 100ms, capped at 5s. */\nconst defaultDelayMs = (attempt: number): number =>\n Math.min(5_000, 100 * 2 ** (attempt - 1))\n\n/** The policy a step gets when it declares none: three attempts, exponential backoff. */\nexport const DEFAULT_RETRY: RetryPolicy = {\n maxAttempts: 3,\n delayMs: defaultDelayMs,\n}\n\n/**\n * Normalize authored retry options into a {@link RetryPolicy}, validating\n * loudly (definition-time validation: a malformed policy should fail the\n * deploy, not an execution).\n */\nexport const normalizeRetry = (\n stepName: string,\n options: RetryOptions | undefined,\n): RetryPolicy => {\n if (options === undefined) return DEFAULT_RETRY\n if (\n typeof options !== 'object' ||\n options === null ||\n Array.isArray(options)\n ) {\n throw new TypeError(\n `step '${stepName}': retry must be { maxAttempts?, delayMs? }`,\n )\n }\n const { maxAttempts = DEFAULT_RETRY.maxAttempts, delayMs = defaultDelayMs } =\n options\n if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {\n throw new TypeError(\n `step '${stepName}': retry.maxAttempts must be an integer >= 1`,\n )\n }\n if (typeof delayMs === 'number') {\n if (!Number.isFinite(delayMs) || delayMs < 0) {\n throw new TypeError(\n `step '${stepName}': retry.delayMs must be a non-negative number or a function`,\n )\n }\n const fixed = delayMs\n return { maxAttempts, delayMs: () => fixed }\n }\n if (typeof delayMs !== 'function') {\n throw new TypeError(\n `step '${stepName}': retry.delayMs must be a non-negative number or a function`,\n )\n }\n return { maxAttempts, delayMs }\n}\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped steps.
|
|
3
|
+
*
|
|
4
|
+
* A step may declare a **scope** over a state collection: each element the
|
|
5
|
+
* scope selects yields one independent affordance, and the affordance's
|
|
6
|
+
* identity is (step name × scope key) — `escalate-verification(buyer_007)`. Journal
|
|
7
|
+
* entries, correlation keys, and execution requests all address a scoped
|
|
8
|
+
* step by that pair, so the key must be **stable**: derived from the
|
|
9
|
+
* element's own identity (an id the app owns), never from its position in
|
|
10
|
+
* the collection.
|
|
11
|
+
*/
|
|
12
|
+
import type { ConditionContext, ConditionMapEntry, ConditionOutcome } from '../guards/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* A step's scope declaration: which elements of case state the step ranges
|
|
15
|
+
* over, and how each element is identified.
|
|
16
|
+
*
|
|
17
|
+
* - `select` — pure selector over case state returning the matching elements
|
|
18
|
+
* (e.g. `s => s.buyers.filter(b => b.verification?.status === 'review')`).
|
|
19
|
+
* Subject to the same discipline as conditions: synchronous,
|
|
20
|
+
* side-effect-free, total over historical state.
|
|
21
|
+
* - `key` — derives the element's **scope key**, a non-empty string that is
|
|
22
|
+
* stable for the life of the element and unique within one selection
|
|
23
|
+
* (typically `i => i.id`). The framework deliberately does not require an
|
|
24
|
+
* `id` field on elements: state schemas are app-owned, so identity
|
|
25
|
+
* derivation is declared, not assumed. Duplicate or invalid keys are an
|
|
26
|
+
* identity-corruption bug and fail affordance computation loudly
|
|
27
|
+
* ({@link ScopeKeyError}).
|
|
28
|
+
*/
|
|
29
|
+
export interface ScopeDeclaration<TState, TElement> {
|
|
30
|
+
readonly select: (state: TState) => readonly TElement[];
|
|
31
|
+
readonly key: (element: TElement) => string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The context a scoped step's conditions receive: the base condition context
|
|
35
|
+
* with the bound scope element present and typed. Authors read the element
|
|
36
|
+
* as `ctx.scope` with no need to narrow away `undefined` — the engine always
|
|
37
|
+
* binds it when evaluating a scoped step.
|
|
38
|
+
*/
|
|
39
|
+
export type ScopedConditionContext<TElement, TActor = unknown> = ConditionContext<TActor, TElement> & {
|
|
40
|
+
readonly scope: TElement;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A condition of a scoped step: same contract as {@link Condition} — pure,
|
|
44
|
+
* synchronous, total, clock-free — receiving the whole case state first and
|
|
45
|
+
* the bound scope element via `ctx.scope`, so cross-cutting facts stay
|
|
46
|
+
* expressible (`(s, ctx) => (ctx.scope.committed ?? 0) <
|
|
47
|
+
* (s.fundingCall?.amount ?? 0)`).
|
|
48
|
+
*/
|
|
49
|
+
export type ScopedCondition<TState, TElement, TActor = unknown> = (state: TState, ctx: ScopedConditionContext<TElement, TActor>) => ConditionOutcome;
|
|
50
|
+
/**
|
|
51
|
+
* What may sit under a name in a scoped step's `requires`/`permits`: a
|
|
52
|
+
* scoped condition.
|
|
53
|
+
*
|
|
54
|
+
* `anyOf` groups are not part of the scoped guard surface (the unscoped
|
|
55
|
+
* algebra keeps them); adding a scoped-typed `anyOf` later is an additive
|
|
56
|
+
* change.
|
|
57
|
+
*/
|
|
58
|
+
export type ScopedConditionMapEntry<TState, TElement, TActor = unknown> = ScopedCondition<TState, TElement, TActor>;
|
|
59
|
+
/** A flat AND-map of named scoped conditions — the scoped counterpart of `ConditionMap`. */
|
|
60
|
+
export type ScopedConditionMap<TState, TElement, TActor = unknown> = Readonly<Record<string, ScopedConditionMapEntry<TState, TElement, TActor>>>;
|
|
61
|
+
/**
|
|
62
|
+
* Erase a scoped condition map to the guards module's entry shape so the
|
|
63
|
+
* engine can hand a scoped step's guard to `evaluateGuard` unchanged. Sound
|
|
64
|
+
* because evaluation of a scoped guard always binds `scope` (which the
|
|
65
|
+
* scoped condition context requires).
|
|
66
|
+
*/
|
|
67
|
+
export declare const eraseScopedConditionMap: <TState, TElement, TActor>(map: ScopedConditionMap<TState, TElement, TActor> | undefined) => Readonly<Record<string, ConditionMapEntry<TState, TActor>>> | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped steps.
|
|
3
|
+
*
|
|
4
|
+
* A step may declare a **scope** over a state collection: each element the
|
|
5
|
+
* scope selects yields one independent affordance, and the affordance's
|
|
6
|
+
* identity is (step name × scope key) — `escalate-verification(buyer_007)`. Journal
|
|
7
|
+
* entries, correlation keys, and execution requests all address a scoped
|
|
8
|
+
* step by that pair, so the key must be **stable**: derived from the
|
|
9
|
+
* element's own identity (an id the app owns), never from its position in
|
|
10
|
+
* the collection.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Erase a scoped condition map to the guards module's entry shape so the
|
|
14
|
+
* engine can hand a scoped step's guard to `evaluateGuard` unchanged. Sound
|
|
15
|
+
* because evaluation of a scoped guard always binds `scope` (which the
|
|
16
|
+
* scoped condition context requires).
|
|
17
|
+
*/
|
|
18
|
+
export const eraseScopedConditionMap = (map) => map;
|
|
19
|
+
//# sourceMappingURL=scope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope.js","sourceRoot":"","sources":["../../src/model/scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAyEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CACrC,GAA6D,EACY,EAAE,CAC3E,GAEa,CAAA","sourcesContent":["/**\n * Scoped steps.\n *\n * A step may declare a **scope** over a state collection: each element the\n * scope selects yields one independent affordance, and the affordance's\n * identity is (step name × scope key) — `escalate-verification(buyer_007)`. Journal\n * entries, correlation keys, and execution requests all address a scoped\n * step by that pair, so the key must be **stable**: derived from the\n * element's own identity (an id the app owns), never from its position in\n * the collection.\n */\n\nimport type {\n ConditionContext,\n ConditionMapEntry,\n ConditionOutcome,\n} from '../guards/index.js'\n\n/**\n * A step's scope declaration: which elements of case state the step ranges\n * over, and how each element is identified.\n *\n * - `select` — pure selector over case state returning the matching elements\n * (e.g. `s => s.buyers.filter(b => b.verification?.status === 'review')`).\n * Subject to the same discipline as conditions: synchronous,\n * side-effect-free, total over historical state.\n * - `key` — derives the element's **scope key**, a non-empty string that is\n * stable for the life of the element and unique within one selection\n * (typically `i => i.id`). The framework deliberately does not require an\n * `id` field on elements: state schemas are app-owned, so identity\n * derivation is declared, not assumed. Duplicate or invalid keys are an\n * identity-corruption bug and fail affordance computation loudly\n * ({@link ScopeKeyError}).\n */\nexport interface ScopeDeclaration<TState, TElement> {\n readonly select: (state: TState) => readonly TElement[]\n readonly key: (element: TElement) => string\n}\n\n/**\n * The context a scoped step's conditions receive: the base condition context\n * with the bound scope element present and typed. Authors read the element\n * as `ctx.scope` with no need to narrow away `undefined` — the engine always\n * binds it when evaluating a scoped step.\n */\nexport type ScopedConditionContext<\n TElement,\n TActor = unknown,\n> = ConditionContext<TActor, TElement> & {\n readonly scope: TElement\n}\n\n/**\n * A condition of a scoped step: same contract as {@link Condition} — pure,\n * synchronous, total, clock-free — receiving the whole case state first and\n * the bound scope element via `ctx.scope`, so cross-cutting facts stay\n * expressible (`(s, ctx) => (ctx.scope.committed ?? 0) <\n * (s.fundingCall?.amount ?? 0)`).\n */\nexport type ScopedCondition<TState, TElement, TActor = unknown> = (\n state: TState,\n ctx: ScopedConditionContext<TElement, TActor>,\n) => ConditionOutcome\n\n/**\n * What may sit under a name in a scoped step's `requires`/`permits`: a\n * scoped condition.\n *\n * `anyOf` groups are not part of the scoped guard surface (the unscoped\n * algebra keeps them); adding a scoped-typed `anyOf` later is an additive\n * change.\n */\nexport type ScopedConditionMapEntry<\n TState,\n TElement,\n TActor = unknown,\n> = ScopedCondition<TState, TElement, TActor>\n\n/** A flat AND-map of named scoped conditions — the scoped counterpart of `ConditionMap`. */\nexport type ScopedConditionMap<TState, TElement, TActor = unknown> = Readonly<\n Record<string, ScopedConditionMapEntry<TState, TElement, TActor>>\n>\n\n/**\n * Erase a scoped condition map to the guards module's entry shape so the\n * engine can hand a scoped step's guard to `evaluateGuard` unchanged. Sound\n * because evaluation of a scoped guard always binds `scope` (which the\n * scoped condition context requires).\n */\nexport const eraseScopedConditionMap = <TState, TElement, TActor>(\n map: ScopedConditionMap<TState, TElement, TActor> | undefined,\n): Readonly<Record<string, ConditionMapEntry<TState, TActor>>> | undefined =>\n map as unknown as\n | Readonly<Record<string, ConditionMapEntry<TState, TActor>>>\n | undefined\n"]}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step definitions.
|
|
3
|
+
*
|
|
4
|
+
* A **step** is an independently-defined unit of possible work on a case: a
|
|
5
|
+
* guard plus a handler (CONTEXT.md). Steps never declare ordering —
|
|
6
|
+
* sequencing is data dependencies between guards. `step()` validates the
|
|
7
|
+
* definition loudly at construction time: a malformed step should fail the
|
|
8
|
+
* deploy, not an evaluation.
|
|
9
|
+
*/
|
|
10
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
11
|
+
import { AffordanceError } from '../errors.js';
|
|
12
|
+
import type { ConditionMap, Guard } from '../guards/index.js';
|
|
13
|
+
import type { ErasedStepHandler, ScopedStepHandler, StepHandler } from './handler.js';
|
|
14
|
+
import type { RetryOptions, RetryPolicy } from './retry.js';
|
|
15
|
+
import type { ScopeDeclaration, ScopedConditionMap } from './scope.js';
|
|
16
|
+
/** Options for an unscoped step. */
|
|
17
|
+
export interface StepOptions<TState, TActor = unknown, TInput = undefined> {
|
|
18
|
+
/** The step's name — unique within its case type; half of a scoped affordance's identity. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/**
|
|
21
|
+
* A short human label for the step ("Issue the funding call"). Definition
|
|
22
|
+
* metadata, not identity: journal entries and refusals name the step by
|
|
23
|
+
* `name`; adapters serialize the title so a client renders steps without
|
|
24
|
+
* an out-of-band label table.
|
|
25
|
+
*/
|
|
26
|
+
readonly title?: string;
|
|
27
|
+
/**
|
|
28
|
+
* A sentence on what the step does and when to take it — what makes the
|
|
29
|
+
* affordance contract a usable tool list for a caller (an agent included)
|
|
30
|
+
* that has never seen this case type.
|
|
31
|
+
*/
|
|
32
|
+
readonly description?: string;
|
|
33
|
+
/** Case conditions: when one fails the step is not possible on this case, for anyone. */
|
|
34
|
+
readonly requires?: ConditionMap<TState, TActor>;
|
|
35
|
+
/** Actor conditions: when one fails the step is possible but not permitted for this actor. */
|
|
36
|
+
readonly permits?: ConditionMap<TState, TActor>;
|
|
37
|
+
/**
|
|
38
|
+
* Optional input schema (any Standard Schema — zod v4 qualifies). Validated
|
|
39
|
+
* by `validateStepInput` before the handler runs.
|
|
40
|
+
*/
|
|
41
|
+
readonly input?: StandardSchemaV1<unknown, TInput>;
|
|
42
|
+
/**
|
|
43
|
+
* How many times a failed attempt is retried, and how long between
|
|
44
|
+
* attempts. Defaults to three attempts with exponential backoff; set
|
|
45
|
+
* `{ maxAttempts: 1 }` to disable retry for this step.
|
|
46
|
+
*/
|
|
47
|
+
readonly retry?: RetryOptions;
|
|
48
|
+
/** The step's effect function — the only thing that mutates Case State. */
|
|
49
|
+
readonly handler: StepHandler<TState, TActor, TInput>;
|
|
50
|
+
}
|
|
51
|
+
/** Options for a scoped step: an unscoped step plus the scope declaration. */
|
|
52
|
+
export interface ScopedStepOptions<TState, TElement, TActor = unknown, TInput = undefined> {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly title?: string;
|
|
55
|
+
readonly description?: string;
|
|
56
|
+
/** The collection the step ranges over and how each element is identified. */
|
|
57
|
+
readonly scope: ScopeDeclaration<TState, TElement>;
|
|
58
|
+
readonly requires?: ScopedConditionMap<TState, TElement, TActor>;
|
|
59
|
+
readonly permits?: ScopedConditionMap<TState, TElement, TActor>;
|
|
60
|
+
readonly input?: StandardSchemaV1<unknown, TInput>;
|
|
61
|
+
readonly retry?: RetryOptions;
|
|
62
|
+
readonly handler: ScopedStepHandler<TState, TElement, TActor, TInput>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A step definition as held by a case type and consumed by the engine — the
|
|
66
|
+
* authoring generics (scope element, input) erased to the case-state level.
|
|
67
|
+
* The options types above carry the precise authoring shapes; this is the
|
|
68
|
+
* machine-facing normal form.
|
|
69
|
+
*/
|
|
70
|
+
export interface StepDefinition<TState, TActor = unknown> {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
/** The declared human label, or `null` — clients fall back to `name`. */
|
|
73
|
+
readonly title: string | null;
|
|
74
|
+
/** The declared what-and-when sentence, or `null`. */
|
|
75
|
+
readonly description: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* The step's guard in the guards module's shape, ready for `evaluateGuard`.
|
|
78
|
+
* For a scoped step the engine evaluates it once per selected element with
|
|
79
|
+
* the element bound as `scope`.
|
|
80
|
+
*/
|
|
81
|
+
readonly guard: Guard<TState, TActor>;
|
|
82
|
+
/** The scope declaration (element type erased), or `null` for an unscoped step. */
|
|
83
|
+
readonly scope: {
|
|
84
|
+
readonly select: (state: TState) => readonly unknown[];
|
|
85
|
+
readonly key: (element: unknown) => string;
|
|
86
|
+
} | null;
|
|
87
|
+
/** The declared input schema, or `null` when the step takes no input. */
|
|
88
|
+
readonly input: StandardSchemaV1 | null;
|
|
89
|
+
/** The normalized retry policy the execution lifecycle applies to this step. */
|
|
90
|
+
readonly retry: RetryPolicy;
|
|
91
|
+
/** The step's handler — invoked only by the execution lifecycle. */
|
|
92
|
+
readonly handler: ErasedStepHandler<TState, TActor>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A step's declared human metadata, as `Engine.stepMetadataFor` answers it —
|
|
96
|
+
* the serializable slice of a {@link StepDefinition} an adapter puts on the
|
|
97
|
+
* wire.
|
|
98
|
+
*/
|
|
99
|
+
export interface StepMetadata {
|
|
100
|
+
readonly title: string | null;
|
|
101
|
+
readonly description: string | null;
|
|
102
|
+
}
|
|
103
|
+
/** Whether a value is a Standard-Schema instance — the one spelling of the check. */
|
|
104
|
+
export declare const isStandardSchema: (value: unknown) => value is StandardSchemaV1;
|
|
105
|
+
/**
|
|
106
|
+
* Whether a value is plausibly a {@link StepDefinition} — the structural
|
|
107
|
+
* check `caseType()` applies to every step it is given.
|
|
108
|
+
*/
|
|
109
|
+
export declare const looksLikeStepDefinition: (value: unknown) => value is StepDefinition<unknown, unknown>;
|
|
110
|
+
/**
|
|
111
|
+
* Define a step. Two shapes, discriminated by the presence of `scope`:
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* // Standalone form (internal — apps author through {@link stepsOf}, which
|
|
115
|
+
* // binds the generics once and takes the same options annotation-free):
|
|
116
|
+
* // type parameters are inferred from annotations in the options — annotate
|
|
117
|
+
* // the state parameter of a condition (and the actor on a permits ctx).
|
|
118
|
+
* step({
|
|
119
|
+
* name: 'issue-funding-call',
|
|
120
|
+
* requires: { escrowReady: (s: Purchase) => s.escrow?.status === 'open' },
|
|
121
|
+
* permits: { isOrganizer: (_s: Purchase, ctx: ConditionContext<Ops>) => ctx.actor.roles.includes('organizer') },
|
|
122
|
+
* handler: async (s, ctx) => s,
|
|
123
|
+
* })
|
|
124
|
+
*
|
|
125
|
+
* // Scoped, standalone form: annotating scope.select anchors both the state
|
|
126
|
+
* // and element types; conditions then read the bound element as ctx.scope,
|
|
127
|
+
* // fully typed (through stepsOf, even the select annotation goes away):
|
|
128
|
+
* step({
|
|
129
|
+
* name: 'escalate-verification',
|
|
130
|
+
* scope: { select: (s: Purchase) => s.buyers.filter(b => b.verification?.status === 'review'), key: b => b.id },
|
|
131
|
+
* requires: { flagged: (s: Purchase, ctx) => (ctx.scope as Buyer).verification?.flaggedAt != null },
|
|
132
|
+
* handler: async (s, ctx) => s,
|
|
133
|
+
* })
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* Validates loudly at construction time — malformed guard entries, a
|
|
137
|
+
* malformed scope declaration, a non-function handler, or a non-schema
|
|
138
|
+
* `input` all throw `TypeError` (definition-time validation; a definition
|
|
139
|
+
* bug should fail the deploy, not an evaluation).
|
|
140
|
+
*
|
|
141
|
+
* The scoped overload is declared first: type parameters are meant to be
|
|
142
|
+
* inferred, and inference resolves each call shape against its own overload
|
|
143
|
+
* (explicit type-argument lists interact badly with overloaded
|
|
144
|
+
* context-sensitive options — annotate inside the options instead).
|
|
145
|
+
*/
|
|
146
|
+
export declare function step<TState, TElement, TActor = unknown, TInput = undefined>(options: ScopedStepOptions<TState, TElement, TActor, TInput>): StepDefinition<TState, TActor>;
|
|
147
|
+
export declare function step<TState, TActor = unknown, TInput = undefined>(options: StepOptions<TState, TActor, TInput>): StepDefinition<TState, TActor>;
|
|
148
|
+
/**
|
|
149
|
+
* The `step()` authoring surface with the case's state and actor types fixed.
|
|
150
|
+
*
|
|
151
|
+
* Same two call shapes as `step()` — scoped first, discriminated by the
|
|
152
|
+
* presence of `scope` — but `TState`/`TActor` are already substituted, so
|
|
153
|
+
* only the per-step generics (scope element, input) remain to be inferred.
|
|
154
|
+
* That is what makes annotation-free authoring work: conditions and handlers
|
|
155
|
+
* no longer participate in inferring the state type, they just receive it
|
|
156
|
+
* contextually, and a scoped step's element type anchors on `scope.select`
|
|
157
|
+
* alone (so `ctx.scope` is the element, with no `undefined` to narrow away).
|
|
158
|
+
*/
|
|
159
|
+
export interface BoundStep<TState, TActor = unknown> {
|
|
160
|
+
<TElement, TInput = undefined>(options: ScopedStepOptions<TState, TElement, TActor, TInput>): StepDefinition<TState, TActor>;
|
|
161
|
+
<TInput = undefined>(options: StepOptions<TState, TActor, TInput>): StepDefinition<TState, TActor>;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A value-level carrier for `stepsOf`'s actor type — nothing but the type.
|
|
165
|
+
* Exists because `TActor` has no value to be inferred from (a case's actor
|
|
166
|
+
* shape is app-defined and never materializes at definition time), and
|
|
167
|
+
* spelling it as a type argument would force spelling the schema's type too
|
|
168
|
+
* (TypeScript has no partial type-argument inference).
|
|
169
|
+
*/
|
|
170
|
+
export interface ActorMarker<TActor> {
|
|
171
|
+
readonly __actor?: TActor;
|
|
172
|
+
}
|
|
173
|
+
/** Name the actor type of a `stepsOf` factory: `stepsOf(PurchaseState, actor<PurchaseActor>())`. */
|
|
174
|
+
export declare const actor: <TActor>() => ActorMarker<TActor>;
|
|
175
|
+
/**
|
|
176
|
+
* Bind `step()` to a case's state schema — the schema-anchored authoring
|
|
177
|
+
* factory.
|
|
178
|
+
*
|
|
179
|
+
* ```ts
|
|
180
|
+
* const purchaseStep = stepsOf(PurchaseState, actor<PurchaseActor>())
|
|
181
|
+
*
|
|
182
|
+
* purchaseStep({
|
|
183
|
+
* name: 'issue-funding-call',
|
|
184
|
+
* requires: { escrowReady: s => s.escrow.status === 'open' }, // s: inferred from the schema
|
|
185
|
+
* permits: { isOrganizer: (_s, ctx) => hasRole(ctx.actor, 'organizer') },
|
|
186
|
+
* handler: async s => s,
|
|
187
|
+
* })
|
|
188
|
+
*
|
|
189
|
+
* purchaseStep({
|
|
190
|
+
* name: 'escalate-verification',
|
|
191
|
+
* scope: { select: s => s.buyers.filter(b => b.verification.status === 'review'), key: b => b.id },
|
|
192
|
+
* requires: { flagged: (_s, ctx) => ctx.scope.verification.flaggedAt !== null }, // ctx.scope: Buyer
|
|
193
|
+
* handler: async s => s,
|
|
194
|
+
* })
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* The state type is derived from the schema *value* — the same
|
|
198
|
+
* `InferOutput` derivation `caseType` performs — so the factory and the case
|
|
199
|
+
* type are anchored to one declaration and cannot drift apart: the state a
|
|
200
|
+
* condition sees is definitionally the state the engine validates against.
|
|
201
|
+
* The per-condition annotations the bare `step()` needs
|
|
202
|
+
* (`(s: Purchase) => …`) disappear, because `TState` is no longer inferred
|
|
203
|
+
* from the options.
|
|
204
|
+
*
|
|
205
|
+
* The second argument exists only to name the actor type and carries no
|
|
206
|
+
* runtime information; omit it for an untyped actor. Define one factory per
|
|
207
|
+
* case type module, next to the schema, and author every step of that case
|
|
208
|
+
* type through it.
|
|
209
|
+
*
|
|
210
|
+
* Returns `step` itself, re-typed — a step authored through the factory is
|
|
211
|
+
* bit-for-bit an ordinary step definition. `step` is deliberately not part
|
|
212
|
+
* of the package barrel: this factory is the public authoring surface, and
|
|
213
|
+
* a helper that builds steps generically should accept a
|
|
214
|
+
* {@link BoundStep} rather than reach for the unbound `step`.
|
|
215
|
+
* Throws at definition time when `state` is not a Standard Schema, like
|
|
216
|
+
* every other malformed-definition case in this module.
|
|
217
|
+
*/
|
|
218
|
+
export declare const stepsOf: <S extends StandardSchemaV1, TActor = unknown>(state: S, _actor?: ActorMarker<TActor>) => BoundStep<StandardSchemaV1.InferOutput<S>, TActor>;
|
|
219
|
+
/** A step's declared input failed validation against its input schema. */
|
|
220
|
+
export declare class StepInputValidationError extends AffordanceError {
|
|
221
|
+
readonly stepName: string;
|
|
222
|
+
readonly issues: readonly StandardSchemaV1.Issue[];
|
|
223
|
+
constructor(stepName: string, issues: readonly StandardSchemaV1.Issue[]);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Validate a step's input against its declared input schema — the validation
|
|
227
|
+
* plumbing the execution lifecycle runs before invoking the handler ("validated before the
|
|
228
|
+
* handler runs"). Returns the schema *output* (defaults applied). A step
|
|
229
|
+
* without an input schema accepts only `undefined` and yields `undefined`;
|
|
230
|
+
* anything else is a caller bug and throws.
|
|
231
|
+
*/
|
|
232
|
+
export declare const validateStepInput: <TState, TActor>(definition: StepDefinition<TState, TActor>, input: unknown) => Promise<unknown>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step definitions.
|
|
3
|
+
*
|
|
4
|
+
* A **step** is an independently-defined unit of possible work on a case: a
|
|
5
|
+
* guard plus a handler (CONTEXT.md). Steps never declare ordering —
|
|
6
|
+
* sequencing is data dependencies between guards. `step()` validates the
|
|
7
|
+
* definition loudly at construction time: a malformed step should fail the
|
|
8
|
+
* deploy, not an evaluation.
|
|
9
|
+
*/
|
|
10
|
+
import { AffordanceError } from '../errors.js';
|
|
11
|
+
import { guardEntries } from '../guards/index.js';
|
|
12
|
+
import { normalizeRetry } from './retry.js';
|
|
13
|
+
import { eraseScopedConditionMap } from './scope.js';
|
|
14
|
+
/** Whether a value is a Standard-Schema instance — the one spelling of the check. */
|
|
15
|
+
export const isStandardSchema = (value) => typeof value === 'object' &&
|
|
16
|
+
value !== null &&
|
|
17
|
+
typeof value['~standard'] === 'object';
|
|
18
|
+
/**
|
|
19
|
+
* Whether a value is plausibly a {@link StepDefinition} — the structural
|
|
20
|
+
* check `caseType()` applies to every step it is given.
|
|
21
|
+
*/
|
|
22
|
+
export const looksLikeStepDefinition = (value) => typeof value === 'object' &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
typeof value.name === 'string' &&
|
|
25
|
+
typeof value.handler === 'function' &&
|
|
26
|
+
typeof value.guard === 'object';
|
|
27
|
+
/**
|
|
28
|
+
* Validate one guard section map at definition time: every entry must be a
|
|
29
|
+
* condition function or an `anyOf(...)` group
|
|
30
|
+
* (unscoped only — scoped maps admit conditions alone).
|
|
31
|
+
*
|
|
32
|
+
* The classification and the `requires.escrowReady` addressing both come from
|
|
33
|
+
* `guardEntries`, so what a definition is allowed to contain is decided
|
|
34
|
+
* against the same walk evaluation uses — a definition `step()` accepts is
|
|
35
|
+
* one `evaluateGuard` can read.
|
|
36
|
+
*/
|
|
37
|
+
const validateConditionMap = (stepName, section, map, allowAnyOf) => {
|
|
38
|
+
if (map === undefined)
|
|
39
|
+
return;
|
|
40
|
+
if (typeof map !== 'object' || map === null || Array.isArray(map)) {
|
|
41
|
+
throw new TypeError(`step '${stepName}': ${section} must be a plain object of named conditions`);
|
|
42
|
+
}
|
|
43
|
+
const section_ = { [section]: map };
|
|
44
|
+
for (const entry of guardEntries(section_)) {
|
|
45
|
+
if (entry.kind === 'condition')
|
|
46
|
+
continue;
|
|
47
|
+
if (entry.kind === 'anyOf' && allowAnyOf)
|
|
48
|
+
continue;
|
|
49
|
+
const allowed = allowAnyOf
|
|
50
|
+
? 'a condition function or an anyOf(...) group'
|
|
51
|
+
: 'a condition function (anyOf is not part of the scoped guard surface)';
|
|
52
|
+
throw new TypeError(`step '${stepName}': ${entry.address} must be ${allowed}`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const validateCommon = (options) => {
|
|
56
|
+
const { name } = options;
|
|
57
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
58
|
+
throw new TypeError('step: name must be a non-empty string');
|
|
59
|
+
}
|
|
60
|
+
if (typeof options.handler !== 'function') {
|
|
61
|
+
throw new TypeError(`step '${name}': handler must be an async function`);
|
|
62
|
+
}
|
|
63
|
+
if (options.input !== undefined && !isStandardSchema(options.input)) {
|
|
64
|
+
throw new TypeError(`step '${name}': input must be a Standard Schema (e.g. a zod schema)`);
|
|
65
|
+
}
|
|
66
|
+
for (const field of ['title', 'description']) {
|
|
67
|
+
const value = options[field];
|
|
68
|
+
if (value !== undefined &&
|
|
69
|
+
(typeof value !== 'string' || value.trim() === '')) {
|
|
70
|
+
throw new TypeError(`step '${name}': ${field} must be a non-empty string when given`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return name;
|
|
74
|
+
};
|
|
75
|
+
export function step(options) {
|
|
76
|
+
const name = validateCommon(options);
|
|
77
|
+
const scoped = 'scope' in options && options.scope !== undefined;
|
|
78
|
+
let scope = null;
|
|
79
|
+
let guard;
|
|
80
|
+
if (scoped) {
|
|
81
|
+
const declaration = options.scope;
|
|
82
|
+
if (typeof declaration !== 'object' ||
|
|
83
|
+
declaration === null ||
|
|
84
|
+
typeof declaration.select !== 'function' ||
|
|
85
|
+
typeof declaration.key !== 'function') {
|
|
86
|
+
throw new TypeError(`step '${name}': scope must be { select: state => elements, key: element => string }`);
|
|
87
|
+
}
|
|
88
|
+
validateConditionMap(name, 'requires', options.requires, false);
|
|
89
|
+
validateConditionMap(name, 'permits', options.permits, false);
|
|
90
|
+
scope = { select: declaration.select, key: declaration.key };
|
|
91
|
+
// Erasure, not conversion: scoped maps are runtime-identical to unscoped
|
|
92
|
+
// ones; evaluation binds the scope element the scoped types promise.
|
|
93
|
+
const scopedOptions = options;
|
|
94
|
+
guard = {
|
|
95
|
+
...(scopedOptions.requires !== undefined && {
|
|
96
|
+
requires: eraseScopedConditionMap(scopedOptions.requires),
|
|
97
|
+
}),
|
|
98
|
+
...(scopedOptions.permits !== undefined && {
|
|
99
|
+
permits: eraseScopedConditionMap(scopedOptions.permits),
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const unscoped = options;
|
|
105
|
+
validateConditionMap(name, 'requires', unscoped.requires, true);
|
|
106
|
+
validateConditionMap(name, 'permits', unscoped.permits, true);
|
|
107
|
+
guard = {
|
|
108
|
+
...(unscoped.requires !== undefined && { requires: unscoped.requires }),
|
|
109
|
+
...(unscoped.permits !== undefined && { permits: unscoped.permits }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
title: options.title ?? null,
|
|
115
|
+
description: options.description ?? null,
|
|
116
|
+
guard,
|
|
117
|
+
scope,
|
|
118
|
+
input: options.input ?? null,
|
|
119
|
+
retry: normalizeRetry(name, options.retry),
|
|
120
|
+
// The one erasure cast for handlers: the authored context (typed input,
|
|
121
|
+
// typed scope element) is what the execution lifecycle constructs; see
|
|
122
|
+
// ErasedStepHandler.
|
|
123
|
+
handler: options.handler,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const ACTOR_MARKER = Object.freeze({});
|
|
127
|
+
/** Name the actor type of a `stepsOf` factory: `stepsOf(PurchaseState, actor<PurchaseActor>())`. */
|
|
128
|
+
export const actor = () => ACTOR_MARKER;
|
|
129
|
+
/**
|
|
130
|
+
* Bind `step()` to a case's state schema — the schema-anchored authoring
|
|
131
|
+
* factory.
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* const purchaseStep = stepsOf(PurchaseState, actor<PurchaseActor>())
|
|
135
|
+
*
|
|
136
|
+
* purchaseStep({
|
|
137
|
+
* name: 'issue-funding-call',
|
|
138
|
+
* requires: { escrowReady: s => s.escrow.status === 'open' }, // s: inferred from the schema
|
|
139
|
+
* permits: { isOrganizer: (_s, ctx) => hasRole(ctx.actor, 'organizer') },
|
|
140
|
+
* handler: async s => s,
|
|
141
|
+
* })
|
|
142
|
+
*
|
|
143
|
+
* purchaseStep({
|
|
144
|
+
* name: 'escalate-verification',
|
|
145
|
+
* scope: { select: s => s.buyers.filter(b => b.verification.status === 'review'), key: b => b.id },
|
|
146
|
+
* requires: { flagged: (_s, ctx) => ctx.scope.verification.flaggedAt !== null }, // ctx.scope: Buyer
|
|
147
|
+
* handler: async s => s,
|
|
148
|
+
* })
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* The state type is derived from the schema *value* — the same
|
|
152
|
+
* `InferOutput` derivation `caseType` performs — so the factory and the case
|
|
153
|
+
* type are anchored to one declaration and cannot drift apart: the state a
|
|
154
|
+
* condition sees is definitionally the state the engine validates against.
|
|
155
|
+
* The per-condition annotations the bare `step()` needs
|
|
156
|
+
* (`(s: Purchase) => …`) disappear, because `TState` is no longer inferred
|
|
157
|
+
* from the options.
|
|
158
|
+
*
|
|
159
|
+
* The second argument exists only to name the actor type and carries no
|
|
160
|
+
* runtime information; omit it for an untyped actor. Define one factory per
|
|
161
|
+
* case type module, next to the schema, and author every step of that case
|
|
162
|
+
* type through it.
|
|
163
|
+
*
|
|
164
|
+
* Returns `step` itself, re-typed — a step authored through the factory is
|
|
165
|
+
* bit-for-bit an ordinary step definition. `step` is deliberately not part
|
|
166
|
+
* of the package barrel: this factory is the public authoring surface, and
|
|
167
|
+
* a helper that builds steps generically should accept a
|
|
168
|
+
* {@link BoundStep} rather than reach for the unbound `step`.
|
|
169
|
+
* Throws at definition time when `state` is not a Standard Schema, like
|
|
170
|
+
* every other malformed-definition case in this module.
|
|
171
|
+
*/
|
|
172
|
+
export const stepsOf = (state, _actor) => {
|
|
173
|
+
if (!isStandardSchema(state)) {
|
|
174
|
+
throw new TypeError('stepsOf: state must be a Standard Schema (e.g. a zod schema)');
|
|
175
|
+
}
|
|
176
|
+
return step;
|
|
177
|
+
};
|
|
178
|
+
/** A step's declared input failed validation against its input schema. */
|
|
179
|
+
export class StepInputValidationError extends AffordanceError {
|
|
180
|
+
stepName;
|
|
181
|
+
issues;
|
|
182
|
+
constructor(stepName, issues) {
|
|
183
|
+
super('invalid-input', `invalid input for step '${stepName}': ${issues.map((issue) => issue.message).join('; ')}`);
|
|
184
|
+
this.name = 'StepInputValidationError';
|
|
185
|
+
this.stepName = stepName;
|
|
186
|
+
this.issues = issues;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Validate a step's input against its declared input schema — the validation
|
|
191
|
+
* plumbing the execution lifecycle runs before invoking the handler ("validated before the
|
|
192
|
+
* handler runs"). Returns the schema *output* (defaults applied). A step
|
|
193
|
+
* without an input schema accepts only `undefined` and yields `undefined`;
|
|
194
|
+
* anything else is a caller bug and throws.
|
|
195
|
+
*/
|
|
196
|
+
export const validateStepInput = async (definition, input) => {
|
|
197
|
+
if (definition.input === null) {
|
|
198
|
+
if (input !== undefined) {
|
|
199
|
+
throw new StepInputValidationError(definition.name, [
|
|
200
|
+
{ message: 'step declares no input schema, but input was provided' },
|
|
201
|
+
]);
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const result = await definition.input['~standard'].validate(input);
|
|
206
|
+
if (result.issues)
|
|
207
|
+
throw new StepInputValidationError(definition.name, result.issues);
|
|
208
|
+
return result.value;
|
|
209
|
+
};
|
|
210
|
+
//# sourceMappingURL=step.js.map
|