@effectstream/sm 0.200.4 → 0.200.6
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 +49 -23
- package/Stm.ts +8 -4
- package/package.json +9 -9
- package/test/examples.test.ts +15 -12
- package/test/state-machine.test.ts +112 -0
- package/test/state-machine.typecheck.ts +69 -0
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @effectstream/sm
|
|
2
2
|
|
|
3
3
|
The state-machine DSL inside an EffectStream node. Define a typed grammar
|
|
4
|
-
of commands, register one generator per command, and `
|
|
4
|
+
of commands, register one generator per command, and `StateMachine` parses each
|
|
5
5
|
incoming batcher input, dispatches it to the right handler, and yields
|
|
6
|
-
SQL updates through the runtime.
|
|
6
|
+
SQL updates through the runtime. `Stm` is an alias of the same constructor for
|
|
7
|
+
source compatibility.
|
|
7
8
|
|
|
8
9
|
- State-machine DSL: define a typed grammar, register one generator per command.
|
|
9
10
|
- Parses each batcher input, dispatches to the handler, yields SQL through the runtime.
|
|
@@ -31,45 +32,73 @@ without a database) - see
|
|
|
31
32
|
[`primitives/src/evm-erc20/erc20-primitive.test.ts`](https://github.com/effectstream/effectstream/blob/main/packages/node-sdk/sm/primitives/src/evm-erc20/erc20-primitive.test.ts).
|
|
32
33
|
|
|
33
34
|
```typescript
|
|
34
|
-
import {
|
|
35
|
+
import { StateMachine } from "@effectstream/sm";
|
|
35
36
|
import { World } from "@effectstream/coroutine";
|
|
36
37
|
import { Type } from "@sinclair/typebox";
|
|
37
38
|
import { join, leave } from "./queries.ts"; // pgtyped queries
|
|
38
39
|
|
|
40
|
+
const stateMachine = new StateMachine({
|
|
41
|
+
join: [["user", Type.String()]],
|
|
42
|
+
leave: [["user", Type.String()]],
|
|
43
|
+
})
|
|
44
|
+
.addStateTransition("join", function* ({ parsedInput, blockTimestamp }) {
|
|
45
|
+
yield* World.resolve(join, {
|
|
46
|
+
user: parsedInput.user,
|
|
47
|
+
ts: blockTimestamp,
|
|
48
|
+
});
|
|
49
|
+
})
|
|
50
|
+
.addStateTransition("leave", function* ({ parsedInput }) {
|
|
51
|
+
yield* World.resolve(leave, { user: parsedInput.user });
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The constructor requires a grammar and exposes that exact object as
|
|
56
|
+
`stateMachine.grammar`. `addStateTransition` returns the same instance, so the
|
|
57
|
+
registrations above can be chained without introducing a wrapper or a second
|
|
58
|
+
transition map. A host dispatches directly with
|
|
59
|
+
`stateMachine.processInput(input)`.
|
|
60
|
+
|
|
61
|
+
Use a named grammar when it is shared or when an application supplies an
|
|
62
|
+
explicit events type:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { StateMachine, Stm } from "@effectstream/sm";
|
|
66
|
+
import { Type } from "@sinclair/typebox";
|
|
67
|
+
|
|
39
68
|
const grammar = {
|
|
40
69
|
join: [["user", Type.String()]] as const,
|
|
41
|
-
leave: [["user", Type.String()]] as const,
|
|
42
70
|
} as const;
|
|
43
71
|
|
|
44
|
-
const
|
|
72
|
+
const stateMachine = new StateMachine(grammar).addStateTransition(
|
|
73
|
+
"join",
|
|
74
|
+
function* ({ parsedInput }) {
|
|
75
|
+
console.log(parsedInput.user);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
45
78
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
79
|
+
type Events = { readonly example: "event" };
|
|
80
|
+
const compatible = new Stm<typeof grammar, Events>(grammar);
|
|
49
81
|
|
|
50
|
-
|
|
51
|
-
yield* World.resolve(leave, { user: parsedInput.user });
|
|
52
|
-
});
|
|
82
|
+
StateMachine === Stm; // true: Stm is the same value and type constructor
|
|
53
83
|
```
|
|
54
84
|
|
|
55
|
-
The runtime calls `stm.processInput(input)` for every batcher subunit; the
|
|
56
|
-
DSL parses against `grammar`, finds the right handler, and the generator
|
|
57
|
-
yields `World.resolve(...)` so the runtime can execute the pgtyped queries.
|
|
58
|
-
|
|
59
85
|
## Inside EffectStream
|
|
60
86
|
|
|
61
|
-
`
|
|
62
|
-
loop wires each user input through the corresponding
|
|
87
|
+
`StateMachine` is the central piece a node author writes. The runtime's
|
|
88
|
+
per-block loop wires each user input through the corresponding instance,
|
|
63
89
|
collects yielded SQL, and commits it inside the per-block transaction.
|
|
64
90
|
The built-in primitives package (`@effectstream/sm/builtin`) covers
|
|
65
91
|
common on-chain events - ERC-20/721/1155 transfers, Cardano transfers,
|
|
66
|
-
Midnight events, etc. - so you don't re-implement them.
|
|
92
|
+
Midnight events, etc. - so you don't re-implement them. Canonical-runner
|
|
93
|
+
adoption of the public `stateMachine.grammar` is separate downstream work; this
|
|
94
|
+
package supplies the constructor-owned grammar and direct dispatcher.
|
|
67
95
|
|
|
68
96
|
## Key exports
|
|
69
97
|
|
|
70
|
-
- `
|
|
98
|
+
- `StateMachine<Grammar, Events>`: the canonical state machine. `.addStateTransition(prefix, handler)`, `.processInput(input)`, `.grammar`, `.fullJsonGrammar`, `.keyedJsonGrammar`.
|
|
99
|
+
- `Stm<Grammar, Events>`: compatibility alias of the exact `StateMachine` constructor.
|
|
71
100
|
- `ParamToData<Params>` derives the typed argument shape from a grammar entry.
|
|
72
|
-
- `BaseStfInput`: input shape passed to every handler. Includes `
|
|
101
|
+
- `BaseStfInput`: input shape passed to every handler. Includes `blockTimestamp`, `blockHeight`, etc.
|
|
73
102
|
- `delegate-wallet` helpers - account delegation primitives reused by built-ins.
|
|
74
103
|
|
|
75
104
|
`MessageListener<Events, Params>` is exported as the handler type but is
|
|
@@ -83,9 +112,6 @@ Subpath exports:
|
|
|
83
112
|
## Examples
|
|
84
113
|
|
|
85
114
|
- [`primitives/src/evm-erc20/erc20-primitive.test.ts`](https://github.com/effectstream/effectstream/blob/main/packages/node-sdk/sm/primitives/src/evm-erc20/erc20-primitive.test.ts) - a real primitive's behavior unit-tested.
|
|
86
|
-
- Game logic in
|
|
87
|
-
[`templates/dice/packages/node/`](https://github.com/effectstream/effectstream/tree/main/templates/dice/packages/node)
|
|
88
|
-
shows the full `new Stm(...).addStateTransition(...)` pattern.
|
|
89
115
|
|
|
90
116
|
Runnable: [`test/examples.test.ts`](./test/examples.test.ts).
|
|
91
117
|
|
package/Stm.ts
CHANGED
|
@@ -21,9 +21,10 @@ export type MessageListener<
|
|
|
21
21
|
input: BaseStfInput & { parsedInput: ParamToData<Params> },
|
|
22
22
|
) => SyncStateUpdateStream<void>;
|
|
23
23
|
|
|
24
|
-
export class
|
|
25
|
-
Grammar extends GrammarDefinition,
|
|
26
|
-
Events extends AppEvents,
|
|
24
|
+
export class StateMachine<
|
|
25
|
+
const Grammar extends GrammarDefinition,
|
|
26
|
+
Events extends AppEvents = AppEvents,
|
|
27
|
+
RegisteredPrefixes extends keyof Grammar & string = never,
|
|
27
28
|
> {
|
|
28
29
|
public readonly keyedJsonGrammar: CommandTuples<Grammar>;
|
|
29
30
|
public readonly fullJsonGrammar: FullJsonGrammar<Grammar>;
|
|
@@ -45,13 +46,14 @@ export class Stm<
|
|
|
45
46
|
addStateTransition<const Prefix extends keyof Grammar & string>(
|
|
46
47
|
prefix: Prefix,
|
|
47
48
|
call: MessageListener<Events, Grammar[Prefix]>,
|
|
48
|
-
):
|
|
49
|
+
): StateMachine<Grammar, Events, RegisteredPrefixes | Prefix> {
|
|
49
50
|
if (this.messageListeners.has(prefix)) {
|
|
50
51
|
throw new Error(
|
|
51
52
|
`Disallowed: duplicate listener for prefix ${prefix}. Duplicate prefixes can cause determinism issues`,
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
this.messageListeners.set(prefix, call);
|
|
56
|
+
return this;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
*processInput(input: BaseStfInput): SyncStateUpdateStream<void> {
|
|
@@ -86,3 +88,5 @@ export class Stm<
|
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
90
|
}
|
|
91
|
+
|
|
92
|
+
export { StateMachine as Stm };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sm",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.6",
|
|
4
4
|
"license": "(MIT OR Apache-2.0)",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
"./grammar": "./primitives/src/grammar.ts"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@effectstream/concise": "0.200.
|
|
21
|
-
"@effectstream/config": "0.200.
|
|
22
|
-
"@effectstream/coroutine": "0.200.
|
|
23
|
-
"@effectstream/crypto": "0.200.
|
|
24
|
-
"@effectstream/db": "0.200.
|
|
25
|
-
"@effectstream/event-client": "0.200.
|
|
26
|
-
"@effectstream/log": "0.200.
|
|
27
|
-
"@effectstream/utils": "0.200.
|
|
20
|
+
"@effectstream/concise": "0.200.6",
|
|
21
|
+
"@effectstream/config": "0.200.6",
|
|
22
|
+
"@effectstream/coroutine": "0.200.6",
|
|
23
|
+
"@effectstream/crypto": "0.200.6",
|
|
24
|
+
"@effectstream/db": "0.200.6",
|
|
25
|
+
"@effectstream/event-client": "0.200.6",
|
|
26
|
+
"@effectstream/log": "0.200.6",
|
|
27
|
+
"@effectstream/utils": "0.200.6",
|
|
28
28
|
"@noble/hashes": "^2.2.0",
|
|
29
29
|
"pg": "8.21.0",
|
|
30
30
|
"pg-tx": "^1.0.1",
|
package/test/examples.test.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
|
-
// Examples for the README. We exercise
|
|
1
|
+
// Examples for the README. We exercise StateMachine in isolation — define a
|
|
2
2
|
// grammar, register handlers, and check `.processInput` dispatches to
|
|
3
3
|
// the right one. The runtime side (executing yielded SQL) is the job of
|
|
4
4
|
// @effectstream/runtime, not this package.
|
|
5
5
|
|
|
6
6
|
import { test, expect } from "bun:test";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
-
import {
|
|
8
|
+
import { StateMachine } from "../Stm.ts";
|
|
9
9
|
|
|
10
|
-
test("README:
|
|
10
|
+
test("README: StateMachine dispatches inputs to the registered handler", () => {
|
|
11
11
|
const grammar = {
|
|
12
12
|
join: [["user", Type.String()]] as const,
|
|
13
13
|
leave: [["user", Type.String()]] as const,
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
|
-
const stm = new
|
|
16
|
+
const stm = new StateMachine(grammar);
|
|
17
17
|
const calls: string[] = [];
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
const registered = stm
|
|
20
|
+
.addStateTransition("join", function* ({ parsedInput }) {
|
|
21
|
+
calls.push(`join:${parsedInput.user}`);
|
|
22
|
+
// Empty generator — no DB writes.
|
|
23
|
+
})
|
|
24
|
+
.addStateTransition("leave", function* ({ parsedInput }) {
|
|
25
|
+
calls.push(`leave:${parsedInput.user}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(registered).toBe(stm);
|
|
26
29
|
|
|
27
30
|
// Build the on-wire concise input.
|
|
28
31
|
const input = JSON.stringify(["join", "alice"]);
|
|
@@ -40,7 +43,7 @@ test("README: Stm dispatches inputs to the registered handler", () => {
|
|
|
40
43
|
|
|
41
44
|
test("README: duplicate prefix registration throws", () => {
|
|
42
45
|
const grammar = { join: [["user", Type.String()]] as const } as const;
|
|
43
|
-
const stm = new
|
|
46
|
+
const stm = new StateMachine(grammar);
|
|
44
47
|
stm.addStateTransition("join", function* () {});
|
|
45
48
|
expect(() => stm.addStateTransition("join", function* () {})).toThrow();
|
|
46
49
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StateMachine, Stm } from "../Stm.ts";
|
|
4
|
+
|
|
5
|
+
const input = (conciseInput: string) => ({
|
|
6
|
+
conciseInput,
|
|
7
|
+
blockHeight: 1,
|
|
8
|
+
blockTimestamp: 2,
|
|
9
|
+
}) as any;
|
|
10
|
+
|
|
11
|
+
const drain = (stream: Generator) => {
|
|
12
|
+
while (!stream.next().done) {}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test("StateMachine is the Stm constructor and chains on the same instance", () => {
|
|
16
|
+
expect(StateMachine).toBe(Stm);
|
|
17
|
+
|
|
18
|
+
const grammar = {
|
|
19
|
+
join: [["user", Type.String()]] as const,
|
|
20
|
+
leave: [["reason", Type.String()]] as const,
|
|
21
|
+
} as const;
|
|
22
|
+
const machine = new StateMachine(grammar);
|
|
23
|
+
const calls: string[] = [];
|
|
24
|
+
|
|
25
|
+
expect(machine.grammar).toBe(grammar);
|
|
26
|
+
|
|
27
|
+
const afterJoin = machine.addStateTransition(
|
|
28
|
+
"join",
|
|
29
|
+
function* ({ parsedInput }) {
|
|
30
|
+
calls.push(`join:${parsedInput.user}`);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
expect(afterJoin).toBe(machine);
|
|
34
|
+
|
|
35
|
+
const afterLeave = afterJoin.addStateTransition(
|
|
36
|
+
"leave",
|
|
37
|
+
function* ({ parsedInput }) {
|
|
38
|
+
calls.push(`leave:${parsedInput.reason}`);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
expect(afterLeave).toBe(machine);
|
|
42
|
+
|
|
43
|
+
drain(machine.processInput(input(JSON.stringify(["join", "alice"]))));
|
|
44
|
+
expect(calls).toEqual(["join:alice"]);
|
|
45
|
+
|
|
46
|
+
drain(machine.processInput(input(JSON.stringify(["leave", "done"]))));
|
|
47
|
+
expect(calls).toEqual(["join:alice", "leave:done"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("duplicate transition registration remains rejected", () => {
|
|
51
|
+
const machine = new StateMachine({
|
|
52
|
+
join: [["user", Type.String()]] as const,
|
|
53
|
+
});
|
|
54
|
+
machine.addStateTransition("join", function* () {});
|
|
55
|
+
|
|
56
|
+
expect(() => machine.addStateTransition("join", function* () {})).toThrow(
|
|
57
|
+
"duplicate listener for prefix join",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("parse errors and grammar matches without handlers log and return", () => {
|
|
62
|
+
const machine = new StateMachine({
|
|
63
|
+
join: [["user", Type.String()]] as const,
|
|
64
|
+
});
|
|
65
|
+
const originalError = console.error;
|
|
66
|
+
const errors: unknown[][] = [];
|
|
67
|
+
console.error = (...args: unknown[]) => errors.push(args);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
expect(machine.processInput(input("not-json")).next().done).toBe(true);
|
|
71
|
+
expect(errors).toHaveLength(2);
|
|
72
|
+
expect(errors[0]?.[0]).toBe("[STM] Parsing error:");
|
|
73
|
+
expect(errors[1]?.[0]).toBe(
|
|
74
|
+
"[STM] Cannot parse the input with the known grammar:",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
errors.length = 0;
|
|
78
|
+
expect(
|
|
79
|
+
machine
|
|
80
|
+
.processInput(input(JSON.stringify(["join", "alice"])))
|
|
81
|
+
.next().done,
|
|
82
|
+
).toBe(true);
|
|
83
|
+
expect(errors).toEqual([
|
|
84
|
+
[
|
|
85
|
+
"Grammar match, but no prefix found with corresponding state transition: join",
|
|
86
|
+
],
|
|
87
|
+
]);
|
|
88
|
+
} finally {
|
|
89
|
+
console.error = originalError;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("processInput preserves handler yields and propagates handler errors", () => {
|
|
94
|
+
const machine = new StateMachine({
|
|
95
|
+
run: [["value", Type.Number()]] as const,
|
|
96
|
+
});
|
|
97
|
+
const yielded = ["query", { value: 7 }] as any;
|
|
98
|
+
let calls = 0;
|
|
99
|
+
|
|
100
|
+
machine.addStateTransition("run", function* ({ parsedInput }) {
|
|
101
|
+
calls += 1;
|
|
102
|
+
expect(parsedInput.value).toBe(7);
|
|
103
|
+
yield yielded;
|
|
104
|
+
throw new Error("handler failure");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const stream = machine.processInput(input(JSON.stringify(["run", 7])));
|
|
108
|
+
expect(stream.next()).toEqual({ done: false, value: yielded });
|
|
109
|
+
expect(calls).toBe(1);
|
|
110
|
+
expect(() => stream.next()).toThrow("handler failure");
|
|
111
|
+
expect(calls).toBe(1);
|
|
112
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { StateMachine, Stm } from "../Stm.ts";
|
|
3
|
+
|
|
4
|
+
type Equal<Left, Right> =
|
|
5
|
+
(<Value>() => Value extends Left ? 1 : 2) extends
|
|
6
|
+
(<Value>() => Value extends Right ? 1 : 2) ? true : false;
|
|
7
|
+
type Expect<Value extends true> = Value;
|
|
8
|
+
type RegisteredPrefixes<Value> =
|
|
9
|
+
Value extends StateMachine<any, any, infer Prefix> ? Prefix : never;
|
|
10
|
+
|
|
11
|
+
const grammar = {
|
|
12
|
+
join: [
|
|
13
|
+
["user", Type.String()],
|
|
14
|
+
["age", Type.Number()],
|
|
15
|
+
] as const,
|
|
16
|
+
leave: [["reason", Type.String()]] as const,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const machine = new StateMachine(grammar);
|
|
20
|
+
const afterJoin = machine.addStateTransition("join", function* ({
|
|
21
|
+
parsedInput,
|
|
22
|
+
}) {
|
|
23
|
+
const user: string = parsedInput.user;
|
|
24
|
+
const age: number = parsedInput.age;
|
|
25
|
+
void user;
|
|
26
|
+
void age;
|
|
27
|
+
|
|
28
|
+
// @ts-expect-error The join payload has no leave reason.
|
|
29
|
+
parsedInput.reason;
|
|
30
|
+
});
|
|
31
|
+
const afterLeave = afterJoin.addStateTransition("leave", function* ({
|
|
32
|
+
parsedInput,
|
|
33
|
+
}) {
|
|
34
|
+
const reason: string = parsedInput.reason;
|
|
35
|
+
void reason;
|
|
36
|
+
|
|
37
|
+
// @ts-expect-error The leave payload has no join user.
|
|
38
|
+
parsedInput.user;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type _RegisteredPrefixes = Expect<
|
|
42
|
+
Equal<RegisteredPrefixes<typeof afterLeave>, "join" | "leave">
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
// @ts-expect-error Prefixes must come from the constructor grammar.
|
|
46
|
+
machine.addStateTransition("missing", function* () {});
|
|
47
|
+
|
|
48
|
+
// @ts-expect-error The constructor grammar is mandatory.
|
|
49
|
+
new StateMachine();
|
|
50
|
+
|
|
51
|
+
new StateMachine({
|
|
52
|
+
inline: [["enabled", Type.Boolean()]],
|
|
53
|
+
}).addStateTransition("inline", function* ({ parsedInput }) {
|
|
54
|
+
const enabled: boolean = parsedInput.enabled;
|
|
55
|
+
void enabled;
|
|
56
|
+
|
|
57
|
+
// @ts-expect-error Inline constructor inference retains exact fields.
|
|
58
|
+
parsedInput.missing;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type Events = { readonly example: "event" };
|
|
62
|
+
const legacy = new Stm<typeof grammar, Events>(grammar);
|
|
63
|
+
legacy.addStateTransition("join", function* ({ parsedInput }) {
|
|
64
|
+
const user: string = parsedInput.user;
|
|
65
|
+
void user;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const aliasConstructor: typeof StateMachine = Stm;
|
|
69
|
+
void aliasConstructor;
|