Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v5] Recursive persistence #3743

Merged
merged 81 commits into from
Mar 14, 2023
Merged
Show file tree
Hide file tree
Changes from 75 commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
a608233
Add PersistedState
davidkpiano Jan 3, 2023
dbfb7d5
Merge branch 'next' into v5/recursive-persistence
davidkpiano Jan 7, 2023
d341071
behavior.at (WIP)
davidkpiano Jan 8, 2023
4ae6151
Use invoke action instead
davidkpiano Jan 9, 2023
76e291a
Ensure that persisted state isn't lost
davidkpiano Jan 9, 2023
3ecca35
Allow machine.at(...) to take optional arg
davidkpiano Jan 9, 2023
2c4f211
Cleanup
davidkpiano Jan 11, 2023
cd9c622
Merge branch 'next' into v5/recursive-persistence
davidkpiano Jan 20, 2023
bbb17ef
Renaming
davidkpiano Jan 20, 2023
a5dd8eb
Fix types
davidkpiano Jan 20, 2023
dd74f9e
No more arguments for .start()
davidkpiano Jan 20, 2023
d1e7fce
Merge branch 'next' into v5/recursive-persistence
davidkpiano Jan 20, 2023
953033f
Merge branch 'next' into v5/recursive-persistence
davidkpiano Jan 21, 2023
6e92889
Improve types/tests
davidkpiano Jan 27, 2023
3769cae
Merge branch 'next' into v5/recursive-persistence
davidkpiano Jan 27, 2023
cbd6b67
Add input with loose types
davidkpiano Jan 29, 2023
9a48360
Update packages/core/src/StateMachine.ts
davidkpiano Feb 1, 2023
d440b5e
Move input closer to the interpreter (#3803)
Andarist Feb 1, 2023
8fb1b29
Update packages/core/src/types.ts
davidkpiano Feb 2, 2023
925fe22
Remove UseMachineOptions (React)
davidkpiano Feb 6, 2023
c21ae81
Remove options from other libraries
davidkpiano Feb 6, 2023
246f3ef
Merge branch 'v5/input-2' into v5/recursive-persistence
davidkpiano Feb 6, 2023
2b5f2ed
Remove public usage of .at(...) in favor of { state: ... }
davidkpiano Feb 6, 2023
dabca9e
Remove .at() (mostly)
davidkpiano Feb 6, 2023
26d9a22
Lint
davidkpiano Feb 6, 2023
8a08bdc
Remove input from this PR
davidkpiano Feb 7, 2023
da08941
Oops
davidkpiano Feb 7, 2023
1517afc
Remove machine.at
davidkpiano Feb 8, 2023
541b6cc
Remove state
davidkpiano Feb 8, 2023
29552aa
Use `machine.getPersistedState`
davidkpiano Feb 13, 2023
2c37941
public preInitialState -> private _preInitialState
davidkpiano Feb 13, 2023
efc4ae4
Remove _preInitialState
davidkpiano Feb 13, 2023
ae13d04
Clarifying comment
davidkpiano Feb 13, 2023
3abcf0b
getPersistedState should use initial state
davidkpiano Feb 13, 2023
8c1a5c2
Use .js at the end
davidkpiano Feb 13, 2023
9c18cbd
start returns void
davidkpiano Feb 13, 2023
9827f16
Add todo
davidkpiano Feb 13, 2023
3f3f460
Lint
davidkpiano Feb 13, 2023
2bdd8c5
Merge branch 'next' into v5/recursive-persistence
davidkpiano Feb 27, 2023
c069959
Attempt to fix types
davidkpiano Feb 27, 2023
22eeaec
Ugh types
davidkpiano Feb 27, 2023
4462f15
Address comments
davidkpiano Mar 1, 2023
19ce012
Add changeset
davidkpiano Mar 1, 2023
07bd0dc
Update .changeset/shy-cobras-ring.md
davidkpiano Mar 1, 2023
ba8fcef
Remove constraint
davidkpiano Mar 1, 2023
4d37b6f
Update packages/core/src/State.ts
davidkpiano Mar 1, 2023
ccdbaa0
Don't do the hacky thing
davidkpiano Mar 1, 2023
b27c40e
Merge branch 'v5/recursive-persistence' of https://github.com/stately…
davidkpiano Mar 1, 2023
3f81e11
Renaming etc
davidkpiano Mar 1, 2023
0f71646
Change PersistedMachineState children shape
davidkpiano Mar 3, 2023
fa7d371
Small tweaks
davidkpiano Mar 3, 2023
a0f547c
Change test, remove action unshifting for starting actors
davidkpiano Mar 4, 2023
4f53c70
Add skipped test
davidkpiano Mar 4, 2023
5dc9f46
Do better here (PersistedMachineState)
davidkpiano Mar 4, 2023
739d8cb
Remove comment
davidkpiano Mar 4, 2023
8c9ae22
Fix promise test
davidkpiano Mar 4, 2023
2b01891
Fix everything
davidkpiano Mar 5, 2023
ecff33b
Add src
davidkpiano Mar 5, 2023
f556d42
Fix types
davidkpiano Mar 5, 2023
544b298
Rename PersistedFrom -> PersistedStateFrom
davidkpiano Mar 5, 2023
e87e818
Sigh, types
davidkpiano Mar 6, 2023
06d65ed
self != this
davidkpiano Mar 7, 2023
b75d480
Add test case: initial state of a child is available before starting …
davidkpiano Mar 7, 2023
1d0c983
Apply suggestion
davidkpiano Mar 7, 2023
c761ca6
Update packages/core/src/StateNode.ts
davidkpiano Mar 7, 2023
ee8ad87
Update packages/core/src/StateMachine.ts
davidkpiano Mar 7, 2023
999ff7d
Update packages/core/src/types.ts
davidkpiano Mar 7, 2023
e58f68c
Made the test work
davidkpiano Mar 8, 2023
3234ac2
fixed types
Andarist Mar 9, 2023
3fb54b4
Initialize the `Interpreter`'s state eagerly (#3874)
Andarist Mar 9, 2023
a170a00
Remove `persisted: true` check
davidkpiano Mar 13, 2023
a087432
Do not restart a completed observable actor
davidkpiano Mar 13, 2023
5253a63
Update packages/core/test/actor.test.ts
davidkpiano Mar 13, 2023
67400de
Update packages/core/test/actor.test.ts
davidkpiano Mar 13, 2023
a8b30b9
Update packages/core/test/actor.test.ts
davidkpiano Mar 13, 2023
ad07b01
Update packages/core/test/actor.test.ts
Andarist Mar 13, 2023
8bdf86b
Add event observable test
davidkpiano Mar 13, 2023
b4f28ba
Fix types
davidkpiano Mar 13, 2023
b6db333
Change back
davidkpiano Mar 14, 2023
55e5952
Add changeset
davidkpiano Mar 14, 2023
7489eb6
Add another changeset
davidkpiano Mar 14, 2023
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
2 changes: 2 additions & 0 deletions .changeset/shy-cobras-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ const lightMachine = createMachine({

const currentState = 'green';

const nextState = lightMachine.transition(currentState, { type: 'TIMER' })
.value;
const nextState = lightMachine.transition(currentState, {
type: 'TIMER'
}).value;

// => 'yellow'
```
Expand Down Expand Up @@ -278,8 +279,9 @@ const lightMachine = createMachine({

const currentState = 'yellow';

const nextState = lightMachine.transition(currentState, { type: 'TIMER' })
.value;
const nextState = lightMachine.transition(currentState, {
type: 'TIMER'
}).value;
// => {
// red: 'walk'
// }
Expand Down Expand Up @@ -374,8 +376,9 @@ const wordMachine = createMachine({
}
});

const boldState = wordMachine.transition('bold.off', { type: 'TOGGLE_BOLD' })
.value;
const boldState = wordMachine.transition('bold.off', {
type: 'TOGGLE_BOLD'
}).value;

// {
// bold: 'on',
Expand Down
19 changes: 10 additions & 9 deletions docs/fr/guides/communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,23 +131,24 @@ The resolved data is placed into a `'done.invoke.<id>'` event, under the `data`
If a Promise rejects, the `onError` transition will be taken with a `{ type: 'error.platform' }` event. The error data is available on the event's `data` property:

```js
const search = (context, event) => new Promise((resolve, reject) => {
if (!event.query.length) {
return reject('No query specified');
// or:
// throw new Error('No query specified');
}
const search = (context, event) =>
new Promise((resolve, reject) => {
if (!event.query.length) {
return reject('No query specified');
// or:
// throw new Error('No query specified');
}

return resolve(getSearchResults(event.query));
});
return resolve(getSearchResults(event.query));
});

// ...
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
context: {
results: undefined,
errorMessage: undefined,
errorMessage: undefined
},
states: {
idle: {
Expand Down
19 changes: 10 additions & 9 deletions docs/zh/guides/communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,23 +131,24 @@ const userMachine = createMachine({
如果 Promise 拒绝,则将使用 `{ type: 'error.platform' }` 事件进行 `onError` 转换。 错误数据在事件的 `data` 属性中可用:

```js
const search = (context, event) => new Promise((resolve, reject) => {
if (!event.query.length) {
return reject('No query specified');
// or:
// throw new Error('No query specified');
}
const search = (context, event) =>
new Promise((resolve, reject) => {
if (!event.query.length) {
return reject('No query specified');
// or:
// throw new Error('No query specified');
}

return resolve(getSearchResults(event.query));
});
return resolve(getSearchResults(event.query));
});

// ...
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
context: {
results: undefined,
errorMessage: undefined,
errorMessage: undefined
},
states: {
idle: {
Expand Down
26 changes: 24 additions & 2 deletions packages/core/src/State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
EventObject,
HistoryValue,
MachineContext,
PersistedMachineState,
Prop,
SCXML,
StateConfig,
Expand Down Expand Up @@ -124,7 +125,7 @@ export class State<
return stateValue;
}

const _event = initEvent as SCXML.Event<TEvent>;
const _event = initEvent as SCXML.Event<TEvent>; // TODO: fix

const configuration = getConfiguration(
getStateNodes(machine.root, stateValue)
Expand Down Expand Up @@ -167,7 +168,7 @@ export class State<
this.configuration =
config.configuration ??
Array.from(getConfiguration(getStateNodes(machine.root, config.value)));
this.transitions = config.transitions;
this.transitions = config.transitions as any;
this.children = config.children;

this.value = getStateValue(machine.root, this.configuration);
Expand Down Expand Up @@ -287,3 +288,24 @@ export function cloneState<TState extends AnyState>(
state.machine
) as TState;
}

export function getPersistedState<TState extends AnyState>(
state: TState
): PersistedMachineState<TState> {
const { configuration, transitions, tags, machine, children, ...jsonValues } =
state;

const childrenJson: Partial<PersistedMachineState<any>['children']> = {};

for (const id in children) {
childrenJson[id] = {
state: children[id].getPersistedState?.(),
src: children[id].src
};
}

return {
...jsonValues,
children: childrenJson
} as PersistedMachineState<TState>;
}
132 changes: 111 additions & 21 deletions packages/core/src/StateMachine.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { initEvent } from './actions.js';
import { error, initEvent } from './actions.js';
import { STATE_DELIMITER } from './constants.js';
import { createSpawner } from './spawn.js';
import { isStateConfig, State } from './State.js';
import { getPersistedState, State } from './State.js';
import { StateNode } from './StateNode.js';
import { interpret } from './interpreter.js';
import {
getConfiguration,
getInitialConfiguration,
Expand Down Expand Up @@ -41,7 +42,8 @@ import type {
StateConfig,
StateMachineDefinition,
StateValue,
TransitionDefinition
TransitionDefinition,
PersistedMachineState
} from './types.js';
import { isFunction, isSCXMLErrorEvent, toSCXMLEvent } from './utils.js';

Expand Down Expand Up @@ -88,18 +90,28 @@ export class StateMachine<
ActorBehavior<
TEvent | SCXML.Event<TEvent>,
State<TContext, TEvent, TResolvedTypesMeta>,
State<TContext, TEvent, TResolvedTypesMeta>
State<TContext, TEvent, TResolvedTypesMeta>,
PersistedMachineState<State<TContext, TEvent, TResolvedTypesMeta>>
>
{
private _contextFactory: (stuff: { spawn: Spawner }) => TContext;
// TODO: this getter should be removed
public get context(): TContext {
return this.getContextAndActions()[0];
}
private getContextAndActions(): [TContext, InvokeActionObject[]] {
private getContextAndActions(
actorCtx?: ActorContext<any, any>
): [TContext, InvokeActionObject[]] {
const actions: InvokeActionObject[] = [];
// TODO: merge with this.options.context
const context = this._contextFactory({
spawn: createSpawner(this, null as any, null as any, actions) // TODO: fix types
spawn: createSpawner(
actorCtx?.self,
this,
null as any,
null as any,
actions
) // TODO: fix types
});

return [context, actions];
Expand Down Expand Up @@ -151,7 +163,6 @@ export class StateMachine<
partialContext
) as TContext;
}; // TODO: fix types
// this.context = resolveContext(config.context, options?.context);
this.delimiter = this.config.delimiter || STATE_DELIMITER;
this.version = this.config.version;
this.schema = this.config.schema ?? ({} as any as this['schema']);
Expand Down Expand Up @@ -315,13 +326,13 @@ export class StateMachine<
private getPreInitialState(
actorCtx: ActorContext<any, any> | undefined
): State<TContext, TEvent, TResolvedTypesMeta> {
const [context, actions] = this.getContextAndActions();
const [context, actions] = this.getContextAndActions(actorCtx);
const config = getInitialConfiguration(this.root);
const preInitial = this.resolveState(
this.createState({
value: {}, // TODO: this is computed in state constructor
context,
_event: initEvent as SCXML.Event<TEvent>,
_event: initEvent as SCXML.Event<TEvent>, // TODO: fix
_sessionid: actorCtx?.sessionId ?? undefined,
actions: [],
meta: undefined,
Expand Down Expand Up @@ -374,16 +385,24 @@ export class StateMachine<

return macroState;
}

public start(
state: State<TContext, TEvent, TResolvedTypesMeta>,
actorCtx: ActorContext<TEvent, State<TContext, TEvent, TResolvedTypesMeta>>
): State<TContext, TEvent, TResolvedTypesMeta> {
// When starting from a restored state, execute the actions
): void {
davidkpiano marked this conversation as resolved.
Show resolved Hide resolved
state.actions.forEach((action) => {
action.execute?.(actorCtx);
});

return state;
Object.values(state.children).forEach((child) => {
if (child.status === 0) {
try {
child.start?.();
} catch (err) {
// TODO: unify error handling when child starts
actorCtx.self.send(error(child.id, err) as unknown as TEvent);
}
}
});
}

public getStateNodeById(stateId: string): StateNode<TContext, TEvent> {
Expand Down Expand Up @@ -411,6 +430,12 @@ export class StateMachine<
return this.definition;
}

public getPersistedState(
state: State<TContext, TEvent, TResolvedTypesMeta>
): PersistedMachineState<State<TContext, TEvent, TResolvedTypesMeta>> {
return getPersistedState(state);
}

public createState(
stateConfig:
| State<TContext, TEvent, TResolvedTypesMeta>
Expand All @@ -436,15 +461,80 @@ export class StateMachine<
}

public restoreState(
state: State<TContext, TEvent, TResolvedTypesMeta>,
_actorCtx?: ActorContext<
TEvent,
State<TContext, TEvent, TResolvedTypesMeta>
>
state: PersistedMachineState<State<TContext, TEvent, TResolvedTypesMeta>>,
_actorCtx: ActorContext<TEvent, State<TContext, TEvent, TResolvedTypesMeta>>
): State<TContext, TEvent, TResolvedTypesMeta> {
const restoredState = isStateConfig(state)
? this.resolveState(state as any)
: this.resolveState(State.from(state as any, this.context, this));
const children = {};

Object.keys(state.children).forEach((actorId) => {
const actorData = state.children[actorId];
const childState = actorData.state;
const src = actorData.src;

const behaviorImpl = src ? this.options.actors[src.type] : undefined;

if (!behaviorImpl) {
return;
}

const behavior =
typeof behaviorImpl === 'function'
? behaviorImpl(state.context, state._event.data, {
id: actorId,
data: undefined,
src: src!,
_event: state._event,
meta: {}
})
: behaviorImpl;

const actorState = behavior.restoreState?.(childState, _actorCtx);

const actorRef = interpret(behavior, {
id: actorId,
state: actorState
});

children[actorId] = actorRef;
});

const restoredState: State<TContext, TEvent, TResolvedTypesMeta> =
this.createState(new State({ ...state, children }, this));

// TODO: DRY this up
restoredState.configuration.forEach((stateNode) => {
if (stateNode.invoke) {
stateNode.invoke.forEach((invokeConfig) => {
const { id, src } = invokeConfig;

if (children[id]) {
return;
}

const behaviorImpl = this.options.actors[src.type];

const behavior =
typeof behaviorImpl === 'function'
? behaviorImpl(state.context, state._event.data, {
id,
data: undefined,
src,
_event: state._event,
meta: {}
})
: behaviorImpl;

if (behavior) {
const actorRef = interpret(behavior, {
id,
parent: _actorCtx?.self
});

children[id] = actorRef;
}
});
}
});

return restoredState;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/StateNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export class StateNode<
) {
this.machine.options.actors = {
...this.machine.options.actors,
// TODO: this should accept `src` as-is
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense to remove this completely. We shouldn't have to put those on this.machine.options.actors. They were not provided there so why we'd keep them there?

This might impact the rehydration story for inline actors but to fix this we can use some extra information to understand how to look those up. We can either encode it in the auto-generated id somehow. Or we might have to use some extra .type-like thing somewhere.

I would also consider completely disallowing getPersistedState when the state has inline actors. How do we rehydrate this?

createMachine({
  entry: assign((_, __, { spawn }) => ({
    childRef: spawn(fromPromise(() => Promise.resolve(42)))
  }))
})

The problem here is that when serializing the persisted state we don't have any way to "point" to a stable behavior implementation of this actor. When rehydrating we won't have a clue how to access the original behavior of that child

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also have the same problem with invoke, despite having a "stable" reference to it (object path). And more than that - even referenced implementations have this problem when they are declared using factories.

createMachine({
  invoke: {
    src: 'dynamic'
  }
}, {
  actors: {
    dynamic: (ctx) => fromPromise(() => Promise.resolve(42))
  }
)

How do we rehydrate this? It looks a little bit like maybe src should just never be a factory. We need to have the ability to use the same actor behavior with different arguments and stuff... but that seems to be solved by input! The only thing is that the JS API might look a little bit quirky for this, because we'd have to accept two things for any given actor: 1. a static src 2. an input resolver~ (with the "standard" (ctx, ev) => input signature)

Copy link
Member Author

@davidkpiano davidkpiano Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is removed in v5/input-3 #3815

[resolvedId]: typeof src === 'function' ? src : () => src
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/actions/assign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function assign<
assignment
}
},
(_event, { state, action }) => {
(_event, { state, action, actorContext }) => {
const capturedActions: InvokeActionObject[] = [];

if (!state.context) {
Expand All @@ -58,6 +58,7 @@ export function assign<
action,
_event,
spawn: createSpawner(
actorContext?.self,
state.machine,
state.context,
_event,
Expand Down
Loading