Skip to main content

@effectstream/runtime

Package: @effectstream/runtime · Source

The state-machine runtime - the loop that ties sync, state machine, database, events, and HTTP API together inside an EffectStream node. Boot it with init() then drive it with start(config) and your node is up.

  • The state-machine runtime that owns an Effectstream node's process model.
  • init() once, start(config) to run; ties sync, state machine, DB, events, and HTTP API together.
  • Used by every template.
  • Also reachable through @effectstream/node-sdk/runtime.

Install

bun add @effectstream/runtime
# or
npm install @effectstream/runtime

@effectstream/runtime is the canonical import for init and start, used directly by every template. The same surface is also reachable through @effectstream/node-sdk/runtime if you prefer the umbrella.

Standalone usage

This package owns the node's process model. You don't pick parts of it; you call init() once at boot and then start(config). The config brings together everything else:

init() and start() are Effection operations, so they must be yielded inside an Effection main():

import { main } from "effection";
import { init, start } from "@effectstream/runtime";
import { Stm } from "@effectstream/sm";
import { config } from "./config.dev.ts";

await main(function* () {
yield* init();

const gameStm = new Stm(grammar);
gameStm.addStateTransition("join", function* () { /* ... */ });

yield* start({
config,
gameStateTransitions: [gameStm],
apiRouter: undefined, // optional Fastify route plugin
migrations: [], // SQL migrations
});
});

While running, the runtime:

  • Reads finalized blocks via @effectstream/sync.
  • Routes batcher inputs through your registered Stms.
  • Commits all yielded SQL inside a per-block transaction.
  • Publishes lifecycle and app events via @effectstream/event-server.
  • Serves the optional Fastify API.
  • Emits OpenTelemetry traces and logs through @effectstream/log.

Inside EffectStream

@effectstream/runtime is the conductor: it doesn't define any chain integrations or queries itself, but every other node package only gets exercised when this loop is running. The StartConfig shape determines which DB migrations apply, which state machines fire, and whether the runtime exposes a Fastify router.

Empty-block coalescing (catch-up)

The runtime replays one Effectstream block per main-clock tick, each in its own DB transaction. During a deep catch-up over an idle gap (e.g. an NTP main chain at one block/second across days), most of those blocks are empty - no on-chain content, no scheduled input, no migration - yet still cost a full BEGIN…COMMIT round-trip. Empty-block coalescing folds a run of consecutive empty blocks into a single transaction that writes only the run's endpoint. It is opt-in and off by default:

EFFECTSTREAM_COALESCE_EMPTY_BLOCKS=true

The lag threshold that defines "behind the chain tip" defaults to 20× the main clock's block time (falling back to 60 s when no blockTimeMS is exposed by the network config). Override it with EFFECTSTREAM_LAG_THRESHOLD_MS (milliseconds), e.g. for chains whose main clock fires faster than the runtime can keep up with one transaction per block:

EFFECTSTREAM_LAG_THRESHOLD_MS=30000 # engage coalescing past 30 s of lag

Behavior and guarantees:

  • Catch-up only. Coalescing engages only while behind the chain tip (now - block.timestamp greater than the lag threshold) and disengages at the tip, so steady-state stays one block per transaction.
  • Identical database. A coalesced sync produces a byte-for-byte identical database to a non-coalesced one. Empty blocks already never advance the block hash or RNG seed, so only the endpoint's block row and the per-protocol resume watermark are written - exactly as the normal path would leave them.
  • Never skips work. A run is flushed before any block that has on-chain content, a migration at its height, or a due block-height/timestamp-scheduled input - so nothing that mutates state is ever folded away.
  • Tip stays visible. If the producer goes quiet on an empty tail, the run is flushed on a short idle timeout so the latest height still commits.
  • A run is also capped (MAX_EMPTY_RUN) to bound transaction size and recovery.

Implementation: src/coalesce.ts (the createEmptyBlockCoalescer coordinator the main loop drives).

Key exports

  • init() - Operation<void>. One-shot setup: OpenTelemetry, config validation, version pinning. Call before start.
  • start(config: StartConfig) - Operation<void>. Run the node loop until cancelled.

Types and helpers re-exported alongside init / start:

  • DBMigrations - versioned SQL migrations passed into start.
  • StartConfig, StartConfigGameStateTransitions, StartConfigApiRouter - start's config types. Templates type-check against these implicitly but don't usually import them by name.
  • PrimitiveConstructor<T> - extension point for new primitives.
  • VERSION - ${number}.${number}.${number} literal type for version pinning.
  • Pagination helpers re-exported from ./api/pagination.ts.

Examples

The templates under templates/ are full working init() + start() examples. The simplest is templates/minimal/.

End-to-end EVM sync test: e2e/evm/sync/.

Runnable: test/examples.test.ts.