Skip to content

feat(effect): Effect SDK design#5244

Merged
NathanFlurry merged 321 commits into
mainfrom
feat/effect-sdk-design
Jun 13, 2026
Merged

feat(effect): Effect SDK design#5244
NathanFlurry merged 321 commits into
mainfrom
feat/effect-sdk-design

Conversation

@NathanFlurry

@NathanFlurry NathanFlurry commented Jun 13, 2026

Copy link
Copy Markdown
Member

Effect SDK design work authored by @IGassmann.

IGassmann added 30 commits May 1, 2026 10:29
Runner.start now reads the collected RegistryEntry list, builds the
underlying rivetkit `use` map by calling actor({ actions, options })
per registered actor, then setup({ use, endpoint, token, namespace })
.start(). Action handlers are stub-throwing placeholders until Step 4
opens per-instance scopes and routes dispatch through a global
ManagedRuntime. RunnerShape is now a real `{ mode }` marker so
Runner.of({ mode: "start" }) can construct without phantom-symbol
gymnastics.

Smoke-tested: examples/effect prints the RivetKit serverful welcome
banner with `Actors: 1` and stays running.
…start

Runner.start now snapshots the current Effect context, then builds
rivetkit actor() definitions that dispatch through it. Per actor:

- onWake creates a fresh Scope.make() and runs entry.buildHandlers
  with that scope provided. The resulting Handlers bag plus the
  scope is stored on a side Map<actorId, ActorInstance>.
- Each action callback decodes the incoming payload via
  Schema.decodeUnknownEffect(payloadSchema), runs the matching
  handler effect, and encodes the result via
  Schema.encodeUnknownEffect(successSchema).
- onSleep closes the scope (firing user Effect.addFinalizer
  cleanup) and drops the map entry.

Schema services for the slice's pure-data schemas reduce to never,
so Effect.runPromiseWith(context) is enough — handler-specific
service injection (and typed-error / defect wire encoding) are
follow-ups. Smoke test: examples/effect boots clean against the
remote engine endpoint.
… engine

- main.ts now uses Registry.layer() with no endpoint so the example
  resolves through env (RIVET_RUN_ENGINE=1 spawns the local engine)
  instead of pointing at api.rivet.dev.
- start/dev scripts bake in RIVET_RUN_ENGINE=1 so `pnpm start` runs
  the full stack out of the box. Repo contributors running off
  cargo build still need RIVET_ENGINE_BINARY pointing at their
  target/debug/rivet-engine.
- Add a `pnpm client` smoke test in src/client.ts that drives
  Counter.Increment + GetCount via a plain rivetkit client. The
  parked Effect-Client preview block stays at the bottom for the
  next slice.

Verified end-to-end: GetCount 0 -> Increment(5)=5 -> Increment(3)=8
-> GetCount 8. Counter.Increment(20) triggers CounterOverflowError
server-side; it crosses the wire as a generic RivetError defect
(typed-error encoding via action.errorSchema is the next slice).
…mple

Pure TypeScript example with no UI framework: actor in src/index.ts and
a rivetkit/client script in src/client.ts. Registry self-spawns the
engine via startEngine: true. Includes vitest coverage via setupTest.
Add a per-instance Address ({ actorId, name, key }) carrying both
addressing modes (engine-assigned actorId and the user-facing
name+key pair) and a CurrentAddress Context.Service that holds it.

Runner.start's onWake now reads c.actorId/name/key and provides
CurrentAddress alongside Scope.Scope when running buildHandlers,
mirroring effect-cluster's Entity.CurrentAddress pattern.
Actor.toLayer's RX excludes CurrentAddress so consumers don't leak
the service into the resulting layer's R channel.

Counter example uses it to log waking/sleeping with the address;
verified end-to-end via examples/effect.
…rror

Client.layer wraps a single rivetkit createClient transport behind a
narrow callAction surface. Counter.client yields a typed accessor whose
handle methods encode payloads, dispatch through the transport, and
decode success / typed errors from the wire.

Typed errors round-trip end-to-end: the server-side action wrapper
encodes failures via action.errorSchema and throws a rivetkit UserError
carrying the encoded shape as metadata (with the typed error's _tag as
the wire code). The client decodes that metadata back into the original
error class, falling through to RivetError when the schema doesn't match.

Verified against the example: Effect.catchTag("CounterOverflowError")
fires with the original instance (e.limit accessible). Raw-transport
diagnostics moved to client-raw.ts under a pnpm client:raw script.
Schema.TaggedErrorClass's `message` field is special-cased onto the
underlying Error instance, so it surfaces both server-side (rivetkit
core's dispatch_action warning gains a real description) and
client-side (catchTag handler reads `e.message` alongside `e.limit`).

Replaces the previous empty-message fallback that left the wire
UserError with `""` as its message.
Restore the persisted state, durable messages, typed events, KV/DB
services, queued message loop, and client `send` / `subscribe` calls
in `examples/effect/src/` as commented-out code with notes that they
are not yet implemented in the v1 SDK. Keeps the intended public
contract visible so it can be re-enabled once each feature lands.
Replace hand-rolled `Options`, `EngineOptions`, and `ClientOptions`
declarations with `Pick`s of the canonical rivetkit input types
(`GlobalActorOptionsInput`, `RegistryConfigInput`, `ClientConfigInput`)
to prevent drift, and use `WakeContextOf`/`SleepContextOf` for the
wake/sleep callback parameters instead of structural sub-shapes.
Derive `ActorAddress` from `Rivetkit.ActorContext` instead of
hand-rolling the field types, and reuse `ActorKeyParam` for
`ClientShape.callAction.params.key` in place of the duplicated
`string | ReadonlyArray<string>` shape.
`Runner.test` is the Effect-Cluster-`TestRunner.layer` analogue: one
`Layer.effectContext` that boots the rivetkit registry in test mode,
auto-spawns the engine when no endpoint is configured, and provides
`Runner | Client` so consumers wire the test runtime in a single
`Layer.provideMerge`.

Adds a `Runner.test.ts` exercising the wire path against a Counter
actor (success, in-wake state, typed-error round-trip), plus a
`globalSetup` that wipes orphaned engine state so repeated `pnpm test`
invocations are deterministic.
IGassmann added 25 commits June 1, 2026 19:55
# Conflicts:
#	rivetkit-rust/packages/rivetkit-core/tests/context.rs
#	rivetkit-rust/packages/rivetkit-core/tests/modules/action_dispatch_error.rs
#	rivetkit-rust/packages/rivetkit-core/tests/schedule.rs
#	rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs
#	rivetkit-rust/packages/rivetkit-core/tests/task.rs
@railway-app railway-app Bot temporarily deployed to rivet-frontend / rivet-pr-5244 June 13, 2026 00:03 Destroyed
@railway-app

railway-app Bot commented Jun 13, 2026

Copy link
Copy Markdown

🚅 Deployed to the rivet-pr-5244 environment in rivet-frontend

Service Status Web Updated (UTC)
kitchen-sink 🕗 Deploying (View Logs) Web Jun 13, 2026 at 12:06 am
frontend-cloud ❌ Build Failed (View Logs) Web Jun 13, 2026 at 12:04 am
ladle ❌ Build Failed (View Logs) Web Jun 13, 2026 at 12:04 am
frontend-inspector ❌ Build Failed (View Logs) Web Jun 13, 2026 at 12:04 am
mcp-hub ✅ Success (View Logs) Web Jun 13, 2026 at 12:04 am
website 🕒 Building (View Logs) Web Jun 13, 2026 at 12:03 am

@NathanFlurry NathanFlurry merged commit 7e1c18e into main Jun 13, 2026
13 of 27 checks passed
@NathanFlurry NathanFlurry deleted the feat/effect-sdk-design branch June 13, 2026 00:06
@claude

claude Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Code Review: feat(effect): Effect SDK design

Overview

This PR introduces @rivetkit/effect — a new package providing an Effect-native SDK for Rivet Actors. It wraps the existing RivetKit runtime with Effect Schema for typed payloads, typed error channels, scoped actor lifecycle, and state as a reactive PubSub-backed cell. The PR also includes a complete chat-room+moderator example and a substantial test suite (type tests, unit tests, e2e).

The design is well-thought-out and idiomatic Effect. The action/actor/state separation is clean, and the tracing/logging integration is solid. A few issues are worth addressing before merge.


Issues

1. Breaking change in trace attributes (existing code)

rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts renames trace attributes:

  • "rivet.actor.id""rivet.actors.actor.id"
  • "rivet.actor.name""rivet.actors.actor.name"
  • etc.

This is a semantic versioning breaking change — any dashboards, alert rules, or OTEL queries filtering on rivet.actor.* will silently stop matching. This should either be in a separate PR with a migration note, or the old attribute names should be preserved alongside the new ones during a deprecation window.

2. Massive code duplication in RivetError.ts

Every error reason class (20+) has identical getter bodies — get group(), get code(), get metadata(), get actor(), get statusCode(), get public() — all delegating to this.cause. That is roughly 350 lines of repetition. Consider a shared base class or helper mixin. Effect's TaggedErrorClass uses class inheritance, so this should be straightforward.

3. Unstructured error in ActionDispatcher.ts

throw new Error("actor instance missing");

This throws a plain Error instead of a structured RivetError. Per CLAUDE.md, errors must use universal RivetError at all boundaries:

throw new Rivetkit.RivetError("actor", "instance_missing", "Actor instance missing");

4. main.ts example dual-mode problem

examples/effect/src/main.ts simultaneously starts a long-running Layer AND exports a serverless handler:

// Starts immediately on import:
Layer.launch(MainLayer).pipe(NodeRuntime.runMain);

// Also exported for serverless use:
export const { handler, dispose } = Registry.toWebHandler(...);

If imported in a serverless context, runMain will start a blocking long-running process. These two modes should be separated — consider splitting into main.server.ts and main.serverless.ts, or at minimum add a prominent warning comment.

5. Indentation inconsistency in ActorInstanceManager.ts

makeInstance is indented 4 extra spaces relative to its surrounding scope — looks like a stray indentation from an edit.

6. effect/unstable/http import

import { HttpEffect, ... } from "effect/unstable/http";

Marked unstable in Effect's API and could break across minor releases. The tight version pin (4.0.0-beta.66) mitigates this, but worth tracking as a known risk.

7. Beta peer dependency range

The peer dep in package.json uses ^4.0.0-beta.66, which could resolve to a newer beta that breaks the unstable/http API. Consider an exact peer dep pin during the beta period.

8. Missing AGENTS.md symlink

Per CLAUDE.md: "Every directory that has a CLAUDE.md must also have an AGENTS.md symlink pointing to it." The new rivetkit-typescript/packages/effect/ directory does not appear to have a CLAUDE.md or AGENTS.md.


Minor Observations

  • content-length deletion in actor-http-client.ts: Deleting instead of setting content-length is correct for streaming, but the change comment is minimal. Worth documenting why this fixes the issue.
  • DateTime.nowUnsafe() in initialValue: Called inside a pure config value for the Admin member's joinedAt. Correct since it runs once on state creation, but surprising to readers — a brief comment would help.
  • Registry.serve indentation: The closing ) before : Layer.Layer<...> has unusual alignment.
  • onStateChange null-check comment: The if (!instance) return; guard is correct but would benefit from a comment noting this is safe by design — onTeardown deletes from the map before closing scope.

Positive Highlights

  • The ActionErrorEnvelope versioned protocol with _tag + version is robust forward-compatibility design.
  • State.ts with semaphore-serialized read/apply/write is exactly right for actor concurrency.
  • Tracing integration (client-side Effect.fn + server-side withSpan + trace propagation via ActionMeta) is thorough.
  • Type-level service requirements (Action.ServicesClient, Action.ServicesServer) threading through type signatures is impressive and genuinely useful.
  • The toWakeHandler overloads covering all four wake forms with correct type narrowing are well done.
  • Test coverage is comprehensive — type tests, unit tests, and e2e tests all present.

Summary

The design is solid and idiomatic. The two items that should be addressed before merge are the breaking trace attribute rename (needs a migration plan) and the unstructured error throw in ActionDispatcher.ts. The code duplication in RivetError.ts is significant but not blocking. Everything else is polish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants