Skip to content

Commit

Permalink
feat: move fsm to corresponding property in instance
Browse files Browse the repository at this point in the history
  • Loading branch information
bondiano committed Sep 1, 2023
1 parent 2bce92a commit 658198e
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 53 deletions.
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ enum OrderItemEvent {

### Entity

The entity class must extend `StateMachineEntity` with defined initial state and transitions and have `id` property.
To create an entity class, it must extend `StateMachineEntity` and have defined initial state and transitions. Additionally, you can combine `StateMachineEntity` with your own `BaseEntity`, which should be extended from TypeORM's base entity.

```typescript
class BaseEntity extends TypeOrmBaseEntity {
Expand Down Expand Up @@ -151,10 +151,10 @@ To make a transition, we need to call the `transition` method of the entity or u

```typescript
const order = new Order();
await order.itemsStatus.create();
await order.itemsStatus.assemble();
await order.itemsStatus.transfer('Another warehouse');
await order.itemsStatus.ship();
await order.fsm.itemsStatus.create();
await order.fsm.itemsStatus.assemble();
await order.fsm.itemsStatus.transfer('Another warehouse');
await order.fsm.itemsStatus.ship();
```

We're passing the `place` argument to the `transfer` method. It will be passed to the `guard` and `onExit` functions.
Expand All @@ -165,14 +165,14 @@ You can get the current state of the state machine using the `current` property.

```typescript
const order = new Order();
console.log(order.itemsStatus.current); // draft
console.log(order.fsm.itemsStatus.current); // draft
```

Also you can use `is` + `state name` method to check the current state.

```typescript
const order = new Order();
console.log(order.itemsStatus.isDraft()); // true
console.log(order.fsm.itemsStatus.isDraft()); // true
```

Also `is(state: State)` method is available.
Expand All @@ -184,9 +184,9 @@ You can check if the transition is available using the `can` + `event name` meth
```typescript
const order = new Order();

console.log(order.itemsStatus.canCreate()); // true
console.log(order.fsm.itemsStatus.canCreate()); // true
await order.itemsStatus.create();
console.log(order.itemsStatus.canCreate()); // false
console.log(order.fsm.itemsStatus.canCreate()); // false
await order.itemsStatus.assemble();
```

Expand Down Expand Up @@ -235,10 +235,10 @@ The entity instance will be bound to the lifecycle methods. You can access the e
```typescript
const order = new Order();

order.itemsStatus.onEnter(function (this: Order) {
order.fsm.itemsStatus.onEnter(function (this: Order) {
console.log(this.id);
});
order.itemStatus.on(OrderItemEvent.create, function (this: Order) {
order.fsm.itemStatus.on(OrderItemEvent.create, function (this: Order) {
console.log(this.id);
});

Expand All @@ -253,7 +253,7 @@ Library throws `StateMachineError` if transition is not available. It can be cau
import { isStateMachineError } from 'typeorm-fsm';

try {
await order.itemsStatus.create();
await order.fsm.itemsStatus.create();
} catch (error) {
if (isStateMachineError(error)) {
console.log(error.message);
Expand Down
26 changes: 14 additions & 12 deletions src/__tests__/fsm.entity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,25 +139,27 @@ describe('StateMachineEntity', () => {
await order.save();

expect(order).toBeDefined();
expect(order.status.isDraft()).toBe(true);
expect(order.itemsStatus.isDraft()).toBe(true);
expect(order.fsm.status.isDraft()).toBe(true);
expect(order.fsm.itemsStatus.isDraft()).toBe(true);
expect(order.status).toBe(OrderState.draft);
expect(order.itemsStatus).toBe(OrderItemState.draft);
});

it('state should change after event', async () => {
const order = new Order();
await order.save();

await order.status.create();
await order.fsm.status.create();

expect(order.status.isPending()).toBe(true);
expect(order.fsm.status.isPending()).toBe(true);

const orderFromDatabase = await dataSource.manager.findOneOrFail(Order, {
where: {
id: order.id,
},
});

expect(orderFromDatabase.status.current).toBe(OrderState.pending);
expect(orderFromDatabase.fsm.status.current).toBe(OrderState.pending);
});

it('should be able to pass correct contexts (this and ctx) to subscribers', async () => {
Expand All @@ -173,9 +175,9 @@ describe('StateMachineEntity', () => {
handlerContext = this;
});

order.itemsStatus.on(OrderItemEvent.create, handler);
order.fsm.itemsStatus.on(OrderItemEvent.create, handler);

await order.itemsStatus.create();
await order.fsm.itemsStatus.create();

expect(handlerContext).toBeInstanceOf(Order);
expect(handler).toBeCalledTimes(1);
Expand All @@ -186,21 +188,21 @@ describe('StateMachineEntity', () => {
const order = new Order();
await order.save();

await expect(order.status.pay()).rejects.toThrowError();
await expect(order.fsm.status.pay()).rejects.toThrowError();
});

it('should throw error when transition guard is not passed', async () => {
const order = new Order();

await order.save();

await order.itemsStatus.create();
await order.itemsStatus.assemble();
await order.fsm.itemsStatus.create();
await order.fsm.itemsStatus.assemble();

await order.itemsStatus.transfer('John warehouse');
await order.fsm.itemsStatus.transfer('John warehouse');

await expect(
order.itemsStatus.transfer('John warehouse'),
order.fsm.itemsStatus.transfer('John warehouse'),
).rejects.toThrowError();
});
});
82 changes: 53 additions & 29 deletions src/fsm.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,25 +72,34 @@ type ExtractContext<
: never
: never;

type BaseStateMachineEntity = BaseEntity & {
type BaseStateMachineEntity<
State extends AllowedNames,
Event extends AllowedNames,
Context extends object,
Column extends string,
> = BaseEntity & {
id: number | string | Date | ObjectId;
} & {
[key: string]: unknown;
} & {
fsm: {
[column in Column]: IStateMachine<State, Event, Context>;
};
};

const buildAfterLoadMethodName = (column: string) =>
`__${column}FSM__afterLoad` as const;

const buildContextColumnName = (column: string) =>
`${column}__context` as const;
`__${column}FSM__context` as const;

function initializeStateMachine<
const State extends AllowedNames,
const Event extends AllowedNames,
const Column extends string,
const Context extends object,
>(
entity: BaseStateMachineEntity,
entity: BaseStateMachineEntity<State, Event, Context, Column>,
column: Column,
parameters: IStateMachineEntityColumnParameters<State, Event, Context>,
) {
Expand All @@ -109,10 +118,18 @@ function initializeStateMachine<
parameters.transitions = transitions.map((transition) => {
return {
...transition,
async onExit(context, ...arguments_) {
async onExit(
this: {
[key: string]: unknown;
},
context,
...arguments_
) {
// @ts-expect-error - bind entity to transition
await transition.onExit?.call(entity, context, ...arguments_);

this[column] = transition.to as State;

if (persistContext) {
entity[buildContextColumnName(column)] = JSON.stringify(context);
}
Expand All @@ -135,17 +152,17 @@ function initializeStateMachine<
context = entity[buildContextColumnName(column)];
}

entity[column] = new StateMachine({
entity.fsm[column] = new StateMachine({
...parameters,
initial: entity[column] as State,
ctx: context,
});

// @ts-expect-error - bind entity to transition
entity[column].on = function (
// @ts-expect-error - this as _StateMachine is with private methods
entity.fsm[column].on = function (
this: _StateMachine<AllowedNames, AllowedNames, object>,
event: AllowedNames,
callback: Callback<object>,
callback: Callback<Context>,
) {
if (!this._subscribers.has(event)) {
this._subscribers.set(event, new Map());
Expand All @@ -166,16 +183,16 @@ function initializeStateMachine<
* import { StateMachineEntity, t } from 'typeorm-fsm';
*
* enum OrderState {
* draft = 'draft',
* pending = 'pending',
* paid = 'paid',
* completed = 'completed',
* }
* draft = 'draft',
* pending = 'pending',
* paid = 'paid',
* completed = 'completed',
* }
*
* enum OrderEvent {
* create = 'create',
* pay = 'pay',
* complete = 'complete',
* create = 'create',
* pay = 'pay',
* complete = 'complete',
* }
*
* @Entity()
Expand All @@ -202,7 +219,17 @@ export const StateMachineEntity = function <
const Columns extends keyof Parameters = keyof Parameters,
>(parameters: Parameters, _BaseEntity?: { new (): Entity }) {
const _Entity = _BaseEntity ?? BaseEntity;
class _StateMachineEntity extends _Entity {}

class _StateMachineEntity extends _Entity {
constructor() {
super();
Object.defineProperty(this, 'fsm', {
value: {},
writable: true,
enumerable: false,
});
}
}

const metadataStorage = getMetadataArgsStorage();

Expand Down Expand Up @@ -232,13 +259,6 @@ export const StateMachineEntity = function <
[
Column('text', {
default: initial,
transformer: {
// we're transforming the value in after-load/after-insert hooks
from: (value) => value,
to: (value) => {
return value?.current ?? initial;
},
},
}),
],
_StateMachineEntity.prototype,
Expand Down Expand Up @@ -297,11 +317,15 @@ export const StateMachineEntity = function <
return _StateMachineEntity as unknown as {
new (): BaseEntity &
Entity & {
[Column in keyof Parameters]: IStateMachine<
ExtractState<Parameters, Column>,
ExtractEvent<Parameters, Column>,
ExtractContext<Parameters, Column>
>;
fsm: {
[Column in keyof Parameters]: IStateMachine<
ExtractState<Parameters, Column>,
ExtractEvent<Parameters, Column>,
ExtractContext<Parameters, Column>
>;
};
} & {
[Column in keyof Parameters]: ExtractState<Parameters, Column>;
};
};
};

0 comments on commit 658198e

Please sign in to comment.