@effectstream/sm 0.200.5 → 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 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 `Stm` parses each
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 { Stm } from "@effectstream/sm";
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 stm = new Stm(grammar);
72
+ const stateMachine = new StateMachine(grammar).addStateTransition(
73
+ "join",
74
+ function* ({ parsedInput }) {
75
+ console.log(parsedInput.user);
76
+ },
77
+ );
45
78
 
46
- stm.addStateTransition("join", function* ({ parsedInput, msTimestamp }) {
47
- yield* World.resolve(join, { user: parsedInput.user, ts: msTimestamp });
48
- });
79
+ type Events = { readonly example: "event" };
80
+ const compatible = new Stm<typeof grammar, Events>(grammar);
49
81
 
50
- stm.addStateTransition("leave", function* ({ parsedInput }) {
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
- `Stm` is the central piece a node author writes. The runtime's per-block
62
- loop wires each user input through the corresponding `Stm` instance,
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
- - `Stm<Grammar, Events>`: the state machine. `.addStateTransition(prefix, handler)`, `.processInput(input)`, `.grammar`, `.fullJsonGrammar`, `.keyedJsonGrammar`.
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 `msTimestamp`, `blockHeight`, etc.
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 Stm<
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
- ): void {
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.5",
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.5",
21
- "@effectstream/config": "0.200.5",
22
- "@effectstream/coroutine": "0.200.5",
23
- "@effectstream/crypto": "0.200.5",
24
- "@effectstream/db": "0.200.5",
25
- "@effectstream/event-client": "0.200.5",
26
- "@effectstream/log": "0.200.5",
27
- "@effectstream/utils": "0.200.5",
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",
@@ -1,28 +1,31 @@
1
- // Examples for the README. We exercise Stm in isolation — define a
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 { Stm } from "../Stm.ts";
8
+ import { StateMachine } from "../Stm.ts";
9
9
 
10
- test("README: Stm dispatches inputs to the registered handler", () => {
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 Stm(grammar);
16
+ const stm = new StateMachine(grammar);
17
17
  const calls: string[] = [];
18
18
 
19
- stm.addStateTransition("join", function* ({ parsedInput }) {
20
- calls.push(`join:${parsedInput.user}`);
21
- // Empty generator — no DB writes.
22
- });
23
- stm.addStateTransition("leave", function* ({ parsedInput }) {
24
- calls.push(`leave:${parsedInput.user}`);
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 Stm(grammar);
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;