From f7fa4fb8ce493b3bfe7e62305cfa640ce109e68e Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Fri, 14 Aug 2026 19:21:14 +0200 Subject: [PATCH] Add machine event constructor collections --- .changeset/calm-events-build.md | 7 + README.md | 49 +++-- docs/agent-guide.md | 70 ++++--- scripts/fixtures/consumer/deep-bound.ts | 2 +- src/Machine.ts | 158 +++++++++++++-- src/internal/machine/atom.ts | 6 +- src/internal/machine/machine.ts | 20 +- src/internal/machine/protocol.ts | 149 ++++++++++++++ src/unstable/reactivity/AtomMachine.ts | 6 +- .../machine/strategyDifferential.test.ts | 42 ++++ test/machine/Machine.test.ts | 186 ++++++++++++++++++ typetest/machine/EventConstructors.tst.ts | 90 +++++++++ typetest/machine/Machine.tst.ts | 2 +- typetest/machine/Readiness.tst.ts | 28 ++- .../unstable/reactivity/AtomMachine.tst.ts | 8 +- 15 files changed, 741 insertions(+), 82 deletions(-) create mode 100644 .changeset/calm-events-build.md create mode 100644 typetest/machine/EventConstructors.tst.ts diff --git a/.changeset/calm-events-build.md b/.changeset/calm-events-build.md new file mode 100644 index 0000000..1115403 --- /dev/null +++ b/.changeset/calm-events-build.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `Machine.events(machine)` and `Machine.internalEvents(machine)` as the standard way to construct protocol events. + +The returned tag-keyed constructors preserve schema make inputs and defer decoding until machine delivery, so invalid values fail with `MachineSchemaDecodeError` through planning or the running machine instead of throwing at the construction call site. diff --git a/README.md b/README.md index 2440fce..63192ad 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,14 @@ const Event = Schema.TaggedUnion({ const States = Machine.defineStates(State.cases) -const Counter = Machine.make({ +const CounterDefinition = Machine.make({ id: "Counter", states: States.states, events: [Event], initial: () => States.initial.Idle.from() -}).handle({ +}) + +const Counter = CounterDefinition.handle({ Idle: { on: { Start: ({ target }) => target.full.Running.from({ count: 0 }) @@ -59,10 +61,12 @@ const Counter = Machine.make({ } }) +const CounterEvent = Machine.events(Counter) + const program = Effect.gen(function*() { const ref = yield* Machine.start(Counter) - yield* ref.send(Machine.event(Counter, Event.cases.Start)) - yield* ref.send(Machine.event(Counter, Event.cases.Increment)) + yield* ref.send(CounterEvent.Start()) + yield* ref.send(CounterEvent.Increment()) }) ``` @@ -130,20 +134,32 @@ const Internal = Schema.TaggedUnion({ SaveFailed: { message: Schema.String } }) -const machine = Machine.make({ +const definition = Machine.make({ states: States.states, events: [Command], internalEvents: [Internal], initial: () => States.initial.Idle.from() }) + +const CommandEvent = Machine.events(definition) +const InternalEvent = Machine.internalEvents(definition) ``` Handlers see both protocols. Typed `send` and `Machine.plan` accept only public events. Event tags must be unique and public/internal tags must be disjoint. -Use `Machine.event(machine, schema, fields?)` for reusable machine-owned event -values. Ordinary objects and schema-constructed values are also accepted and -decoded at the machine boundary. +Use `Machine.events(machine)` and `Machine.internalEvents(machine)` as the +standard constructors for their respective protocols: + +```ts +ref.send(CommandEvent.Save()) +enqueue.raise(InternalEvent.Saved({ id: "entry-1" })) +``` + +The returned constructors preserve each schema's make input, including required +fields and constructor defaults. They defer schema construction until delivery, +so invalid values fail planning or the running machine with +`MachineSchemaDecodeError` instead of throwing at the call site. ### Choose the target by scope @@ -188,15 +204,15 @@ Loading: { invoke: Machine.invokeEffect({ id: "save-document", effect: saveDocument, - onSuccess: (entry) => Internal.cases.Saved.make({ id: entry.id }), - onFailure: (error) => Internal.cases.SaveFailed.make({ message: String(error) }) + onSuccess: (entry) => InternalEvent.Saved({ id: entry.id }), + onFailure: (error) => InternalEvent.SaveFailed({ message: String(error) }) }) } Waiting: { invoke: Machine.after( "3 seconds", - Internal.cases.SaveFailed.make({ message: "Timed out" }) + InternalEvent.SaveFailed({ message: "Timed out" }) ) } ``` @@ -260,14 +276,19 @@ The testing entrypoint provides complementary layers: import { MachineTest } from "@typeonce/effect-machine/testing" const trace = yield* MachineTest.run(Counter, { - events: [Event.cases.Start.make({}), Event.cases.Increment.make({})] + events: [ + Machine.event(Counter, Event.cases.Start), + Machine.event(Counter, Event.cases.Increment) + ] }) yield* MachineTest.verify(Counter, trace) ``` -Pure planner tests do not execute invokes or time. Use a started machine and a -probe when those semantics matter. +`MachineTest` scenarios retain decoded event values for model inspection, so +this is the main case for the eager `Machine.event` API. Pure planner tests do +not execute invokes or time. Use a started machine and a probe when those +semantics matter. ## Entrypoints diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 10b4d09..3b13276 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -51,11 +51,13 @@ const InternalEvent = Schema.TaggedUnion({ const States = Machine.defineStates(State.cases) ``` -Construct event values with `Event.cases.Save.make({})`. Construct new state -values through the target or initial builder's `.from(...)` method so schema -construction runs inside planning. Pass a state directly only when it is -already decoded. Use `Schema.TaggedClass` when a case needs class methods or -nominal class identity; `.from(...)` preserves that identity. +After `Machine.make`, derive public constructors with `Machine.events(machine)` +and internal constructors with `Machine.internalEvents(machine)`. Construct new +state values through the target or initial builder's `.from(...)` method. Both +event constructors and state `.from(...)` defer schema construction until +planning, so validation failures remain typed machine errors. Use +`Schema.TaggedClass` when a case needs class methods or nominal class identity; +the deferred constructors preserve that identity after decoding. ## Hard invariants @@ -499,41 +501,51 @@ an event for the parent. Both operations validate their schemas. union handled inside the statechart: ```ts -const machine = Machine.make({ +const definition = Machine.make({ states: States.states, - events: [Event.cases.Save], - internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed], + events: [Event], + internalEvents: [InternalEvent], initial: () => States.initial.Idle.from() }) + +const Events = Machine.events(definition) +const InternalEvents = Machine.internalEvents(definition) ``` -When the same already-constructed event may be delivered repeatedly, construct -it once through its owning machine protocol: +Use the protocol-bound constructors at every machine delivery boundary: ```ts -const save = Machine.event(machine, Event.cases.Save) -yield* ref.send(save) +yield* ref.send(Events.Save()) +enqueue.raise(InternalEvents.Saved({ id: "entry-1" })) ``` -`Machine.event` runs the configured schema constructor once. That machine and -definitions derived from it with `handle` then recognize the decoded event as -trusted and do not decode it again. Tagged-union case schemas are recognized -when their union is configured. Treat the returned event as immutable. Raw -objects and values constructed for another machine continue through normal -runtime validation on every delivery. +`Machine.events` exposes only public constructors; +`Machine.internalEvents` exposes only machine-local constructors. Both flatten +configured tagged unions and preserve tagged classes, finite discriminator +unions, required inputs, and constructor defaults. A constructor returns an +opaque instruction whose `_tag` is available for activity metadata. Its decoded +fields are intentionally unavailable until the owning machine processes it. + +Invalid constructor input fails `Machine.plan` or the running machine with +`MachineSchemaDecodeError`; creating the instruction itself never performs +schema validation. `Machine.event(machine, schema, fields?)` remains available +as an eager low-level constructor for callers that explicitly want an already +decoded value and accept synchronous failure. Use the exported utility types when another API must preserve the boundary: ```ts -type PublicEvent = Machine.Machine.InputEvent -type AnyHandledEvent = Machine.Machine.Event +type PublicEvent = Machine.Machine.InputEvent +type AnyHandledEvent = Machine.Machine.Event ``` -`MachineRef.send`, `machineAtom.send`, and `Machine.plan` use `InputEvent` at -their TypeScript boundary. Transition handlers, raised events, invoke results, -and mapped child events use the complete `Event` union. The local planner and -runtime intentionally share the complete decoder to support those internal -deliveries, so JavaScript or `any` can bypass the local public distinction. +`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public +events or constructions returned by `Machine.events`. Transition handlers +receive only decoded events. Raised events, invoke results, and mapped child +events additionally accept constructions from `Machine.internalEvents`. The +local planner and runtime intentionally share the complete decoder to support +those internal deliveries, so JavaScript or `any` can bypass the local public +distinction. Cluster RPC payloads are additionally decoded against the public `events` schemas at the transport boundary. Never repeat an `_tag` within a list or across both configuration lists. @@ -548,8 +560,8 @@ invoke: ({ state }) => Machine.invokeEffect({ id: "save", effect: SaveService.save(state.draft), - onSuccess: (entry) => new Saved({ entry }), - onFailure: (error) => new SaveFailed({ message: error.message }) + onSuccess: (entry) => InternalEvents.Saved({ entry }), + onFailure: (error) => InternalEvents.SaveFailed({ message: error.message }) }) ``` @@ -566,7 +578,7 @@ recover only expected typed failures. A cancellable timer uses `Machine.after`: ```ts -invoke: Machine.after("3 seconds", new ClearStatus({}), { +invoke: Machine.after("3 seconds", InternalEvents.ClearStatus(), { id: "clear-status" }) ``` @@ -601,7 +613,7 @@ invoke: Machine.invokeMachine({ Use `Editor` for: ```ts -Machine.sendTo(Editor, new Reset({})) +Machine.sendTo(Editor, EditorEvent.Reset()) parentRef.child(Editor) parentAtom.child(Editor) ``` diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index d3d7b3c..9632b8a 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -302,7 +302,7 @@ type Output = typeof machineAtom extends AtomMachine.MachineAtom type StateIsExact = Expect> -type EventsArePublicOnly = Expect> +type EventsArePublicOnly = Expect>> type OutputIsExact = Expect> type RuntimeErrorIsPreserved = Expect, RuntimeFailure>> type FailureIsNotUnknown = Expect> diff --git a/src/Machine.ts b/src/Machine.ts index 4431495..4f43b94 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -49,6 +49,7 @@ export const TypeId: TypeId = "~effect/Machine" declare const MachineOutputStatesTypeId: unique symbol declare const MachineTypeId: unique symbol +declare const EventConstructionTypeId: unique symbol /** * Type identifier used for the synthetic event passed to startup lifecycle @@ -350,7 +351,7 @@ export interface Runtime { * * @since 0.4.0 */ - readonly raise: (event: Events) => Effect.Effect + readonly raise: (event: Machine.EventInput) => Effect.Effect /** * Emits an event through the running machine's parent boundary. @@ -370,7 +371,7 @@ export interface Runtime { */ export interface Enqueue { /** Raises an event inside the current macrostep. */ - readonly raise: (event: Events) => void + readonly raise: (event: Machine.EventInput) => void /** Emits an event through the machine's parent boundary. */ readonly emit: (event: Emits) => void @@ -1868,7 +1869,7 @@ export declare namespace ChildMachine { */ export type Ref = Child extends ChildMachine ? MachineRef< Machine.Snapshot>, - Machine.InputEvent, + Machine.EventInput>, | Machine.Error | ActionError> | InfiniteTransitionError @@ -2152,6 +2153,13 @@ export declare namespace Machine { */ export type InputEvents = M[typeof MachineTypeId]["inputEvents"] + /** Extracts the internal event schema tuple carried by a machine definition. */ + export type InternalEvents = Events extends readonly [ + ...InputEvents, + ...infer Internal extends ReadonlyArray + ] ? Internal + : readonly [] + /** * Extracts the complete event protocol handled inside a machine. * @@ -2168,6 +2176,53 @@ export declare namespace Machine { */ export type InputEvent = EventOf> + /** + * Opaque event construction returned by {@link events} and + * {@link internalEvents}. + * + * The machine resolves the instruction through its event schema when it is + * delivered. Only the discriminator is available before decoding succeeds. + */ + export interface EventConstruction { + readonly [EventConstructionTypeId]: Event + readonly _tag: Event["_tag"] + } + + /** A decoded event or a deferred machine-bound construction of that event. */ + export type EventInput = + | Event + | (Event extends { readonly _tag: PropertyKey } ? EventConstruction : never) + + /** Event inputs accepted for a schema tuple at machine delivery boundaries. */ + export type EventInputOf> = EventInput> + + type EventConstructorInput = Omit + + type EventConstructor< + EventSchema extends TaggedSchema, + Tag extends EventSchema["Type"]["_tag"] + > = {} extends EventConstructorInput ? + (input?: EventConstructorInput) => EventConstruction> + : (input: EventConstructorInput) => EventConstruction> + + type EventConstructorsForSchema = EventSchema extends { + readonly cases: infer Cases extends Readonly> + } ? { + readonly [Tag in keyof Cases]: Tag extends Cases[Tag]["Type"]["_tag"] ? EventConstructor + : never + } + : EventSchema extends { readonly members: infer Members extends ReadonlyArray } ? + Types.UnionToIntersection> + : { + readonly [Tag in EventSchema["Type"]["_tag"]]: EventConstructor + } + + /** Protocol-bound constructors keyed by each configured event tag. */ + export type EventConstructors> = { + readonly [Tag in keyof Types.UnionToIntersection>]: + Types.UnionToIntersection>[Tag] + } + /** * Extracts the event protocol emitted by a machine. * @@ -4927,12 +4982,12 @@ export declare namespace Machine { StateId extends string, Config > = [InvokeReturn] extends [never] ? unknown - : [Exclude>, EventOf | void>] extends [never] ? unknown + : [Exclude>, EventInput> | void>] extends [never] ? unknown : { readonly invoke: HandlerValidationError< "Invoked child output must be a machine event or void", StateId, - Exclude>, EventOf | void> + Exclude>, EventInput> | void> > } @@ -4956,12 +5011,13 @@ export declare namespace Machine { Config > = [InvokeReturn] extends [never] ? unknown : IsAny>> extends true ? unknown - : [Exclude>, EventOf | undefined>] extends [never] ? unknown + : [Exclude>, EventInput> | undefined>] extends [never] + ? unknown : { readonly invoke: HandlerValidationError< "Invoked child snapshot mapper must return a machine event or undefined", StateId, - Exclude>, EventOf | undefined> + Exclude>, EventInput> | undefined> > } @@ -5544,6 +5600,9 @@ type EventConstructorArgs = {} extends * Events supplied through ordinary `send` and `plan` calls remain untrusted * and continue through full runtime schema validation. * Treat the returned event as immutable after construction. + * This eager low-level constructor throws `MachineSchemaDecodeError` when + * construction fails. Prefer {@link events} and {@link internalEvents} for + * ordinary delivery so construction remains inside the machine error channel. * * The schema must be one of the machine's configured public or internal event * schemas, or a case schema belonging to a configured `Schema.TaggedUnion`. @@ -5578,6 +5637,55 @@ export const event: ) => EventSchema["Type"] = internal.event +/** + * Returns deferred constructors for every public event in a machine protocol. + * + * Constructor inputs retain their schema-derived required fields, defaults, + * and transformations. Construction is deferred until delivery so failures + * enter the machine's `MachineSchemaDecodeError` channel rather than throwing + * at the call site. + * + * **Example** + * + * ```ts + * const Event = Machine.events(counter) + * yield* ref.send(Event.Increment({ by: 1 })) + * ``` + * + * @category constructors + * @since 0.9.0 + */ +export const events: ( + machine: M +) => Machine.EventConstructors> = internal.events + +/** + * Returns deferred constructors for every internal event in a machine + * protocol. + * + * Use these constructors for invoke results, timers, raised events, and other + * machine-local deliveries. Construction failures are reported through the + * owning machine's `MachineSchemaDecodeError` channel. + * + * **Example** + * + * ```ts + * const InternalEvent = Machine.internalEvents(definition) + * + * const load = Machine.invokeEffect({ + * id: "load", + * effect: Effect.succeed("ready"), + * onSuccess: (value) => InternalEvent.Loaded({ value }) + * }) + * ``` + * + * @category constructors + * @since 0.9.0 + */ +export const internalEvents: ( + machine: M +) => Machine.EventConstructors> = internal.internalEvents + /** * Encodes a decoded machine snapshot into a normalized data representation. * @@ -5887,14 +5995,20 @@ type InvokeEffectConfig< * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * - * class Loaded extends Schema.TaggedClass("Loaded")("Loaded", { - * value: Schema.String - * }) {} + * const Internal = Schema.TaggedUnion({ Loaded: { value: Schema.String } }) + * const States = Machine.defineStates({ Loading: {} }) + * const definition = Machine.make({ + * states: States.states, + * events: [], + * internalEvents: [Internal], + * initial: () => States.initial.Loading.from() + * }) + * const InternalEvent = Machine.internalEvents(definition) * * const load = Machine.invokeEffect({ * id: "load", * effect: Effect.succeed("ready"), - * onSuccess: (value) => new Loaded({ value }) + * onSuccess: (value) => InternalEvent.Loaded({ value }) * }) * ``` * @@ -5923,9 +6037,17 @@ export const invokeEffect: , Succe * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * - * class TimedOut extends Schema.TaggedClass("TimedOut")("TimedOut", {}) {} + * const Internal = Schema.TaggedUnion({ TimedOut: {} }) + * const States = Machine.defineStates({ Waiting: {} }) + * const definition = Machine.make({ + * states: States.states, + * events: [], + * internalEvents: [Internal], + * initial: () => States.initial.Waiting.from() + * }) + * const InternalEvent = Machine.internalEvents(definition) * - * const timeout = Machine.after("5 seconds", new TimedOut({})) + * const timeout = Machine.after("5 seconds", InternalEvent.TimedOut()) * ``` * * @category constructors @@ -6081,7 +6203,7 @@ export const invokeMachine: { any, SnapshotEvent, Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, @@ -6158,7 +6280,7 @@ export const invokeMachine: { any, SnapshotEvent, Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, @@ -6489,7 +6611,7 @@ export const plan: < > & EnsureExecutable, state: Machine.Snapshot, - event: Machine.EventOf + event: Machine.EventInputOf ) => Effect.Effect< & { readonly next: Machine.Snapshot @@ -6858,7 +6980,7 @@ export const start: < ) => Effect.Effect< MachineRef< Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, | E | ActionError | InfiniteTransitionError @@ -6967,7 +7089,7 @@ export const resume: < ) => Effect.Effect< MachineRef< Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, | E | ActionError | InfiniteTransitionError diff --git a/src/internal/machine/atom.ts b/src/internal/machine/atom.ts index fe1e882..1a26427 100644 --- a/src/internal/machine/atom.ts +++ b/src/internal/machine/atom.ts @@ -120,7 +120,7 @@ const startMachineAtomEffect = < ): Effect.Effect< Machine.MachineRef< Machine.Machine.Snapshot, - Machine.Machine.EventOf, + Machine.Machine.EventInputOf, MachineRuntimeError, Output >, @@ -585,7 +585,7 @@ type EnsureMachineExecutable = IsAny = MachineAtom< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, MachineRuntimeError, Machine.Machine.Services>, Machine.Machine.Output, Machine.MachineSchemaDecodeError | RuntimeError @@ -635,7 +635,7 @@ export const make: { ...args: [...Machine.Machine.InputArgs] ): MachineAtom< Machine.Machine.Snapshot, - Machine.Machine.EventOf, + Machine.Machine.EventInputOf, MachineRuntimeError, Output, MachineStartError diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 322e664..c45282d 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -883,6 +883,16 @@ export const event = < ...args: EventConstructorArgs ): EventSchema["Type"] => Protocol.makeEvent(machine, schema, args.length === 0 ? {} : args[0]) +export const events = ( + machine: M +): Machine.EventConstructors> => + Protocol.eventConstructors(machine.events) as Machine.EventConstructors> + +export const internalEvents = ( + machine: M +): Machine.EventConstructors> => + Protocol.eventConstructors(machine.internalEvents) as Machine.EventConstructors> + export const encodeSnapshot: < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, @@ -1173,7 +1183,7 @@ export const invokeMachine: { any, SnapshotEvent, Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, @@ -1250,7 +1260,7 @@ export const invokeMachine: { any, SnapshotEvent, Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, @@ -1494,7 +1504,7 @@ export const plan: < > & EnsureExecutable, state: Machine.Snapshot, - event: Machine.EventOf + event: Machine.EventInputOf ) => Effect.Effect< & { readonly next: Machine.Snapshot @@ -1703,7 +1713,7 @@ export const start: < ) => Effect.Effect< MachineRef< Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, | E | ActionError | InfiniteTransitionError @@ -1761,7 +1771,7 @@ export const resume: < ) => Effect.Effect< MachineRef< Machine.Snapshot, - Machine.EventOf, + Machine.EventInputOf, | E | ActionError | InfiniteTransitionError diff --git a/src/internal/machine/protocol.ts b/src/internal/machine/protocol.ts index 1875dc6..14a4259 100644 --- a/src/internal/machine/protocol.ts +++ b/src/internal/machine/protocol.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect" import { hasProperty } from "effect/Predicate" import * as Result from "effect/Result" import * as Schema from "effect/Schema" +import * as SchemaAST from "effect/SchemaAST" import type { Machine } from "../../Machine.js" import { MachineSchemaDecodeError } from "./errors.js" import { getStateNodeSchema, isStateInput } from "./topology.js" @@ -26,6 +27,15 @@ interface MachineProtocolSchemas { readonly trustedEvents: WeakSet } +export const EventConstructionTypeId: unique symbol = Symbol("effect/Machine/EventConstruction") + +interface EventConstruction { + readonly [EventConstructionTypeId]: typeof EventConstructionTypeId + readonly _tag: PropertyKey + readonly schema: Machine.TaggedSchema + readonly input: unknown +} + type BoundaryDecoder = (value: unknown) => Effect.Effect type BoundaryResultDecoder = (value: unknown) => Result.Result @@ -111,6 +121,88 @@ export const copyProtocol = (source: Machine.Any, target: Machine.Any): void => export const getEventName = (event: unknown): string | undefined => hasProperty(event, "_tag") ? String(event._tag) : undefined +const makeEventConstruction = ( + schema: Machine.TaggedSchema, + tag: PropertyKey, + input: unknown +): EventConstruction => ({ + [EventConstructionTypeId]: EventConstructionTypeId, + _tag: tag, + schema, + input +}) + +const isEventConstruction = (value: unknown): value is EventConstruction => hasProperty(value, EventConstructionTypeId) + +export const eventConstructors = ( + schemas: ReadonlyArray +): Readonly) => EventConstruction>> => { + const leaves: Array = [] + const collect = (schema: Machine.TaggedSchema): void => { + if (hasProperty(schema, "cases") && typeof schema.cases === "object" && schema.cases !== null) { + for (const candidate of Reflect.ownKeys(schema.cases)) { + collect(Reflect.get(schema.cases, candidate) as Machine.TaggedSchema) + } + return + } + if (hasProperty(schema, "members") && Array.isArray(schema.members)) { + for (const member of schema.members) collect(member as Machine.TaggedSchema) + return + } + leaves.push(schema) + } + for (const schema of schemas) collect(schema) + const constructors = Object.create(null) as Record< + PropertyKey, + (...args: ReadonlyArray) => EventConstruction + > + const tags = (ast: SchemaAST.AST): ReadonlyArray => { + if (SchemaAST.isLiteral(ast)) { + return typeof ast.literal === "string" || typeof ast.literal === "number" ? [ast.literal] : [] + } + if (SchemaAST.isUniqueSymbol(ast)) return [ast.symbol] + if (SchemaAST.isEnum(ast)) return ast.enums.map(([, value]) => value) + if (SchemaAST.isUnion(ast)) return ast.types.flatMap(tags) + if (SchemaAST.isSuspend(ast)) return tags(ast.thunk()) + return [] + } + const schemaTags = (schema: Machine.TaggedSchema): ReadonlyArray => { + const ast = SchemaAST.toType(schema.ast) + if (SchemaAST.isObjects(ast)) { + const tag = ast.propertySignatures.find(({ name }) => name === "_tag") + const discriminants = tag === undefined ? [] : tags(tag.type) + if (discriminants.length > 0) return discriminants + } + try { + return Schema.Union([schema]).pipe(Schema.toTaggedUnion("_tag")).discriminants + } catch { + return [] + } + } + for (const schema of leaves) { + const discriminants = schemaTags(schema) + if (discriminants.length === 0) { + throw new Error("Machine event constructors require finite literal or unique symbol event tags") + } + for (const tag of discriminants) { + if (hasProperty(constructors, tag)) { + throw new Error(`Duplicate machine event constructor tag: ${String(tag)}`) + } + Object.defineProperty(constructors, tag, { + value: (...args: ReadonlyArray) => { + const fields = args.length === 0 ? {} : args[0] + const input = typeof fields === "object" && fields !== null + ? { ...fields, _tag: tag } + : { _tag: tag } + return makeEventConstruction(schema, tag, input) + }, + enumerable: true + }) + } + } + return constructors +} + export const decodeBoundary = ( machine: Machine.Any, schema: Schema.Top, @@ -195,6 +287,54 @@ export const makeEvent = ( return event } +const eventConstructionProtocolError = ( + machine: Machine.Any, + construction: EventConstruction +): MachineSchemaDecodeError => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "event", + event: String(construction._tag), + cause: Cause.die(new Error("Constructed event schema does not belong to the machine event protocol")) + }) + +const decodeEventConstruction = ( + machine: Machine.Any, + protocol: MachineProtocolSchemas, + construction: EventConstruction +): Effect.Effect => { + if (!protocol.eventConstructors.has(construction.schema as object)) { + return Effect.fail(eventConstructionProtocolError(machine, construction)) + } + return construction.schema.makeEffect(construction.input as never).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "event", + event: String(construction._tag), + cause: new Schema.SchemaError(cause) + }) + ), + Effect.tap((event) => Effect.sync(() => protocol.trustedEvents.add(event as object))) + ) +} + +const decodeEventConstructionSync = ( + machine: Machine.Any, + protocol: MachineProtocolSchemas, + construction: EventConstruction +): unknown => { + if (!protocol.eventConstructors.has(construction.schema as object)) { + throw eventConstructionProtocolError(machine, construction) + } + const event = makeBoundarySync(machine, construction.schema, construction.input, { + boundary: "event", + event: String(construction._tag) + }) + protocol.trustedEvents.add(event as object) + return event +} + const isTrustedEvent = (protocol: MachineProtocolSchemas, event: unknown): boolean => typeof event === "object" && event !== null && protocol.trustedEvents.has(event) @@ -210,6 +350,12 @@ export const decodeEvent = , MachineSchemaDecodeError> => { const protocol = getProtocolSchemas(machine) + if (isEventConstruction(event)) { + return decodeEventConstruction(machine, protocol, event) as Effect.Effect< + Machine.EventOf, + MachineSchemaDecodeError + > + } if (isTrustedEvent(protocol, event)) { return Effect.succeed(event as Machine.EventOf) } @@ -227,6 +373,9 @@ export const decodeEventSync = => { const protocol = getProtocolSchemas(machine) + if (isEventConstruction(event)) { + return decodeEventConstructionSync(machine, protocol, event) as Machine.EventOf + } if (isTrustedEvent(protocol, event)) { return event as Machine.EventOf } diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index 799974b..a7bf348 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -537,7 +537,7 @@ type MachineInputArgsOf = [ type MachineAtomOf = MachineAtom< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, MachineRuntimeError, Machine.Machine.Services>, Machine.Machine.Output, MachineStartError< @@ -551,7 +551,7 @@ type MachineAtomOf = MachineAtom< type ResumedMachineAtomOf = MachineAtom< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, MachineRuntimeError, Machine.Machine.Services>, Machine.Machine.Output, Machine.MachineSchemaDecodeError | RuntimeError @@ -661,7 +661,7 @@ export const make: { ...args: [...Machine.Machine.InputArgs] ): MachineAtom< Machine.Machine.Snapshot, - Machine.Machine.EventOf, + Machine.Machine.EventInputOf, MachineRuntimeError, Output, MachineStartError diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index ea2e4c3..6681030 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -277,6 +277,48 @@ describe("machine planner and runtime strategies", () => { } }) as Effect.Effect) + it.effect("decodes deferred event constructions in generic and compiled managed runtimes", () => + Effect.gen(function*() { + const Event = Schema.TaggedUnion({ Set: { value: Schema.NonEmptyString } }) + const states = Machine.defineStates({ Count }) + const definition = Machine.make({ + states: states.states, + events: [Event], + initial: () => states.initial.Count(new Count({ value: 0 })) + }) + const events = Machine.events(definition) + const machine = definition.handle({ + Count: { + on: { + Set: ({ event, target }) => target.full.Count(new Count({ value: event.value.length })) + } + } + }) + + for (const strategy of ["generic", "compiled"] as const) { + const ref = yield* openWithRuntimeStrategy(machine, strategy) + const updated = yield* ref.changes.pipe( + Stream.filter((snapshot) => snapshot.status === "active" && snapshot.state.value.value === 2), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }) + ) + yield* ref.send(events.Set({ value: "ok" })) + yield* Fiber.join(updated) + const snapshot = yield* ref.snapshot + assert.strictEqual(snapshot.status, "active") + assert.strictEqual(snapshot.state.value.value, 2, `${strategy} decoded the construction`) + yield* ref.stop + + const invalidRef = yield* openWithRuntimeStrategy(machine, strategy) + yield* invalidRef.send(events.Set({ value: "" })) + const error = yield* Effect.flip(invalidRef.join) + assert.instanceOf(error, Machine.MachineSchemaDecodeError) + assert.strictEqual(error.boundary, "event") + assert.strictEqual(error.event, "Set") + } + }) as Effect.Effect) + it.effect("matches acknowledged probe delivery in generic and compiled managed runtimes", () => Effect.gen(function*() { const machine = makeFlatMachine() diff --git a/test/machine/Machine.test.ts b/test/machine/Machine.test.ts index b951653..4b6acb2 100644 --- a/test/machine/Machine.test.ts +++ b/test/machine/Machine.test.ts @@ -551,6 +551,192 @@ describe("Machine", () => { })) describe("event constructor", () => { + it.effect("constructs public and internal events lazily through protocol-bound collections", () => + Effect.gen(function*() { + const PublicEvent = Schema.TaggedUnion({ + SetValue: { value: Schema.NonEmptyString }, + Reset: {} + }) + const FiniteEvent = Schema.Struct({ + _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]), + value: Schema.String + }) + class Defaulted extends Schema.TaggedClass("DeferredDefaulted")("Defaulted", { + id: Schema.String, + label: Schema.String.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed("default-label")) + ) + }) {} + const InternalEvent = Schema.TaggedUnion({ + Loaded: { value: Schema.String }, + TimedOut: {} + }) + const State = Schema.TaggedStruct("DeferredEventState", { value: Schema.String }) + const states = Machine.defineStates({ Active: State }) + const definition = Machine.make({ + states: states.states, + events: [PublicEvent, Defaulted, FiniteEvent], + internalEvents: [InternalEvent], + initial: () => states.initial.Active.from({ value: "initial" }) + }) + const events = Machine.events(definition) + const internalEvents = Machine.internalEvents(definition) + const machine = definition.handle({ + Active: { + on: { + SetValue: ({ event, target }) => target.full.Active.from({ value: event.value }), + Reset: (_, enqueue) => { + enqueue.raise(internalEvents.Loaded({ value: "loaded" })) + }, + Defaulted: ({ event, target }) => target.full.Active.from({ value: event.label ?? "default-label" }), + Loaded: ({ event, target }) => target.full.Active.from({ value: event.value }), + TimedOut: ({ target }) => target.full.Active.from({ value: "timed-out" }), + Alpha: ({ event, target }) => target.full.Active.from({ value: event.value }), + Beta: ({ event, target }) => target.full.Active.from({ value: event.value }) + } + } + }) + + assert.deepStrictEqual(Object.keys(events), ["SetValue", "Reset", "Defaulted", "Alpha", "Beta"]) + assert.deepStrictEqual(Object.keys(internalEvents), ["Loaded", "TimedOut"]) + + const initial = yield* Machine.planInitial(machine) + const set = yield* Machine.plan(machine, initial.state, events.SetValue({ value: "next" })) + assert.deepStrictEqual(set.next, { + path: "Active", + value: { _tag: "DeferredEventState", value: "next" } + }) + + const defaulted = yield* Machine.plan(machine, set.next, events.Defaulted({ id: "event-1" })) + assert.deepStrictEqual(defaulted.next, { + path: "Active", + value: { _tag: "DeferredEventState", value: "default-label" } + }) + + const loaded = yield* Machine.plan(machine, defaulted.next, events.Reset()) + assert.deepStrictEqual(loaded.next, { + path: "Active", + value: { _tag: "DeferredEventState", value: "loaded" } + }) + + const alpha = yield* Machine.plan(machine, loaded.next, events.Alpha({ value: "alpha" })) + assert.deepStrictEqual(alpha.next, { + path: "Active", + value: { _tag: "DeferredEventState", value: "alpha" } + }) + })) + + it.effect("reports deferred constructor failures through the running machine", () => + Effect.gen(function*() { + const Event = Schema.TaggedUnion({ + Submit: { value: Schema.NonEmptyString } + }) + const states = Machine.defineStates({ Idle: {} }) + const definition = Machine.make({ + id: "deferred-event-failure", + states: states.states, + events: [Event], + initial: () => states.initial.Idle.from() + }) + const events = Machine.events(definition) + const machine = definition.handle({ Idle: { on: { Submit: () => undefined } } }) + + let construction: ReturnType | undefined + assert.doesNotThrow(() => { + construction = events.Submit({ value: "" }) + }) + + const initial = yield* Machine.planInitial(machine) + const planningError = yield* Machine.plan(machine, initial.state, construction!).pipe(Effect.flip) + assertMachineSchemaDecodeError(planningError, "event", { event: "Submit" }) + + const actor = yield* Machine.start(machine) + const snapshot = yield* sendAndWaitForSnapshot( + actor, + construction!, + (snapshot) => snapshot.status === "error" + ) + const error = yield* Effect.flip(actor.join) + + assertMachineSchemaDecodeError(error, "event", { event: "Submit" }) + assert.strictEqual(snapshot.status, "error") + })) + + it.effect("delivers internal constructions from invokeEffect and after", () => + Effect.gen(function*() { + const release = yield* Deferred.make() + const InternalEvent = Schema.TaggedUnion({ Loaded: {}, TimedOut: {} }) + const states = Machine.defineStates({ Loading: {}, Waiting: {}, Done: {} }) + const definition = Machine.make({ + states: states.states, + events: [], + internalEvents: [InternalEvent], + initial: () => states.initial.Loading.from() + }) + const internalEvents = Machine.internalEvents(definition) + const machine = definition.handle({ + Loading: { + invoke: Machine.invokeEffect({ + id: "load", + effect: Deferred.await(release), + onSuccess: () => internalEvents.Loaded() + }), + on: { + Loaded: ({ target }) => target.full.Waiting.from() + } + }, + Waiting: { + invoke: Machine.after("1 second", internalEvents.TimedOut()), + on: { + TimedOut: ({ target }) => target.full.Done.from() + } + }, + Done: {} + }) + + const actor = yield* Machine.start(machine) + const waiting = yield* waitForSnapshot( + actor, + (snapshot) => snapshot.status === "active" && snapshot.state.path === "Waiting" + ).pipe(Effect.forkChild) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(waiting) + + const done = yield* waitForSnapshot( + actor, + (snapshot) => snapshot.status === "active" && snapshot.state.path === "Done" + ).pipe(Effect.forkChild) + yield* TestClock.adjust("1 second") + yield* Fiber.join(done) + yield* actor.stop + })) + + it.effect("rejects a construction owned by another machine protocol", () => + Effect.gen(function*() { + const FirstEvent = Schema.TaggedUnion({ Submit: { value: Schema.String } }) + const SecondEvent = Schema.TaggedUnion({ Submit: { value: Schema.String } }) + const states = Machine.defineStates({ Idle: {} }) + const first = Machine.make({ + states: states.states, + events: [FirstEvent], + initial: () => states.initial.Idle.from() + }) + const second = Machine.make({ + states: states.states, + events: [SecondEvent], + initial: () => states.initial.Idle.from() + }).handle({ Idle: { on: { Submit: () => undefined } } }) + const construction = Machine.events(first).Submit({ value: "value" }) + const initial = yield* Machine.planInitial(second) + const error = yield* Machine.plan(second, initial.state, construction).pipe(Effect.flip) + + assert.instanceOf(error, Machine.MachineSchemaDecodeError) + assert.strictEqual(error.boundary, "event") + assert.strictEqual(error.event, "Submit") + assert.isTrue(Cause.isCause(error.cause)) + })) + it.effect("validates once and shares trust only with derived machine definitions", () => Effect.gen(function*() { let validations = 0 diff --git a/typetest/machine/EventConstructors.tst.ts b/typetest/machine/EventConstructors.tst.ts new file mode 100644 index 0000000..570ac58 --- /dev/null +++ b/typetest/machine/EventConstructors.tst.ts @@ -0,0 +1,90 @@ +import { Effect, Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +describe("Machine event constructor collections", () => { + const PublicEvent = Schema.TaggedUnion({ + Increment: { by: Schema.Number }, + Reset: {} + }) + + class SetLabel extends Schema.TaggedClass("SetLabel")("SetLabel", { + id: Schema.String, + label: Schema.String.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed("default-label")) + ) + }) {} + + const InternalEvent = Schema.TaggedUnion({ + Loaded: { value: Schema.String }, + Failed: {} + }) + + const FiniteEvent = Schema.Struct({ + _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]), + value: Schema.String + }) + + const states = Machine.defineStates({ Idle: {} }) + const machine = Machine.make({ + states: states.states, + events: [PublicEvent, SetLabel, FiniteEvent], + internalEvents: [InternalEvent], + initial: () => states.initial.Idle.from() + }) + + const events = Machine.events(machine) + const internalEvents = Machine.internalEvents(machine) + + it("derives public constructors and their schema make inputs", () => { + expect(events.Increment({ by: 1 })).type.toBe< + Machine.Machine.EventConstruction + >() + expect(events.Reset()).type.toBe>() + expect(events.SetLabel({ id: "label-1" })).type.toBe>() + expect(events.Alpha({ value: "alpha" })).type.toBe< + Machine.Machine.EventConstruction<{ readonly _tag: "Alpha"; readonly value: string }> + >() + + expect(events.Increment()).type.toRaiseError() + expect(events.Increment({ by: "1" })).type.toRaiseError() + expect(events.SetLabel({})).type.toRaiseError() + expect(events.Alpha({ _tag: "Beta", value: "alpha" })).type.toRaiseError() + expect(events.Loaded).type.toRaiseError() + }) + + it("keeps internal constructors separate from public constructors", () => { + expect(internalEvents.Loaded({ value: "ready" })).type.toBe< + Machine.Machine.EventConstruction + >() + expect(internalEvents.Failed()).type.toBe< + Machine.Machine.EventConstruction + >() + + expect(internalEvents.Increment).type.toRaiseError() + expect(internalEvents.Loaded()).type.toRaiseError() + }) + + it("accepts public constructions at machine delivery boundaries", () => { + expect(Machine.plan(machine.handle({ Idle: {} }), { path: "Idle", value: undefined }, events.Reset())).type.not + .toRaiseError() + }) + + it("accepts internal constructions from invokeEffect and after", () => { + expect( + machine.handle({ + Idle: { + invoke: [ + Machine.invokeEffect({ + id: "load", + effect: Effect.succeed("ready"), + onSuccess: (value) => internalEvents.Loaded({ value }) + }), + Machine.after("1 second", internalEvents.Failed()) + ] + } + }) + ).type.not.toRaiseError() + }) +}) diff --git a/typetest/machine/Machine.tst.ts b/typetest/machine/Machine.tst.ts index f4efe61..d8c9585 100644 --- a/typetest/machine/Machine.tst.ts +++ b/typetest/machine/Machine.tst.ts @@ -573,7 +573,7 @@ describe("Machine", () => { expect(anyMachine).type.not.toHaveProperty("eventSchemas") expect>().type.toBe() expect>().type.toBe() - expect[0]>().type.toBe() + expect[0]>().type.toBe>() expect(Machine.plan).type.toBeCallableWith( machine, UpStates.initial.down(new Down({})), diff --git a/typetest/machine/Readiness.tst.ts b/typetest/machine/Readiness.tst.ts index b5ed2e8..0da8213 100644 --- a/typetest/machine/Readiness.tst.ts +++ b/typetest/machine/Readiness.tst.ts @@ -194,24 +194,40 @@ describe("executable machine readiness", () => { Machine.Machine.Snapshot >() expect["send"]>().type.toBe< - (event: Tick) => Effect.Effect + (event: Machine.Machine.EventInput) => Effect.Effect >() expect["send"]>().type.toBe< - (event: Tick) => Effect.Effect + (event: Machine.Machine.EventInput) => Effect.Effect >() expect(invocation).type.toBeAssignableTo() expect>().type.toBe>() expect>().type.toBe< - readonly [Machine.Machine.Snapshot, Tick, string] + readonly [ + Machine.Machine.Snapshot, + Machine.Machine.EventInput, + string + ] >() expect>().type.toBe< - readonly [Machine.Machine.Snapshot, Tick, string] + readonly [ + Machine.Machine.Snapshot, + Machine.Machine.EventInput, + string + ] >() expect>().type.toBe< - readonly [Machine.Machine.Snapshot, Tick, string] + readonly [ + Machine.Machine.Snapshot, + Machine.Machine.EventInput, + string + ] >() expect>().type.toBe< - readonly [Machine.Machine.Snapshot, Tick, string] + readonly [ + Machine.Machine.Snapshot, + Machine.Machine.EventInput, + string + ] >() expect(cluster.machine).type.toBe() }) diff --git a/typetest/unstable/reactivity/AtomMachine.tst.ts b/typetest/unstable/reactivity/AtomMachine.tst.ts index 78ed288..d8beeec 100644 --- a/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -131,7 +131,9 @@ describe("AtomMachine", () => { const child = AtomMachine.make(parentMachine).child(Child) expect>().type.toBe>>() - expect ? Event : never>().type.toBe() + expect ? Event : never>().type.toBe< + Machine.Machine.EventInput + >() }) it("derives fail-aware results, selectors, and child bridge types", () => { @@ -287,7 +289,9 @@ describe("AtomMachine", () => { }) const bridge = AtomMachine.make(machine) - expect ? Event : never>().type.toBe() + expect ? Event : never>().type.toBe< + Machine.Machine.EventInput + >() }) it("requires output implementations and preserves exact terminal output", () => {