Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@standard-schema/spec": "^1.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"oxfmt": "^0.46.0",
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/entry-exit.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type {
EagerModuleEntryPoint,
EntryPointMap,
ExitContract,
ExitPointMap,
ExitPointSchema,
InputSchema,
LazyModuleEntryPoint,
ModuleDescriptor,
ModuleEntryPoint,
StandardSchemaLike,
} from "./types.js";

/**
Expand Down Expand Up @@ -51,6 +54,68 @@ export function defineExit<TOutput = void>(): ExitPointSchema<TOutput> {
return {} as ExitPointSchema<TOutput>;
}

/**
* Define a shared exit contract — an `ExitPointSchema` with a stable
* identity (`kind`) and an optional Standard Schema for runtime payload
* validation. Two modules that emit the same kind of exit can both
* reference the same contract value as their exit's schema; the journey
* runtime then treats them uniformly under wildcard transitions and
* (when a schema is supplied) validates payloads at every emit.
*
* Two call shapes:
*
* ```ts
* // Type-only — declared TOutput, zero runtime cost. Equivalent to
* // `defineExit<T>()` plus a stable identity for cross-module sharing.
* const errorContract = defineExitContract<{ code: string }>("error");
*
* // Schema form — TOutput inferred from the schema (any
* // StandardSchemaV1 implementation: Zod, Valibot, ArkType, ...).
* // Runtime validates payloads at emit time; bad payloads abort the
* // journey with reason `exit-payload-invalid`.
* const cancelledContract = defineExitContract(
* "cancelled",
* z.object({ reason: z.string() }),
* );
* ```
*
* Modules opt in by referencing the contract value as their exit's
* schema:
*
* ```ts
* const exits = {
* cancelled: cancelledContract, // shared
* error: errorContract, // shared
* finished: defineExit<{ profileId: string }>(),
* } as const;
* ```
*/
export function defineExitContract<TOutput>(kind: string): ExitContract<TOutput>;
export function defineExitContract<TSchema extends StandardSchemaV1>(
kind: string,
schema: TSchema,
): ExitContract<StandardSchemaV1.InferOutput<TSchema>>;
export function defineExitContract(kind: string, schema?: StandardSchemaV1): ExitContract<unknown> {
return schema ? { kind, schema: schema as StandardSchemaLike<unknown> } : { kind };
}

/**
* Type predicate distinguishing an `ExitContract` from a plain
* `ExitPointSchema`. Used by the journey runtime to decide whether to
* apply schema validation at emit time and by validators to enforce
* cross-module shape consistency under wildcard transitions.
*
* `kind: string` is the discriminator: `defineExit()` returns `{}` (no
* `kind`), so a string `kind` field reliably identifies a contract.
*/
export function isExitContract(schema: unknown): schema is ExitContract<unknown> {
return (
typeof schema === "object" &&
schema !== null &&
typeof (schema as { kind?: unknown }).kind === "string"
);
}

/**
* Structural validation for a module's entry/exit declarations. Accumulates
* all issues into a single error — callers wrap the module id around the
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,25 @@ export type {
LazyEntryComponent,
ModuleEntryProps,
ExitPointSchema,
ExitContract,
StandardSchemaLike,
StandardSchemaResult,
StandardSchemaIssue,
EntryPointMap,
ExitPointMap,
ExitFn,
InputSchema,
} from "./types.js";

// Entry / exit helpers
export { defineEntry, defineExit, schema, validateModuleEntryExit } from "./entry-exit.js";
export {
defineEntry,
defineExit,
defineExitContract,
isExitContract,
schema,
validateModuleEntryExit,
} from "./entry-exit.js";

// Store
export { createStore } from "./store.js";
Expand Down Expand Up @@ -113,6 +124,15 @@ export type {
TransitionResult,
EntryTransitions,
TransitionMap,
WildcardTransitionMap,
EntryExitWildcardMap,
ExitOnlyWildcardMap,
WildcardEntryNamesOf,
WildcardExitNamesOf,
ExitNamesPairedWithEntry,
WildcardExitOutputOf,
WildcardExitOutputForEntry,
WildcardEntryInputOf,
TransitionEvent,
AbandonCtx,
TerminalCtx,
Expand Down
158 changes: 156 additions & 2 deletions packages/core/src/journey-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

import type {
EntryPointMap,
ExitContract,

Check warning on line 16 in packages/core/src/journey-contracts.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Type 'ExitContract' is imported but never used.
ExitPointMap,
ExitPointSchema,
ModuleDescriptor,
ModuleEntryPoint,
StandardSchemaIssue,
} from "./types.js";

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -228,6 +230,131 @@
};
};

// -----------------------------------------------------------------------------
// Wildcard transitions — match by exit (and optionally entry) when the source
// module isn't the dispatching key. Resolution precedence in `dispatchExit`:
//
// 1. transitions[mod][entry][exit] — exact
// 2. wildcardTransitions.byEntryAndExit[entry][exit] — module wildcard
// 3. wildcardTransitions.byExit[exit] — module + entry wildcard
//
// More precise always wins because the lookup checks 1 → 2 → 3 in order; the
// first hit fires. See `packages/journeys/src/runtime.ts` `dispatchExit`.
// -----------------------------------------------------------------------------

/** Union of every entry name declared by any module in the map. */
export type WildcardEntryNamesOf<TModules extends ModuleTypeMap> = {
[M in keyof TModules]: EntryNamesOf<TModules[M]>;
}[keyof TModules];

/** Union of every exit name declared by any module in the map. */
export type WildcardExitNamesOf<TModules extends ModuleTypeMap> = {
[M in keyof TModules]: ExitNamesOf<TModules[M]>;
}[keyof TModules];

/** Exit names emitted by modules that ALSO declare entry `E`. */
export type ExitNamesPairedWithEntry<TModules extends ModuleTypeMap, E> = {
[M in keyof TModules]: E extends EntryNamesOf<TModules[M]> ? ExitNamesOf<TModules[M]> : never;
}[keyof TModules];

/**
* Output type for a wildcard handler keyed by `[entry, exit]` (tier 2).
* Intersection of the matching modules' `ExitOutputOf<TModules[M], X>` —
* the safest read when the source module isn't known. When every
* matching module uses the same `ExitContract`, this collapses to the
* contract's `T` because the contract object is referenced by identity.
*/
export type WildcardExitOutputForEntry<TModules extends ModuleTypeMap, E, X> = UnionToIntersection<
{
[M in keyof TModules]: E extends EntryNamesOf<TModules[M]>
? X extends ExitNamesOf<TModules[M]>
? ExitOutputOf<TModules[M], X>
: never
: never;
}[keyof TModules]
>;

/** Output type for a wildcard handler keyed by `[exit]` only (tier 3). */
export type WildcardExitOutputOf<TModules extends ModuleTypeMap, X> = UnionToIntersection<
{
[M in keyof TModules]: X extends ExitNamesOf<TModules[M]>
? ExitOutputOf<TModules[M], X>
: never;
}[keyof TModules]
>;

/**
* Input type passed to a tier-2 handler — intersection of
* `EntryInputOf<TModules[M], E>` across modules that declare entry `E`.
* Tier-3 handlers receive `unknown` instead (the entry is also unknown).
*/
export type WildcardEntryInputOf<TModules extends ModuleTypeMap, E> = UnionToIntersection<
{
[M in keyof TModules]: E extends EntryNamesOf<TModules[M]>
? EntryInputOf<TModules[M], E>
: never;
}[keyof TModules]
>;

/**
* Tier-2 wildcard map: module unknown, entry name + exit name known.
* Keyed by entry first (the more stable axis at the call site), then by
* exit. Handlers receive a typed `output` and `input` derived from the
* matching modules.
*/
export type EntryExitWildcardMap<TModules extends ModuleTypeMap, TState, TOutput = unknown> = {
readonly [E in WildcardEntryNamesOf<TModules>]?: {
readonly [X in ExitNamesPairedWithEntry<TModules, E>]?: (
ctx: ExitCtx<
TState,
WildcardExitOutputForEntry<TModules, E, X>,
WildcardEntryInputOf<TModules, E>
>,
) => TransitionResult<TModules, TState, TOutput>;
};
};

/**
* Tier-3 wildcard map: module + entry both unknown. Keyed by exit name
* only. Handlers receive `input: unknown` since the source entry is
* unknown.
*/
export type ExitOnlyWildcardMap<TModules extends ModuleTypeMap, TState, TOutput = unknown> = {
readonly [X in WildcardExitNamesOf<TModules>]?: (
ctx: ExitCtx<TState, WildcardExitOutputOf<TModules, X>, unknown>,
) => TransitionResult<TModules, TState, TOutput>;
};

/**
* Sibling of `TransitionMap` for transitions that match without knowing
* the source module. Two precision tiers — `byEntryAndExit` (more
* specific) and `byExit` (catch-all). The runtime tries them in order
* after the exact `transitions` lookup misses, so a matching exact
* handler always wins.
*
* Kept separate from `TransitionMap` for the same TS-variance reason
* `ResumeMap` is split out (see `EntryTransitions`' doc above): nesting
* an index-signature value inside the existing intersection collapses
* mapped-type variance and breaks assignability to the registry's wide
* `AnyJourneyDefinition`.
*/
export type WildcardTransitionMap<TModules extends ModuleTypeMap, TState, TOutput = unknown> = {
readonly byEntryAndExit?: EntryExitWildcardMap<TModules, TState, TOutput>;
readonly byExit?: ExitOnlyWildcardMap<TModules, TState, TOutput>;
};

/**
* Distribute a union into an intersection. Used by the wildcard output
* types so a handler declared for an exit name multiple modules emit
* sees only fields every variant guarantees — the safest read when the
* source module is unknown.
*/
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;

/**
* Resume map — sibling of `TransitionMap` keyed identically by
* `[moduleId][entryName]`, with each leaf an object of named resume
Expand Down Expand Up @@ -598,7 +725,9 @@
| "resume-returned-promise"
| "resume-bounce-limit"
| "transition-error"
| "transition-returned-promise";
| "transition-returned-promise"
| "exit-payload-invalid"
| "exit-payload-invalid-async";

/**
* Discriminated union of every abort payload the runtime emits. Each arm
Expand Down Expand Up @@ -702,7 +831,30 @@
readonly exit: string | null;
readonly error: unknown;
}
| { readonly reason: "transition-returned-promise"; readonly exit: string | null };
| { readonly reason: "transition-returned-promise"; readonly exit: string | null }
| {
/**
* The active step's exit point is declared via an `ExitContract`
* with a runtime schema, and the emitted payload failed validation.
* `issues` is the schema's reported issue list, verbatim from the
* Standard Schema result.
*/
readonly reason: "exit-payload-invalid";
readonly exit: string;
readonly issues: ReadonlyArray<StandardSchemaIssue>;
}
| {
/**
* The exit's contract schema returned a Promise. Transitions and
* payload validation must be synchronous (mirrors the same rule
* applied to transition handlers — see
* `transition-returned-promise`); async schemas abort here so the
* authoring loop surfaces the misconfiguration instead of silently
* swallowing the validation result.
*/
readonly reason: "exit-payload-invalid-async";
readonly exit: string;
};

const JOURNEY_SYSTEM_ABORT_REASON_CODES: ReadonlySet<string> =
new Set<JourneySystemAbortReasonCode>([
Expand All @@ -723,6 +875,8 @@
"resume-bounce-limit",
"transition-error",
"transition-returned-promise",
"exit-payload-invalid",
"exit-payload-invalid-async",
]);

/**
Expand Down
Loading
Loading