effect-machine 0.4.0 → 0.7.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/README.md +36 -0
- package/dist/actor.d.ts +40 -4
- package/dist/actor.js +89 -34
- package/dist/cluster/entity-machine.d.ts +2 -2
- package/dist/cluster/entity-machine.js +1 -1
- package/dist/cluster/to-entity.d.ts +5 -5
- package/dist/cluster/to-entity.js +2 -2
- package/dist/errors.d.ts +25 -40
- package/dist/errors.js +10 -10
- package/dist/index.d.ts +2 -2
- package/dist/inspection.d.ts +3 -3
- package/dist/inspection.js +2 -2
- package/dist/internal/brands.d.ts +3 -6
- package/dist/internal/inspection.js +5 -1
- package/dist/internal/transition.d.ts +2 -2
- package/dist/internal/transition.js +6 -6
- package/dist/internal/utils.js +11 -2
- package/dist/machine.d.ts +5 -5
- package/dist/machine.js +9 -5
- package/dist/persistence/adapter.d.ts +18 -21
- package/dist/persistence/adapter.js +4 -4
- package/dist/persistence/adapters/in-memory.js +4 -4
- package/dist/persistence/persistent-actor.js +23 -14
- package/dist/persistence/persistent-machine.d.ts +3 -3
- package/dist/schema.d.ts +4 -4
- package/dist/schema.js +2 -2
- package/dist/slot.d.ts +3 -3
- package/dist/slot.js +2 -2
- package/dist-v3/_virtual/_rolldown/runtime.js +18 -0
- package/dist-v3/actor.d.ts +291 -0
- package/dist-v3/actor.js +459 -0
- package/dist-v3/cluster/entity-machine.d.ts +90 -0
- package/dist-v3/cluster/entity-machine.js +80 -0
- package/dist-v3/cluster/index.d.ts +3 -0
- package/dist-v3/cluster/index.js +4 -0
- package/dist-v3/cluster/to-entity.d.ts +61 -0
- package/dist-v3/cluster/to-entity.js +53 -0
- package/dist-v3/errors.d.ts +27 -0
- package/dist-v3/errors.js +38 -0
- package/dist-v3/index.d.ts +13 -0
- package/dist-v3/index.js +14 -0
- package/dist-v3/inspection.d.ts +125 -0
- package/dist-v3/inspection.js +50 -0
- package/dist-v3/internal/brands.d.ts +40 -0
- package/dist-v3/internal/brands.js +0 -0
- package/dist-v3/internal/inspection.d.ts +11 -0
- package/dist-v3/internal/inspection.js +15 -0
- package/dist-v3/internal/transition.d.ts +160 -0
- package/dist-v3/internal/transition.js +238 -0
- package/dist-v3/internal/utils.d.ts +60 -0
- package/dist-v3/internal/utils.js +51 -0
- package/dist-v3/machine.d.ts +278 -0
- package/dist-v3/machine.js +317 -0
- package/dist-v3/persistence/adapter.d.ts +125 -0
- package/dist-v3/persistence/adapter.js +27 -0
- package/dist-v3/persistence/adapters/in-memory.d.ts +32 -0
- package/dist-v3/persistence/adapters/in-memory.js +176 -0
- package/dist-v3/persistence/index.d.ts +5 -0
- package/dist-v3/persistence/index.js +6 -0
- package/dist-v3/persistence/persistent-actor.d.ts +49 -0
- package/dist-v3/persistence/persistent-actor.js +367 -0
- package/dist-v3/persistence/persistent-machine.d.ts +105 -0
- package/dist-v3/persistence/persistent-machine.js +24 -0
- package/dist-v3/schema.d.ts +141 -0
- package/dist-v3/schema.js +165 -0
- package/dist-v3/slot.d.ts +130 -0
- package/dist-v3/slot.js +99 -0
- package/dist-v3/testing.d.ts +136 -0
- package/dist-v3/testing.js +138 -0
- package/package.json +29 -21
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { InvalidSchemaError, MissingMatchHandlerError } from "./errors.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src-v3/schema.ts
|
|
5
|
+
/**
|
|
6
|
+
* Schema-first State/Event definitions for effect-machine.
|
|
7
|
+
*
|
|
8
|
+
* MachineSchema provides a single source of truth that combines:
|
|
9
|
+
* - Schema for validation/serialization
|
|
10
|
+
* - Variant constructors (like Data.taggedEnum)
|
|
11
|
+
* - $is and $match helpers for pattern matching
|
|
12
|
+
* - Brand integration for compile-time safety
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { State, Event, Machine } from "effect-machine"
|
|
17
|
+
*
|
|
18
|
+
* // Define schema-first state
|
|
19
|
+
* const OrderState = State({
|
|
20
|
+
* Pending: { orderId: Schema.String },
|
|
21
|
+
* Shipped: { trackingId: Schema.String },
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* // Infer type from schema
|
|
25
|
+
* type OrderState = typeof OrderState.Type
|
|
26
|
+
*
|
|
27
|
+
* // Use constructors
|
|
28
|
+
* const pending = OrderState.Pending({ orderId: "123" })
|
|
29
|
+
*
|
|
30
|
+
* // Pattern match
|
|
31
|
+
* OrderState.$match(state, {
|
|
32
|
+
* Pending: (s) => `Order ${s.orderId} pending`,
|
|
33
|
+
* Shipped: (s) => `Shipped: ${s.trackingId}`,
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* // Use as Schema for persistence/cluster
|
|
37
|
+
* machine.pipe(Machine.persist({ stateSchema: OrderState, ... }))
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Build a schema-first definition from a record of tag -> fields
|
|
44
|
+
*/
|
|
45
|
+
const buildMachineSchema = (definition) => {
|
|
46
|
+
const variants = {};
|
|
47
|
+
const constructors = {};
|
|
48
|
+
for (const tag of Object.keys(definition)) {
|
|
49
|
+
const fields = definition[tag];
|
|
50
|
+
if (fields === void 0) continue;
|
|
51
|
+
variants[tag] = Schema.TaggedStruct(tag, fields);
|
|
52
|
+
const fieldNames = new Set(Object.keys(fields));
|
|
53
|
+
if (fieldNames.size > 0) {
|
|
54
|
+
const constructor = (args) => ({
|
|
55
|
+
...args,
|
|
56
|
+
_tag: tag
|
|
57
|
+
});
|
|
58
|
+
constructor._tag = tag;
|
|
59
|
+
constructor.derive = (source, partial) => {
|
|
60
|
+
const result = { _tag: tag };
|
|
61
|
+
for (const key of fieldNames) if (key in source) result[key] = source[key];
|
|
62
|
+
if (partial !== void 0) for (const [key, value] of Object.entries(partial)) result[key] = value;
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
constructors[tag] = constructor;
|
|
66
|
+
} else constructors[tag] = {
|
|
67
|
+
_tag: tag,
|
|
68
|
+
derive: () => ({ _tag: tag })
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const variantArray = Object.values(variants);
|
|
72
|
+
if (variantArray.length === 0) throw new InvalidSchemaError();
|
|
73
|
+
const unionSchema = variantArray.length === 1 ? variantArray[0] : Schema.Union(...variantArray);
|
|
74
|
+
const $is = (tag) => (u) => typeof u === "object" && u !== null && "_tag" in u && u._tag === tag;
|
|
75
|
+
const $match = (valueOrCases, maybeCases) => {
|
|
76
|
+
if (maybeCases !== void 0) {
|
|
77
|
+
const value = valueOrCases;
|
|
78
|
+
const handler = maybeCases[value._tag];
|
|
79
|
+
if (handler === void 0) throw new MissingMatchHandlerError({ tag: value._tag });
|
|
80
|
+
return handler(value);
|
|
81
|
+
}
|
|
82
|
+
const cases = valueOrCases;
|
|
83
|
+
return (value) => {
|
|
84
|
+
const handler = cases[value._tag];
|
|
85
|
+
if (handler === void 0) throw new MissingMatchHandlerError({ tag: value._tag });
|
|
86
|
+
return handler(value);
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
schema: unionSchema,
|
|
91
|
+
variants,
|
|
92
|
+
constructors,
|
|
93
|
+
_definition: definition,
|
|
94
|
+
$is,
|
|
95
|
+
$match
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Internal helper to create a machine schema (shared by State and Event).
|
|
100
|
+
* Builds the schema object with variants, constructors, $is, and $match.
|
|
101
|
+
*/
|
|
102
|
+
const createMachineSchema = (definition) => {
|
|
103
|
+
const { schema, variants, constructors, _definition, $is, $match } = buildMachineSchema(definition);
|
|
104
|
+
return Object.assign(Object.create(schema), {
|
|
105
|
+
variants,
|
|
106
|
+
_definition,
|
|
107
|
+
$is,
|
|
108
|
+
$match,
|
|
109
|
+
...constructors
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Create a schema-first State definition.
|
|
114
|
+
*
|
|
115
|
+
* The schema's definition type D creates a unique brand, preventing
|
|
116
|
+
* accidental use of constructors from different state schemas
|
|
117
|
+
* (unless they have identical definitions).
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const OrderState = MachineSchema.State({
|
|
122
|
+
* Pending: { orderId: Schema.String },
|
|
123
|
+
* Shipped: { trackingId: Schema.String },
|
|
124
|
+
* })
|
|
125
|
+
*
|
|
126
|
+
* type OrderState = typeof OrderState.Type
|
|
127
|
+
*
|
|
128
|
+
* // Construct
|
|
129
|
+
* const s = OrderState.Pending({ orderId: "123" })
|
|
130
|
+
*
|
|
131
|
+
* // Pattern match
|
|
132
|
+
* OrderState.$match(s, {
|
|
133
|
+
* Pending: (v) => v.orderId,
|
|
134
|
+
* Shipped: (v) => v.trackingId,
|
|
135
|
+
* })
|
|
136
|
+
*
|
|
137
|
+
* // Validate
|
|
138
|
+
* Schema.decodeUnknownSync(OrderState)(rawJson)
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
const State = (definition) => createMachineSchema(definition);
|
|
142
|
+
/**
|
|
143
|
+
* Create a schema-first Event definition.
|
|
144
|
+
*
|
|
145
|
+
* The schema's definition type D creates a unique brand, preventing
|
|
146
|
+
* accidental use of constructors from different event schemas
|
|
147
|
+
* (unless they have identical definitions).
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* const OrderEvent = MachineSchema.Event({
|
|
152
|
+
* Ship: { trackingId: Schema.String },
|
|
153
|
+
* Cancel: {},
|
|
154
|
+
* })
|
|
155
|
+
*
|
|
156
|
+
* type OrderEvent = typeof OrderEvent.Type
|
|
157
|
+
*
|
|
158
|
+
* // Construct
|
|
159
|
+
* const e = OrderEvent.Ship({ trackingId: "abc" })
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
const Event = (definition) => createMachineSchema(definition);
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
export { Event, State };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { ActorSystem } from "./actor.js";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src-v3/slot.d.ts
|
|
5
|
+
/** Schema fields definition (like Schema.Struct.Fields) */
|
|
6
|
+
type Fields = Record<string, Schema.Schema.All>;
|
|
7
|
+
/** Extract the encoded type from schema fields (used for parameters) */
|
|
8
|
+
type FieldsToParams<F extends Fields> = keyof F extends never ? void : Schema.Schema.Type<Schema.Struct<F>>;
|
|
9
|
+
/**
|
|
10
|
+
* A guard slot - callable function that returns Effect<boolean>.
|
|
11
|
+
*/
|
|
12
|
+
interface GuardSlot<Name extends string, Params> {
|
|
13
|
+
readonly _tag: "GuardSlot";
|
|
14
|
+
readonly name: Name;
|
|
15
|
+
(params: Params): Effect.Effect<boolean>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* An effect slot - callable function that returns Effect<void>.
|
|
19
|
+
*/
|
|
20
|
+
interface EffectSlot<Name extends string, Params> {
|
|
21
|
+
readonly _tag: "EffectSlot";
|
|
22
|
+
readonly name: Name;
|
|
23
|
+
(params: Params): Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Guard definition - name to schema fields mapping
|
|
27
|
+
*/
|
|
28
|
+
type GuardsDef = Record<string, Fields>;
|
|
29
|
+
/**
|
|
30
|
+
* Effect definition - name to schema fields mapping
|
|
31
|
+
*/
|
|
32
|
+
type EffectsDef = Record<string, Fields>;
|
|
33
|
+
/**
|
|
34
|
+
* Convert guard definitions to callable guard slots
|
|
35
|
+
*/
|
|
36
|
+
type GuardSlots<D extends GuardsDef> = { readonly [K in keyof D & string]: GuardSlot<K, FieldsToParams<D[K]>> };
|
|
37
|
+
/**
|
|
38
|
+
* Convert effect definitions to callable effect slots
|
|
39
|
+
*/
|
|
40
|
+
type EffectSlots<D extends EffectsDef> = { readonly [K in keyof D & string]: EffectSlot<K, FieldsToParams<D[K]>> };
|
|
41
|
+
/**
|
|
42
|
+
* Type for machine context - state, event, and self reference.
|
|
43
|
+
* Shared across all machines via MachineContextTag.
|
|
44
|
+
*/
|
|
45
|
+
interface MachineContext<State, Event, Self> {
|
|
46
|
+
readonly state: State;
|
|
47
|
+
readonly event: Event;
|
|
48
|
+
readonly self: Self;
|
|
49
|
+
readonly system: ActorSystem;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Shared Context tag for all machines.
|
|
53
|
+
* Single module-level tag instead of per-machine allocation.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
declare const MachineContextTag: any;
|
|
57
|
+
/**
|
|
58
|
+
* Guard handler implementation.
|
|
59
|
+
* Receives params and context, returns Effect<boolean>.
|
|
60
|
+
*/
|
|
61
|
+
type GuardHandler<Params, Ctx, R = never> = (params: Params, ctx: Ctx) => boolean | Effect.Effect<boolean, never, R>;
|
|
62
|
+
/**
|
|
63
|
+
* Effect handler implementation.
|
|
64
|
+
* Receives params and context, returns Effect<void>.
|
|
65
|
+
*/
|
|
66
|
+
type EffectHandler<Params, Ctx, R = never> = (params: Params, ctx: Ctx) => Effect.Effect<void, never, R>;
|
|
67
|
+
/**
|
|
68
|
+
* Handler types for all guards in a definition
|
|
69
|
+
*/
|
|
70
|
+
type GuardHandlers<D extends GuardsDef, MachineCtx, R = never> = { readonly [K in keyof D & string]: GuardHandler<FieldsToParams<D[K]>, MachineCtx, R> };
|
|
71
|
+
/**
|
|
72
|
+
* Handler types for all effects in a definition
|
|
73
|
+
*/
|
|
74
|
+
type EffectHandlers<D extends EffectsDef, MachineCtx, R = never> = { readonly [K in keyof D & string]: EffectHandler<FieldsToParams<D[K]>, MachineCtx, R> };
|
|
75
|
+
/**
|
|
76
|
+
* Guards schema - returned by Slot.Guards()
|
|
77
|
+
*/
|
|
78
|
+
interface GuardsSchema<D extends GuardsDef> {
|
|
79
|
+
readonly _tag: "GuardsSchema";
|
|
80
|
+
readonly definitions: D;
|
|
81
|
+
/** Create callable guard slots (used by Machine internally) */
|
|
82
|
+
readonly _createSlots: (resolve: <N extends keyof D & string>(name: N, params: FieldsToParams<D[N]>) => Effect.Effect<boolean>) => GuardSlots<D>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Effects schema - returned by Slot.Effects()
|
|
86
|
+
*/
|
|
87
|
+
interface EffectsSchema<D extends EffectsDef> {
|
|
88
|
+
readonly _tag: "EffectsSchema";
|
|
89
|
+
readonly definitions: D;
|
|
90
|
+
/** Create callable effect slots (used by Machine internally) */
|
|
91
|
+
readonly _createSlots: (resolve: <N extends keyof D & string>(name: N, params: FieldsToParams<D[N]>) => Effect.Effect<void>) => EffectSlots<D>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create a guards schema with parameterized guard definitions.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* const MyGuards = Slot.Guards({
|
|
99
|
+
* canRetry: { max: Schema.Number },
|
|
100
|
+
* isValid: {},
|
|
101
|
+
* })
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
declare const Guards: <D extends GuardsDef>(definitions: D) => GuardsSchema<D>;
|
|
105
|
+
/**
|
|
106
|
+
* Create an effects schema with parameterized effect definitions.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* const MyEffects = Slot.Effects({
|
|
111
|
+
* fetchData: { url: Schema.String },
|
|
112
|
+
* notify: { message: Schema.String },
|
|
113
|
+
* })
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare const Effects: <D extends EffectsDef>(definitions: D) => EffectsSchema<D>;
|
|
117
|
+
/** Extract guard definition type from GuardsSchema */
|
|
118
|
+
type GuardsDefOf<G> = G extends GuardsSchema<infer D> ? D : never;
|
|
119
|
+
/** Extract effect definition type from EffectsSchema */
|
|
120
|
+
type EffectsDefOf<E> = E extends EffectsSchema<infer D> ? D : never;
|
|
121
|
+
/** Extract guard slots type from GuardsSchema */
|
|
122
|
+
type GuardSlotsOf<G> = G extends GuardsSchema<infer D> ? GuardSlots<D> : never;
|
|
123
|
+
/** Extract effect slots type from EffectsSchema */
|
|
124
|
+
type EffectSlotsOf<E> = E extends EffectsSchema<infer D> ? EffectSlots<D> : never;
|
|
125
|
+
declare const Slot: {
|
|
126
|
+
readonly Guards: <D extends GuardsDef>(definitions: D) => GuardsSchema<D>;
|
|
127
|
+
readonly Effects: <D extends EffectsDef>(definitions: D) => EffectsSchema<D>;
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
export { EffectHandler, EffectHandlers, EffectSlot, EffectSlots, EffectSlotsOf, Effects, EffectsDef, EffectsDefOf, EffectsSchema, GuardHandler, GuardHandlers, GuardSlot, GuardSlots, GuardSlotsOf, Guards, GuardsDef, GuardsDefOf, GuardsSchema, MachineContext, MachineContextTag, Slot };
|
package/dist-v3/slot.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src-v3/slot.ts
|
|
4
|
+
/**
|
|
5
|
+
* Slot module - schema-based, parameterized guards and effects.
|
|
6
|
+
*
|
|
7
|
+
* Guards and Effects are defined with schemas for their parameters,
|
|
8
|
+
* and provided implementations receive typed parameters plus machine context.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { Slot } from "effect-machine"
|
|
13
|
+
* import { Schema } from "effect"
|
|
14
|
+
*
|
|
15
|
+
* const MyGuards = Slot.Guards({
|
|
16
|
+
* canRetry: { max: Schema.Number },
|
|
17
|
+
* isValid: {}, // no params
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* const MyEffects = Slot.Effects({
|
|
21
|
+
* fetchData: { url: Schema.String },
|
|
22
|
+
* notify: { message: Schema.String },
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* // Used in handlers:
|
|
26
|
+
* .on(State.X, Event.Y, ({ guards, effects }) =>
|
|
27
|
+
* Effect.gen(function* () {
|
|
28
|
+
* if (yield* guards.canRetry({ max: 3 })) {
|
|
29
|
+
* yield* effects.fetchData({ url: "/api" })
|
|
30
|
+
* return State.Next
|
|
31
|
+
* }
|
|
32
|
+
* return state
|
|
33
|
+
* })
|
|
34
|
+
* )
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Shared Context tag for all machines.
|
|
41
|
+
* Single module-level tag instead of per-machine allocation.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
const MachineContextTag = Context.GenericTag("@effect-machine/Context");
|
|
45
|
+
/**
|
|
46
|
+
* Generic slot schema factory. Used internally by Guards() and Effects().
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
const createSlotSchema = (tag, slotTag, definitions) => ({
|
|
50
|
+
_tag: tag,
|
|
51
|
+
definitions,
|
|
52
|
+
_createSlots: (resolve) => {
|
|
53
|
+
const slots = {};
|
|
54
|
+
for (const name of Object.keys(definitions)) {
|
|
55
|
+
const slot = (params) => resolve(name, params);
|
|
56
|
+
Object.defineProperty(slot, "_tag", {
|
|
57
|
+
value: slotTag,
|
|
58
|
+
enumerable: true
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(slot, "name", {
|
|
61
|
+
value: name,
|
|
62
|
+
enumerable: true
|
|
63
|
+
});
|
|
64
|
+
slots[name] = slot;
|
|
65
|
+
}
|
|
66
|
+
return slots;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Create a guards schema with parameterized guard definitions.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const MyGuards = Slot.Guards({
|
|
75
|
+
* canRetry: { max: Schema.Number },
|
|
76
|
+
* isValid: {},
|
|
77
|
+
* })
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
const Guards = (definitions) => createSlotSchema("GuardsSchema", "GuardSlot", definitions);
|
|
81
|
+
/**
|
|
82
|
+
* Create an effects schema with parameterized effect definitions.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const MyEffects = Slot.Effects({
|
|
87
|
+
* fetchData: { url: Schema.String },
|
|
88
|
+
* notify: { message: Schema.String },
|
|
89
|
+
* })
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
const Effects = (definitions) => createSlotSchema("EffectsSchema", "EffectSlot", definitions);
|
|
93
|
+
const Slot = {
|
|
94
|
+
Guards,
|
|
95
|
+
Effects
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
export { Effects, Guards, MachineContextTag, Slot };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { AssertionError } from "./errors.js";
|
|
2
|
+
import { EffectsDef, GuardsDef } from "./slot.js";
|
|
3
|
+
import { BuiltMachine, Machine } from "./machine.js";
|
|
4
|
+
import { Effect, SubscriptionRef } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src-v3/testing.d.ts
|
|
7
|
+
/** Accept either Machine or BuiltMachine for testing utilities. */
|
|
8
|
+
type MachineInput<S, E, R, GD extends GuardsDef, EFD extends EffectsDef> = Machine<S, E, R, any, any, GD, EFD> | BuiltMachine<S, E, R>;
|
|
9
|
+
/**
|
|
10
|
+
* Result of simulating events through a machine
|
|
11
|
+
*/
|
|
12
|
+
interface SimulationResult<S> {
|
|
13
|
+
readonly states: ReadonlyArray<S>;
|
|
14
|
+
readonly finalState: S;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Simulate a sequence of events through a machine without running an actor.
|
|
18
|
+
* Useful for testing state transitions in isolation.
|
|
19
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
20
|
+
* within transition handlers.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const result = yield* simulate(
|
|
25
|
+
* fetcherMachine,
|
|
26
|
+
* [
|
|
27
|
+
* Event.Fetch({ url: "https://example.com" }),
|
|
28
|
+
* Event._Done({ data: { foo: "bar" } })
|
|
29
|
+
* ]
|
|
30
|
+
* )
|
|
31
|
+
*
|
|
32
|
+
* expect(result.finalState._tag).toBe("Success")
|
|
33
|
+
* expect(result.states).toHaveLength(3) // Idle -> Loading -> Success
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
declare const simulate: <S extends {
|
|
37
|
+
readonly _tag: string;
|
|
38
|
+
}, E extends {
|
|
39
|
+
readonly _tag: string;
|
|
40
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: MachineInput<S, E, R, GD, EFD>, events: readonly E[]) => Effect.Effect<{
|
|
41
|
+
states: S[];
|
|
42
|
+
finalState: S;
|
|
43
|
+
}, never, Exclude<R, unknown>>;
|
|
44
|
+
/**
|
|
45
|
+
* Assert that a machine can reach a specific state given a sequence of events
|
|
46
|
+
*/
|
|
47
|
+
declare const assertReaches: <S extends {
|
|
48
|
+
readonly _tag: string;
|
|
49
|
+
}, E extends {
|
|
50
|
+
readonly _tag: string;
|
|
51
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: MachineInput<S, E, R, GD, EFD>, events: readonly E[], expectedTag: string) => Effect.Effect<any, unknown, unknown>;
|
|
52
|
+
/**
|
|
53
|
+
* Assert that a machine follows a specific path of state tags
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* yield* assertPath(
|
|
58
|
+
* machine,
|
|
59
|
+
* [Event.Start(), Event.Increment(), Event.Stop()],
|
|
60
|
+
* ["Idle", "Counting", "Counting", "Done"]
|
|
61
|
+
* )
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare const assertPath: <S extends {
|
|
65
|
+
readonly _tag: string;
|
|
66
|
+
}, E extends {
|
|
67
|
+
readonly _tag: string;
|
|
68
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: MachineInput<S, E, R, GD, EFD>, events: readonly E[], expectedPath: readonly string[]) => Effect.Effect<any, unknown, unknown>;
|
|
69
|
+
/**
|
|
70
|
+
* Assert that a machine never reaches a specific state given a sequence of events
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* // Verify error handling doesn't reach crash state
|
|
75
|
+
* yield* assertNeverReaches(
|
|
76
|
+
* machine,
|
|
77
|
+
* [Event.Error(), Event.Retry(), Event.Success()],
|
|
78
|
+
* "Crashed"
|
|
79
|
+
* )
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
declare const assertNeverReaches: <S extends {
|
|
83
|
+
readonly _tag: string;
|
|
84
|
+
}, E extends {
|
|
85
|
+
readonly _tag: string;
|
|
86
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: MachineInput<S, E, R, GD, EFD>, events: readonly E[], forbiddenTag: string) => Effect.Effect<any, unknown, unknown>;
|
|
87
|
+
/**
|
|
88
|
+
* Create a controllable test harness for a machine
|
|
89
|
+
*/
|
|
90
|
+
interface TestHarness<S, E, R> {
|
|
91
|
+
readonly state: SubscriptionRef.SubscriptionRef<S>;
|
|
92
|
+
readonly send: (event: E) => Effect.Effect<S, never, R>;
|
|
93
|
+
readonly getState: Effect.Effect<S>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Options for creating a test harness
|
|
97
|
+
*/
|
|
98
|
+
interface TestHarnessOptions<S, E> {
|
|
99
|
+
/**
|
|
100
|
+
* Called after each transition with the previous state, event, and new state.
|
|
101
|
+
* Useful for logging or spying on transitions.
|
|
102
|
+
*/
|
|
103
|
+
readonly onTransition?: (from: S, event: E, to: S) => void;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create a test harness for step-by-step testing.
|
|
107
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
108
|
+
* within transition handlers.
|
|
109
|
+
*
|
|
110
|
+
* @example Basic usage
|
|
111
|
+
* ```ts
|
|
112
|
+
* const harness = yield* createTestHarness(machine)
|
|
113
|
+
* yield* harness.send(Event.Start())
|
|
114
|
+
* const state = yield* harness.getState
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @example With transition observer
|
|
118
|
+
* ```ts
|
|
119
|
+
* const transitions: Array<{ from: string; event: string; to: string }> = []
|
|
120
|
+
* const harness = yield* createTestHarness(machine, {
|
|
121
|
+
* onTransition: (from, event, to) =>
|
|
122
|
+
* transitions.push({ from: from._tag, event: event._tag, to: to._tag })
|
|
123
|
+
* })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
declare const createTestHarness: <S extends {
|
|
127
|
+
readonly _tag: string;
|
|
128
|
+
}, E extends {
|
|
129
|
+
readonly _tag: string;
|
|
130
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: MachineInput<S, E, R, GD, EFD>, options?: TestHarnessOptions<S, E> | undefined) => Effect.Effect<{
|
|
131
|
+
state: SubscriptionRef.SubscriptionRef<S>;
|
|
132
|
+
send: (event: E) => Effect.Effect<S, never, never>;
|
|
133
|
+
getState: Effect.Effect<S, never, never>;
|
|
134
|
+
}, never, never>;
|
|
135
|
+
//#endregion
|
|
136
|
+
export { AssertionError, SimulationResult, TestHarness, TestHarnessOptions, assertNeverReaches, assertPath, assertReaches, createTestHarness, simulate };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { stubSystem } from "./internal/utils.js";
|
|
2
|
+
import { AssertionError } from "./errors.js";
|
|
3
|
+
import { BuiltMachine } from "./machine.js";
|
|
4
|
+
import { executeTransition } from "./internal/transition.js";
|
|
5
|
+
import { Effect, SubscriptionRef } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src-v3/testing.ts
|
|
8
|
+
/**
|
|
9
|
+
* Simulate a sequence of events through a machine without running an actor.
|
|
10
|
+
* Useful for testing state transitions in isolation.
|
|
11
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
12
|
+
* within transition handlers.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const result = yield* simulate(
|
|
17
|
+
* fetcherMachine,
|
|
18
|
+
* [
|
|
19
|
+
* Event.Fetch({ url: "https://example.com" }),
|
|
20
|
+
* Event._Done({ data: { foo: "bar" } })
|
|
21
|
+
* ]
|
|
22
|
+
* )
|
|
23
|
+
*
|
|
24
|
+
* expect(result.finalState._tag).toBe("Success")
|
|
25
|
+
* expect(result.states).toHaveLength(3) // Idle -> Loading -> Success
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
const simulate = Effect.fn("effect-machine.simulate")(function* (input, events) {
|
|
29
|
+
const machine = input instanceof BuiltMachine ? input._inner : input;
|
|
30
|
+
const dummySelf = {
|
|
31
|
+
send: Effect.fn("effect-machine.testing.simulate.send")((_event) => Effect.void),
|
|
32
|
+
spawn: () => Effect.die("spawn not supported in simulation")
|
|
33
|
+
};
|
|
34
|
+
let currentState = machine.initial;
|
|
35
|
+
const states = [currentState];
|
|
36
|
+
for (const event of events) {
|
|
37
|
+
const result = yield* executeTransition(machine, currentState, event, dummySelf, stubSystem);
|
|
38
|
+
if (!result.transitioned) continue;
|
|
39
|
+
currentState = result.newState;
|
|
40
|
+
states.push(currentState);
|
|
41
|
+
if (machine.finalStates.has(currentState._tag)) break;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
states,
|
|
45
|
+
finalState: currentState
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Assert that a machine can reach a specific state given a sequence of events
|
|
50
|
+
*/
|
|
51
|
+
const assertReaches = Effect.fn("effect-machine.assertReaches")(function* (input, events, expectedTag) {
|
|
52
|
+
const result = yield* simulate(input, events);
|
|
53
|
+
if (result.finalState._tag !== expectedTag) return yield* new AssertionError({ message: `Expected final state "${expectedTag}" but got "${result.finalState._tag}". States visited: ${result.states.map((s) => s._tag).join(" -> ")}` });
|
|
54
|
+
return result.finalState;
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* Assert that a machine follows a specific path of state tags
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* yield* assertPath(
|
|
62
|
+
* machine,
|
|
63
|
+
* [Event.Start(), Event.Increment(), Event.Stop()],
|
|
64
|
+
* ["Idle", "Counting", "Counting", "Done"]
|
|
65
|
+
* )
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
const assertPath = Effect.fn("effect-machine.assertPath")(function* (input, events, expectedPath) {
|
|
69
|
+
const result = yield* simulate(input, events);
|
|
70
|
+
const actualPath = result.states.map((s) => s._tag);
|
|
71
|
+
if (actualPath.length !== expectedPath.length) return yield* new AssertionError({ message: `Path length mismatch. Expected ${expectedPath.length} states but got ${actualPath.length}.\nExpected: ${expectedPath.join(" -> ")}\nActual: ${actualPath.join(" -> ")}` });
|
|
72
|
+
for (let i = 0; i < expectedPath.length; i++) if (actualPath[i] !== expectedPath[i]) return yield* new AssertionError({ message: `Path mismatch at position ${i}. Expected "${expectedPath[i]}" but got "${actualPath[i]}".\nExpected: ${expectedPath.join(" -> ")}\nActual: ${actualPath.join(" -> ")}` });
|
|
73
|
+
return result;
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Assert that a machine never reaches a specific state given a sequence of events
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // Verify error handling doesn't reach crash state
|
|
81
|
+
* yield* assertNeverReaches(
|
|
82
|
+
* machine,
|
|
83
|
+
* [Event.Error(), Event.Retry(), Event.Success()],
|
|
84
|
+
* "Crashed"
|
|
85
|
+
* )
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
const assertNeverReaches = Effect.fn("effect-machine.assertNeverReaches")(function* (input, events, forbiddenTag) {
|
|
89
|
+
const result = yield* simulate(input, events);
|
|
90
|
+
const visitedIndex = result.states.findIndex((s) => s._tag === forbiddenTag);
|
|
91
|
+
if (visitedIndex !== -1) return yield* new AssertionError({ message: `Machine reached forbidden state "${forbiddenTag}" at position ${visitedIndex}.\nStates visited: ${result.states.map((s) => s._tag).join(" -> ")}` });
|
|
92
|
+
return result;
|
|
93
|
+
});
|
|
94
|
+
/**
|
|
95
|
+
* Create a test harness for step-by-step testing.
|
|
96
|
+
* Does not run onEnter/spawn/background effects, but does run guard/effect slots
|
|
97
|
+
* within transition handlers.
|
|
98
|
+
*
|
|
99
|
+
* @example Basic usage
|
|
100
|
+
* ```ts
|
|
101
|
+
* const harness = yield* createTestHarness(machine)
|
|
102
|
+
* yield* harness.send(Event.Start())
|
|
103
|
+
* const state = yield* harness.getState
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @example With transition observer
|
|
107
|
+
* ```ts
|
|
108
|
+
* const transitions: Array<{ from: string; event: string; to: string }> = []
|
|
109
|
+
* const harness = yield* createTestHarness(machine, {
|
|
110
|
+
* onTransition: (from, event, to) =>
|
|
111
|
+
* transitions.push({ from: from._tag, event: event._tag, to: to._tag })
|
|
112
|
+
* })
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
const createTestHarness = Effect.fn("effect-machine.createTestHarness")(function* (input, options) {
|
|
116
|
+
const machine = input instanceof BuiltMachine ? input._inner : input;
|
|
117
|
+
const dummySelf = {
|
|
118
|
+
send: Effect.fn("effect-machine.testing.harness.send")((_event) => Effect.void),
|
|
119
|
+
spawn: () => Effect.die("spawn not supported in test harness")
|
|
120
|
+
};
|
|
121
|
+
const stateRef = yield* SubscriptionRef.make(machine.initial);
|
|
122
|
+
return {
|
|
123
|
+
state: stateRef,
|
|
124
|
+
send: Effect.fn("effect-machine.testHarness.send")(function* (event) {
|
|
125
|
+
const currentState = yield* SubscriptionRef.get(stateRef);
|
|
126
|
+
const result = yield* executeTransition(machine, currentState, event, dummySelf, stubSystem);
|
|
127
|
+
if (!result.transitioned) return currentState;
|
|
128
|
+
const newState = result.newState;
|
|
129
|
+
yield* SubscriptionRef.set(stateRef, newState);
|
|
130
|
+
if (options?.onTransition !== void 0) options.onTransition(currentState, event, newState);
|
|
131
|
+
return newState;
|
|
132
|
+
}),
|
|
133
|
+
getState: SubscriptionRef.get(stateRef)
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
export { AssertionError, assertNeverReaches, assertPath, assertReaches, createTestHarness, simulate };
|