diff --git a/examples/effect/package.json b/examples/effect/package.json new file mode 100644 index 0000000000..6e6b794c90 --- /dev/null +++ b/examples/effect/package.json @@ -0,0 +1,28 @@ +{ + "name": "effect", + "private": true, + "type": "module", + "scripts": { + "dev": "RIVET_RUN_ENGINE=1 RIVET_ENGINE_BINARY=../../target/debug/rivet-engine tsx watch src/main.ts", + "start": "RIVET_RUN_ENGINE=1 RIVET_ENGINE_BINARY=../../target/debug/rivet-engine tsx src/main.ts", + "client": "tsx src/client.ts", + "client:raw": "tsx src/client-raw.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "rivetkit": "workspace:*", + "@rivetkit/effect": "workspace:*", + "effect": "4.0.0-beta.66", + "@effect/platform-node": "4.0.0-beta.66" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.5.2" + }, + "template": { + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/effect/src/actors/chat-room/api.ts b/examples/effect/src/actors/chat-room/api.ts new file mode 100644 index 0000000000..b4d0028251 --- /dev/null +++ b/examples/effect/src/actors/chat-room/api.ts @@ -0,0 +1,87 @@ +import { Schema } from "effect"; +import { Action, Actor } from "@rivetkit/effect"; + +export const Member = Schema.Struct({ + name: Schema.String, + joinedAt: Schema.Number, +}); + +export const Message = Schema.Struct({ + id: Schema.Number, + sender: Schema.String, + text: Schema.String, + createdAt: Schema.Number, +}); + +export const SendMessageResult = Schema.Struct({ + ok: Schema.Boolean, + reason: Schema.optionalKey(Schema.String), + createdAt: Schema.optionalKey(Schema.Number), +}); + +// The plain RivetKit example uses createState input to name the room at +// creation time. The Effect SDK does not expose create input yet, so this +// action initializes the persisted room state explicitly after getOrCreate. +export const Initialize = Action.make("Initialize", { + payload: { name: Schema.String }, +}); + +export const Join = Action.make("Join", { + payload: { name: Schema.String }, + success: Member, +}); + +export const Leave = Action.make("Leave", { + payload: { name: Schema.String }, +}); + +export const SendMessage = Action.make("SendMessage", { + payload: { + sender: Schema.String, + text: Schema.String, + }, + success: SendMessageResult, +}); + +export const GetHistory = Action.make("GetHistory", { + success: Schema.Array(Message), +}); + +export const GetMembers = Action.make("GetMembers", { + success: Schema.Array(Member), +}); + +export const ScheduleAnnouncement = Action.make("ScheduleAnnouncement", { + payload: { + text: Schema.String, + delayMs: Schema.Number, + }, + success: Schema.Struct({ + firesAt: Schema.Number, + }), +}); + +// Scheduled actions receive the same single schema payload that normal +// Effect actions use. This replaces the plain SDK example's positional +// triggerAnnouncement(text) action. +export const TriggerAnnouncement = Action.make("TriggerAnnouncement", { + payload: { text: Schema.String }, +}); + +// The plain RivetKit example closes the room from onDestroy. The Effect SDK +// does not expose onDestroy yet, so archive performs cleanup before destroy. +export const Archive = Action.make("Archive"); + +export const ChatRoom = Actor.make("chatRoom", { + actions: [ + Initialize, + Join, + Leave, + SendMessage, + GetHistory, + GetMembers, + ScheduleAnnouncement, + TriggerAnnouncement, + Archive, + ], +}); diff --git a/examples/effect/src/actors/chat-room/live.ts b/examples/effect/src/actors/chat-room/live.ts new file mode 100644 index 0000000000..81cb230863 --- /dev/null +++ b/examples/effect/src/actors/chat-room/live.ts @@ -0,0 +1,236 @@ +import { Effect, Schema } from "effect"; +import { Actor, State } from "@rivetkit/effect"; +import { db } from "rivetkit/db"; +import { ChatRoom } from "./api.ts"; + +interface ModerationVerdict { + readonly approved: boolean; + readonly reason?: string; +} + +export const ChatRoomLive = ChatRoom.toLayer( + ({ rawRivetkitContext, state }) => + Effect.gen(function* () { + const database = rawRivetkitContext.db; + const address = yield* Actor.CurrentAddress; + // The plain SDK example stores this in createVars. The Effect SDK + // does not expose vars yet, so the wake-scope closure owns it. + const sessionId = crypto.randomUUID(); + + yield* State.update(state, (current) => ({ + ...current, + wakeCount: current.wakeCount + 1, + })).pipe(Effect.orDie); + + yield* Effect.log("room awake", { + actorId: address.actorId, + key: address.key.join("/"), + sessionId, + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const current = yield* State.get(state).pipe(Effect.orDie); + yield* Effect.log("room sleeping", { + actorId: address.actorId, + key: address.key.join("/"), + roomName: current.name, + sessionId, + wakeCount: current.wakeCount, + }); + }), + ); + + const directory = () => + // Server-side Effect actor clients are not available yet. Use the + // raw RivetKit actor client and keep the action shape explicit. + rawRivetkitContext + .client() + .directory.getOrCreate(["main"]); + const moderator = () => + // The normal example uses a typed registry client here. This raw + // client keeps the runtime behavior while giving up type inference. + rawRivetkitContext + .client() + .moderator.getOrCreate(["main"]); + + const roomName = State.get(state).pipe( + Effect.orDie, + Effect.map((s) => s.name), + ); + + return ChatRoom.of({ + Initialize: ({ payload }) => + // This replaces createState(input). Callers should initialize + // a room before actions that depend on a persisted room name. + State.update(state, (current) => { + if (current.initialized) return current; + return { + ...current, + name: payload.name, + members: [], + initialized: true, + }; + }), + Join: ({ payload }) => + Effect.gen(function* () { + const member = { + name: payload.name, + joinedAt: Date.now(), + }; + const next = yield* State.updateAndGet( + state, + (current) => ({ + ...current, + members: [...current.members, member], + }), + ); + + rawRivetkitContext.broadcast("memberJoined", { + member, + }); + + if (next.name !== "") { + // Directory registration is still actor-to-actor RPC, but + // it uses the Effect action name and object payload. + yield* Effect.tryPromise(() => + directory().RegisterRoom({ name: next.name }), + ).pipe(Effect.orDie); + } + + return member; + }), + Leave: ({ payload }) => + Effect.gen(function* () { + yield* State.update(state, (current) => ({ + ...current, + members: current.members.filter( + (member) => member.name !== payload.name, + ), + })).pipe(Effect.orDie); + rawRivetkitContext.broadcast("memberLeft", { + name: payload.name, + }); + }), + SendMessage: ({ payload }) => + Effect.gen(function* () { + // The normal example sends moderation work through a + // completable queue drained by run(). The Effect SDK does + // not expose queues or run loops yet, so moderation is a + // direct actor RPC and has no queue timeout path. + const verdict = yield* Effect.tryPromise( + () => + moderator().Review({ + text: payload.text, + }) as Promise, + ).pipe(Effect.orDie); + + if (!verdict.approved) { + return { ok: false, reason: verdict.reason }; + } + + const createdAt = Date.now(); + yield* Effect.tryPromise(() => + database.execute( + "INSERT INTO messages (sender, text, created_at) VALUES (?, ?, ?)", + payload.sender, + payload.text, + createdAt, + ), + ).pipe(Effect.orDie); + + rawRivetkitContext.broadcast("newMessage", { + sender: payload.sender, + text: payload.text, + createdAt, + }); + return { ok: true, createdAt }; + }), + GetHistory: () => + Effect.tryPromise(() => + database.execute<{ + id: number; + sender: string; + text: string; + createdAt: number; + }>( + "SELECT id, sender, text, created_at as createdAt FROM messages ORDER BY id", + ), + ).pipe(Effect.orDie), + GetMembers: () => + State.get(state).pipe( + Effect.orDie, + Effect.map((s) => s.members), + ), + ScheduleAnnouncement: ({ payload }) => + Effect.sync(() => { + const firesAt = Date.now() + payload.delayMs; + // The raw scheduler dispatches the Effect action by name + // with the same object payload that a client would send. + rawRivetkitContext.schedule.after( + payload.delayMs, + "TriggerAnnouncement", + { + text: payload.text, + }, + ); + return { firesAt }; + }), + TriggerAnnouncement: ({ payload }) => + Effect.sync(() => { + rawRivetkitContext.broadcast("announcement", { + text: payload.text, + }); + }), + Archive: () => + Effect.gen(function* () { + const name = yield* roomName; + if (name !== "") { + // This only covers destruction through Archive. A future + // Effect onDestroy hook would cover every destroy path. + yield* Effect.tryPromise(() => + directory().CloseRoom({ name }), + ).pipe(Effect.orDie); + } + yield* Effect.sync(() => { + rawRivetkitContext.destroy(); + }); + }), + }); + }), + { + state: { + schema: Schema.Struct({ + name: Schema.String, + members: Schema.Array( + Schema.Struct({ + name: Schema.String, + joinedAt: Schema.Number, + }), + ), + wakeCount: Schema.Number, + initialized: Schema.Boolean, + }), + initialValue: () => ({ + name: "", + members: [], + wakeCount: 0, + initialized: false, + }), + }, + db: db({ + onMigrate: async (client) => { + await client.execute(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + }, + }), + name: "Chat Room", + icon: "comments", + }, +); diff --git a/examples/effect/src/actors/counter/api.ts b/examples/effect/src/actors/counter/api.ts new file mode 100644 index 0000000000..19cb35024e --- /dev/null +++ b/examples/effect/src/actors/counter/api.ts @@ -0,0 +1,68 @@ +import { Schema } from "effect"; +import { Actor, Action } from "@rivetkit/effect"; + +// --- Errors --- + +export class CounterOverflowError extends Schema.TaggedErrorClass()( + "CounterOverflowError", + { + limit: Schema.Number, + message: Schema.String, + }, +) {} + +// --- Actions --- + +// Actions use explicit schemas rather than inferring types from +// the handler signature (like the current Rivet SDK does) because: +// +// - Runtime validation. Client-to-server is an untrusted boundary. +// Schemas validate wire data before it reaches handler code. +// Handler inference erases types at runtime and trusts whatever +// arrives. +// +// - Wire encoding control. Effect Schema distinguishes encoded +// (wire) and decoded (runtime) types, e.g. Schema.Date decodes +// a string into a Date. Handler inference only gives the decoded +// type. +// +// Actions are standalone values (vs. embedded in the actor +// definition) because: +// +// - Shared action protocols. A Ping health-check or GetMetrics +// action defined once and composed into multiple actors. +export const Increment = Action.make("Increment", { + payload: { amount: Schema.Number }, + success: Schema.Number, + error: CounterOverflowError, +}); + +export const GetCount = Action.make("GetCount", { + success: Schema.Number, +}); + +// --- Messages (not yet implemented) --- +// +// // Non-completable (fire-and-forget) +// export const Reset = Message.make("Reset", { +// payload: { reason: Schema.String }, +// }) +// +// // Completable (sender can await a typed response) +// export const IncrementBy = Message.make("IncrementBy", { +// payload: { amount: Schema.Number }, +// success: Schema.Number, +// }) + +// --- Actor Definition --- + +// The definition is the actor's public contract. It carries no +// implementation and no persisted-state schema (state is server-only, +// configured via `ActorState.make` + `toLayer(wake, { state })` in `live.ts`). +// Both server and client code import this; the implementation stays +// server-only. +export const Counter = Actor.make("Counter", { + actions: [Increment, GetCount], + // messages: [Reset, IncrementBy], // durable, queued, background + // events: { countChanged: Schema.Number }, +}); diff --git a/examples/effect/src/actors/counter/live.ts b/examples/effect/src/actors/counter/live.ts new file mode 100644 index 0000000000..f233d7f437 --- /dev/null +++ b/examples/effect/src/actors/counter/live.ts @@ -0,0 +1,116 @@ +import { Effect, Schema } from "effect"; +import { Actor, State } from "@rivetkit/effect"; +import { Counter, CounterOverflowError } from "./api.ts"; + +// --- Actor Implementation --- + +// Counter.toLayer produces a Layer that registers this actor +// with whatever registry is in context. The Effect inside runs +// once per actor instance (not once per action call), so +// yielded refs are instance-scoped and survive across action +// calls within a wake. Finalizers run on sleep. +export const CounterLive = Counter.toLayer( + // Wake scope (runs each wake, finalizers run on sleep) + (wakeOptions) => + Effect.gen(function* () { + // Actor-provided services are yielded from the Effect context. + // They are scoped to this actor instance, not to individual + // action calls. This means all action handlers below close + // over the same state, events, kv, and db references. + // + // Because services come through the context (not a context + // parameter like the current SDK's `c`), they are: + // + // - Visible in the type signature. The Effect's R channel + // declares exactly which services are required. + // + // - Swappable via layers. Tests can provide an in-memory KV + // or a mock DB without changing the actor code. + + // `wakeOptions.state` is a `State` view over the persisted store. + // `State.changes` exposes every state change commit as a stream. + const state = wakeOptions.state; + // ^ State.State<{ count: number }> + // const events = yield* Counter.Events + // // ^ { countChanged: PubSub } + // const messages = yield* Counter.Messages + // // ^ MessageQueue + // const kv = yield* Actor.Kv + // const db = yield* Actor.Db + const address = yield* Actor.CurrentAddress; + yield* Effect.log( + `waking ${address.name}/${address.key.join(",")} actorId=${address.actorId}`, + ); + + yield* Effect.addFinalizer(() => + State.get(state).pipe( + Effect.orDie, + Effect.flatMap(({ count }) => + Effect.log( + `sleeping ${address.name}/${address.key.join(",")} count=${count}`, + ), + ), + ), + ); + + // --- Message processing (not yet implemented) --- + // Pull-based: the actor controls when to take the next message. + // Forked into a scoped fiber, so it runs in the background and + // is canceled on sleep. Re-enable once Counter.Messages lands. + // + // yield* Effect.gen(function* () { + // const msg = yield* Queue.take(messages) + // yield* Match.value(msg).pipe( + // Match.tag("Reset", () => + // Effect.gen(function* () { + // yield* State.set(state, 0) + // yield* PubSub.publish(events.countChanged, 0) + // }) + // ), + // Match.tag("IncrementBy", ({ payload, complete }) => + // Effect.gen(function* () { + // const next = yield* State.updateAndGet( + // state, + // (s) => ({ count: s.count + payload.amount }), + // ) + // yield* PubSub.publish(events.countChanged, next.count) + // yield* complete(next.count) + // }) + // ), + // Match.exhaustive, + // ) + // }).pipe(Effect.forever, Effect.forkScoped) + + // --- Action handlers (request-response) --- + return Counter.of({ + Increment: ({ payload }) => + Effect.gen(function* () { + const { count: next } = yield* State.updateAndGet( + state, + (s) => ({ count: s.count + payload.amount }), + ); + if (next > 20) { + return yield* new CounterOverflowError({ + limit: 20, + message: `count ${next} would exceed limit 20`, + }); + } + // yield* PubSub.publish(events.countChanged, next) + return next; + }), + + GetCount: () => + State.get(state).pipe(Effect.map((s) => s.count)), + }); + }), + { + state: { + schema: Schema.Struct({ + count: Schema.Number, + }), + initialValue: () => ({ count: 0 }), + }, + name: "Counter", // Human-friendly display name + icon: "comments", // FontAwesome icon name + }, +); diff --git a/examples/effect/src/actors/directory/api.ts b/examples/effect/src/actors/directory/api.ts new file mode 100644 index 0000000000..afe46923c6 --- /dev/null +++ b/examples/effect/src/actors/directory/api.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect"; +import { Action, Actor } from "@rivetkit/effect"; + +export const RoomEntry = Schema.Struct({ + name: Schema.String, + openedAt: Schema.Number, + closedAt: Schema.optionalKey(Schema.Number), +}); + +export const RegisterRoom = Action.make("RegisterRoom", { + payload: { name: Schema.String }, +}); + +export const CloseRoom = Action.make("CloseRoom", { + payload: { name: Schema.String }, +}); + +export const ListRooms = Action.make("ListRooms", { + success: Schema.Array(RoomEntry), +}); + +export const Directory = Actor.make("directory", { + actions: [RegisterRoom, CloseRoom, ListRooms], +}); + diff --git a/examples/effect/src/actors/directory/live.ts b/examples/effect/src/actors/directory/live.ts new file mode 100644 index 0000000000..8778cad0ce --- /dev/null +++ b/examples/effect/src/actors/directory/live.ts @@ -0,0 +1,60 @@ +import { Effect, Schema } from "effect"; +import { State } from "@rivetkit/effect"; +import { Directory } from "./api.ts"; + +export const DirectoryLive = Directory.toLayer( + ({ state }) => + Effect.gen(function* () { + return Directory.of({ + RegisterRoom: ({ payload }) => + // State writes go through Effect Schema validation. This + // example treats schema failures as defects instead of adding + // typed error channels to the action contract. + State.update(state, (current) => { + if ( + current.rooms.some( + (room) => room.name === payload.name, + ) + ) { + return current; + } + + return { + rooms: [ + ...current.rooms, + { name: payload.name, openedAt: Date.now() }, + ], + }; + }).pipe(Effect.orDie), + CloseRoom: ({ payload }) => + State.update(state, (current) => ({ + rooms: current.rooms.map((room) => + room.name === payload.name + ? { ...room, closedAt: Date.now() } + : room, + ), + })).pipe(Effect.orDie), + ListRooms: () => + State.get(state).pipe( + Effect.orDie, + Effect.map((s) => s.rooms), + ), + }); + }), + { + state: { + schema: Schema.Struct({ + rooms: Schema.Array( + Schema.Struct({ + name: Schema.String, + openedAt: Schema.Number, + closedAt: Schema.optionalKey(Schema.Number), + }), + ), + }), + initialValue: () => ({ rooms: [] }), + }, + name: "Directory", + icon: "folder", + }, +); diff --git a/examples/effect/src/actors/mod.ts b/examples/effect/src/actors/mod.ts new file mode 100644 index 0000000000..21ccfe82eb --- /dev/null +++ b/examples/effect/src/actors/mod.ts @@ -0,0 +1,4 @@ +export * from "./counter/api.ts" +export * from "./directory/api.ts" +export * from "./moderator/api.ts" +export * from "./chat-room/api.ts" diff --git a/examples/effect/src/actors/moderator/api.ts b/examples/effect/src/actors/moderator/api.ts new file mode 100644 index 0000000000..7d4f126648 --- /dev/null +++ b/examples/effect/src/actors/moderator/api.ts @@ -0,0 +1,23 @@ +import { Schema } from "effect"; +import { Action, Actor } from "@rivetkit/effect"; + +export const ModerationVerdict = Schema.Struct({ + approved: Schema.Boolean, + reason: Schema.optionalKey(Schema.String), +}); + +export const Review = Action.make("Review", { + payload: { text: Schema.String }, + success: ModerationVerdict, +}); + +export const Stats = Action.make("Stats", { + success: Schema.Struct({ + reviewed: Schema.Number, + }), +}); + +export const Moderator = Actor.make("moderator", { + actions: [Review, Stats], +}); + diff --git a/examples/effect/src/actors/moderator/live.ts b/examples/effect/src/actors/moderator/live.ts new file mode 100644 index 0000000000..61e9535561 --- /dev/null +++ b/examples/effect/src/actors/moderator/live.ts @@ -0,0 +1,54 @@ +import { Effect, Schema } from "effect"; +import { State } from "@rivetkit/effect"; +import { Moderator } from "./api.ts"; + +export const ModeratorLive = Moderator.toLayer( + ({ state }) => + Effect.gen(function* () { + return Moderator.of({ + Review: ({ payload }) => + Effect.gen(function* () { + // State writes go through Effect Schema validation. This + // example treats schema failures as defects instead of adding + // typed error channels to the action contract. + const next = yield* State.updateAndGet( + state, + (current) => ({ + ...current, + reviewed: current.reviewed + 1, + }), + ).pipe(Effect.orDie); + const lower = payload.text.toLowerCase(); + const hit = next.bannedWords.find((word) => + lower.includes(word), + ); + + return hit + ? { + approved: false, + reason: `contains banned word "${hit}"`, + } + : { approved: true }; + }), + Stats: () => + State.get(state).pipe( + Effect.orDie, + Effect.map(({ reviewed }) => ({ reviewed })), + ), + }); + }), + { + state: { + schema: Schema.Struct({ + bannedWords: Schema.Array(Schema.String), + reviewed: Schema.Number, + }), + initialValue: () => ({ + bannedWords: ["spam", "scam"], + reviewed: 0, + }), + }, + name: "Moderator", + icon: "shield", + }, +); diff --git a/examples/effect/src/client-raw.ts b/examples/effect/src/client-raw.ts new file mode 100644 index 0000000000..9aad7f2ca0 --- /dev/null +++ b/examples/effect/src/client-raw.ts @@ -0,0 +1,34 @@ +import { createClient } from "rivetkit/client" + +const client = createClient("http://127.0.0.1:6420") as any + +async function main() { + const counter = client.Counter.getOrCreate("counter-raw") + + const initial = await counter.GetCount() + console.log("GetCount (initial):", initial) + + const afterFive = await counter.Increment({ amount: 5 }) + console.log("Increment(5):", afterFive) + + const afterEight = await counter.Increment({ amount: 3 }) + console.log("Increment(3):", afterEight) + + const total = await counter.GetCount() + console.log("GetCount (total):", total) + + // Trigger overflow (limit: 20). Plain client surfaces this as a + // thrown rivetkit RivetError; group should be "user" once typed + // errors are wired and "actor" otherwise. + try { + const overflowed = await counter.Increment({ amount: 20 }) + console.log("Increment(20) [unexpected success]:", overflowed) + } catch (err) { + console.log("Increment(20) [expected error]:", err) + } +} + +main().catch((err) => { + console.error("client smoke test failed:", err) + process.exit(1) +}) diff --git a/examples/effect/src/client.ts b/examples/effect/src/client.ts new file mode 100644 index 0000000000..8b8b6c5438 --- /dev/null +++ b/examples/effect/src/client.ts @@ -0,0 +1,86 @@ +import { Effect } from "effect"; +import { Client } from "@rivetkit/effect"; +import { Counter /*, IncrementBy */ } from "./actors/counter/api.ts"; +import { ChatRoom } from "./actors/chat-room/api.ts"; +import { Directory } from "./actors/directory/api.ts"; +import { Moderator } from "./actors/moderator/api.ts"; + +const program = Effect.gen(function* () { + const counterClient = yield* Counter.client; + const counter = counterClient.getOrCreate(["counter-effect"]); + + const count = yield* counter.Increment({ amount: 5 }); + yield* Effect.log(`Increment(5) -> ${count}`); + + const total = yield* counter.GetCount(); + yield* Effect.log(`GetCount -> ${total}`); + + const chatRoomClient = yield* ChatRoom.client; + const directoryClient = yield* Directory.client; + const moderatorClient = yield* Moderator.client; + + const room = chatRoomClient.getOrCreate(["effect-room"]); + const directory = directoryClient.getOrCreate(["main"]); + const moderator = moderatorClient.getOrCreate(["main"]); + + yield* room.Initialize({ name: "effect-room" }); + yield* Effect.log(`ChatRoom.Initialize`); + + const member = yield* room.Join({ name: "Alice" }); + yield* Effect.log(`ChatRoom.Join -> ${member.name}`); + + const sent = yield* room.SendMessage({ + sender: "Alice", + text: "hello from Effect", + }); + yield* Effect.log(`ChatRoom.SendMessage -> ok=${sent.ok}`); + + const rejected = yield* room.SendMessage({ + sender: "Alice", + text: "this contains spam", + }); + yield* Effect.log( + `ChatRoom.SendMessage rejected -> ok=${rejected.ok} reason=${rejected.reason}`, + ); + + const history = yield* room.GetHistory(); + yield* Effect.log(`ChatRoom.GetHistory -> ${history.length} messages`); + + const members = yield* room.GetMembers(); + yield* Effect.log(`ChatRoom.GetMembers -> ${members.length} members`); + + const rooms = yield* directory.ListRooms(); + yield* Effect.log(`Directory.ListRooms -> ${rooms.length} rooms`); + + const stats = yield* moderator.Stats(); + yield* Effect.log(`Moderator.Stats -> reviewed=${stats.reviewed}`); + + // const newCount = yield* counter.send(IncrementBy({ amount: 3 })) + // yield* Effect.log(`IncrementBy(3) -> ${newCount}`) + // + // // subscribe returns a Stream typed from the event schema. + // yield* counter.subscribe("countChanged").pipe( + // Stream.take(3), + // Stream.runForEach((n) => Effect.log(`countChanged: ${n}`)), + // ) + + // Trigger overflow (limit: 20). The typed CounterOverflowError + // round-trips through a UserError on the wire and decodes back + // into the original error class — caught by the outer + // `catchTag("CounterOverflowError", ...)`. + const overflowed = yield* counter.Increment({ amount: 100 }); + yield* Effect.log(`Increment(100) [unexpected success]: ${overflowed}`); +}).pipe( + Effect.catchTag("CounterOverflowError", (e) => + Effect.log( + `CounterOverflowError caught: limit=${e.limit} message="${e.message}"`, + ), + ), +); + +const ClientLayer = Client.layer({ endpoint: "http://127.0.0.1:6420" }); + +program.pipe(Effect.provide(ClientLayer), Effect.runPromise).catch((err) => { + console.error("client failed:", err); + process.exit(1); +}); diff --git a/examples/effect/src/main.ts b/examples/effect/src/main.ts new file mode 100644 index 0000000000..8bbf496bb9 --- /dev/null +++ b/examples/effect/src/main.ts @@ -0,0 +1,31 @@ +import { Layer } from "effect" +import { NodeRuntime } from "@effect/platform-node" +import { Registry } from "@rivetkit/effect" +import { CounterLive } from "./actors/counter/live.ts" +import { ChatRoomLive } from "./actors/chat-room/live.ts" +import { DirectoryLive } from "./actors/directory/live.ts" +import { ModeratorLive } from "./actors/moderator/live.ts" + +const ActorsLayer = Layer.mergeAll( + CounterLive, + DirectoryLive, + ModeratorLive, + ChatRoomLive, +) + +// Engine config defaults to spawning a local rivet-engine process and +// listening on http://127.0.0.1:6420 (override via RIVET_ENDPOINT to +// point at a remote engine). For dev builds without a packaged engine, +// set RIVET_ENGINE_BINARY to the path of a `cargo build` binary, e.g.: +// RIVET_ENGINE_BINARY=$(pwd)/target/debug/rivet-engine pnpm start +const MainLayer = Registry.serve(ActorsLayer).pipe( + Layer.provide(Registry.layer()), +) + +// Keeps the layer alive. Tears down on SIGINT/SIGTERM. +Layer.launch(MainLayer).pipe(NodeRuntime.runMain) + +// Or create a web handler, which can be used in serverless environments. +export const { handler, dispose } = Registry.toWebHandler( + ActorsLayer.pipe(Layer.provideMerge(Registry.layer())), +) diff --git a/examples/effect/tsconfig.json b/examples/effect/tsconfig.json new file mode 100644 index 0000000000..c3382bb665 --- /dev/null +++ b/examples/effect/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*"] +} diff --git a/examples/effect/turbo.json b/examples/effect/turbo.json new file mode 100644 index 0000000000..29d4cb2625 --- /dev/null +++ b/examples/effect/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/package.json b/package.json index c1883a112f..df0e9d9882 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@rivetkit/rivetkit-napi": "workspace:*", "@rivetkit/rivetkit-wasm": "workspace:*", "@rivetkit/engine-cli": "workspace:*", + "@rivetkit/effect": "workspace:*", "@types/react": "^19", "@types/react-dom": "^19" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a2a16634..332f158dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: '@rivetkit/rivetkit-napi': workspace:* '@rivetkit/rivetkit-wasm': workspace:* '@rivetkit/engine-cli': workspace:* + '@rivetkit/effect': workspace:* '@types/react': ^19 '@types/react-dom': ^19 react: 19.1.0 @@ -59,7 +60,7 @@ importers: version: 7.7.4 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) turbo: specifier: ^2.5.6 version: 2.5.6 @@ -127,7 +128,7 @@ importers: version: 20.19.13 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 @@ -164,7 +165,7 @@ importers: version: 5.0.1 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.20.5 version: 4.21.0 @@ -293,7 +294,7 @@ importers: version: 0.0.260331072558 '@rivet-dev/agent-os-pi': specifier: ^0.1.1 - version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) rivetkit: specifier: workspace:* version: link:../../rivetkit-typescript/packages/rivetkit @@ -431,7 +432,7 @@ importers: version: 9.6.1 freestyle-sandboxes: specifier: ^0.0.95 - version: 0.0.95(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.19.0) + version: 0.0.95(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.20.0) react: specifier: 19.1.0 version: 19.1.0 @@ -1147,6 +1148,31 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/effect: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) + '@rivetkit/effect': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/effect + effect: + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + tsx: + specifier: ^4.20.5 + version: 4.21.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + examples/elysia: dependencies: elysia: @@ -2413,7 +2439,7 @@ importers: version: 19.2.3(@types/react@19.2.13) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) concurrently: specifier: ^9.1.2 version: 9.2.1 @@ -2425,7 +2451,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.5 - version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -2471,7 +2497,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) concurrently: specifier: ^9.1.2 version: 9.2.1 @@ -2483,7 +2509,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.5 - version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -2529,7 +2555,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -2538,7 +2564,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.5 - version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -2578,7 +2604,7 @@ importers: version: 19.2.3(@types/react@19.2.13) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -2587,7 +2613,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.5 - version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -3343,7 +3369,7 @@ importers: version: 5.2.2(react-hook-form@7.62.0(react@19.1.0)) '@ladle/react': specifier: ^5.1.1 - version: 5.1.1(@swc/helpers@0.5.17)(@types/node@20.19.13)(@types/react@19.2.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.1.1(@swc/helpers@0.5.17)(@types/node@20.19.13)(@types/react@19.2.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@marsidev/react-turnstile': specifier: ^1.5.0 version: 1.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3445,10 +3471,10 @@ importers: version: 5.1.8(react@19.1.0)(typescript@5.9.3) '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.16 - version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/history': specifier: ^1.133.28 version: 1.133.28 @@ -3577,7 +3603,7 @@ importers: version: 12.10.0(@types/react@19.2.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) actor-core: specifier: ^0.6.3 - version: 0.6.3(eventsource@3.0.7)(ws@8.19.0) + version: 0.6.3(eventsource@3.0.7)(ws@8.20.0) autoprefixer: specifier: ^10.4.21 version: 10.4.22(postcss@8.5.6) @@ -3586,7 +3612,7 @@ importers: version: 2.4.3 better-auth: specifier: ^1.5.6 - version: 1.5.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(drizzle-kit@0.31.5)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.8.0)(bun-types@1.3.11)(kysely@0.28.15)(pg@8.17.2)(sql.js@1.13.0))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2))(pg@8.17.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.5.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(drizzle-kit@0.31.5)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.8.0)(bun-types@1.3.11)(kysely@0.28.15)(pg@8.17.2)(sql.js@1.13.0))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2))(pg@8.17.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) canvas-confetti: specifier: ^1.9.3 version: 1.9.3 @@ -3691,10 +3717,10 @@ importers: version: 2.6.0 tailwindcss: specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) ts-pattern: specifier: ^5.8.0 version: 5.8.0 @@ -3706,7 +3732,7 @@ importers: version: 5.2.0(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@20.19.13)(typescript@5.9.3))(typescript@5.9.3) unplugin-macros: specifier: ^0.18.3 - version: 0.18.3(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 0.18.3(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) usehooks-ts: specifier: ^3.1.1 version: 3.1.1(react@19.1.0) @@ -3728,7 +3754,7 @@ importers: version: 2.14.4(@types/node@20.19.13)(typescript@5.9.3) vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) frontend/packages/components: dependencies: @@ -3842,10 +3868,10 @@ importers: version: 3.21.0 '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3929,7 +3955,7 @@ importers: version: 2.6.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) usehooks-ts: specifier: ^3.1.1 version: 3.1.1(react@19.1.0) @@ -3960,7 +3986,7 @@ importers: version: 8.5.6 tailwindcss: specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) vite: specifier: ^5.4.20 version: 5.4.21(@types/node@20.19.13)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -4075,11 +4101,39 @@ importers: version: 14.2.5 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.5.2 version: 5.9.3 + rivetkit-typescript/packages/effect: + dependencies: + rivetkit: + specifier: workspace:* + version: link:../rivetkit + devDependencies: + '@effect/language-service': + specifier: ^0.85.1 + version: 0.85.1 + '@effect/vitest': + specifier: ^4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))) + '@types/node': + specifier: ^22.18.1 + version: 22.19.15 + effect: + specifier: ^4.0.0-beta.66 + version: 4.0.0-beta.66 + tsup: + specifier: ^8.4.0 + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + rivetkit-typescript/packages/engine-cli: {} rivetkit-typescript/packages/engine-runner: @@ -4111,7 +4165,7 @@ importers: version: 5.0.1 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.20.5 version: 4.21.0 @@ -4133,7 +4187,7 @@ importers: version: 20.19.13 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 @@ -4152,7 +4206,7 @@ importers: devDependencies: tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.5.2 version: 5.9.3 @@ -4177,7 +4231,7 @@ importers: version: 22.19.10 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -4214,7 +4268,7 @@ importers: version: 19.2.3(@types/react@19.2.13) tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.5.2 version: 5.9.3 @@ -4245,7 +4299,7 @@ importers: version: 19.2.3(@types/react@19.2.13) tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.5.2 version: 5.9.3 @@ -4351,7 +4405,7 @@ importers: version: 4.0.0 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.19.4 version: 4.21.0 @@ -4360,7 +4414,7 @@ importers: version: 5.9.3 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) @@ -4393,7 +4447,7 @@ importers: version: 22.19.10 tsup: specifier: ^8.5.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) rivetkit-typescript/packages/traces: dependencies: @@ -4421,7 +4475,7 @@ importers: version: 12.1.0 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.7.0 version: 4.21.0 @@ -4464,7 +4518,7 @@ importers: version: 12.1.0 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.7.0 version: 4.21.0 @@ -4538,7 +4592,7 @@ importers: version: 22.19.10 tsup: specifier: ^8.4.0 - version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -4547,7 +4601,7 @@ importers: dependencies: '@astrojs/mdx': specifier: ^4.0.2 - version: 4.3.13(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 4.3.13(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) '@astrojs/react': specifier: ^4.1.2 version: 4.4.2(@types/node@25.0.7)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -4556,7 +4610,7 @@ importers: version: 3.6.1 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3)) '@fortawesome/fontawesome-svg-core': specifier: ^7.1.0 version: 7.1.0 @@ -4592,7 +4646,7 @@ importers: version: link:../frontend/packages/shared-data '@sentry/astro': specifier: ^10.42.0 - version: 10.42.0(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(rollup@4.57.1) + version: 10.42.0(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(rollup@4.57.1) '@shikijs/transformers': specifier: ^3.15.0 version: 3.15.0 @@ -4619,7 +4673,7 @@ importers: version: 8.15.0 astro: specifier: ^5.1.1 - version: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) autoprefixer: specifier: ^10.4.22 version: 10.4.22(postcss@8.5.6) @@ -5965,6 +6019,29 @@ packages: resolution: {tarball: https://pkg.pr.new/rivet-dev/durable-streams/@durable-streams/writer@0323b8bcf1c9b38f1014629e1a8b6c74cc662100} version: 0.0.0 + '@effect/language-service@0.85.1': + resolution: {integrity: sha512-EXnJjIy6zQ3nUO/MZ+ynWUb8B895KZPotd1++oTs9JjDkplwM7cb6zo8Zq2zU6piwq+KflO7amXbEfj1UMpHkw==} + hasBin: true + + '@effect/platform-node-shared@4.0.0-beta.66': + resolution: {integrity: sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.66 + + '@effect/platform-node@4.0.0-beta.66': + resolution: {integrity: sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.66 + ioredis: ^5.7.0 + + '@effect/vitest@4.0.0-beta.66': + resolution: {integrity: sha512-UHPNtU0xXkKtNgyRQEh2c8jh4nIIm8Mzp3xc4j2ZdFU4nq5ZSySnpovjPMdoWbVClg1ki8UbpNGEZUfxEJo+6Q==} + peerDependencies: + effect: ^4.0.0-beta.66 + vitest: ^3.0.0 || ^4.0.0 + '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -7341,6 +7418,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -10580,6 +10660,9 @@ packages: '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -10613,6 +10696,17 @@ packages: vite: optional: true + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} @@ -10622,6 +10716,9 @@ packages: '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + '@vitest/runner@1.6.1': resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} @@ -10634,6 +10731,9 @@ packages: '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + '@vitest/snapshot@1.6.1': resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} @@ -10646,6 +10746,9 @@ packages: '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + '@vitest/spy@1.6.1': resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} @@ -10658,6 +10761,9 @@ packages: '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/utils@1.6.1': resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} @@ -10670,6 +10776,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@volar/language-core@1.11.1': resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==} @@ -11621,6 +11730,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -12183,6 +12296,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -12510,6 +12627,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-beta.66: + resolution: {integrity: sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==} + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} @@ -12588,6 +12708,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -12810,6 +12933,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expo-asset@12.0.12: resolution: {integrity: sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==} peerDependencies: @@ -12925,6 +13052,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@4.7.0: + resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==} + engines: {node: '>=12.17.0'} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -13080,6 +13211,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -13696,6 +13830,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -13715,6 +13853,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -14100,6 +14242,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + kysely@0.28.15: resolution: {integrity: sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==} engines: {node: '>=20.0.0'} @@ -14313,10 +14458,16 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. @@ -14818,6 +14969,11 @@ packages: engines: {node: '>=16'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@1.2.0: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} @@ -14953,8 +15109,8 @@ packages: resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} hasBin: true - msgpackr@1.11.5: - resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + msgpackr@1.11.10: + resolution: {integrity: sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==} msw@2.14.4: resolution: {integrity: sha512-HVPZJ9Rx4nDCWhjNQ57lKQGSE+0zDHw0xWE2IN2rLOUTLkagEBWNlvWuKYNwG2pQWq96TMd8NiSK/6vO1udnWQ==} @@ -14969,6 +15125,9 @@ packages: muggle-string@0.3.1: resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -15928,6 +16087,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + pyodide@0.28.3: resolution: {integrity: sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A==} engines: {node: '>=18.0.0'} @@ -16245,6 +16407,14 @@ packages: reconnectingwebsocket@1.0.0: resolution: {integrity: sha512-r7H/dwkkfBu9x5eMGIt8td5WLqNbqy675x8Xg0+SoXaUS3xzniVlmfO7t7HSYmN/ZGzYjOKa9G2W4xCgCo7Zlg==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reduce-css-calc@1.3.0: resolution: {integrity: sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==} @@ -16794,6 +16964,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -16811,6 +16984,9 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} @@ -17127,6 +17303,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -17168,6 +17348,10 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + toml@4.1.1: + resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -17403,10 +17587,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.23.0: - resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} - engines: {node: '>=18.17'} - undici@6.24.1: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} @@ -17415,6 +17595,10 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + undici@8.1.0: + resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==} + engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -17662,6 +17846,10 @@ packages: resolution: {integrity: sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -17976,6 +18164,47 @@ packages: jsdom: optional: true + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -18191,6 +18420,18 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -18259,6 +18500,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -18597,12 +18843,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.13(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/mdx@4.3.13(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': dependencies: '@astrojs/markdown-remark': 6.3.10 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -18649,9 +18895,9 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/tailwind@6.0.2(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3))': + '@astrojs/tailwind@6.0.2(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3))': dependencies: - astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) autoprefixer: 10.4.22(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3)) @@ -19961,6 +20207,33 @@ snapshots: '@durable-streams/client': https://pkg.pr.new/rivet-dev/durable-streams/@durable-streams/client@0323b8bcf1c9b38f1014629e1a8b6c74cc662100 fastq: 1.20.1 + '@effect/language-service@0.85.1': {} + + '@effect/platform-node-shared@4.0.0-beta.66(effect@4.0.0-beta.66)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-beta.66 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-beta.66(effect@4.0.0-beta.66) + effect: 4.0.0-beta.66 + ioredis: 5.10.1 + mime: 4.1.0 + undici: 8.1.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/vitest@4.0.0-beta.66(effect@4.0.0-beta.66)(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + effect: 4.0.0-beta.66 + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -20448,7 +20721,7 @@ snapshots: terminal-link: 2.1.1 undici: 6.24.1 wrap-ansi: 7.0.0 - ws: 8.19.0 + ws: 8.20.0 optionalDependencies: expo-router: 4.0.21(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0) react-native: 0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0) @@ -20620,7 +20893,7 @@ snapshots: '@expo/mcp-tunnel@0.0.8(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))': dependencies: - ws: 8.19.0 + ws: 8.20.0 zod: 3.25.76 zod-to-json-schema: 3.25.1(zod@3.25.76) optionalDependencies: @@ -20732,7 +21005,7 @@ snapshots: abort-controller: 3.0.0 debug: 4.4.3 source-map-support: 0.5.21 - undici: 6.23.0 + undici: 6.24.1 transitivePeerDependencies: - supports-color @@ -21219,6 +21492,8 @@ snapshots: '@types/node': 22.19.15 optional: true + '@ioredis/commands@1.5.1': {} + '@isaacs/balanced-match@4.0.1': optional: true @@ -21349,7 +21624,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@ladle/react@5.1.1(@swc/helpers@0.5.17)(@types/node@20.19.13)(@types/react@19.2.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)': + '@ladle/react@5.1.1(@swc/helpers@0.5.17)(@types/node@20.19.13)(@types/react@19.2.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@babel/code-frame': 7.29.0 '@babel/core': 7.29.0 @@ -21361,8 +21636,8 @@ snapshots: '@ladle/react-context': 1.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mdx-js/mdx': 3.1.1 '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.1.0) - '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vitejs/plugin-react-swc': 3.11.0(@swc/helpers@0.5.17)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitejs/plugin-react-swc': 3.11.0(@swc/helpers@0.5.17)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) axe-core: 4.11.1 boxen: 8.0.1 chokidar: 4.0.3 @@ -21389,8 +21664,8 @@ snapshots: remark-gfm: 4.0.1 source-map: 0.7.6 vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@swc/helpers' - '@types/node' @@ -21502,9 +21777,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -21526,7 +21801,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + '@mariozechner/pi-ai@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1024.0 @@ -21536,7 +21811,7 @@ snapshots: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.26.0(ws@8.19.0)(zod@3.25.76) + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) partial-json: 0.1.7 proxy-agent: 6.5.0 undici: 7.24.7 @@ -21574,11 +21849,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-tui': 0.60.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -21898,7 +22173,7 @@ snapshots: react-simple-code-editor: 0.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) serve-handler: 6.1.6 tailwind-merge: 2.6.0 - tailwindcss-animate: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss-animate: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) zod: 3.25.76 transitivePeerDependencies: - '@cfworker/json-schema' @@ -23370,11 +23645,11 @@ snapshots: '@rivet-dev/agent-os-gzip@0.0.260331072558': {} - '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + '@rivet-dev/agent-os-pi@0.1.1(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@3.25.76) - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) - '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@mariozechner/pi-coding-agent': 0.60.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.9)(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@rivet-dev/agent-os-core': 0.1.1(pyodide@0.28.3) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -23795,13 +24070,13 @@ snapshots: '@sentry-internal/browser-utils': 8.55.0 '@sentry/core': 8.55.0 - '@sentry/astro@10.42.0(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(rollup@4.57.1)': + '@sentry/astro@10.42.0(astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(rollup@4.57.1)': dependencies: '@sentry/browser': 10.42.0 '@sentry/core': 10.42.0 '@sentry/node': 10.42.0 '@sentry/vite-plugin': 5.1.1(rollup@4.57.1) - astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) transitivePeerDependencies: - encoding - rollup @@ -24527,9 +24802,9 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))': dependencies: - tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) '@tailwindcss/forms@0.5.10(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -24610,6 +24885,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': dependencies: postcss-selector-parser: 6.0.10 @@ -25458,11 +25738,11 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 - '@vitejs/plugin-react-swc@3.11.0(@swc/helpers@0.5.17)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react-swc@3.11.0(@swc/helpers@0.5.17)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.15.11(@swc/helpers@0.5.17) - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@swc/helpers' @@ -25502,7 +25782,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -25510,11 +25790,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -25522,7 +25802,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -25568,6 +25848,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0))': dependencies: '@vitest/spy': 2.1.9 @@ -25604,14 +25893,23 @@ snapshots: msw: 2.14.4(@types/node@22.19.15)(typescript@5.9.3) vite: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - '@vitest/mocker@4.0.18(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.4(@types/node@20.19.13)(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/mocker@4.1.5(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.4(@types/node@22.19.15)(typescript@5.9.3) + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@2.1.9': dependencies: @@ -25625,6 +25923,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@1.6.1': dependencies: '@vitest/utils': 1.6.1 @@ -25647,6 +25949,11 @@ snapshots: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + '@vitest/snapshot@1.6.1': dependencies: magic-string: 0.30.21 @@ -25671,6 +25978,13 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@1.6.1': dependencies: tinyspy: 2.2.1 @@ -25685,6 +25999,8 @@ snapshots: '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.5': {} + '@vitest/utils@1.6.1': dependencies: diff-sequences: 29.6.3 @@ -25709,6 +26025,12 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@volar/language-core@1.11.1': dependencies: '@volar/source-map': 1.11.1 @@ -25914,7 +26236,7 @@ snapshots: acorn@8.16.0: {} - actor-core@0.6.3(eventsource@3.0.7)(ws@8.19.0): + actor-core@0.6.3(eventsource@3.0.7)(ws@8.20.0): dependencies: cbor-x: 1.6.0 hono: 4.11.9 @@ -25923,7 +26245,7 @@ snapshots: zod: 3.25.76 optionalDependencies: eventsource: 3.0.7 - ws: 8.19.0 + ws: 8.20.0 agent-base@6.0.2: dependencies: @@ -26125,7 +26447,7 @@ snapshots: astring@1.9.0: {} - astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + astro@5.16.9(@types/node@25.0.7)(idb-keyval@6.2.1)(ioredis@5.10.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(rollup@4.57.1)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.5 @@ -26180,7 +26502,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.7.1 unist-util-visit: 5.0.0 - unstorage: 1.17.3(idb-keyval@6.2.1) + unstorage: 1.17.3(idb-keyval@6.2.1)(ioredis@5.10.1) vfile: 6.0.3 vite: 6.4.1(@types/node@25.0.7)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@6.4.1(@types/node@25.0.7)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -26432,7 +26754,7 @@ snapshots: bcryptjs@2.4.3: {} - better-auth@1.5.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(drizzle-kit@0.31.5)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.8.0)(bun-types@1.3.11)(kysely@0.28.15)(pg@8.17.2)(sql.js@1.13.0))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2))(pg@8.17.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + better-auth@1.5.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(drizzle-kit@0.31.5)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.8.0)(bun-types@1.3.11)(kysely@0.28.15)(pg@8.17.2)(sql.js@1.13.0))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.93.2))(pg@8.17.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.25.76))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.25.76))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.8.0)(bun-types@1.3.11)(kysely@0.28.15)(pg@8.17.2)(sql.js@1.13.0)) @@ -26459,7 +26781,7 @@ snapshots: pg: 8.17.2 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -26965,6 +27287,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@19.1.0) @@ -27545,6 +27869,8 @@ snapshots: delegates@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -27698,6 +28024,19 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-beta.66: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.7.0 + find-my-way-ts: 0.1.6 + ini: 6.0.0 + kubernetes-types: 1.30.0 + msgpackr: 1.11.10 + multipasta: 0.2.7 + toml: 4.1.1 + uuid: 13.0.0 + yaml: 2.8.3 + electron-to-chromium@1.5.286: {} elliptic@6.6.1: @@ -27767,6 +28106,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -28149,6 +28490,8 @@ snapshots: expect-type@1.2.2: {} + expect-type@1.3.0: {} + expo-asset@12.0.12(expo@54.0.18)(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0): dependencies: '@expo/image-utils': 0.8.12 @@ -28335,6 +28678,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.7.0: + dependencies: + pure-rand: 8.4.0 + fast-copy@3.0.2: {} fast-decode-uri-component@1.0.1: {} @@ -28487,6 +28834,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way-ts@0.1.6: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -28582,14 +28931,14 @@ snapshots: freeport-async@2.0.0: {} - freestyle-sandboxes@0.0.66(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.19.0): + freestyle-sandboxes@0.0.66(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.20.0): dependencies: '@hey-api/client-fetch': 0.5.7 '@tanstack/react-query': 5.87.1(react@19.1.0) expo-router: 4.0.21(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0) glob: 11.1.0 hono: 4.11.9 - openai: 4.104.0(ws@8.19.0)(zod@3.25.76) + openai: 4.104.0(ws@8.20.0)(zod@3.25.76) openapi: 1.0.1 react: 19.1.0 zod: 3.25.76 @@ -28609,16 +28958,16 @@ snapshots: - supports-color - ws - freestyle-sandboxes@0.0.95(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.19.0): + freestyle-sandboxes@0.0.95(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.20.0): dependencies: '@hey-api/client-fetch': 0.5.7 '@tanstack/react-query': 5.87.1(react@19.1.0) '@types/react': 19.2.13 expo-router: 4.0.21(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0) - freestyle-sandboxes: 0.0.66(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.19.0) + freestyle-sandboxes: 0.0.66(expo-constants@18.0.13)(expo-linking@7.0.5)(expo@54.0.18)(react-dom@19.1.0(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.17.1(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0))(react-native@0.82.1(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(ws@8.20.0) glob: 11.1.0 hono: 4.11.9 - openai: 4.104.0(ws@8.19.0)(zod@3.25.76) + openai: 4.104.0(ws@8.20.0)(zod@3.25.76) openapi: 1.0.1 react: 19.1.0 zod: 3.25.76 @@ -29291,6 +29640,8 @@ snapshots: ini@1.3.8: {} + ini@6.0.0: {} + inline-style-parser@0.2.7: {} input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -29306,6 +29657,20 @@ snapshots: dependencies: loose-envify: 1.4.0 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -29698,6 +30063,8 @@ snapshots: kolorist@1.8.0: {} + kubernetes-types@1.30.0: {} + kysely@0.28.15: {} lan-network@0.1.7: {} @@ -29850,7 +30217,7 @@ snapshots: lmdb@3.4.4: dependencies: - msgpackr: 1.11.5 + msgpackr: 1.11.10 node-addon-api: 6.1.0 node-gyp-build-optional-packages: 5.2.2 ordered-binary: 1.6.0 @@ -29890,8 +30257,12 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.get@4.4.2: {} + lodash.isarguments@3.1.0: {} + lodash.isequal@4.5.0: {} lodash.merge@4.6.2: {} @@ -30284,7 +30655,7 @@ snapshots: metro-cache: 0.83.2 metro-core: 0.83.2 metro-runtime: 0.83.2 - yaml: 2.8.2 + yaml: 2.8.3 transitivePeerDependencies: - bufferutil - supports-color @@ -30299,7 +30670,7 @@ snapshots: metro-cache: 0.83.5 metro-core: 0.83.5 metro-runtime: 0.83.5 - yaml: 2.8.2 + yaml: 2.8.3 transitivePeerDependencies: - bufferutil - supports-color @@ -30876,6 +31247,8 @@ snapshots: mime@4.0.7: {} + mime@4.1.0: {} + mimic-fn@1.2.0: {} mimic-fn@2.1.0: {} @@ -30996,7 +31369,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 optional: true - msgpackr@1.11.5: + msgpackr@1.11.10: optionalDependencies: msgpackr-extract: 3.0.3 @@ -31079,6 +31452,8 @@ snapshots: muggle-string@0.3.1: {} + multipasta@0.2.7: {} + mute-stream@3.0.0: {} mz@2.7.0: @@ -31367,7 +31742,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(ws@8.19.0)(zod@3.25.76): + openai@4.104.0(ws@8.20.0)(zod@3.25.76): dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.11 @@ -31377,26 +31752,26 @@ snapshots: formdata-node: 4.4.1 node-fetch: 2.7.0 optionalDependencies: - ws: 8.19.0 + ws: 8.20.0 zod: 3.25.76 transitivePeerDependencies: - encoding - openai@6.26.0(ws@8.19.0)(zod@3.25.76): - optionalDependencies: - ws: 8.19.0 - zod: 3.25.76 - openai@6.26.0(ws@8.19.0)(zod@4.1.13): optionalDependencies: ws: 8.19.0 zod: 4.1.13 + openai@6.26.0(ws@8.20.0)(zod@3.25.76): + optionalDependencies: + ws: 8.20.0 + zod: 3.25.76 + openapi-types@12.1.3: {} openapi3-ts@4.5.0: dependencies: - yaml: 2.8.2 + yaml: 2.8.3 openapi@1.0.1: dependencies: @@ -31793,7 +32168,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 - yaml: 2.8.2 + yaml: 2.8.3 optionalDependencies: postcss: 8.5.6 ts-node: 10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3) @@ -31807,14 +32182,23 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.3 + + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.6 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 postcss-modules-extract-imports@3.1.0(postcss@8.5.6): dependencies: @@ -32035,6 +32419,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@8.4.0: {} + pyodide@0.28.3: dependencies: ws: 8.19.0 @@ -32429,6 +32815,12 @@ snapshots: reconnectingwebsocket@1.0.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reduce-css-calc@1.3.0: dependencies: balanced-match: 0.4.2 @@ -33211,6 +33603,8 @@ snapshots: dependencies: type-fest: 0.7.1 + standard-as-callback@2.1.0: {} + state-local@1.0.7: {} statuses@1.5.0: {} @@ -33221,6 +33615,8 @@ snapshots: std-env@3.9.0: {} + std-env@4.1.0: {} + stream-browserify@3.0.0: dependencies: inherits: 2.0.4 @@ -33407,9 +33803,9 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2)): + tailwindcss-animate@1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)): dependencies: - tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) tailwindcss-animate@1.0.7(tailwindcss@4.2.2): dependencies: @@ -33443,6 +33839,34 @@ snapshots: - tsx - yaml + tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + tailwindcss@4.2.2: {} tapable@2.3.0: {} @@ -33562,6 +33986,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tinyspy@2.2.1: {} tinyspy@3.0.2: {} @@ -33596,6 +34022,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + toml@4.1.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.23 @@ -33698,7 +34126,7 @@ snapshots: tsscmp@1.0.6: {} - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@20.19.13))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -33709,7 +34137,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -33728,7 +34156,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.10))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -33739,7 +34167,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -33758,7 +34186,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -33769,7 +34197,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -33788,7 +34216,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.0.7))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -33799,7 +34227,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -33959,12 +34387,12 @@ snapshots: undici-types@7.16.0: optional: true - undici@6.23.0: {} - undici@6.24.1: {} undici@7.24.7: {} + undici@8.1.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -34068,13 +34496,13 @@ snapshots: unpipe@1.0.0: {} - unplugin-macros@0.18.3(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + unplugin-macros@0.18.3(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: ast-kit: 2.2.0 magic-string-ast: 1.0.3 unplugin: 2.3.10 - vite: 7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 5.2.0(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 5.2.0(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -34115,7 +34543,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.3(idb-keyval@6.2.1): + unstorage@1.17.3(idb-keyval@6.2.1)(ioredis@5.10.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -34127,6 +34555,7 @@ snapshots: ufo: 1.6.1 optionalDependencies: idb-keyval: 6.2.1 + ioredis: 5.10.1 until-async@3.0.2: {} @@ -34209,6 +34638,8 @@ snapshots: uuid@12.0.0: {} + uuid@13.0.0: {} + uuid@7.0.3: {} v8-compile-cache-lib@3.0.1: {} @@ -34361,13 +34792,13 @@ snapshots: - supports-color - terser - vite-node@5.2.0(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite-node@5.2.0(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 es-module-lexer: 1.7.0 obug: 2.0.0(ms@2.1.3) pathe: 2.0.3 - vite: 7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -34440,24 +34871,24 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - typescript @@ -34504,7 +34935,7 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 - vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -34522,9 +34953,9 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 - vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -34542,7 +34973,7 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 vite@6.4.1(@types/node@25.0.7)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -34564,7 +34995,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -34582,9 +35013,9 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 - vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -34602,9 +35033,29 @@ snapshots: stylus: 0.62.0 terser: 5.46.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 optional: true + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.4.1 + lightningcss: 1.32.0 + sass: 1.93.2 + stylus: 0.62.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.3 + vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -34849,10 +35300,10 @@ snapshots: - supports-color - terser - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -34869,7 +35320,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -34887,6 +35338,34 @@ snapshots: - tsx - yaml + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.15 + transitivePeerDependencies: + - msw + vlq@1.0.1: {} vm-browserify@1.1.2: {} @@ -35094,6 +35573,8 @@ snapshots: ws@8.19.0: {} + ws@8.20.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 @@ -35144,6 +35625,8 @@ snapshots: yaml@2.8.2: {} + yaml@2.8.3: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/rivetkit-typescript/packages/effect/package.json b/rivetkit-typescript/packages/effect/package.json new file mode 100644 index 0000000000..867e67304f --- /dev/null +++ b/rivetkit-typescript/packages/effect/package.json @@ -0,0 +1,48 @@ +{ + "name": "@rivetkit/effect", + "version": "2.3.0-rc.4", + "description": "Effect SDK for Rivet Actors", + "license": "Apache-2.0", + "type": "module", + "sideEffects": [ + "./dist/chunk-*.js", + "./dist/chunk-*.cjs" + ], + "files": [ + "dist", + "package.json" + ], + "exports": { + ".": { + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + } + } + }, + "scripts": { + "build": "tsup src/mod.ts", + "dev": "tsup src/mod.ts --watch", + "check-types": "tsc --noEmit", + "test": "vitest --typecheck" + }, + "dependencies": { + "rivetkit": "workspace:*" + }, + "peerDependencies": { + "effect": "^4.0.0-beta.66" + }, + "devDependencies": { + "@effect/language-service": "^0.85.1", + "@effect/vitest": "^4.0.0-beta.66", + "@types/node": "^22.18.1", + "effect": "^4.0.0-beta.66", + "tsup": "^8.4.0", + "typescript": "^5.9.2", + "vitest": "^4.1.5" + } +} diff --git a/rivetkit-typescript/packages/effect/src/Action.ts b/rivetkit-typescript/packages/effect/src/Action.ts new file mode 100644 index 0000000000..a78f85371c --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Action.ts @@ -0,0 +1,226 @@ +import { type Deferred, type Effect, Predicate, Schema } from "effect"; + +const TypeId = "~@rivetkit/effect/Action"; + +export const isAction = (u: unknown): u is Action => + Predicate.hasProperty(u, TypeId); + +/** + * A value-level definition for a non-durable, request-response call. + */ +export interface Action< + in out Tag extends string, + out Payload extends Schema.Top = Schema.Void, + out Success extends Schema.Top = Schema.Void, + out Error extends Schema.Top = Schema.Never, +> { + readonly [TypeId]: typeof TypeId; + readonly _tag: Tag; + readonly key: string; + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; +} + +/** + * Type-erased view of any `Action`. Useful for collections of actions + * where the specific schemas don't matter. + */ +export interface Any { + readonly [TypeId]: typeof TypeId; + readonly _tag: string; + readonly key: string; +} + +/** + * Like `Any`, but with the prop fields (`*Schema`) accessible. Used + * by internal builders that need to read schemas off an action. + */ +export interface AnyWithProps { + readonly [TypeId]: typeof TypeId; + readonly _tag: string; + readonly key: string; + readonly payloadSchema: Schema.Top; + readonly successSchema: Schema.Top; + readonly errorSchema: Schema.Top; +} + +// --- Type helpers --------------------------------------------------- + +export type Tag = + R extends Action + ? _Tag + : never; + +export type PayloadSchema = + R extends Action + ? _Payload + : never; + +export type Payload = PayloadSchema["Type"]; + +/** + * The shape accepted by the payload schema's `make` constructor on the + * client side (i.e. before encoding). Useful for typing the call site. + */ +export type PayloadConstructor = + R extends Action + ? _Payload["~type.make.in"] + : never; + +export type SuccessSchema = + R extends Action + ? _Success + : never; + +export type Success = SuccessSchema["Type"]; + +export type ErrorSchema = + R extends Action + ? _Error + : never; + +export type Error = ErrorSchema["Type"]; + +/** + * The full set of decoding/encoding services required by every schema + * referenced by the action. Code generators include this in the `R` + * channel of any effect that handles or invokes the action. + */ +export type Services = + R extends Action + ? + | _Payload["DecodingServices"] + | _Payload["EncodingServices"] + | _Success["DecodingServices"] + | _Success["EncodingServices"] + | _Error["DecodingServices"] + | _Error["EncodingServices"] + : never; + +/** + * The subset of `Services` actually needed on the client side: encoding + * the payload, decoding the success response, decoding the error. + */ +export type ServicesClient = + R extends Action + ? + | _Payload["EncodingServices"] + | _Success["DecodingServices"] + | _Error["DecodingServices"] + : never; + +/** + * The subset of `Services` needed on the server side: decoding the + * payload, encoding the success response, encoding the error. + */ +export type ServicesServer = + R extends Action + ? + | _Payload["DecodingServices"] + | _Success["EncodingServices"] + | _Error["EncodingServices"] + : never; + +/** + * Extract the action with the matching tag from a union of actions. + */ +export type ExtractTag = R extends { + readonly _tag: Tag; +} + ? R + : never; + +export type ResultFrom = R extends Action< + infer _Tag, + infer _Payload, + infer _Success, + infer _Error +> + ? Effect.Effect< + | _Success["Type"] + | Deferred.Deferred<_Success["Type"], _Error["Type"]>, + _Error["Type"], + Services + > + : never; + +// --- Implementation ------------------------------------------------- + +const Proto = { + [TypeId]: TypeId, +}; + +const makeProto = < + const Tag extends string, + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +>(options: { + readonly _tag: Tag; + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; +}): Action => { + const self = Object.assign(Object.create(Proto), options); + self.key = `@rivetkit/effect/Action/${options._tag}`; + return self; +}; + +/** + * Define a Rivet Actor action. + * + * @example + * ```ts + * import { Schema } from "effect" + * import { Action } from "@rivetkit/effect" + * + * class CounterOverflow extends Schema.TaggedErrorClass()( + * "CounterOverflow", + * { limit: Schema.Number }, + * ) {} + * + * export const Increment = Action.make("Increment", { + * payload: { amount: Schema.Number }, + * success: Schema.Number, + * error: CounterOverflow, + * }) + * ``` + */ +export const make = < + const Tag extends string, + Payload extends Schema.Top | Schema.Struct.Fields = Schema.Void, + Success extends Schema.Top = Schema.Void, + Error extends Schema.Top = Schema.Never, +>( + tag: Tag, + options?: { + readonly payload?: Payload; + readonly success?: Success; + readonly error?: Error; + }, +): Action< + Tag, + Payload extends Schema.Struct.Fields ? Schema.Struct : Payload, + Success, + Error +> => { + const successSchema = options?.success ?? Schema.Void; + const errorSchema = options?.error ?? Schema.Never; + const payloadSchema: Schema.Top = Schema.isSchema(options?.payload) + ? (options?.payload as any) + : options?.payload + ? Schema.Struct(options?.payload as any) + : Schema.Void; + return makeProto({ + _tag: tag, + payloadSchema, + successSchema, + errorSchema, + }) as Action< + Tag, + Payload extends Schema.Struct.Fields ? Schema.Struct : Payload, + Success, + Error + >; +}; diff --git a/rivetkit-typescript/packages/effect/src/Actor.test-d.ts b/rivetkit-typescript/packages/effect/src/Actor.test-d.ts new file mode 100644 index 0000000000..1ed9579e6d --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Actor.test-d.ts @@ -0,0 +1,316 @@ +import { + Context, + Effect, + type Layer, + Schema, + SchemaTransformation, +} from "effect"; +import type { RawAccess } from "rivetkit/db"; +import { db } from "rivetkit/db"; +import { describe, expectTypeOf, test } from "vitest"; +import * as Action from "./Action"; +import * as Actor from "./Actor"; +import type * as Client from "./Client"; +import type * as State from "./State"; + +class SomeDep extends Context.Service()( + "SomeDep", +) {} + +const TestActor = Actor.make("TestActor", { + actions: [Action.make("GetContext")], +}); + +const TestState = { + schema: Schema.Struct({ + count: Schema.Number, + }), + initialValue: () => ({ count: 0 }), +}; + +const TagsCsv = Schema.String.pipe( + Schema.decodeTo( + Schema.Array(Schema.String), + SchemaTransformation.transform({ + decode: (s: string): ReadonlyArray => s.split(","), + encode: (arr: ReadonlyArray) => arr.join(","), + }), + ), +); + +const TransformedState = { + schema: Schema.Struct({ + when: Schema.DateFromString, + url: Schema.URLFromString, + id: Schema.BigIntFromString, + bytes: Schema.Uint8ArrayFromBase64, + tags: TagsCsv, + history: Schema.Array( + Schema.Struct({ + at: Schema.DateFromString, + payload: Schema.Uint8ArrayFromBase64, + }), + ), + }), + initialValue: () => ({ + when: new Date("2024-01-15T10:30:00.000Z"), + url: new URL("https://rivet.dev/docs"), + id: 1n, + bytes: new Uint8Array([1, 2, 3]), + tags: ["alpha", "beta"], + history: [ + { + at: new Date("2024-01-15T10:30:00.000Z"), + payload: new Uint8Array([4, 5, 6]), + }, + ], + }), +}; + +describe("Actor.make", () => { + test("preserves the name literal", () => { + expectTypeOf(TestActor.name).toEqualTypeOf<"TestActor">(); + }); +}); + +describe("Actor.make(...).toLayer", () => { + test("is a function", () => { + expectTypeOf(TestActor.toLayer).toBeFunction(); + }); + + test("accepts a plain action handlers object", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith({ + GetContext: () => Effect.void, + }); + }); + + test("accepts an Effect of action handlers", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith( + Effect.gen(function* () { + return { + GetContext: () => Effect.void, + }; + }), + ); + }); + + test("accepts a function returning a plain action handlers object", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith( + (_wakeOptions: any) => ({ + GetContext: () => Effect.void, + }), + ); + }); + + test("wake options omit state without a configured state type", () => { + TestActor.toLayer((wakeOptions) => { + // @ts-expect-error: stateless actors do not expose wakeOptions.state + wakeOptions.state; + expectTypeOf( + wakeOptions.rawRivetkitContext.state, + ).toEqualTypeOf(); + + return { + GetContext: () => Effect.void, + }; + }); + + TestActor.toLayer((wakeOptions) => { + // @ts-expect-error: actors without a state option do not expose wakeOptions.state + wakeOptions.state; + + expectTypeOf( + wakeOptions.rawRivetkitContext.state, + ).toEqualTypeOf(); + + return { + GetContext: () => Effect.void, + }; + }, {}); + }); + + test("wake options carry the configured state type", () => { + TestActor.toLayer( + (wakeOptions) => { + expectTypeOf(wakeOptions.state).toEqualTypeOf< + State.State<{ readonly count: number }, Schema.SchemaError> + >(); + + return { + GetContext: () => Effect.void, + }; + }, + { state: TestState }, + ); + }); + + test("wake options carry the transformed state type", () => { + TestActor.toLayer( + (wakeOptions) => { + expectTypeOf(wakeOptions.state).toEqualTypeOf< + State.State< + { + readonly when: Date; + readonly url: URL; + readonly id: bigint; + readonly bytes: Uint8Array; + readonly tags: ReadonlyArray; + readonly history: ReadonlyArray<{ + readonly at: Date; + readonly payload: Uint8Array; + }>; + }, + Schema.SchemaError + > + >(); + + return { + GetContext: () => Effect.void, + }; + }, + { state: TransformedState }, + ); + }); + + test("wake options carry the raw RivetKit context with the encoded configured state type", () => { + TestActor.toLayer( + (wakeOptions) => { + expectTypeOf( + wakeOptions.rawRivetkitContext.state, + ).toEqualTypeOf<{ readonly count: number }>(); + + return { + GetContext: () => Effect.void, + }; + }, + { state: TestState }, + ); + }); + + test("wake options carry the raw RivetKit context with the encoded transformed state type", () => { + TestActor.toLayer( + (wakeOptions) => { + expectTypeOf( + wakeOptions.rawRivetkitContext.state, + ).toEqualTypeOf<{ + readonly when: string; + readonly url: string; + readonly id: string; + readonly bytes: string; + readonly tags: string; + readonly history: ReadonlyArray<{ + readonly at: string; + readonly payload: string; + }>; + }>(); + + return { + GetContext: () => Effect.void, + }; + }, + { state: TransformedState }, + ); + }); + + test("wake options carry the configured database client type", () => { + TestActor.toLayer( + (wakeOptions) => { + expectTypeOf( + wakeOptions.rawRivetkitContext.db, + ).toEqualTypeOf(); + + return { + GetContext: () => Effect.void, + }; + }, + { db: db() }, + ); + }); + + test("accepts a function returning an Effect of action handlers", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith((_wakeOptions: any) => + Effect.gen(function* () { + return { + GetContext: () => Effect.void, + }; + }), + ); + }); + + test("accepts an Effect that resolves to a wake function", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith( + Effect.gen(function* () { + // Allow for initialization logic before the per-entity wake function is called + + return (_wakeOptions: any) => + Effect.gen(function* () { + return { + GetContext: () => Effect.void, + }; + }); + }), + ); + }); + + test("accepts an Effect.fn returning action handlers", () => { + expectTypeOf(TestActor.toLayer).toBeCallableWith( + Effect.fn("wake")(function* (_wakeOptions) { + return { + GetContext: () => Effect.void, + }; + }), + ); + }); + + test("returns a Layer", () => { + expectTypeOf(TestActor.toLayer).returns.toExtend(); + }); + + test("action handler's envelope is typed against the action", () => { + TestActor.toLayer({ + GetContext: (envelope) => { + expectTypeOf(envelope._tag).toEqualTypeOf<"GetContext">(); + expectTypeOf(envelope.action).toExtend(); + return Effect.void; + }, + }); + }); + + test("missing action handler is rejected", () => { + // @ts-expect-error: GetContext handler is required + TestActor.toLayer({}); + }); + + test.todo("unknown action handler key is rejected", () => { + TestActor.toLayer({ + GetContext: () => Effect.void, + // TODO: toLayer should reject unknown action handler keys + Unknown: () => Effect.void, + }); + }); + + test.todo("wake-effect requirements surface in the Layer", () => { + const layer = TestActor.toLayer( + Effect.gen(function* () { + yield* SomeDep; + return { GetContext: () => Effect.void }; + }), + ); + type Reqs = + typeof layer extends Layer.Layer ? R : never; + // @ts-expect-error: TODO - expectTypeOf() no-arg generic form not resolving + expectTypeOf().toExtend(); + }); +}); + +describe("Actor.make(...).client", () => { + test("yields a typed Accessor", () => { + expectTypeOf(TestActor.client).toEqualTypeOf< + Effect.Effect< + Actor.Accessor<(typeof TestActor.actions)[number]>, + never, + Client.Client + > + >(); + }); +}); diff --git a/rivetkit-typescript/packages/effect/src/Actor.test.ts b/rivetkit-typescript/packages/effect/src/Actor.test.ts new file mode 100644 index 0000000000..b851ba33ed --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Actor.test.ts @@ -0,0 +1,201 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; +import type * as Rivetkit from "rivetkit"; +import * as Actor from "./Actor"; +import * as State from "./State"; + +class Prefix extends Context.Service()( + "Actor.test/Prefix", +) {} +const PrefixLive = Layer.succeed(Prefix, Prefix.of({ value: "svc" })); + +describe("Actor.toWakeHandler", () => { + it.effect("wraps a plain action handler object", () => + Effect.gen(function* () { + const wake = { Ping: () => Effect.succeed("pong") }; + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler({} as Actor.WakeOptions); + + assert.strictEqual(actionHandlers, wake); + }), + ); + + it.effect("runs an Effect that resolves to action handlers", () => + Effect.gen(function* () { + const wake = Effect.gen(function* () { + const prefix = yield* Prefix; + + return { + Ping: () => Effect.succeed(`${prefix.value}:pong`), + }; + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler({} as Actor.WakeOptions); + + assert.strictEqual(yield* actionHandlers.Ping(), "svc:pong"); + }).pipe(Effect.provide(PrefixLive)), + ); + + it.effect("calls a wake function with wake options", () => + Effect.gen(function* () { + const rawRivetkitContext = { + key: ["room", "1"], + } as Rivetkit.WakeContextOf; + const wakeOptions: Actor.WakeOptions = { + rawRivetkitContext, + }; + const wake = (wakeOptions: Actor.WakeOptions) => ({ + GetKey: () => + Effect.succeed( + wakeOptions.rawRivetkitContext.key.join("/"), + ), + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler(wakeOptions); + + assert.strictEqual(wakeOptions.rawRivetkitContext, rawRivetkitContext); + assert.strictEqual(yield* actionHandlers.GetKey(), "room/1"); + }), + ); + + it.effect("passes actor state through wake options", () => + Effect.gen(function* () { + const cell = { value: { count: 1 } }; + const state = yield* State.make( + () => Effect.sync(() => cell.value), + (value: { readonly count: number }) => + Effect.sync(() => { + cell.value = value; + }), + ); + type StatefulWakeOptions = Actor.WakeOptions & { + readonly state: State.State< + { readonly count: number }, + never, + never + >; + }; + const wakeOptions: StatefulWakeOptions = { + rawRivetkitContext: + {} as Rivetkit.WakeContextOf, + state, + }; + const wake = (wakeOptions: StatefulWakeOptions) => ({ + GetCount: () => State.get(wakeOptions.state), + SetCount: (count: number) => + State.set(wakeOptions.state, { count }), + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler(wakeOptions); + + assert.deepStrictEqual(yield* actionHandlers.GetCount(), { + count: 1, + }); + + yield* actionHandlers.SetCount(7); + assert.deepStrictEqual(cell.value, { count: 7 }); + }), + ); + + it.effect("flattens a wake function returning an Effect", () => + Effect.gen(function* () { + const wakeOptions: Actor.WakeOptions = { + rawRivetkitContext: { + key: ["room", "2"], + } as Rivetkit.WakeContextOf, + }; + const wake = (options: Actor.WakeOptions) => + Effect.gen(function* () { + const prefix = yield* Prefix; + + return { + GetKey: () => + Effect.succeed( + `${prefix.value}:${options.rawRivetkitContext.key.join("/")}`, + ), + }; + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler(wakeOptions); + + assert.strictEqual(yield* actionHandlers.GetKey(), "svc:room/2"); + }).pipe(Effect.provide(PrefixLive)), + ); + + it.effect("runs an Effect that resolves to a wake function", () => + Effect.gen(function* () { + const wakeOptions: Actor.WakeOptions = { + rawRivetkitContext: { + actorId: "actor-1", + } as Rivetkit.WakeContextOf, + }; + const wake = Effect.gen(function* () { + const prefix = yield* Prefix; + + return (options: Actor.WakeOptions) => + Effect.succeed({ + GetActorId: () => + Effect.succeed( + `${prefix.value}:${options.rawRivetkitContext.actorId}`, + ), + }); + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler(wakeOptions); + + assert.strictEqual( + yield* actionHandlers.GetActorId(), + "svc:actor-1", + ); + }).pipe(Effect.provide(PrefixLive)), + ); + + it.effect("accepts an Effect.fn wake function", () => + Effect.gen(function* () { + const wakeOptions: Actor.WakeOptions = { + rawRivetkitContext: { + key: ["effect", "fn"], + } as Rivetkit.WakeContextOf, + }; + const wake = Effect.fn("wake")(function* ( + options: Actor.WakeOptions, + ) { + const prefix = yield* Prefix; + + return { + GetKey: () => + Effect.succeed( + `${prefix.value}:${options.rawRivetkitContext.key.join("/")}`, + ), + }; + }); + const wakeHandler = Actor.toWakeHandler(wake); + const actionHandlers = yield* wakeHandler(wakeOptions); + + assert.strictEqual(yield* actionHandlers.GetKey(), "svc:effect/fn"); + }).pipe(Effect.provide(PrefixLive)), + ); + + it.effect( + "defers wake functions until the returned handler is invoked", + () => + Effect.gen(function* () { + let calls = 0; + const wake = () => { + calls++; + return { Count: () => Effect.succeed(calls) }; + }; + const wakeHandler = Actor.toWakeHandler(wake); + + assert.strictEqual(calls, 0); + + const first = yield* wakeHandler({} as Actor.WakeOptions); + assert.strictEqual(calls, 1); + assert.strictEqual(yield* first.Count(), 1); + + const second = yield* wakeHandler({} as Actor.WakeOptions); + assert.strictEqual(calls, 2); + assert.strictEqual(yield* second.Count(), 2); + }), + ); +}); diff --git a/rivetkit-typescript/packages/effect/src/Actor.ts b/rivetkit-typescript/packages/effect/src/Actor.ts new file mode 100644 index 0000000000..f84d74dc5d --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Actor.ts @@ -0,0 +1,805 @@ +import { + Cause, + Context, + Effect, + Exit, + identity, + Layer, + MutableHashMap, + Option, + Predicate, + Record, + Schema, + Scope, + Semaphore, + Struct, + Tracer, + UndefinedOr, +} from "effect"; +import * as Rivetkit from "rivetkit"; +import type * as RivetkitDb from "rivetkit/db"; +import { hasStringProperty } from "./internal/utils"; +import type * as Action from "./Action"; +import * as Client from "./Client"; +import * as ActionErrorEnvelope from "./internal/ActionErrorEnvelope"; +import type * as StateOptions from "./internal/StateOptions"; +import { readTraceMeta, rpcSystem } from "./internal/tracing"; +import * as Registry from "./Registry"; +import type * as RivetError from "./RivetError"; +import * as State from "./State"; + +const TypeId = "~@rivetkit/effect/Actor"; + +export const isActor = (u: unknown): u is Actor => + Predicate.hasProperty(u, TypeId); + +const rivetkitActorOptionsKeys = [ + "name", + "icon", +] as const satisfies ReadonlyArray< + keyof NonNullable +>; + +export type RivetkitActorOptions = Pick< + NonNullable, + (typeof rivetkitActorOptionsKeys)[number] +>; + +/** + * Per-actor instance options. Combines the public + * `RivetkitActorOptions` (forwarded verbatim to `Rivetkit.actor`) + * with the effect-SDK-only options. + */ +export type Options< + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, +> = Readonly & { + readonly state?: State; + readonly db?: Database; +}; + +type StatelessOptions< + Database extends RivetkitDb.AnyDatabaseProvider = undefined, +> = Readonly & { + readonly state?: never; + readonly db?: Database; +}; + +type StatefulOptions< + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, +> = Readonly & { + readonly state: State; + readonly db?: Database; +}; + +const splitOptions = < + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider, +>( + options: Options, +) => ({ + rivetkitOptions: Struct.pick(options, rivetkitActorOptionsKeys), + effectOptions: Struct.omit(options, rivetkitActorOptionsKeys), +}); + +/** + * Per-instance identity carried inside the wake scope. An actor + * instance is addressable in two ways: + * + * - `(name, key)` — stable user-facing pair (e.g. "Counter", ["counter-123"]) + * - `actorId` — opaque engine-assigned unique identifier + * + * Available inside `Actor.toLayer`'s wake effect via + * `yield* Actor.CurrentAddress`. + */ +export type ActorAddress = Pick< + Rivetkit.ActorContext, + "actorId" | "name" | "key" +>; + +/** + * Context tag for the current actor instance's address. Provided + * once per wake when the wake effect runs; capture it into a + * closure if action handlers need it. + */ +export class CurrentAddress extends Context.Service< + CurrentAddress, + ActorAddress +>()("@rivetkit/effect/Actor/CurrentAddress") {} + +export class Sleep extends Context.Service>()( + "@rivetkit/effect/Actor/Sleep", +) {} + +export type ActionRequest = + A extends Action.Action< + infer Tag, + infer Payload, + infer _Success, + infer _Error + > + ? { + readonly _tag: Tag; + readonly action: A; + readonly payload: Payload["Type"]; + } + : never; + +type ActionHandlerServices = { + readonly [Name in keyof ActionHandlers]: ActionHandlers[Name] extends ( + ...args: ReadonlyArray + ) => Effect.Effect + ? R + : never; +}[keyof ActionHandlers]; + +type StateOptionsCodec = { + readonly decode: ( + input: StateOptions.Encoded, + ) => Effect.Effect< + StateOptions.Decoded, + Schema.SchemaError, + State["schema"]["DecodingServices"] + >; + readonly decodeUnknown: ( + input: unknown, + ) => Effect.Effect< + StateOptions.Decoded, + Schema.SchemaError, + State["schema"]["DecodingServices"] + >; + readonly encode: ( + input: StateOptions.Decoded, + ) => Effect.Effect< + StateOptions.Encoded, + Schema.SchemaError, + State["schema"]["EncodingServices"] + >; +}; + +const makeStateOptionsCodec = ( + state: State, +): StateOptionsCodec => { + const schema = state.schema as State["schema"]; + + return { + decode: Schema.decodeEffect(schema), + decodeUnknown: Schema.decodeUnknownEffect(schema), + encode: Schema.encodeEffect(schema), + }; +}; + +type RivetkitActorDefinitionFor< + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider, +> = Rivetkit.ActorDefinition< + StateOptions.Encoded, + undefined, + undefined, + undefined, + undefined, + Database, + Record, + Record, + any +>; + +export type WakeOptions< + ActorDefinition extends + Rivetkit.AnyActorDefinition = Rivetkit.AnyActorDefinition, +> = { + readonly rawRivetkitContext: Rivetkit.WakeContextOf; +}; + +type RawWakeContextFor< + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider, +> = { + [Key in keyof Rivetkit.WakeContextOf< + RivetkitActorDefinitionFor + >]: Key extends "state" + ? [State] extends [never] + ? never + : StateOptions.Encoded + : Rivetkit.WakeContextOf< + RivetkitActorDefinitionFor + >[Key]; +}; + +type WakeOptionsFor< + StateDefinition extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider, +> = { + readonly rawRivetkitContext: RawWakeContextFor; +} & ([StateDefinition] extends [never] + ? {} + : { + readonly state: State.State< + StateOptions.Decoded, + Schema.SchemaError + >; + }); + +type WakeFunction = + | ((wakeOptions: W) => ActionHandlers) + | ((wakeOptions: W) => Effect.Effect); + +type Wake = + | ActionHandlers + | Effect.Effect + | WakeFunction + | Effect.Effect, never, RX>; + +export type AccessorKeyParam = string | Rivetkit.ActorKey; + +/** + * A typed handle for one actor instance. Each action becomes a + * method that takes the action's payload-constructor input and + * returns an Effect with the action's success / typed error + * channels baked in. + */ +export type Handle = { + readonly [A in Actions as Action.Tag]: ( + payload: Action.PayloadConstructor, + ) => Effect.Effect< + Action.Success, + Action.Error | RivetError.RivetError + >; +}; + +/** + * Yielded by `Actor.client`. Address an actor instance by key, then + * dispatch typed action calls against the returned `Handle`. + */ +export type Accessor = { + readonly getOrCreate: (key: AccessorKeyParam) => Handle; +}; + +type UnknownToNever = unknown extends T ? never : T; + +type ExcludeBuiltInWakeServices< + T, + _State extends StateOptions.Any, +> = UnknownToNever>; + +type ToLayerRequirements< + Actions extends Action.Any, + ActionHandlers, + State extends StateOptions.Any, + R, + RX, +> = + | ExcludeBuiltInWakeServices + | ExcludeBuiltInWakeServices + | UnknownToNever> + | UnknownToNever> + | UnknownToNever> + | Registry.Registry; + +/** + * A Rivet Actor contract. It carries the action schemas and + * display options, but no server implementation. + */ +export interface Actor< + in out Name extends string, + in out Actions extends Action.Any = never, +> { + readonly [TypeId]: typeof TypeId; + readonly name: Name; + readonly actions: ReadonlyArray; + + of>( + actionHandlers: ActionHandlers, + ): ActionHandlers; + + toLayer< + ActionHandlers extends ActionHandlersFrom, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, + R = never, + RX = never, + >( + wake: Wake>, + options: StatelessOptions, + ): Layer.Layer< + never, + never, + ToLayerRequirements + >; + + toLayer< + ActionHandlers extends ActionHandlersFrom, + R = never, + RX = never, + >( + wake: Wake>, + ): Layer.Layer< + never, + never, + ToLayerRequirements + >; + + toLayer< + ActionHandlers extends ActionHandlersFrom, + State extends StateOptions.Any, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, + R = never, + RX = never, + >( + wake: Wake>, + options: StatefulOptions, + ): Layer.Layer< + never, + never, + ToLayerRequirements + >; + + /** + * Effect-yielded typed accessor for this actor. Provide a + * `Client.layer({ ... })` once at the program root; every + * `yield* SomeActor.client` then dispatches through the same + * transport. + */ + readonly client: Effect.Effect, never, Client.Client>; +} + +export type Any = Actor; + +export type ActionHandlersFrom = { + readonly [Action in Actions as Action["_tag"]]: ( + envelope: ActionRequest, + ) => Action.ResultFrom; +}; + +const Proto: Omit, "name" | "actions"> = { + [TypeId]: TypeId, + toLayer< + Actions extends Action.AnyWithProps, + ActionHandlers extends ActionHandlersFrom, + State extends StateOptions.Any = never, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, + R = never, + RX = never, + >( + this: Actor, + wake: Wake>, + options: Options = {}, + ) { + return makeRivetkitActor({ + actor: this, + wakeHandler: toWakeHandler< + ActionHandlers, + R, + RX, + WakeOptionsFor + >(wake), + options, + }).pipe( + Effect.flatMap((rivetKitActor) => + Registry.Registry.pipe( + Effect.flatMap((registry) => + Effect.sync(() => + registry.rivetkitActors.set( + this.name, + rivetKitActor, + ), + ), + ), + ), + ), + Layer.effectDiscard, + ); + }, + get client() { + return Client.Client.pipe( + Effect.map((client) => client.makeActorAccessor(this as Any)), + ); + }, + of: identity, +}; + +/** + * Define a Rivet Actor contract. + */ +export const make = < + const Name extends string, + const Actions extends ReadonlyArray = readonly [], +>( + name: Name, + options?: { + readonly actions?: Actions; + }, +): Actor => { + const self = Object.create(Proto); + self.name = name; + self.actions = options?.actions; + return self; +}; + +export function toWakeHandler< + ActionHandlers extends object, + R, + RX, + W extends WakeOptions = WakeOptions, +>( + wake: Effect.Effect< + (wakeOptions: W) => Effect.Effect, + never, + RX + >, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + RX, + W extends WakeOptions = WakeOptions, +>( + wake: Effect.Effect<(wakeOptions: W) => ActionHandlers, never, RX>, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + R, + W extends WakeOptions = WakeOptions, +>( + wake: (wakeOptions: W) => Effect.Effect, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + W extends WakeOptions = WakeOptions, +>( + wake: (wakeOptions: W) => ActionHandlers, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + RX, + W extends WakeOptions = WakeOptions, +>( + wake: Effect.Effect, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + W extends WakeOptions = WakeOptions, +>( + wake: ActionHandlers, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + R, + RX, + W extends WakeOptions = WakeOptions, +>( + wake: Wake, +): (wakeOptions: W) => Effect.Effect; +export function toWakeHandler< + ActionHandlers extends object, + R, + RX, + W extends WakeOptions = WakeOptions, +>(wake: Wake) { + return (wakeOptions: W) => { + const wakeEffect = Effect.isEffect(wake) + ? (wake as Effect.Effect< + ActionHandlers | WakeFunction, + never, + RX + >) + : Effect.succeed(wake); + + return wakeEffect.pipe( + Effect.flatMap((resolvedWake) => { + if (typeof resolvedWake === "function") { + const actionHandlers = resolvedWake(wakeOptions); + return Effect.isEffect(actionHandlers) + ? actionHandlers + : Effect.succeed(actionHandlers); + } + + return Effect.succeed(resolvedWake); + }), + ); + }; +} + +const makeRivetkitActor = Effect.fnUntraced(function* < + Name extends string, + Actions extends Action.AnyWithProps, + ActionHandlers extends ActionHandlersFrom, + RX, + State extends StateOptions.Any = never, + Database extends RivetkitDb.AnyDatabaseProvider = undefined, +>({ + actor, + wakeHandler, + options, +}: { + readonly actor: Actor; + readonly wakeHandler: ( + wakeOptions: WakeOptionsFor, + ) => Effect.Effect; + readonly options: Options; +}) { + // Snapshot the current Effect context so action callbacks + // (which run in rivetkit's plain Promise world) can run + // handler effects against the same services the Registry.start / + // Registry.test layer was provided with. + const services = yield* Effect.context(); + + const { effectOptions, rivetkitOptions } = splitOptions(options); + const stateCodec = UndefinedOr.map( + effectOptions.state, + makeStateOptionsCodec, + ); + + const instances = MutableHashMap.empty< + string, + { + readonly actionHandlers: ActionHandlers; + readonly scope: Scope.Closeable; + readonly state?: State.State< + StateOptions.Decoded, + Schema.SchemaError + >; + } + >(); + + type RivetkitDefinition = RivetkitActorDefinitionFor; + + const onWake = async (c: Rivetkit.WakeContextOf) => { + await Effect.runPromiseWith(services)( + Effect.gen(function* () { + const scope = yield* Scope.make(); + + const state = stateCodec + ? // `c.state` IS the state — `State` is just a typed + // view + change stream over it. Effect-typed + // read/write so async schema transforms work, + // and `SchemaError` flows through `State.get` / + // `set` / `update` to action handlers. The + // wake-time initial read still dies if persisted + // state can't be decoded — no caller exists yet + // to handle it. `Schema.Top`'s requirements show + // up as `unknown`; the captured `services` + // context satisfies them at runtime, so we erase + // R at the boundary. + ((yield* State.make( + () => stateCodec.decode(c.state), + (next) => + stateCodec.encode(next).pipe( + Effect.tap((encoded) => + Effect.sync(() => { + c.state = encoded; + }), + ), + Effect.asVoid, + ), + ).pipe(Effect.orDie)) as State.State< + StateOptions.Decoded, + Schema.SchemaError + >) + : undefined; + + const context = Context.mergeAll( + Context.make(CurrentAddress, { + actorId: c.actorId, + name: c.name, + key: c.key, + }), + Context.make(Scope.Scope, scope), + Context.make( + Sleep, + Effect.sync(() => c.sleep()), + ), + ); + + const wakeOptions = { + rawRivetkitContext: c, + ...(state ? { state } : {}), + } as WakeOptionsFor; + const actionHandlers = yield* wakeHandler(wakeOptions).pipe( + Effect.provide(context), + ); + + yield* Effect.sync(() => + MutableHashMap.set(instances, c.actorId, { + actionHandlers, + scope, + state, + }), + ); + }), + ); + }; + + const actions = Record.fromIterableWith(actor.actions, (action) => { + const decodePayload = Schema.decodeUnknownEffect( + Schema.toCodecJson(action.payloadSchema), + ); + const encodeSuccess = Schema.encodeEffect( + Schema.toCodecJson(action.successSchema), + ); + const encodeError = Schema.encodeEffect( + Schema.toCodecJson(action.errorSchema), + ); + + return [ + action._tag, + async ( + c: Rivetkit.ActionContextOf, + payload: Action.Payload, + meta?: Client.ActionMeta, // TODO: Find better type + ) => { + // Always wrap in a server-side span so the handler has a + // live `currentSpan` even when the caller didn't ship trace + // context (e.g. a non-Effect-SDK client). When trace context + // is present, reattach it as the parent so the server span + // joins the caller's trace. + const rpcMethod = `${actor.name}/${action._tag}`; + const traceMeta = readTraceMeta(meta); + + const exit = await Effect.runPromiseExitWith(services)( + Effect.gen(function* () { + const instance = yield* MutableHashMap.get( + instances, + c.actorId, + ).pipe(Effect.fromOption, Effect.orDie); + // The handler map is keyed by the same action + // definitions being registered here, but + // TypeScript loses that relationship once the + // actions are widened into the RivetKit actions + // record. + const actionHandler = instance.actionHandlers[ + action._tag as keyof ActionHandlers + ] as ( + envelope: ActionRequest, + ) => Action.ResultFrom; + const decodedPayload = yield* decodePayload( + payload, + ).pipe(Effect.orDie); + // The payload was decoded with this action's schema, + // so this is the runtime boundary that restores the + // typed envelope expected by the user handler. + const actionRequest = { + _tag: action._tag, + action, + payload: decodedPayload, + } as ActionRequest; + + const resultExit = yield* Effect.exit( + actionHandler(actionRequest), + ); + + if (Exit.isSuccess(resultExit)) { + return yield* encodeSuccess(resultExit.value).pipe( + Effect.orDie, + ); + } + + const expectedError = Exit.findErrorOption(resultExit); + + if (Option.isSome(expectedError)) { + const encodedError = yield* encodeError( + expectedError.value, + ).pipe(Effect.orDie); + + return yield* Effect.fail( + new Rivetkit.UserError( + hasStringProperty("message")(encodedError) + ? encodedError.message + : `${action._tag} failed`, + { + code: hasStringProperty("_tag")( + encodedError, + ) + ? action._tag + : undefined, + metadata: + ActionErrorEnvelope.make( + encodedError, + ), + }, + ), + ); + } + + // Defect / interruption. Do not encode these as action errors. + // Let them escape, so Rivetkit maps them to its internal_error shape. + return yield* Effect.die( + Cause.squash(resultExit.cause), + ); + }).pipe( + Effect.withSpan(rpcMethod, { + parent: traceMeta + ? Tracer.externalSpan(traceMeta) + : undefined, + kind: "server", + attributes: { + "rpc.system.name": rpcSystem, + "rpc.method": rpcMethod, + }, + }), + ), + ); + + if (Exit.isSuccess(exit)) return exit.value; + throw Cause.squash(exit.cause); + }, + ]; + }); + + const onStateChange = ( + c: Rivetkit.WakeContextOf, + newState: unknown, + ) => { + void Effect.runForkWith(services)( + Effect.gen(function* () { + if (!stateCodec) return; + + const instance = yield* MutableHashMap.get( + instances, + c.actorId, + ).pipe(Effect.fromOption, Effect.orDie); + + const state = yield* Effect.fromNullishOr(instance.state).pipe( + Effect.orDie, + ); + + yield* Semaphore.withPermit( + state.semaphore, + Effect.gen(function* () { + const decoded = yield* stateCodec + .decodeUnknown(newState) + .pipe(Effect.orDie); + State.publishUnsafe(state, decoded); + }), + ); + }), + ); + }; + + const onSleep = async (c: Rivetkit.SleepContextOf) => { + await Effect.runPromiseWith(services)( + Effect.gen(function* () { + const instance = yield* MutableHashMap.get( + instances, + c.actorId, + ).pipe(Effect.fromOption, Effect.orDie); + yield* Scope.close(instance.scope, Exit.void); + yield* Effect.sync(() => { + MutableHashMap.remove(instances, c.actorId); + }); + }), + ); + }; + + return Rivetkit.actor< + StateOptions.Encoded, + undefined, + undefined, + undefined, + undefined, + Database, + Record, + Record, + any + >({ + options: rivetkitOptions, + ...(effectOptions.db ? { db: effectOptions.db } : {}), + onWake, + ...(options.state + ? { + createState: () => + Effect.runPromiseWith(services)( + UndefinedOr.getOrThrow(stateCodec) + .encode( + UndefinedOr.getOrThrow( + options.state, + ).initialValue(), + ) + .pipe(Effect.orDie), + ), + } + : {}), + actions, + onStateChange, + onSleep, + }); +}); diff --git a/rivetkit-typescript/packages/effect/src/Client.test.ts b/rivetkit-typescript/packages/effect/src/Client.test.ts new file mode 100644 index 0000000000..b0f75b2933 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Client.test.ts @@ -0,0 +1,112 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import * as RivetkitErrors from "rivetkit/errors"; +import * as Client from "./Client"; +import * as ActionErrorEnvelope from "./internal/ActionErrorEnvelope"; +import * as RivetError from "./RivetError"; + +describe("makeRivetkitActionFailureClassifier", () => { + const ExpectedError = Schema.Struct({ + _tag: Schema.tag("CounterOverflow"), + message: Schema.String, + limit: Schema.Number, + }); + const classifyRivetkitActionFailure = + Client.makeRivetkitActionFailureClassifier(ExpectedError); + + it.effect("preserves non-Rivet failures as UnknownError", () => + Effect.gen(function* () { + const cause = new Error("plain failure"); + const error = yield* classifyRivetkitActionFailure(cause); + + assert.instanceOf(error, RivetError.RivetError); + assert.instanceOf(error.reason, RivetError.UnknownError); + assert.strictEqual(error.reason.message, "plain failure"); + assert.strictEqual(error.reason.cause, cause); + }), + ); + + it.effect("preserves structured non-action Rivet errors", () => + Effect.gen(function* () { + const cause = new RivetkitErrors.RivetError( + "actor", + "not_found", + "actor not found", + ); + const error = yield* classifyRivetkitActionFailure(cause); + + assert.instanceOf(error, RivetError.RivetError); + assert.instanceOf(error.reason, RivetError.ActorNotFound); + assert.strictEqual(error.reason.cause.group, "actor"); + assert.strictEqual(error.reason.cause.code, "not_found"); + assert.strictEqual(error.reason.cause.message, "actor not found"); + }), + ); + + it.effect( + "decodes action-error metadata into the declared error type", + () => + Effect.gen(function* () { + const cause = new RivetkitErrors.RivetError( + "user", + "CounterOverflow", + "counter overflow", + { + public: true, + metadata: { + _tag: ActionErrorEnvelope.tag, + version: ActionErrorEnvelope.schemaVersion, + error: { + _tag: "CounterOverflow", + message: "counter overflow", + limit: 10, + }, + }, + }, + ); + const error = yield* classifyRivetkitActionFailure(cause); + + assert.deepStrictEqual(error, { + _tag: "CounterOverflow", + message: "counter overflow", + limit: 10, + }); + }), + ); + + it.effect( + "wraps invalid typed action-error payloads in ActionErrorDecodeFailed", + () => + Effect.gen(function* () { + const cause = new RivetkitErrors.RivetError( + "user", + "CounterOverflow", + "counter overflow", + { + metadata: { + _tag: ActionErrorEnvelope.tag, + version: ActionErrorEnvelope.schemaVersion, + error: { + _tag: "CounterOverflow", + message: "counter overflow", + limit: "10", + }, + }, + }, + ); + + const error = yield* classifyRivetkitActionFailure(cause); + + assert.instanceOf(error, RivetError.RivetError); + assert.instanceOf( + error.reason, + RivetError.ActionErrorDecodeFailed, + ); + assert.strictEqual(error.reason.rivetError.group, "user"); + assert.strictEqual( + error.reason.rivetError.code, + "CounterOverflow", + ); + }), + ); +}); diff --git a/rivetkit-typescript/packages/effect/src/Client.ts b/rivetkit-typescript/packages/effect/src/Client.ts new file mode 100644 index 0000000000..05c2cd0c1e --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Client.ts @@ -0,0 +1,203 @@ +import { Context, Effect, Layer, Record, Result, Schema } from "effect"; +import * as RivetkitClient from "rivetkit/client"; +import * as RivetkitErrors from "rivetkit/errors"; +import type * as Action from "./Action"; +import type * as Actor from "./Actor"; +import * as ActionErrorEnvelope from "./internal/ActionErrorEnvelope"; +import { rpcSystem, type TraceMeta } from "./internal/tracing"; +import * as RivetError from "./RivetError"; + +const TypeId = "~@rivetkit/effect/Client"; + +/** + * Connection options for the Rivet Engine client transport. Mirrors + * the `(endpoint, token, namespace)` subset of rivetkit's + * `ClientConfigInput`. + */ +export type Options = Pick< + RivetkitClient.ClientConfigInput, + "endpoint" | "token" | "namespace" +>; + +/** + * Per-call metadata envelope shipped as `args[1]` alongside the encoded + * payload. The SDK currently uses it for trace propagation (`trace`), + * but it's intentionally extensible so future cross-cutting concerns — + * idempotency keys, deadlines, custom headers — can land as additional + * optional fields without changing the wire shape. + */ +export interface ActionMeta { + readonly trace?: TraceMeta; +} + +export interface Client { + readonly [TypeId]: typeof TypeId; + + readonly makeActorAccessor: ( + actor: Actor.Actor, + ) => Actor.Accessor; +} + +export const Client: Context.Service = Context.Service( + "@rivetkit/effect/Client", +); + +export const make = Effect.fnUntraced(function* (options: Options = {}) { + const rivetkitClient = yield* Effect.acquireRelease( + Effect.sync(() => RivetkitClient.createClient(options)), + (c) => Effect.promise(() => c.dispose()), + ); + + return Client.of({ + [TypeId]: TypeId, + makeActorAccessor: (actor) => ({ + getOrCreate: (key) => { + const rivetkitActorHandle = rivetkitClient.getOrCreate( + actor.name, + key, + ); + + return Record.fromIterableWith(actor.actions, (action) => { + const encodePayload = Schema.encodeEffect( + Schema.toCodecJson(action.payloadSchema), + ); + const decodeSuccess = Schema.decodeUnknownEffect( + Schema.toCodecJson(action.successSchema), + ); + const classifyRivetkitActionFailure = + makeRivetkitActionFailureClassifier(action.errorSchema); + + const rpcMethod = `${actor.name}/${action._tag}`; + + return [ + action._tag, + Effect.fn(rpcMethod, { + kind: "client", + attributes: { + "rpc.system.name": rpcSystem, + "rpc.method": rpcMethod, + }, + })(function* (payload: unknown) { + const span = yield* Effect.currentSpan; + const meta: ActionMeta = { + trace: { + traceId: span.traceId, + spanId: span.spanId, + sampled: span.sampled, + }, + }; + const encodedPayload = yield* encodePayload( + payload, + ).pipe( + Effect.mapError( + (cause) => + new RivetError.RivetError({ + reason: new RivetError.InvalidEncoding( + { + cause: new RivetkitErrors.RivetError( + "encoding", + "invalid", + "Could not encode action payload", + { + public: true, + metadata: cause, + }, + ), + }, + ), + }), + ), + ); + + const encodedSuccess = yield* Effect.tryPromise( + (abortSignal) => + rivetkitActorHandle.action({ + name: action._tag, + args: [encodedPayload, meta], + signal: abortSignal, + }), + ).pipe( + Effect.catch((unknownError) => + classifyRivetkitActionFailure( + unknownError.cause, + ).pipe(Effect.flatMap(Effect.fail)), + ), + ); + + return yield* decodeSuccess(encodedSuccess).pipe( + Effect.orDie, + ); + }), + ]; + }) as Actor.Handle<(typeof actor.actions)[number]>; + }, + }), + }); +}); + +export const layer = (options: Options = {}): Layer.Layer => + Layer.effect(Client, make(options)); + +const decodeActionErrorEnvelope = Schema.decodeUnknownEffect( + ActionErrorEnvelope.ActionErrorEnvelope, +); + +/** @internal */ +export const makeRivetkitActionFailureClassifier = < + ActionErrorSchema extends Schema.Codec, +>( + actionErrorSchema: ActionErrorSchema, +): (( + cause: unknown, +) => Effect.Effect< + ActionErrorSchema["Type"] | RivetError.RivetError, + never, + ActionErrorSchema["DecodingServices"] +>) => { + const decodeActionError = Schema.decodeUnknownEffect( + Schema.toCodecJson(actionErrorSchema), + ); + + return Effect.fnUntraced(function* ( + cause: unknown, + ): Effect.fn.Return< + ActionErrorSchema["Type"] | RivetError.RivetError, + never, + ActionErrorSchema["DecodingServices"] + > { + // In the case where the `cause` is not a `RivetError`. In principle, this shouldn't happen. + if (!RivetkitErrors.isRivetErrorLike(cause)) { + return RivetError.fromUnknown(cause); + } + + const rivetkitRivetError = RivetkitErrors.toRivetError(cause); + + const actionErrorEnvelope = yield* Effect.result( + decodeActionErrorEnvelope(rivetkitRivetError.metadata), + ); + + // If the error's `metadata` is not a valid action error envelope, then + // it means it's not a user-declared action error. + if (Result.isFailure(actionErrorEnvelope)) { + return RivetError.fromRivetkitRivetError(rivetkitRivetError); + } + + const actionErrorResult = yield* Effect.result( + decodeActionError(actionErrorEnvelope.success.error), + ); + + // The envelope was valid, but the inner payload doesn't match the + // declared schema — surface as `ActionErrorDecodeFailed` + if (Result.isFailure(actionErrorResult)) { + return new RivetError.RivetError({ + reason: new RivetError.ActionErrorDecodeFailed({ + cause: actionErrorResult.failure, + rivetError: rivetkitRivetError, + }), + }); + } + + // Successfully decoded user-declared action error + return actionErrorResult.success; + }); +}; diff --git a/rivetkit-typescript/packages/effect/src/Registry.test-d.ts b/rivetkit-typescript/packages/effect/src/Registry.test-d.ts new file mode 100644 index 0000000000..58af3d6be0 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Registry.test-d.ts @@ -0,0 +1,128 @@ +import { type Context, Effect, Layer, type Scope } from "effect"; +import type { + HttpServerError, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import { describe, expectTypeOf, test } from "vitest"; +import * as Action from "./Action"; +import * as Actor from "./Actor"; +import * as Registry from "./Registry"; + +const TestActor = Actor.make("TestActor", { + actions: [Action.make("Test")], +}); + +const TestActorLive = TestActor.toLayer({ + Test: () => Effect.void, +}); + +const RegistryLive = TestActorLive.pipe( + Layer.provideMerge(Registry.layer({ endpoint: "http://127.0.0.1:6420" })), +); + +describe("Registry.layer", () => { + test("accepts connection options", () => { + expectTypeOf(Registry.layer).toBeCallableWith({ + endpoint: "http://127.0.0.1:6420", + token: "dev-token", + namespace: "default", + noWelcome: true, + }); + }); + + test("does not accept serverless options", () => { + Registry.layer({ + // @ts-expect-error: serverless routing belongs to toWebHandler and toHttpEffect options. + serverless: { + basePath: "/", + }, + }); + }); +}); + +describe("Registry.serve", () => { + test("accepts an actor registration layer", () => { + expectTypeOf(Registry.serve).toBeCallableWith(TestActorLive); + }); + + test("returns a server layer that requires Registry", () => { + expectTypeOf(Registry.serve(TestActorLive)).toEqualTypeOf< + Layer.Layer + >(); + }); +}); + +describe("Registry.toWebHandler", () => { + test("accepts a registry layer", () => { + expectTypeOf(Registry.toWebHandler).toBeCallableWith(RegistryLive); + }); + + test("rejects actor registration layers that do not provide Registry", () => { + // @ts-expect-error: actor registration layers require Registry but do not provide it. + // @effect-diagnostics effect/missingLayerContext:off effect/floatingEffect:off + Registry.toWebHandler(TestActorLive); + }); + + test("accepts serverless routing options", () => { + expectTypeOf(Registry.toWebHandler).toBeCallableWith(RegistryLive, { + basePath: "/", + maxStartPayloadBytes: 1024, + }); + }); + + test("rejects registry options", () => { + Registry.toWebHandler(RegistryLive, { + // @ts-expect-error: noWelcome belongs to Registry.layer options. + noWelcome: true, + }); + }); + + test("returns a Fetch-compatible handler", () => { + const handler = Registry.toWebHandler(RegistryLive); + + expectTypeOf(handler.handler).toEqualTypeOf< + ( + request: Request, + context?: Context.Context | undefined, + ) => Promise + >(); + expectTypeOf(handler.dispose).toEqualTypeOf<() => Promise>(); + }); +}); + +describe("Registry.toHttpEffect", () => { + test("accepts serverless routing options", () => { + expectTypeOf(Registry.toHttpEffect).toBeCallableWith(RegistryLive, { + basePath: "/", + maxStartPayloadBytes: 1024, + }); + }); + + test("rejects registry options", () => { + Registry.toHttpEffect(RegistryLive, { + // @ts-expect-error: noWelcome belongs to Registry.layer options. + noWelcome: true, + }); + }); + + test("rejects actor registration layers that do not provide Registry", () => { + // @ts-expect-error: actor registration layers require Registry but do not provide it. + // @effect-diagnostics effect/missingLayerContext:off effect/floatingEffect:off + Registry.toHttpEffect(TestActorLive); + }); + + test("returns a scoped Effect HTTP handler", () => { + expectTypeOf(Registry.toHttpEffect(RegistryLive)).toEqualTypeOf< + Effect.Effect< + Effect.Effect< + HttpServerResponse.HttpServerResponse, + HttpServerError.HttpServerError, + HttpServerRequest.HttpServerRequest + >, + never, + Scope.Scope + > + >(); + }); +}); diff --git a/rivetkit-typescript/packages/effect/src/Registry.test.ts b/rivetkit-typescript/packages/effect/src/Registry.test.ts new file mode 100644 index 0000000000..86913f0ef0 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Registry.test.ts @@ -0,0 +1,337 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpEffect } from "effect/unstable/http"; +import { vi } from "vitest"; +import * as Action from "./Action"; +import * as Actor from "./Actor"; +import * as Registry from "./Registry"; + +const TestActor = Actor.make("TestActor", { + actions: [Action.make("Test")], +}); + +const TestActorLive = TestActor.toLayer({ + Test: () => Effect.void, +}); + +const ActorsLayer = Layer.mergeAll(TestActorLive); + +const RegistryLive = ActorsLayer.pipe( + Layer.provideMerge( + Registry.layer({ + endpoint: "http://127.0.0.1:6420", + noWelcome: true, + }), + ), +); + +describe("Registry.toWebHandler", () => { + it("serves registered actors as a Fetch handler", async () => { + const { handler, dispose } = Registry.toWebHandler(RegistryLive); + + try { + const response = await handler( + new Request("http://runner.test/api/rivet/metadata"), + ); + + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + readonly actorNames: Record; + }; + await assert.ok(body.actorNames.TestActor); + } finally { + await dispose(); + } + }); + + it("uses a custom serverless base path", async () => { + const { handler, dispose } = Registry.toWebHandler(RegistryLive, { + basePath: "/", + }); + + try { + const response = await handler( + new Request("http://runner.test/metadata"), + ); + + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + readonly actorNames: Record; + }; + await assert.ok(body.actorNames.TestActor); + } finally { + await dispose(); + } + }); + + it("uses the custom base path to identify start requests", async () => { + const { handler, dispose } = Registry.toWebHandler(RegistryLive, { + basePath: "/custom", + maxStartPayloadBytes: 1, + }); + + try { + const defaultPrefix = await handler( + new Request("http://runner.test/api/rivet/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ); + assert.notStrictEqual(defaultPrefix.status, 413); + + const customPrefix = await handler( + new Request("http://runner.test/custom/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ); + assert.strictEqual(customPrefix.status, 413); + const body = (await customPrefix.json()) as { + readonly group: string; + readonly code: string; + readonly message: string; + }; + assert.deepStrictEqual( + { group: body.group, code: body.code }, + { group: "message", code: "incoming_too_long" }, + ); + await assert.match(body.message, /limit is 1 bytes/); + } finally { + await dispose(); + } + }); + + it("uses a custom serverless start payload size limit", async () => { + const { handler, dispose } = Registry.toWebHandler(RegistryLive, { + maxStartPayloadBytes: 1, + }); + + try { + const response = await handler( + new Request("http://runner.test/api/rivet/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ); + + assert.strictEqual(response.status, 413); + const body = (await response.json()) as { + readonly group: string; + readonly code: string; + readonly message: string; + }; + assert.deepStrictEqual( + { group: body.group, code: body.code }, + { group: "message", code: "incoming_too_long" }, + ); + await assert.match(body.message, /limit is 1 bytes/); + } finally { + await dispose(); + } + }); + + it("does not print the welcome banner when disabled", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const { handler, dispose } = Registry.toWebHandler(RegistryLive); + + try { + const response = await handler( + new Request("http://runner.test/api/rivet/metadata"), + ); + + assert.strictEqual(response.status, 200); + assert.strictEqual(log.mock.calls.length, 0); + } finally { + await dispose(); + log.mockRestore(); + } + }); + + it("builds the registry layer once across requests", async () => { + let builds = 0; + const CountingRegistryLive = Layer.mergeAll( + RegistryLive, + Layer.effectDiscard( + Effect.sync(() => { + builds += 1; + }), + ), + ); + const { handler, dispose } = + Registry.toWebHandler(CountingRegistryLive); + + try { + const first = await handler( + new Request("http://runner.test/api/rivet/metadata"), + ); + const second = await handler( + new Request("http://runner.test/api/rivet/metadata"), + ); + + assert.strictEqual(first.status, 200); + assert.strictEqual(second.status, 200); + assert.strictEqual(builds, 1); + } finally { + await dispose(); + } + }); + + it("closes registry layer finalizers on dispose", async () => { + let finalizers = 0; + const FinalizedRegistryLive = Layer.mergeAll( + RegistryLive, + Layer.effectDiscard( + Effect.addFinalizer(() => + Effect.sync(() => { + finalizers += 1; + }), + ), + ), + ); + const { handler, dispose } = Registry.toWebHandler( + FinalizedRegistryLive, + ); + + try { + const response = await handler( + new Request("http://runner.test/api/rivet/metadata"), + ); + + assert.strictEqual(response.status, 200); + assert.strictEqual(finalizers, 0); + } finally { + await dispose(); + } + assert.strictEqual(finalizers, 1); + }); +}); + +describe("Registry.toHttpEffect", () => { + it.effect("serves registered actors as an Effect HTTP handler", () => + Effect.scoped( + Effect.gen(function* () { + const httpEffect = yield* Registry.toHttpEffect(RegistryLive); + const handler = HttpEffect.toWebHandler(httpEffect); + const response = yield* Effect.promise(() => + handler( + new Request("http://runner.test/api/rivet/metadata"), + ), + ); + + yield* Effect.promise(() => + (async (response: Response) => { + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + readonly actorNames: Record; + }; + await assert.ok(body.actorNames.TestActor); + })(response), + ); + }), + ), + ); + + it.effect("uses a custom serverless base path", () => + Effect.scoped( + Effect.gen(function* () { + const httpEffect = yield* Registry.toHttpEffect(RegistryLive, { + basePath: "/", + }); + const handler = HttpEffect.toWebHandler(httpEffect); + const response = yield* Effect.promise(() => + handler(new Request("http://runner.test/metadata")), + ); + + yield* Effect.promise(() => + (async (response: Response) => { + assert.strictEqual(response.status, 200); + const body = (await response.json()) as { + readonly actorNames: Record; + }; + await assert.ok(body.actorNames.TestActor); + })(response), + ); + }), + ), + ); + + it.effect("uses the custom base path to identify start requests", () => + Effect.scoped( + Effect.gen(function* () { + const httpEffect = yield* Registry.toHttpEffect(RegistryLive, { + basePath: "/custom", + maxStartPayloadBytes: 1, + }); + const handler = HttpEffect.toWebHandler(httpEffect); + const defaultPrefix = yield* Effect.promise(() => + handler( + new Request("http://runner.test/api/rivet/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ), + ); + assert.notStrictEqual(defaultPrefix.status, 413); + + const customPrefix = yield* Effect.promise(() => + handler( + new Request("http://runner.test/custom/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ), + ); + yield* Effect.promise(() => + (async (response: Response) => { + assert.strictEqual(response.status, 413); + const body = (await response.json()) as { + readonly group: string; + readonly code: string; + readonly message: string; + }; + assert.deepStrictEqual( + { group: body.group, code: body.code }, + { group: "message", code: "incoming_too_long" }, + ); + assert.match(body.message, /limit is 1 bytes/); + })(customPrefix), + ); + }), + ), + ); + + it.effect("uses a custom serverless start payload size limit", () => + Effect.scoped( + Effect.gen(function* () { + const httpEffect = yield* Registry.toHttpEffect(RegistryLive, { + maxStartPayloadBytes: 1, + }); + const handler = HttpEffect.toWebHandler(httpEffect); + const response = yield* Effect.promise(() => + handler( + new Request("http://runner.test/api/rivet/start", { + method: "POST", + body: new Uint8Array([1, 2]), + }), + ), + ); + + yield* Effect.promise(() => + (async (response: Response) => { + assert.strictEqual(response.status, 413); + const body = (await response.json()) as { + readonly group: string; + readonly code: string; + readonly message: string; + }; + assert.deepStrictEqual( + { group: body.group, code: body.code }, + { group: "message", code: "incoming_too_long" }, + ); + assert.match(body.message, /limit is 1 bytes/); + })(response), + ); + }), + ), + ); +}); diff --git a/rivetkit-typescript/packages/effect/src/Registry.ts b/rivetkit-typescript/packages/effect/src/Registry.ts new file mode 100644 index 0000000000..92e75cea86 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/Registry.ts @@ -0,0 +1,209 @@ +import { Context, Effect, Layer, type Scope } from "effect"; +import { + HttpEffect, + type HttpMiddleware, + type HttpServerError, + type HttpServerRequest, + type HttpServerResponse, +} from "effect/unstable/http"; +import * as Rivetkit from "rivetkit"; +import * as Client from "./Client"; + +const TypeId = "~@rivetkit/effect/Registry"; +type ServerlessOptions = NonNullable< + Rivetkit.RegistryConfigInput["serverless"] +>; + +export type Options = Pick< + Rivetkit.RegistryConfigInput, + "endpoint" | "token" | "namespace" | "noWelcome" +>; + +export interface Registry { + readonly [TypeId]: typeof TypeId; + + readonly options: Options; + + readonly rivetkitActors: Map; +} + +export const Registry: Context.Service = + Context.Service("@rivetkit/effect/Registry"); + +const make = (options: Options = {}): Registry => { + return Registry.of({ + [TypeId]: TypeId, + options, + rivetkitActors: new Map(), + }); +}; + +export const layer = (options: Options = {}): Layer.Layer => + Layer.succeed(Registry, make(options)); + +const setupRivetkitRegistry = ( + registry: Registry, + options?: { + readonly serverless?: ServerlessOptions | undefined; + }, +) => + Rivetkit.setup({ + use: Object.fromEntries(registry.rivetkitActors), + ...registry.options, + ...(options?.serverless === undefined + ? {} + : { serverless: options.serverless }), + }); + +/** + * Runs an actor registration layer against the configured engine. + * + * The actor layer is built in the server layer scope. Registered Rivet Actors + * are collected from `Registry`, materialized into a single underlying RivetKit + * registry, and started. + */ +export const serve = ( + actorsLayer: Layer.Layer, +): Layer.Layer => + Layer.effectDiscard( + Effect.gen(function* () { + yield* Layer.build(actorsLayer); + const registry = yield* Registry; + const rivetkitRegistry = setupRivetkitRegistry(registry); + yield* Effect.sync(() => rivetkitRegistry.start()); + }), + ); + +/** + * In-process test runtime. Boots the rivetkit registry against the + * configured engine, waits for `/health` to answer, and provides + * `Client` from the same Layer so consumers don't need to wire + * `Client.layer` separately. Mirrors `Registry.start` plus test-mode + * flags and a scoped client dispose. The registry itself is leaked + * to process exit because the public rivetkit `Registry` doesn't + * expose a public `shutdown()` today; only the SIGINT handler can + * drive `#runShutdown`. This matches `setupTest`'s existing behavior. + */ +export const test: Layer.Layer = Layer.effect( + Client.Client, + Effect.gen(function* () { + const registry = yield* Registry; + const rivetkitRegistry = setupRivetkitRegistry(registry); + rivetkitRegistry.config.test = { + ...rivetkitRegistry.config.test, + enabled: true, + }; + rivetkitRegistry.config.noWelcome = true; + // Auto-spawn the engine when no endpoint was provided, so + // `Registry.test` works out of the box without requiring the + // caller to start an engine externally. If the user wired an + // explicit endpoint via `Registry.layer({ endpoint: ... })`, + // honor it and skip the local spawn. + if (registry.options.endpoint === undefined) { + rivetkitRegistry.config.startEngine = true; + } + yield* Effect.sync(() => rivetkitRegistry.start()); + + // The rivetkitRegistry itself is leaked until process exit (matches + // setupTest's behavior). The public Rivetkit.Registry doesn't + // expose a shutdown method; only the SIGINT handler can drive the + // inner .shutdown(). Disposing the client is the only cleanup we + // can do cleanly today. + // + // When the engine was auto-spawned, propagate its resolved + // endpoint to the client so `createClient` doesn't fall back + // to its (warning-emitting) default. + const resolvedEndpoint = rivetkitRegistry.parseConfig().endpoint; + + return yield* Client.make({ + ...registry.options, + endpoint: registry.options.endpoint ?? resolvedEndpoint, + }); + }), +); + +const makeHttpEffect = ( + registry: Registry, + options?: ToHttpEffectOptions, +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + HttpServerError.HttpServerError, + HttpServerRequest.HttpServerRequest +> => { + const rivetkitRegistry = setupRivetkitRegistry(registry, { + serverless: options, + }); + return HttpEffect.fromWebHandler((request) => + rivetkitRegistry.handler(request), + ); +}; + +export type ToHttpEffectOptions = ServerlessOptions; + +/** + * Builds a scoped Effect HTTP handler from a registry layer. + * + * The registry layer is built once in the surrounding scope. Registered Rivet + * Actors are materialized into a single underlying RivetKit registry, and each + * request is delegated to that registry's serverless handler. + */ +export const toHttpEffect = ( + registryLayer: Layer.Layer, + options?: ToHttpEffectOptions, +): Effect.Effect< + Effect.Effect< + HttpServerResponse.HttpServerResponse, + HttpServerError.HttpServerError, + HttpServerRequest.HttpServerRequest + >, + E, + Scope.Scope +> => + Effect.gen(function* () { + const context = yield* Layer.build(registryLayer); + return makeHttpEffect(Context.get(context, Registry), options); + }); + +export type ToWebHandlerOptions = ServerlessOptions & { + /** + * Effect HTTP middleware applied around the generated handler. + */ + readonly middleware?: HttpMiddleware.HttpMiddleware | undefined; + /** + * Memo map used while building the registry layer. + */ + readonly memoMap?: Layer.MemoMap | undefined; +}; + +/** + * Builds a Fetch-compatible request handler from a registry layer. + * + * This is the serverless entrypoint for the Effect SDK. The registry layer must + * provide `Registry`, usually by composing actor layers with `Registry.layer` + * via `Layer.provideMerge`. + */ +export const toWebHandler = ( + registryLayer: Layer.Layer, + options?: ToWebHandlerOptions, +) => { + const { middleware, memoMap } = options ?? {}; + let serverlessOptions: ServerlessOptions | undefined; + if (options !== undefined) { + const { + middleware: _middleware, + memoMap: _memoMap, + ...handlerOptions + } = options; + serverlessOptions = handlerOptions; + } + + return HttpEffect.toWebHandlerLayerWith(registryLayer, { + toHandler: (context) => + Effect.sync(() => { + const registry = Context.get(context, Registry); + return makeHttpEffect(registry, serverlessOptions); + }), + middleware, + memoMap, + }); +}; diff --git a/rivetkit-typescript/packages/effect/src/RivetError.test.ts b/rivetkit-typescript/packages/effect/src/RivetError.test.ts new file mode 100644 index 0000000000..cbe1dce03b --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/RivetError.test.ts @@ -0,0 +1,188 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Duration, Effect, Schema } from "effect"; +import * as RivetkitErrors from "rivetkit/errors"; +import * as RivetError from "./RivetError"; + +describe("RivetError", () => { + it("preserves non-Rivet causes as UnknownError", () => { + const cause = new Error("plain failure"); + const error = RivetError.fromUnknown(cause); + + assert.instanceOf(error, RivetError.RivetError); + assert.instanceOf(error.reason, RivetError.UnknownError); + assert.strictEqual(error.reason.message, "plain failure"); + assert.strictEqual(error.reason.cause, cause); + }); + + it("allows UnknownError to wrap arbitrary causes", () => { + const cause = { group: "not-a-rivet-error", code: 123 }; + const error = new RivetError.UnknownError({ + message: "malformed failure", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.strictEqual(error.group, undefined); + assert.strictEqual(error.code, undefined); + }); + + it("keeps structured Rivet errors classified by group and code", () => { + const cause = new RivetkitErrors.RivetError( + "rivetkit", + RivetkitErrors.INTERNAL_ERROR_CODE, + "internal failure", + ); + const error = RivetError.fromUnknown(cause); + + assert.instanceOf(error.reason, RivetError.InternalError); + assert.strictEqual(error.reason.group, cause.group); + assert.strictEqual(error.reason.code, cause.code); + assert.strictEqual(error.reason.message, cause.message); + }); + + it("exposes normalized isRetryable on every reason", () => { + const restarting = RivetError.fromUnknown( + new RivetkitErrors.RivetError("actor", "restarting", "restarting"), + ); + const forbidden = RivetError.fromUnknown( + new RivetkitErrors.RivetError("auth", "forbidden", "forbidden"), + ); + const overloaded = RivetError.fromUnknown( + new RivetkitErrors.RivetError("actor", "overloaded", "overloaded"), + ); + const serviceUnavailable = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "guard", + "service_unavailable", + "service unavailable", + ), + ); + const incomingTooLong = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "message", + "incoming_too_long", + "too long", + ), + ); + + assert.strictEqual(restarting.isRetryable, true); + assert.strictEqual(restarting.reason.isRetryable, true); + assert.strictEqual(forbidden.isRetryable, false); + assert.strictEqual(overloaded.isRetryable, true); + assert.strictEqual(serviceUnavailable.isRetryable, true); + assert.strictEqual(incomingTooLong.isRetryable, false); + }); + + it("exposes retryAfter from ActorRestarting metadata", () => { + const restarting = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "actor", + "restarting", + "actor restarting", + { metadata: { retryAfterMs: 250 } }, + ), + ); + const restartingNoHint = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "actor", + "restarting", + "actor restarting", + ), + ); + + assert.instanceOf(restarting.reason, RivetError.ActorRestarting); + assert.deepStrictEqual(restarting.retryAfter, Duration.millis(250)); + assert.deepStrictEqual( + restarting.reason.retryAfter, + Duration.millis(250), + ); + assert.strictEqual(restartingNoHint.retryAfter, undefined); + }); + + it("returns retryAfter undefined for reasons without retry-timing hints", () => { + const overloaded = RivetError.fromUnknown( + new RivetkitErrors.RivetError("actor", "overloaded", "overloaded"), + ); + assert.strictEqual(overloaded.retryAfter, undefined); + }); + + it("classifies known guard errors into specific reasons", () => { + const serviceUnavailable = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "guard", + "service_unavailable", + "service unavailable", + ), + ); + const readyTimeout = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "guard", + "actor_ready_timeout", + "actor ready timeout", + ), + ); + const tunnelTimeout = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "guard", + "tunnel_message_timeout", + "tunnel message timeout", + ), + ); + + assert.instanceOf( + serviceUnavailable.reason, + RivetError.GuardServiceUnavailable, + ); + assert.instanceOf( + readyTimeout.reason, + RivetError.GuardActorReadyTimeout, + ); + assert.instanceOf( + tunnelTimeout.reason, + RivetError.GuardTunnelMessageTimeout, + ); + assert.strictEqual( + serviceUnavailable.reason.code, + "service_unavailable", + ); + }); + + it("keeps unknown guard errors in UnknownError", () => { + const error = RivetError.fromUnknown( + new RivetkitErrors.RivetError( + "guard", + "new_guard_code", + "new guard code", + ), + ); + + assert.instanceOf(error.reason, RivetError.UnknownError); + assert.strictEqual(error.reason.code, "new_guard_code"); + }); + + it("exposes action error decode failures with decode context", () => { + const cause = new RivetkitErrors.RivetError( + "user", + "CounterOverflow", + "counter overflow", + { metadata: { _tag: "EffectActionError", version: 1, error: {} } }, + ); + const schemaError = Effect.runSync( + Schema.decodeUnknownEffect(Schema.String)(123).pipe(Effect.flip), + ); + const error = new RivetError.RivetError({ + reason: new RivetError.ActionErrorDecodeFailed({ + cause: schemaError, + rivetError: cause, + }), + }); + + assert.instanceOf(error.reason, RivetError.ActionErrorDecodeFailed); + assert.strictEqual(error.reason.cause, schemaError); + assert.strictEqual(error.reason.rivetError, cause); + assert.strictEqual( + error.reason.message, + "Failed to decode action error user.CounterOverflow", + ); + }); +}); diff --git a/rivetkit-typescript/packages/effect/src/RivetError.ts b/rivetkit-typescript/packages/effect/src/RivetError.ts new file mode 100644 index 0000000000..3fb88d4ed8 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/RivetError.ts @@ -0,0 +1,1044 @@ +import { Duration, Option, Predicate, Record, Schema } from "effect"; +import * as RivetkitErrors from "rivetkit/errors"; + +const ReasonTypeId = "~@rivetkit/effect/RivetError/Reason" as const; +const TypeId = "~@rivetkit/effect/RivetError" as const; + +export class Forbidden extends Schema.TaggedErrorClass( + `${ReasonTypeId}/Forbidden`, +)("Forbidden", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class ActorNotFound extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActorNotFound`, +)("ActorNotFound", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class ActorStopping extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActorStopping`, +)("ActorStopping", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class ActorRestarting extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActorRestarting`, +)("ActorRestarting", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } + get retryAfter(): Duration.Duration | undefined { + if (!Predicate.isReadonlyObject(this.metadata)) return undefined; + return Record.get(this.metadata, "retryAfterMs").pipe( + Option.filter(Predicate.isNumber), + Option.map(Duration.millis), + Option.getOrUndefined, + ); + } +} + +export class ActionNotFound extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActionNotFound`, +)("ActionNotFound", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class ActionTimedOut extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActionTimedOut`, +)("ActionTimedOut", { cause: Schema.instanceOf(RivetkitErrors.RivetError) }) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class ActionAborted extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActionAborted`, +)("ActionAborted", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class ActorOverloaded extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActorOverloaded`, +)("ActorOverloaded", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class IncomingMessageTooLong extends Schema.TaggedErrorClass( + `${ReasonTypeId}/IncomingMessageTooLong`, +)("IncomingMessageTooLong", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class OutgoingMessageTooLong extends Schema.TaggedErrorClass( + `${ReasonTypeId}/OutgoingMessageTooLong`, +)("OutgoingMessageTooLong", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class InvalidEncoding extends Schema.TaggedErrorClass( + `${ReasonTypeId}/InvalidEncoding`, +)("InvalidEncoding", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class InvalidRequest extends Schema.TaggedErrorClass( + `${ReasonTypeId}/InvalidRequest`, +)("InvalidRequest", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class GuardActorReadyTimeout extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardActorReadyTimeout`, +)("GuardActorReadyTimeout", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardActorRunnerFailed extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardActorRunnerFailed`, +)("GuardActorRunnerFailed", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class GuardServiceUnavailable extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardServiceUnavailable`, +)("GuardServiceUnavailable", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardActorStoppedWhileWaiting extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardActorStoppedWhileWaiting`, +)("GuardActorStoppedWhileWaiting", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardTunnelRequestAborted extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardTunnelRequestAborted`, +)("GuardTunnelRequestAborted", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardTunnelMessageTimeout extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardTunnelMessageTimeout`, +)("GuardTunnelMessageTimeout", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardTunnelResponseClosed extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardTunnelResponseClosed`, +)("GuardTunnelResponseClosed", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class GuardGatewayResponseStartTimeout extends Schema.TaggedErrorClass( + `${ReasonTypeId}/GuardGatewayResponseStartTimeout`, +)("GuardGatewayResponseStartTimeout", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return true; + } +} + +export class InternalError extends Schema.TaggedErrorClass( + `${ReasonTypeId}/InternalError`, +)("InternalError", { + cause: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +export class ActionErrorDecodeFailed extends Schema.TaggedErrorClass( + `${ReasonTypeId}/ActionErrorDecodeFailed`, +)("ActionErrorDecodeFailed", { + cause: Schema.instanceOf(Schema.SchemaError), + rivetError: Schema.instanceOf(RivetkitErrors.RivetError), +}) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return `Failed to decode action error ${this.rivetError.group}.${this.rivetError.code}`; + } + get group() { + return this.rivetError.group; + } + get code() { + return this.rivetError.code; + } + get metadata() { + return this.rivetError.metadata; + } + get actor() { + return this.rivetError.actor; + } + get statusCode() { + return this.rivetError.statusCode; + } + get public() { + return this.rivetError.public; + } + get isRetryable(): boolean { + return false; + } +} + +/** + * Open-ended user error reason. Used when the actor threw `UserError` but + * the failing action did not declare a matching schema in its `error` + * field — so we can't surface it as a typed domain error in the Effect + * error channel. + * + * Actions that declare their user errors via `Action.make({ error: ... })` + * receive those errors **typed** in the error channel; this reason is + * the catch-all for everything else. + */ +export class UnknownUserError extends Schema.TaggedErrorClass( + `${ReasonTypeId}/UnknownUserError`, +)("UnknownUserError", { cause: Schema.instanceOf(RivetkitErrors.RivetError) }) { + readonly [ReasonTypeId] = ReasonTypeId; + override get message() { + return this.cause.message; + } + get group() { + return this.cause.group; + } + get code() { + return this.cause.code; + } + get metadata() { + return this.cause.metadata; + } + get actor() { + return this.cause.actor; + } + get statusCode() { + return this.cause.statusCode; + } + get public() { + return this.cause.public; + } + get isRetryable(): boolean { + return false; + } +} + +/** + * Forward-compatible catch-all for `(group, code)` pairs the SDK does + * not recognize yet, and for malformed non-Rivet failures. Known wire + * fields are mirrored when present, while `cause` preserves the raw input. + */ +export class UnknownError extends Schema.TaggedErrorClass( + `${ReasonTypeId}/UnknownError`, +)("UnknownError", { + message: Schema.String, + cause: Schema.Unknown, +}) { + readonly [ReasonTypeId] = ReasonTypeId; + get group() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.group + : undefined; + } + get code() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.code + : undefined; + } + get metadata() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.metadata + : undefined; + } + get actor() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.actor + : undefined; + } + get statusCode() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.statusCode + : undefined; + } + get public() { + return this.cause instanceof RivetkitErrors.RivetError + ? this.cause.public + : undefined; + } + get isRetryable(): boolean { + return false; + } +} + +export type RivetErrorReason = + | Forbidden + | ActorNotFound + | ActorStopping + | ActorRestarting + | ActionNotFound + | ActionTimedOut + | ActionAborted + | ActorOverloaded + | IncomingMessageTooLong + | OutgoingMessageTooLong + | InvalidEncoding + | InvalidRequest + | GuardActorReadyTimeout + | GuardActorRunnerFailed + | GuardServiceUnavailable + | GuardActorStoppedWhileWaiting + | GuardTunnelRequestAborted + | GuardTunnelMessageTimeout + | GuardTunnelResponseClosed + | GuardGatewayResponseStartTimeout + | InternalError + | UnknownUserError + | ActionErrorDecodeFailed + | UnknownError; + +export const RivetErrorReason: Schema.Union< + [ + typeof Forbidden, + typeof ActorNotFound, + typeof ActorStopping, + typeof ActorRestarting, + typeof ActionNotFound, + typeof ActionTimedOut, + typeof ActionAborted, + typeof ActorOverloaded, + typeof IncomingMessageTooLong, + typeof OutgoingMessageTooLong, + typeof InvalidEncoding, + typeof InvalidRequest, + typeof GuardActorReadyTimeout, + typeof GuardActorRunnerFailed, + typeof GuardServiceUnavailable, + typeof GuardActorStoppedWhileWaiting, + typeof GuardTunnelRequestAborted, + typeof GuardTunnelMessageTimeout, + typeof GuardTunnelResponseClosed, + typeof GuardGatewayResponseStartTimeout, + typeof InternalError, + typeof ActionErrorDecodeFailed, + typeof UnknownUserError, + typeof UnknownError, + ] +> = Schema.Union([ + Forbidden, + ActorNotFound, + ActorStopping, + ActorRestarting, + ActionNotFound, + ActionTimedOut, + ActionAborted, + ActorOverloaded, + IncomingMessageTooLong, + OutgoingMessageTooLong, + InvalidEncoding, + InvalidRequest, + GuardActorReadyTimeout, + GuardActorRunnerFailed, + GuardServiceUnavailable, + GuardActorStoppedWhileWaiting, + GuardTunnelRequestAborted, + GuardTunnelMessageTimeout, + GuardTunnelResponseClosed, + GuardGatewayResponseStartTimeout, + InternalError, + ActionErrorDecodeFailed, + UnknownUserError, + UnknownError, +]); + +export const isRivetErrorReason = (u: unknown): u is RivetErrorReason => + Predicate.hasProperty(u, ReasonTypeId); + +/** + * The infrastructure-failure error surfaced by `@rivetkit/effect` + * calls. Wraps a discriminated `reason` of all known failure + * modes. + * + * Recover with `Effect.catchReason` / `Effect.catchReasons` / + * `Effect.unwrapReason`: + * + * ```ts + * program.pipe( + * Effect.catchReasons("RivetError", { + * Forbidden: () => Effect.fail(new MyAuthError()), + * ConnectionLost: () => Effect.logWarning("reconnecting"), + * }), + * ) + * ``` + * + * User-defined errors declared on an action via `Action.make({ error })` + * arrive in the typed error channel separately and do NOT flow through + * `RivetError`. + */ +export class RivetError extends Schema.TaggedErrorClass( + "@rivetkit/effect/RivetError", +)("RivetError", { + reason: RivetErrorReason, +}) { + /** Marks this value as the top-level Rivet error wrapper for runtime guards. */ + readonly [TypeId] = TypeId; + + /** Exposes the structured Rivet error reason as the JavaScript error cause. */ + override readonly cause = this.reason; + + /** Uses the reason message when present, otherwise falls back to the reason tag. */ + override get message() { + return this.reason.message || this.reason._tag; + } + + /** Delegates to the underlying reason's `group` if present. */ + get group(): string | undefined { + return "group" in this.reason ? this.reason.group : undefined; + } + + /** Delegates to the underlying reason's `code` if present. */ + get code(): string | undefined { + return "code" in this.reason ? this.reason.code : undefined; + } + + /** Delegates to the underlying reason's `metadata` if present. */ + get metadata(): unknown { + return "metadata" in this.reason ? this.reason.metadata : undefined; + } + + /** Delegates to the underlying reason's `actor` if present. */ + get actor() { + return "actor" in this.reason ? this.reason.actor : undefined; + } + + /** Delegates to the underlying reason's `statusCode` if present. */ + get statusCode(): number | undefined { + return "statusCode" in this.reason ? this.reason.statusCode : undefined; + } + + /** Delegates to the underlying reason's `public` if present. */ + get public(): boolean | undefined { + return "public" in this.reason ? this.reason.public : undefined; + } + + /** Delegates to the underlying reason's `isRetryable` getter. */ + get isRetryable(): boolean { + return this.reason.isRetryable; + } + + /** Delegates to the underlying reason's `retryAfter` if present. */ + get retryAfter(): Duration.Duration | undefined { + return "retryAfter" in this.reason ? this.reason.retryAfter : undefined; + } +} + +export const isRivetError = (u: unknown): u is RivetError => + Predicate.hasProperty(u, TypeId); + +type MakeRivetErrorReason = ( + error: RivetkitErrors.RivetError, +) => RivetErrorReason; + +const reasonByCode: { [key: string]: MakeRivetErrorReason | undefined } = { + "auth.forbidden": (error) => new Forbidden({ cause: error }), + "actor.not_found": (error) => new ActorNotFound({ cause: error }), + "actor.stopping": (error) => new ActorStopping({ cause: error }), + "actor.restarting": (error) => new ActorRestarting({ cause: error }), + "actor.action_not_found": (error) => new ActionNotFound({ cause: error }), + "actor.action_timed_out": (error) => new ActionTimedOut({ cause: error }), + "actor.aborted": (error) => new ActionAborted({ cause: error }), + "actor.overloaded": (error) => new ActorOverloaded({ cause: error }), + [`actor.${RivetkitErrors.INTERNAL_ERROR_CODE}`]: (error) => + new InternalError({ cause: error }), + [`core.${RivetkitErrors.INTERNAL_ERROR_CODE}`]: (error) => + new InternalError({ cause: error }), + [`rivetkit.${RivetkitErrors.INTERNAL_ERROR_CODE}`]: (error) => + new InternalError({ cause: error }), + "message.incoming_too_long": (error) => + new IncomingMessageTooLong({ cause: error }), + "message.outgoing_too_long": (error) => + new OutgoingMessageTooLong({ cause: error }), + "encoding.invalid": (error) => new InvalidEncoding({ cause: error }), + "request.invalid": (error) => new InvalidRequest({ cause: error }), + "guard.actor_ready_timeout": (error) => + new GuardActorReadyTimeout({ cause: error }), + "guard.actor_runner_failed": (error) => + new GuardActorRunnerFailed({ cause: error }), + "guard.service_unavailable": (error) => + new GuardServiceUnavailable({ cause: error }), + "guard.actor_stopped_while_waiting": (error) => + new GuardActorStoppedWhileWaiting({ cause: error }), + "guard.tunnel_request_aborted": (error) => + new GuardTunnelRequestAborted({ cause: error }), + "guard.tunnel_message_timeout": (error) => + new GuardTunnelMessageTimeout({ cause: error }), + "guard.tunnel_response_closed": (error) => + new GuardTunnelResponseClosed({ cause: error }), + "guard.gateway_response_start_timeout": (error) => + new GuardGatewayResponseStartTimeout({ cause: error }), +}; + +const reasonFromRivetkitRivetError = ( + error: RivetkitErrors.RivetError, +): RivetErrorReason => { + const makeReason = reasonByCode[`${error.group}.${error.code}`]; + if (makeReason) return makeReason(error); + + if (error.group === "user") return new UnknownUserError({ cause: error }); + + return new UnknownError({ + message: error.message, + cause: error, + }); +}; + +export const fromRivetkitRivetError = ( + error: RivetkitErrors.RivetError, +): RivetError => { + return new RivetError({ reason: reasonFromRivetkitRivetError(error) }); +}; + +export const fromUnknown = (cause: unknown): RivetError => { + if (isRivetError(cause)) return cause; + if (RivetkitErrors.isRivetErrorLike(cause)) { + return fromRivetkitRivetError(RivetkitErrors.toRivetError(cause)); + } + + return new RivetError({ + reason: new UnknownError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); +}; diff --git a/rivetkit-typescript/packages/effect/src/State.test.ts b/rivetkit-typescript/packages/effect/src/State.test.ts new file mode 100644 index 0000000000..3ec3cbb5f3 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/State.test.ts @@ -0,0 +1,181 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Exit, PubSub, Stream } from "effect"; +import * as State from "./State"; + +// Helper: build a State backed by a plain mutable cell, with +// Effect-typed read/write closures. Mirrors how Registry wires +// `decodeUnknownEffect` / `encodeUnknownEffect` over `c.state`. +const makeCellState = (initial: A) => { + const cell = { value: initial }; + return State.make( + () => Effect.sync(() => cell.value), + (v) => + Effect.sync(() => { + cell.value = v; + }), + ).pipe(Effect.map((s) => ({ s, cell }))); +}; + +describe("State", () => { + it.effect("get reflects the backing store", () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState(42); + assert.strictEqual(yield* State.get(s), 42); + + cell.value = 100; + assert.strictEqual(yield* State.get(s), 100); + }), + ); + + it.effect("set writes through to the backing store", () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState(0); + yield* State.set(s, 7); + assert.strictEqual(cell.value, 7); + assert.strictEqual(yield* State.get(s), 7); + }), + ); + + it.effect("update applies f over read/write", () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState(10); + yield* State.update(s, (n) => n + 5); + assert.strictEqual(cell.value, 15); + }), + ); + + it.effect("updateAndGet returns the new value and commits it", () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState(10); + const next = yield* State.updateAndGet(s, (n) => n + 5); + assert.strictEqual(next, 15); + assert.strictEqual(cell.value, 15); + }), + ); + + it.effect("modify returns B and commits the new value", () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState("a"); + const b = yield* State.modify( + s, + (str) => [str.length, `${str}b`] as const, + ); + assert.strictEqual(b, 1); + assert.strictEqual(cell.value, "ab"); + }), + ); + + it.effect( + "update is atomic across concurrent fibers (no lost updates)", + () => + Effect.gen(function* () { + const { s, cell } = yield* makeCellState(0); + yield* Effect.all( + Array.from({ length: 100 }, () => + State.update(s, (n) => n + 1), + ), + { concurrency: "unbounded" }, + ); + assert.strictEqual(cell.value, 100); + }), + ); + + it.effect("changes replays the most recent published value", () => + Effect.gen(function* () { + const { s } = yield* makeCellState(0); + const initial = yield* State.changes(s).pipe( + Stream.take(1), + Stream.runCollect, + ); + assert.deepStrictEqual(initial, [0]); + + State.publishUnsafe(s, 7); + const later = yield* State.changes(s).pipe( + Stream.take(1), + Stream.runCollect, + ); + assert.deepStrictEqual(later, [7]); + }), + ); + + it.effect("publish pushes values to live subscribers", () => + Effect.gen(function* () { + const { s } = yield* makeCellState(0); + yield* Effect.scoped( + Effect.gen(function* () { + const sub = yield* PubSub.subscribe(s.pubsub); + assert.strictEqual(yield* PubSub.take(sub), 0); + + yield* State.publish(s, 1); + yield* State.publish(s, 2); + assert.strictEqual(yield* PubSub.take(sub), 1); + assert.strictEqual(yield* PubSub.take(sub), 2); + }), + ); + }), + ); + + it.effect("set does NOT auto-publish — the runtime does", () => + Effect.gen(function* () { + const { s } = yield* makeCellState(0); + yield* State.set(s, 99); + // replay should still hold the initial 0, not 99 + const latest = yield* State.changes(s).pipe( + Stream.take(1), + Stream.runCollect, + ); + assert.deepStrictEqual(latest, [0]); + }), + ); + + it.effect("isState discriminates", () => + Effect.gen(function* () { + const { s } = yield* makeCellState(0); + assert.isTrue(State.isState(s)); + assert.isFalse(State.isState({})); + assert.isFalse(State.isState(null)); + assert.isFalse(State.isState(42)); + }), + ); + + it.effect("supports .pipe()", () => + Effect.gen(function* () { + const { s } = yield* makeCellState(0); + yield* s.pipe(State.set(5)); + assert.strictEqual(yield* State.get(s), 5); + + yield* s.pipe(State.update((n) => n * 2)); + assert.strictEqual(yield* State.get(s), 10); + }), + ); + + it.effect("read failure propagates through get", () => + Effect.gen(function* () { + const reads = { count: 0 }; + // Construction reads once to seed the pubsub; subsequent reads + // fail. Mirrors a schema mismatch on persisted state. + const s = yield* State.make( + () => + Effect.suspend(() => { + reads.count++; + if (reads.count === 1) return Effect.succeed(0); + return Effect.fail("boom" as const); + }), + () => Effect.void, + ); + const exit = yield* Effect.exit(State.get(s)); + assert.isTrue(Exit.isFailure(exit)); + }), + ); + + it.effect("write failure propagates through set", () => + Effect.gen(function* () { + const s = yield* State.make( + () => Effect.succeed(0), + () => Effect.fail("boom" as const), + ); + const exit = yield* Effect.exit(State.set(s, 1)); + assert.isTrue(Exit.isFailure(exit)); + }), + ); +}); diff --git a/rivetkit-typescript/packages/effect/src/State.ts b/rivetkit-typescript/packages/effect/src/State.ts new file mode 100644 index 0000000000..2bf88e12cb --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/State.ts @@ -0,0 +1,232 @@ +/** + * `State` is a typed view over an actor's persisted state, plus a + * subscribable stream of every change. + * + * Unlike a `Ref`, `State` has no in-memory cell — the persisted store + * is the source of truth. Reads decode the live store on demand; + * writes encode and overwrite it. A `PubSub` backs {@link changes} + * and is fed externally — the runtime publishes to it from rivetkit's + * `onStateChange` callback so subscribers see every committed change, + * including ones initiated outside the SDK. + * + * Read and write are Effect-typed so schemas with asynchronous + * transforms (or service requirements) are supported. `update` and + * `modify` serialize through a per-`State` semaphore so read/apply/ + * write triples are atomic across fibers; `set` shares the same lock + * so all writes are linearized. + * + * The PubSub uses replay = 1, matching `SubscriptionRef`: a new + * subscriber immediately sees the most recent value. + */ +import { + Effect, + Inspectable, + identity, + Pipeable, + Predicate, + PubSub, + Semaphore, + Stream, + type Types, +} from "effect"; +import { dual } from "effect/Function"; + +const TypeId = "~@rivetkit/effect/State"; + +/** + * A view over a persisted state cell with a subscribable change stream. + * + * - `A` — the value type + * - `E` — the read/write closures' failure type (e.g. a schema's + * `SchemaError` when read/write decode/encode against a schema) + * - `R` — the read/write closures' service requirements + */ +export interface State + extends State.Variance, + Pipeable.Pipeable, + Inspectable.Inspectable { + readonly read: () => Effect.Effect; + readonly write: (value: A) => Effect.Effect; + readonly pubsub: PubSub.PubSub; + /** + * Serializes writes (`set`, `update`, `modify`) so the read/apply/ + * write triple is atomic. The runtime may also use this semaphore + * to serialize its own decode-and-publish work from + * `onStateChange`, keeping the change stream's order consistent + * with the write order. + */ + readonly semaphore: Semaphore.Semaphore; +} + +export const isState = (u: unknown): u is State => + Predicate.hasProperty(u, TypeId); + +export declare namespace State { + export interface Variance { + readonly [TypeId]: { + readonly _A: Types.Invariant; + readonly _E: Types.Covariant; + readonly _R: Types.Covariant; + }; + } +} + +const Proto = { + ...Pipeable.Prototype, + ...Inspectable.BaseProto, + [TypeId]: { _A: identity, _E: identity, _R: identity }, + toJSON(this: State) { + return { _id: "State" }; + }, +}; + +/** + * Creates a `State` from `read` and `write` closures over the + * underlying store. The closures are responsible for any + * encoding/decoding; `State` itself is schema-agnostic. + * + * The current value (per `read()`) is published to the pubsub on + * construction so any subscription obtained later replays it. + * + * The PubSub is not explicitly shut down — it's reclaimed by GC when + * the `State` and any subscribers become unreachable. + */ +export const make = ( + read: () => Effect.Effect, + write: (value: A) => Effect.Effect, +): Effect.Effect, E, R> => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded({ replay: 1 }); + const initial = yield* read(); + PubSub.publishUnsafe(pubsub, initial); + const self = Object.create(Proto); + self.read = read; + self.write = write; + self.pubsub = pubsub; + self.semaphore = Semaphore.makeUnsafe(1); + return self; + }); + +/** + * Reads the current value. + */ +export const get = (self: State): Effect.Effect => + self.read(); + +/** + * Replaces the value. Serialized with `update` / `modify` so writes + * happen in invocation order. + */ +export const set: { + (value: A): (self: State) => Effect.Effect; + (self: State, value: A): Effect.Effect; +} = dual( + 2, + (self: State, value: A): Effect.Effect => + Semaphore.withPermit(self.semaphore, self.write(value)), +); + +/** + * Updates the value by applying `f` to the current value. The + * read/apply/write triple is atomic across fibers. + */ +export const update: { + ( + f: (a: A) => A, + ): (self: State) => Effect.Effect; + ( + self: State, + f: (a: A) => A, + ): Effect.Effect; +} = dual( + 2, + ( + self: State, + f: (a: A) => A, + ): Effect.Effect => + Semaphore.withPermit( + self.semaphore, + Effect.flatMap(self.read(), (a) => self.write(f(a))), + ), +); + +/** + * Updates the value by applying `f` and returns the new value. The + * read/apply/write triple is atomic across fibers. + */ +export const updateAndGet: { + ( + f: (a: A) => A, + ): (self: State) => Effect.Effect; + (self: State, f: (a: A) => A): Effect.Effect; +} = dual( + 2, + (self: State, f: (a: A) => A): Effect.Effect => + Semaphore.withPermit( + self.semaphore, + Effect.flatMap(self.read(), (a) => { + const next = f(a); + return Effect.as(self.write(next), next); + }), + ), +); + +/** + * Atomically replaces the value with the second element of `f(prev)` + * and returns the first. The read/apply/write triple is atomic across + * fibers. + */ +export const modify: { + ( + f: (a: A) => readonly [B, A], + ): (self: State) => Effect.Effect; + ( + self: State, + f: (a: A) => readonly [B, A], + ): Effect.Effect; +} = dual( + 2, + ( + self: State, + f: (a: A) => readonly [B, A], + ): Effect.Effect => + Semaphore.withPermit( + self.semaphore, + Effect.flatMap(self.read(), (a) => { + const [b, next] = f(a); + return Effect.as(self.write(next), b); + }), + ), +); + +/** + * Stream of every value published to this `State`. New subscribers + * immediately see the most recent value (replay = 1), then every + * subsequent publish. + */ +export const changes = (self: State): Stream.Stream => + Stream.fromPubSub(self.pubsub); + +/** + * Publish a value to the change stream as an `Effect`. Does not + * modify the underlying store. + */ +export const publish: { + (value: A): (self: State) => Effect.Effect; + (self: State, value: A): Effect.Effect; +} = dual( + 2, + (self: State, value: A): Effect.Effect => + PubSub.publish(self.pubsub, value), +); + +/** + * Synchronous variant of {@link publish}. Returns `true` when the + * publish succeeded, `false` if the pubsub is shut down. The runtime + * uses this from rivetkit's `onStateChange` callback to feed the + * change stream. + */ +export const publishUnsafe = ( + self: State, + value: A, +): boolean => PubSub.publishUnsafe(self.pubsub, value); diff --git a/rivetkit-typescript/packages/effect/src/internal/ActionErrorEnvelope.ts b/rivetkit-typescript/packages/effect/src/internal/ActionErrorEnvelope.ts new file mode 100644 index 0000000000..ce933bdcf4 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/internal/ActionErrorEnvelope.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; + +export const tag = "EffectActionError" as const; + +export const schemaVersion = 1 as const; + +export const ActionErrorEnvelope = Schema.Struct({ + _tag: Schema.tag(tag), + version: Schema.Literal(schemaVersion), + error: Schema.Unknown, +}); + +export type ActionErrorEnvelope = typeof ActionErrorEnvelope.Type; + +export const make = (error: unknown): ActionErrorEnvelope => ({ + _tag: tag, + version: schemaVersion, + error, +}); diff --git a/rivetkit-typescript/packages/effect/src/internal/StateOptions.ts b/rivetkit-typescript/packages/effect/src/internal/StateOptions.ts new file mode 100644 index 0000000000..95cb699093 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/internal/StateOptions.ts @@ -0,0 +1,17 @@ +import type { Schema } from "effect"; + +export interface StateOptions { + readonly schema: S; + readonly initialValue: () => S["Type"]; +} + +export interface Any { + readonly schema: Schema.Top; + readonly initialValue: () => unknown; +} + +export type Encoded = + | State["schema"]["Encoded"] + | ([State] extends [never] ? undefined : never); + +export type Decoded = State["schema"]["Type"]; diff --git a/rivetkit-typescript/packages/effect/src/internal/tracing.ts b/rivetkit-typescript/packages/effect/src/internal/tracing.ts new file mode 100644 index 0000000000..bdb91d92a0 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/internal/tracing.ts @@ -0,0 +1,42 @@ +import { Predicate } from "effect"; + +/** + * Identifies the SDK as the RPC system on action spans. Stamped onto + * the `rpc.system.name` OTel attribute. + */ +export const rpcSystem = "rivet.actors"; + +/** + * Cross-wire trace metadata. Carries just enough of an `Effect.Tracer` + * span to reconstitute it on the server as a `Tracer.externalSpan` + * parent for the handler's span. + */ +export interface TraceMeta { + readonly traceId: string; + readonly spanId: string; + readonly sampled: boolean; +} + +/** + * Pull a valid `TraceMeta` out of the wire `ActionMeta` envelope, or + * `undefined` if the caller didn't ship one (or shipped something + * malformed). Kept lenient because the meta envelope is forward- + * extensible — future fields shouldn't break trace extraction. + */ +export const readTraceMeta = (meta: unknown): TraceMeta | undefined => { + if (!Predicate.isObject(meta)) return undefined; + const trace = meta.trace; + if (!Predicate.isObject(trace)) return undefined; + if ( + !Predicate.isString(trace.traceId) || + !Predicate.isString(trace.spanId) || + !Predicate.isBoolean(trace.sampled) + ) { + return undefined; + } + return { + traceId: trace.traceId, + spanId: trace.spanId, + sampled: trace.sampled, + }; +}; diff --git a/rivetkit-typescript/packages/effect/src/internal/utils.ts b/rivetkit-typescript/packages/effect/src/internal/utils.ts new file mode 100644 index 0000000000..6ad3734cef --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/internal/utils.ts @@ -0,0 +1,12 @@ +import { Predicate } from "effect"; + +/** + * Refinement that narrows `unknown` to an object with `key` set to a + * `string`. + */ +export const hasStringProperty = + ( + key: K, + ): Predicate.Refinement => + (u): u is { readonly [P in K]: string } => + Predicate.hasProperty(u, key) && Predicate.isString(u[key]); diff --git a/rivetkit-typescript/packages/effect/src/mod.ts b/rivetkit-typescript/packages/effect/src/mod.ts new file mode 100644 index 0000000000..65b2722c00 --- /dev/null +++ b/rivetkit-typescript/packages/effect/src/mod.ts @@ -0,0 +1,6 @@ +export * as Action from "./Action"; +export * as Actor from "./Actor"; +export * as Client from "./Client"; +export * as Registry from "./Registry"; +export * as RivetError from "./RivetError"; +export * as State from "./State"; diff --git a/rivetkit-typescript/packages/effect/test/e2e.test.ts b/rivetkit-typescript/packages/effect/test/e2e.test.ts new file mode 100644 index 0000000000..b1bb9b6fe7 --- /dev/null +++ b/rivetkit-typescript/packages/effect/test/e2e.test.ts @@ -0,0 +1,767 @@ +import { assert, layer } from "@effect/vitest"; +import { Registry, RivetError } from "@rivetkit/effect"; +import { Effect, Layer, Schedule } from "effect"; +import { TestClock } from "effect/testing"; +import { inject } from "vitest"; +import { + BuildSetRejected, + BuildSetRejectedLive, + Counter, + CounterLive, + CounterOverflowError, + FailingActor, + FailingActorLive, + Flags, + Greeter, + Multiplier, + Pinger, + PingerLive, + ScaledOverflowError, + Strict, + StrictLive, + TransformedStateActor, + TransformedStateActorLive, + Unregistered, + WakeDecodeFail, + WakeDecodeFailLive, +} from "./fixtures/actors"; +import { TestTracer } from "./fixtures/tracer"; +import { prepareNamespace, waitForEnvoy } from "./shared-engine"; + +// Each test file talks to the shared engine spawned in globalSetup +// against a unique namespace + runner pool, so envoy registrations +// from prior files (or prior test runs) cannot pollute this file's +// actor routing. The namespace is created and the pool's runner +// config is upserted before `Registry.test` registers the in-process +// envoy at `.start()`. +const { endpoint, token, namespace, poolName } = await prepareNamespace( + inject("rivetEngine").endpoint, +); + +const GreeterLive = Layer.succeed( + Greeter, + Greeter.of({ + greet: (name) => `Hello, ${name}!`, + }), +); + +// `Multiplier` has to be in scope on both sides of the wire: the +// `Counter`'s `Scale` action's codec consumes `Action.ServicesServer` +// during registration, and the test body's `Counter.client` getter +// consumes `Action.ServicesClient` for the same action. +// `provideMerge` keeps it as a layer output so the test effect +// itself sees it too. +const MultiplierLive = Layer.succeed(Multiplier, Multiplier.of({ factor: 2 })); + +// Block test execution until the in-process envoy has registered +// against the engine's pool view. `rivetkitRegistry.start()` returns +// before that registration round-trip completes, and the first +// action call against an empty pool would otherwise burn the entire +// per-test timeout waiting on the engine. +const ReadyForEnvoy = Layer.effectDiscard( + Effect.tryPromise(() => waitForEnvoy(endpoint, namespace, poolName)).pipe( + Effect.orDie, + ), +); + +const TestLayer = ReadyForEnvoy.pipe( + Layer.provideMerge( + Registry.test.pipe( + Layer.provideMerge( + Layer.mergeAll( + CounterLive, + PingerLive, + FailingActorLive, + StrictLive, + WakeDecodeFailLive, + BuildSetRejectedLive, + TransformedStateActorLive, + ), + ), + Layer.provideMerge(Flags.layer), + Layer.provide(GreeterLive), + Layer.provideMerge(MultiplierLive), + Layer.provideMerge(TestTracer.layer()), + Layer.provide(Registry.layer({ endpoint, token, namespace })), + ), + ), +); + +layer(TestLayer)("end-to-end", (it) => { + it.effect("round-trips an action with payload and success", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate("t-roundtrip"); + assert.strictEqual(yield* counter.Increment({ amount: 5 }), 5); + }), + ); + + it.effect("preserves in-wake state across calls on the same key", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-state"]); + yield* counter.Increment({ amount: 3 }); + yield* counter.Increment({ amount: 4 }); + const total = yield* counter.GetCount(); + assert.strictEqual(total, 7); + }), + ); + + it.effect("isolates in-wake state across keys", () => + Effect.gen(function* () { + const client = yield* Counter.client; + const a = client.getOrCreate(["t-iso-a"]); + const b = client.getOrCreate(["t-iso-b"]); + yield* a.Increment({ amount: 2 }); + yield* a.Increment({ amount: 3 }); + yield* b.Increment({ amount: 1 }); + assert.strictEqual(yield* a.GetCount(), 5); + assert.strictEqual(yield* b.GetCount(), 1); + }), + ); + + it.effect("persists state across a sleep/wake cycle", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-persist-state", + ]); + + // Bump the in-memory `Ref` so we can later assert that + // the wake actually rebuilt the actor (the ref should + // reset to 0 on each wake). + yield* counter.Increment({ amount: 7 }); + + const beforeSleep = yield* counter.PersistAndSleep({ + amount: 11, + }); + assert.strictEqual(beforeSleep, 11); + + // Engine-side sleep teardown is asynchronous. `count` + // is `Ref.make(0)` per wake, so seeing 0 is the deterministic + // signal that the prior wake torn down and a fresh one started. + // `TestClock.withLive` swaps in the real Clock for the duration + // of the poll so the schedule's interval and the timeout both + // elapse in wall time (the suite otherwise runs under TestClock). + const inMemoryAfterWake = yield* counter.GetCount().pipe( + Effect.repeat({ + until: (n) => n === 0, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(inMemoryAfterWake, 0); + + const persistedAfterWake = yield* counter.GetPersistedState(); + assert.strictEqual(persistedAfterWake.count, 11); + }), + ); + + it.effect("persists state with a non-trivial schema (Date)", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-persist-state-date", + ]); + + // Bump the in-memory `Ref` so we can later assert that + // the wake actually rebuilt the actor (the ref should + // reset to 0 on each wake). + yield* counter.Increment({ amount: 7 }); + + const when = new Date("2024-01-15T10:30:00.000Z"); + const beforeSleep = yield* counter.PersistDateAndSleep({ + when, + }); + assert.strictEqual(beforeSleep.toISOString(), when.toISOString()); + + // Engine-side sleep teardown is asynchronous. `count` + // is `Ref.make(0)` per wake, so seeing 0 is the deterministic + // signal that the prior wake torn down and a fresh one started. + // `TestClock.withLive` swaps in the real Clock for the duration + // of the poll so the schedule's interval and the timeout both + // elapse in wall time (the suite otherwise runs under TestClock). + const inMemoryAfterWake = yield* counter.GetCount().pipe( + Effect.repeat({ + until: (n) => n === 0, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(inMemoryAfterWake, 0); + + const persistedAfterWake = yield* counter.GetPersistedState(); + assert.strictEqual( + persistedAfterWake.when.toISOString(), + when.toISOString(), + ); + }), + ); + + it.effect("persists state with a custom Schema.transform", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-persist-state-transform", + ]); + + // Bump the in-memory `Ref` so we can later assert that + // the wake actually rebuilt the actor (the ref should + // reset to 0 on each wake). + yield* counter.Increment({ amount: 7 }); + + const tags = ["alpha", "beta", "gamma"]; + const beforeSleep = yield* counter.PersistTagsAndSleep({ + tags, + }); + assert.deepEqual(beforeSleep, tags); + + // Engine-side sleep teardown is asynchronous. `count` + // is `Ref.make(0)` per wake, so seeing 0 is the deterministic + // signal that the prior wake torn down and a fresh one started. + // `TestClock.withLive` swaps in the real Clock for the duration + // of the poll so the schedule's interval and the timeout both + // elapse in wall time (the suite otherwise runs under TestClock). + const inMemoryAfterWake = yield* counter.GetCount().pipe( + Effect.repeat({ + until: (n) => n === 0, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(inMemoryAfterWake, 0); + + const persistedAfterWake = yield* counter.GetPersistedState(); + assert.deepEqual(persistedAfterWake.tags, tags); + }), + ); + + it.effect("persists state through a service-dependent transform", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-persist-state-scaled", + ]); + + // Bump the in-memory `Ref` so we can later assert that + // the wake actually rebuilt the actor (the ref should + // reset to 0 on each wake). + yield* counter.Increment({ amount: 7 }); + + // 14 is the decoded (in-memory) value. With `factor: 2`, + // the state schema's encode (write) divides 14 -> 7 and + // its decode (read on wake) multiplies 7 -> 14. Both sites + // run server-side against the Runner's services snapshot; + // an unresolved `Multiplier` at either would corrupt the + // round-trip. + const beforeSleep = yield* counter.PersistScaledAndSleep({ + amount: 14, + }); + assert.strictEqual(beforeSleep, 14); + + // Engine-side sleep teardown is asynchronous. `count` + // is `Ref.make(0)` per wake, so seeing 0 is the deterministic + // signal that the prior wake torn down and a fresh one started. + // `TestClock.withLive` swaps in the real Clock for the duration + // of the poll so the schedule's interval and the timeout both + // elapse in wall time (the suite otherwise runs under TestClock). + const inMemoryAfterWake = yield* counter.GetCount().pipe( + Effect.repeat({ + until: (n) => n === 0, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(inMemoryAfterWake, 0); + + const persistedAfterWake = yield* counter.GetPersistedState(); + assert.strictEqual(persistedAfterWake.scaled, 14); + }), + ); + + it.effect("handler can catch a State.set schema-encode failure", () => + Effect.gen(function* () { + const strict = (yield* Strict.client).getOrCreate([ + "t-strict-handled", + ]); + // A passing value writes through and reports "ok". + assert.strictEqual(yield* strict.StrictSet({ value: 5 }), "ok"); + // A failing value (negative — rejected by the state schema's + // `isGreaterThanOrEqualTo(0)` check on encode) surfaces as a + // typed `SchemaError` through `State.set`; the handler + // catches it via `Effect.match` and reports "rejected". + // Before `State` carried `E`, this failure would + // have died as a defect and the handler had no way to + // observe it. + assert.strictEqual( + yield* strict.StrictSet({ value: -5 }), + "rejected", + ); + // And the prior write of 5 stuck (the rejected -5 never + // touched `c.state`). + assert.strictEqual(yield* strict.StrictGet(), 5); + }), + ); + + it.effect( + "unhandled State.set schema-encode failure surfaces as RivetError", + () => + Effect.gen(function* () { + const strict = (yield* Strict.client).getOrCreate([ + "t-strict-unhandled", + ]); + const exit = yield* strict + .StrictSetUnhandled({ value: -5 }) + .pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, RivetError.RivetError); + } + }), + ); + + it.effect.skip( + "surfaces an expected handler error back into the original error", + () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-expected-error", + ]); + const exit = yield* counter + .Increment({ amount: 100 }) + .pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, CounterOverflowError); + assert.strictEqual(exit.value.limit, 20); + assert.match(exit.value.message, /exceed limit 20/); + } + }), + ); + + it.effect("surfaces an unexpected handler error as a RivetError", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-boom"]); + const exit = yield* counter.Crash().pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, RivetError.RivetError); + } + }), + ); + + it.effect("round-trips a non-trivial schema (Date)", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-date"]); + const when = new Date("2024-01-15T10:30:00.000Z"); + const result = yield* counter.EchoDate({ when }); + assert.instanceOf(result, Date); + assert.strictEqual(result.toISOString(), when.toISOString()); + }), + ); + + it.effect("round-trips a custom Schema.transform", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-transform", + ]); + // `tags` rides the wire as the encoded CSV string and decodes + // back to a string array on the server. If the transform + // didn't fire, `payload.tags.length` would be the byte length + // of the CSV ("alpha,beta,gamma" = 16) instead of 3. + const count = yield* counter.Tags({ + tags: ["alpha", "beta", "gamma"], + }); + assert.strictEqual(count, 3); + }), + ); + + it.effect( + "exposes transformed actor state as encoded raw wake context state", + () => + Effect.gen(function* () { + const actor = (yield* TransformedStateActor.client).getOrCreate( + ["t-raw-transformed-state"], + ); + const when = new Date("2024-04-05T06:07:08.000Z"); + const at = new Date("2024-04-06T07:08:09.000Z"); + const bytes = new Uint8Array([9, 8, 7]); + const payload = new Uint8Array([6, 5, 4]); + const url = new URL( + "https://rivet.dev/docs/actors?section=state", + ); + const id = 9_007_199_254_740_993n; + + yield* actor.SetTransformedStateAndSleep({ + when, + url, + id, + bytes, + tags: ["alpha", "beta", "gamma"], + history: [{ at, payload }], + }); + + const raw = yield* actor.GetRawWakeState().pipe( + Effect.repeat({ + until: (state) => state.id === id.toString(), + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + + assert.deepEqual(raw, { + when: when.toISOString(), + url: url.toString(), + id: id.toString(), + bytes: Buffer.from(bytes).toString("base64"), + tags: "alpha,beta,gamma", + history: [ + { + at: at.toISOString(), + payload: Buffer.from(payload).toString("base64"), + }, + ], + }); + }), + ); + + it.effect( + "wake options state decodes transformed state written through raw wake context state", + () => + Effect.gen(function* () { + const actor = (yield* TransformedStateActor.client).getOrCreate( + ["t-raw-set-transformed-state"], + ); + const when = "2024-05-06T07:08:09.000Z"; + const at = "2024-05-07T08:09:10.000Z"; + const url = "https://rivet.dev/docs/actors/state?source=raw"; + const id = "9007199254740995"; + const bytes = Buffer.from(new Uint8Array([1, 3, 5])).toString( + "base64", + ); + const payload = Buffer.from(new Uint8Array([2, 4, 6])).toString( + "base64", + ); + + yield* actor.SetRawWakeStateAndSleep({ + when, + url, + id, + bytes, + tags: "raw,encoded,state", + history: [{ at, payload }], + }); + + const decoded = yield* actor.GetDecodedState().pipe( + Effect.repeat({ + until: (state) => state.id === BigInt(id), + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + + assert.strictEqual(decoded.when.toISOString(), when); + assert.strictEqual(decoded.url.toString(), url); + assert.strictEqual(decoded.id, BigInt(id)); + assert.deepEqual( + Array.from(decoded.bytes), + Array.from(Buffer.from(bytes, "base64")), + ); + assert.deepEqual(decoded.tags, ["raw", "encoded", "state"]); + assert.strictEqual(decoded.history[0]?.at.toISOString(), at); + assert.deepEqual( + Array.from(decoded.history[0]?.payload ?? []), + Array.from(Buffer.from(payload, "base64")), + ); + }), + ); + + it.effect("resolves a non-built-in service", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-service-wake", + ]); + // `WakeGreeting` returns the string captured when `Greeter` + // was resolved inside the wake-scope build effect. + const greeting = yield* counter.WakeGreeting(); + assert.strictEqual(greeting, "Hello, on wake!"); + }), + ); + + it.effect( + "resolves a non-built-in service yielded by an action handler", + () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-service-handler", + ]); + // `Greet`'s handler yields `Greeter` per call; the + // snapshotted Runner context must satisfy that R. + const greeting = yield* counter.Greet({ name: "Effect" }); + assert.strictEqual(greeting, "Hello, Effect!"); + }), + ); + + it.effect("registers and serves multiple actors", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-multi"]); + const pinger = (yield* Pinger.client).getOrCreate(["t-multi"]); + + const incremented = yield* counter.Increment({ amount: 7 }); + const pong = yield* pinger.Ping(); + + assert.strictEqual(incremented, 7); + assert.strictEqual(pong, "pong"); + }), + ); + + it.effect( + "surfaces a call to an actor with no registered handler as a RivetError", + () => + Effect.gen(function* () { + // `Unregistered` is defined in the fixtures module but its + // `*Live` layer is intentionally not provided, so the engine + // has no runner that can serve the actor. The engine logs + // the precise `not_registered: Actor factory 'Unregistered' + // is not registered.` reason but flattens it on the wire to + // a generic `guard/service_unavailable` — the same code a + // transient engine outage would surface as. Callers can't + // distinguish the two without an engine-side change. + const ghost = (yield* Unregistered.client).getOrCreate([ + "t-unregistered", + ]); + const exit = yield* ghost.Echo().pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, RivetError.RivetError); + assert.instanceOf( + exit.value.reason, + RivetError.GuardServiceUnavailable, + ); + assert.strictEqual( + ( + exit.value + .reason as RivetError.GuardServiceUnavailable + ).code, + "service_unavailable", + ); + } + }), + ); + + it.effect("fires the wake-scope finalizer on sleep", () => + Effect.gen(function* () { + const key = "t-wake-finalizer"; + const counter = (yield* Counter.client).getOrCreate([key]); + // `Flags` is shared across all tests in the suite, so the + // `Counter` build effect namespaces its finalizer flag by + // actor key. + const flagName = `finalizer:${key}`; + + const flags = yield* Flags; + assert.strictEqual(flags.get(flagName), undefined); + + yield* counter.PersistAndSleep({ amount: 1 }); + + // `c.sleep()` is a non-blocking signal: the action returns + // before the engine tears the wake scope down. Poll the + // flag until the wake-scope finalizer has run. `TestClock.withLive` + // swaps in the real Clock so the schedule's interval elapses + // in wall time (the suite otherwise runs under TestClock). + const finalizerFired = yield* Effect.sync(() => + flags.get(flagName), + ).pipe( + Effect.repeat({ + until: (v) => v === true, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(finalizerFired, true); + }), + ); + + it.effect("surfaces an error thrown inside an actor's build effect", () => + Effect.gen(function* () { + // `getOrCreate` only builds a typed proxy on the client and + // rivetkit's wake is lazy on first action, so the build + // defect surfaces on `.Ping()`, not here. + const failing = (yield* FailingActor.client).getOrCreate([ + "t-build-error", + ]); + const exit = yield* failing.Ping().pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, RivetError.RivetError); + } + }), + ); + + it.effect( + "wake options state decode failure inside build effect surfaces as RivetError", + () => + Effect.gen(function* () { + const failing = (yield* WakeDecodeFail.client).getOrCreate([ + "t-wake-decode-fail", + ]); + const exit = yield* failing + .Ping() + .pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, RivetError.RivetError); + } + }), + ); + + it.effect("build effect can catch a State.set schema-encode failure", () => + Effect.gen(function* () { + const a = (yield* BuildSetRejected.client).getOrCreate([ + "t-build-set-rejected", + ]); + assert.strictEqual(yield* a.BuildOutcome(), "rejected"); + }), + ); + + it.effect.skip( + "runs encoding/decoding services for an action's payload, success, and error", + () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-codec-services", + ]); + + // Success path. With `factor: 2` provided on both sides: + // payload encode 10 -> 5 (client divides), payload decode + // 5 -> 10 (server multiplies), handler returns 110, success + // encode 110 -> 55 (server divides), success decode 55 -> 110 + // (client multiplies). A wrong final value would mean one + // of those four codec sites failed to resolve `Multiplier`. + assert.strictEqual(yield* counter.Scale({ amount: 10 }), 110); + + // Error path. The handler short-circuits with a + // `ScaledOverflowError({ limit: 30 })`. The error's `limit` + // flows through the same service-dependent schema: server + // encode 30 -> 15, client decode 15 -> 30. A factor mismatch + // or an unprovided service on either side would surface as + // a numeric mismatch on `exit.value.limit`. + const exit = yield* counter + .Scale({ amount: 40 }) + .pipe(Effect.flip, Effect.exit); + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, ScaledOverflowError); + assert.strictEqual(exit.value.limit, 30); + assert.match(exit.value.message, /exceed limit 30/); + } + }), + ); + + it.effect("propagates Effect tracing spans end-to-end", () => + Effect.gen(function* () { + const tracer = yield* TestTracer; + yield* tracer.clear; + const counter = (yield* Counter.client).getOrCreate(["t-tracing"]); + // Wrapping the call in `Effect.withSpan("client-call")` + // makes that span the active parent. The SDK then opens + // `Counter/Compute` (kind=client) under it, ships the IDs + // over the wire, and on the server opens another + // `Counter/Compute` (kind=server) parented to the client + // span via `externalSpan`. The handler itself wraps its + // work in `Effect.withSpan("step.double")`, which nests + // under the SDK's server span — proving user-defined + // sub-spans join the propagated trace. + const clientTraceId = yield* Effect.gen(function* () { + const clientSpan = yield* Effect.currentSpan; + const doubled = yield* counter.Compute({ n: 21 }); + assert.strictEqual(doubled, 42); + return clientSpan.traceId; + }).pipe(Effect.withSpan("client-call")); + + const spans = yield* tracer.spans; + const onTrace = spans.filter((s) => s.traceId === clientTraceId); + assert.deepStrictEqual( + onTrace.map((s) => s.name), + [ + "client-call", + "Counter/Compute", + "Counter/Compute", + "step.double", + ], + ); + // Each span (after the root) is parented to the prior one, + // proving the chain is intact across the wire boundary. + for (let i = 1; i < onTrace.length; i++) { + const parent = onTrace[i].parent; + assert.strictEqual(parent._tag, "Some"); + if (parent._tag === "Some") { + assert.strictEqual( + parent.value.spanId, + onTrace[i - 1].spanId, + ); + } + } + }), + ); + + it.effect("writes through the db captured", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-db-write"]); + const afterFirst = yield* counter.LogEvent({ event: "alpha" }); + const afterSecond = yield* counter.LogEvent({ event: "beta" }); + assert.strictEqual(afterFirst, 1); + assert.strictEqual(afterSecond, 2); + }), + ); + + it.effect("reads rows back through the captured db", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate(["t-db-list"]); + yield* counter.LogEvent({ event: "one" }); + yield* counter.LogEvent({ event: "two" }); + yield* counter.LogEvent({ event: "three" }); + const events = yield* counter.ListEvents(); + assert.deepStrictEqual(events, ["one", "two", "three"]); + }), + ); + + it.effect("isolates db state across actor keys", () => + Effect.gen(function* () { + const client = yield* Counter.client; + const a = client.getOrCreate(["t-db-iso-a"]); + const b = client.getOrCreate(["t-db-iso-b"]); + yield* a.LogEvent({ event: "a1" }); + yield* a.LogEvent({ event: "a2" }); + yield* b.LogEvent({ event: "b1" }); + assert.strictEqual(yield* a.CountEvents(), 2); + assert.strictEqual(yield* b.CountEvents(), 1); + assert.deepStrictEqual(yield* a.ListEvents(), ["a1", "a2"]); + assert.deepStrictEqual(yield* b.ListEvents(), ["b1"]); + }), + ); + + it.effect("persists db rows across a sleep/wake cycle", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate([ + "t-db-persist", + ]); + yield* counter.LogEvent({ event: "before-sleep" }); + + // `PersistAndSleep` signals `c.sleep()` after writing state; the + // engine tears the wake scope down asynchronously. The + // `in-memory Ref` resets to 0 on the next wake, so polling + // `GetCount` until it reads 0 is the deterministic signal that + // a fresh wake started. `TestClock.withLive` runs the poll in + // wall time since the suite otherwise drives `TestClock`. + yield* counter.PersistAndSleep({ amount: 1 }); + const inMemoryAfterWake = yield* counter.GetCount().pipe( + Effect.repeat({ + until: (n) => n === 0, + schedule: Schedule.spaced("100 millis"), + }), + TestClock.withLive, + ); + assert.strictEqual(inMemoryAfterWake, 0); + + yield* counter.LogEvent({ event: "after-wake" }); + assert.deepStrictEqual(yield* counter.ListEvents(), [ + "before-sleep", + "after-wake", + ]); + }), + ); +}); diff --git a/rivetkit-typescript/packages/effect/test/fixtures/actors.ts b/rivetkit-typescript/packages/effect/test/fixtures/actors.ts new file mode 100644 index 0000000000..9118114bb6 --- /dev/null +++ b/rivetkit-typescript/packages/effect/test/fixtures/actors.ts @@ -0,0 +1,664 @@ +import { Action, Actor, State } from "@rivetkit/effect"; +import { + Context, + Effect, + Layer, + Option, + Ref, + Schema, + SchemaIssue, + SchemaTransformation, +} from "effect"; +import { db } from "rivetkit/db"; + +// --- Counter --- + +export class CounterOverflowError extends Schema.TaggedErrorClass()( + "CounterOverflowError", + { + limit: Schema.Number, + message: Schema.String, + }, +) {} + +export class Flags extends Context.Service()("Flags", { + make: Effect.sync(() => new Map()), +}) { + static readonly layer = Layer.effect(Flags, this.make); +} + +/** + * A non-built-in service used by `Counter` to verify that user-provided + * services resolve in both the wake-scope build effect and inside + * individual action handlers. + */ +export class Greeter extends Context.Service< + Greeter, + { readonly greet: (name: string) => string } +>()("test/Greeter") {} + +const TagsCsv = Schema.String.pipe( + Schema.decodeTo( + Schema.Array(Schema.String), + SchemaTransformation.transform({ + decode: (s: string): ReadonlyArray => s.split(","), + encode: (arr: ReadonlyArray) => arr.join(","), + }), + ), +); + +export const Increment = Action.make("Increment", { + payload: { amount: Schema.Number }, + success: Schema.Number, + error: CounterOverflowError, +}); + +export const GetCount = Action.make("GetCount", { + success: Schema.Number, +}); + +export const Crash = Action.make("Crash"); + +export const EchoDate = Action.make("EchoDate", { + payload: { when: Schema.DateFromString }, + success: Schema.DateFromString, +}); + +export const Tags = Action.make("Tags", { + payload: { tags: TagsCsv }, + success: Schema.Number, +}); + +export const Greet = Action.make("Greet", { + payload: { name: Schema.String }, + success: Schema.String, +}); + +export const WakeGreeting = Action.make("WakeGreeting", { + success: Schema.String, +}); + +// An action whose handler emits its own user-defined sub-span. The +// tracing test asserts the sub-span lands as a child of the SDK's +// server-side span, which itself is a child of the SDK's client-side +// span — proof that user spans nest correctly under the SDK's wire +// propagation. +export const Compute = Action.make("Compute", { + payload: { n: Schema.Number }, + success: Schema.Number, +}); + +// Service that the codec schema below depends on. Yielding it from +// inside a `transformOrFail` puts `Multiplier` into the schema's +// `DecodingServices` / `EncodingServices`, which in turn surfaces in +// `Action.ServicesServer` / `Action.ServicesClient` for any action +// referencing the codec. +export class Multiplier extends Context.Service< + Multiplier, + { readonly factor: number } +>()("test/Multiplier") {} + +// A `Number` schema whose decode multiplies by the live factor and whose +// encode divides by it. With the same factor on both ends, values +// round-trip; the test would fail if any codec site failed to resolve +// `Multiplier`. +const ScaledNumber = Schema.Number.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transformOrFail({ + decode: (n: number) => + Effect.gen(function* () { + const m = yield* Multiplier; + return n * m.factor; + }), + encode: (n: number) => + Effect.gen(function* () { + const m = yield* Multiplier; + return n / m.factor; + }), + }), + ), +); + +export class ScaledOverflowError extends Schema.TaggedErrorClass()( + "ScaledOverflowError", + { + limit: ScaledNumber, + message: Schema.String, + }, +) {} + +// Every channel of this action — payload, success, error — references +// `ScaledNumber`, so a successful round-trip proves all six codec sites +// (payload encode + decode, success encode + decode, error encode + +// decode) resolved their schema services. +export const Scale = Action.make("Scale", { + payload: { amount: ScaledNumber }, + success: ScaledNumber, + error: ScaledOverflowError, +}); + +export const PersistAndSleep = Action.make("PersistAndSleep", { + payload: { amount: Schema.Number }, + success: Schema.Number, +}); + +export const PersistDateAndSleep = Action.make("PersistDateAndSleep", { + payload: { when: Schema.DateFromString }, + success: Schema.Date, +}); + +export const PersistTagsAndSleep = Action.make("PersistTagsAndSleep", { + payload: { tags: TagsCsv }, + success: TagsCsv, +}); + +export const PersistScaledAndSleep = Action.make("PersistScaledAndSleep", { + payload: { amount: ScaledNumber }, + success: ScaledNumber, +}); + +export const GetPersistedState = Action.make("GetPersistedState", { + success: Schema.Struct({ + count: Schema.Number, + when: Schema.DateFromString, + tags: TagsCsv, + scaled: ScaledNumber, + }), +}); + +export const LogEvent = Action.make("LogEvent", { + payload: { event: Schema.String }, + success: Schema.Number, +}); + +export const ListEvents = Action.make("ListEvents", { + success: Schema.Array(Schema.String), +}); + +export const CountEvents = Action.make("CountEvents", { + success: Schema.Number, +}); + +const EncodedTransformedState = Schema.Struct({ + when: Schema.String, + url: Schema.String, + id: Schema.String, + bytes: Schema.String, + tags: Schema.String, + history: Schema.Array( + Schema.Struct({ + at: Schema.String, + payload: Schema.String, + }), + ), +}); + +const TransformedStateSchema = Schema.Struct({ + when: Schema.DateFromString, + url: Schema.URLFromString, + id: Schema.BigIntFromString, + bytes: Schema.Uint8ArrayFromBase64, + tags: TagsCsv, + history: Schema.Array( + Schema.Struct({ + at: Schema.DateFromString, + payload: Schema.Uint8ArrayFromBase64, + }), + ), +}); + +export const GetRawWakeState = Action.make("GetRawWakeState", { + success: EncodedTransformedState, +}); + +export const GetDecodedState = Action.make("GetDecodedState", { + success: TransformedStateSchema, +}); + +export const SetTransformedStateAndSleep = Action.make( + "SetTransformedStateAndSleep", + { + payload: TransformedStateSchema, + }, +); + +export const SetRawWakeStateAndSleep = Action.make("SetRawWakeStateAndSleep", { + payload: EncodedTransformedState, +}); + +export const TransformedStateActor = Actor.make("TransformedStateActor", { + actions: [ + GetRawWakeState, + GetDecodedState, + SetTransformedStateAndSleep, + SetRawWakeStateAndSleep, + ], +}); + +export const TransformedStateActorLive = TransformedStateActor.toLayer( + ({ rawRivetkitContext, state }) => + Effect.gen(function* () { + const sleep = yield* Actor.Sleep; + const rawWakeState = rawRivetkitContext.state; + + return TransformedStateActor.of({ + GetRawWakeState: () => Effect.succeed(rawWakeState), + GetDecodedState: () => State.get(state), + SetTransformedStateAndSleep: ({ payload }) => + State.set(state, payload).pipe(Effect.andThen(sleep)), + SetRawWakeStateAndSleep: ({ payload }) => + Effect.tryPromise(async () => { + rawRivetkitContext.state = payload; + await rawRivetkitContext.saveState({ + immediate: true, + }); + rawRivetkitContext.sleep(); + }).pipe(Effect.orDie), + }); + }), + { + state: { + schema: TransformedStateSchema, + initialValue: () => ({ + when: new Date("2024-01-01T00:00:00.000Z"), + url: new URL("https://rivet.dev/docs"), + id: 1n, + bytes: new Uint8Array([1, 2, 3]), + tags: ["initial"], + history: [], + }), + }, + }, +); + +export const Counter = Actor.make("Counter", { + actions: [ + Increment, + GetCount, + Crash, + EchoDate, + Tags, + Greet, + WakeGreeting, + Compute, + Scale, + PersistAndSleep, + PersistDateAndSleep, + PersistTagsAndSleep, + PersistScaledAndSleep, + GetPersistedState, + LogEvent, + ListEvents, + CountEvents, + ], +}); + +export const CounterLive = Counter.toLayer( + ({ rawRivetkitContext, state }) => + Effect.gen(function* () { + const count = yield* Ref.make(0); + const flags = yield* Flags; + flags.set("on wake", true); + const greeter = yield* Greeter; + const wakeGreeting = greeter.greet("on wake"); + + const sleep = yield* Actor.Sleep; + // `rawRivetkitContext`'s `db` widens to `any` against + // `RunContextOf`. The provider configured on + // `Counter.toLayer` below is the `rivetkit/db` raw-access factory, + // so re-narrow to `RawAccess` for typed `execute` calls inside + // handler closures. + const db = rawRivetkitContext.db; + // `Flags` is a process-wide Map shared across all tests in the + // suite, so the finalizer flag must be namespaced by actor key + // to keep cross-test wake/sleep cycles from leaking into each + // other's assertions. + const address = yield* Actor.CurrentAddress; + const finalizerFlag = `finalizer:${address.key.join("/")}`; + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + flags.set(finalizerFlag, true); + }), + ); + + return Counter.of({ + Increment: ({ payload }) => + Effect.gen(function* () { + const next = yield* Ref.updateAndGet( + count, + (n) => n + payload.amount, + ); + if (next > 20) { + return yield* new CounterOverflowError({ + limit: 20, + message: `count ${next} would exceed limit 20`, + }); + } + return next; + }), + GetCount: () => Ref.get(count), + Crash: () => Effect.die("kaboom"), + EchoDate: ({ payload }) => Effect.succeed(payload.when), + Tags: ({ payload }) => Effect.succeed(payload.tags.length), + // Per-handler yield of a non-built-in service. Resolved on + // every call against the snapshotted Runner context. + Greet: ({ payload }) => + Effect.gen(function* () { + const g = yield* Greeter; + return g.greet(payload.name); + }), + WakeGreeting: () => Effect.succeed(wakeGreeting), + // User-defined sub-span. The SDK already wraps the handler + // in a server-side span; the inner `withSpan("step.double")` + // nests under it, demonstrating that hand-written spans + // inside a handler join the caller's trace transparently. + Compute: ({ payload }) => + Effect.succeed(payload.n * 2).pipe( + Effect.withSpan("step.double"), + ), + Scale: ({ payload }) => + Effect.gen(function* () { + if (payload.amount > 30) { + return yield* new ScaledOverflowError({ + limit: 30, + message: `amount ${payload.amount} would exceed limit 30`, + }); + } + // +100 makes the round-trip non-tautological: the + // test asserts on a value the client never sent, so + // the success path can't pass without the success + // and payload codec sites firing on both sides. + return payload.amount + 100; + }), + PersistAndSleep: ({ payload }) => + Effect.gen(function* () { + const { count } = yield* State.updateAndGet( + state, + (s) => ({ + ...s, + count: s.count + payload.amount, + }), + ); + yield* sleep; + return count; + }), + PersistDateAndSleep: ({ payload }) => + Effect.gen(function* () { + const { when } = yield* State.updateAndGet( + state, + (s) => ({ + ...s, + when: payload.when, + }), + ); + yield* sleep; + return when; + }), + PersistTagsAndSleep: ({ payload }) => + Effect.gen(function* () { + const { tags } = yield* State.updateAndGet( + state, + (s) => ({ + ...s, + tags: payload.tags, + }), + ); + yield* sleep; + return tags; + }), + PersistScaledAndSleep: ({ payload }) => + Effect.gen(function* () { + const { scaled } = yield* State.updateAndGet( + state, + (s) => ({ + ...s, + scaled: payload.amount, + }), + ); + yield* sleep; + return scaled; + }), + GetPersistedState: () => State.get(state), + // Per-actor SQLite is provisioned via the `db:` option on + // `Counter.toLayer` below. The build effect destructures `db` + // from `rawRivetkitContext`, so handlers reach SQLite + // through the captured client without going through `c.db`. + LogEvent: ({ payload }) => + Effect.tryPromise(async () => { + await db.execute( + "INSERT INTO events (event, created_at) VALUES (?, ?)", + payload.event, + Date.now(), + ); + const rows = await db.execute<{ count: number }>( + "SELECT COUNT(*) as count FROM events", + ); + return rows[0]?.count ?? 0; + }).pipe(Effect.orDie), + ListEvents: () => + Effect.tryPromise(async () => { + const rows = await db.execute<{ event: string }>( + "SELECT event FROM events ORDER BY id ASC", + ); + return rows.map((r) => r.event); + }).pipe(Effect.orDie), + CountEvents: () => + Effect.tryPromise(async () => { + const rows = await db.execute<{ count: number }>( + "SELECT COUNT(*) as count FROM events", + ); + return rows[0]?.count ?? 0; + }).pipe(Effect.orDie), + }); + }), + { + state: { + schema: Schema.Struct({ + count: Schema.Number, + when: Schema.DateFromString, + tags: TagsCsv, + // `scaled` is encoded/decoded through `ScaledNumber`, which + // yields `Multiplier` inside the transform. The Registry's state + // encode (write) and decode (wake) sites must resolve the + // service against the snapshotted Runner context, the same way + // action codec sites do. + scaled: ScaledNumber, + }), + initialValue: () => ({ + count: 0, + when: new Date(), + tags: ["default"], + scaled: 0, + }), + }, + // Migration runs once before the wake-scope build effect, so the + // destructured `db` is already pointed at a migrated database + // when handlers capture it. + db: db({ + onMigrate: async (client) => { + await client.execute(` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + }, + }), + }, +); + +// --- Strict --- + +// Catches the `SchemaError` from `State.set` and reports the outcome. +// Proves a handler can react to a schema failure that originates inside +// the State layer — the new behavior since `State` carries `E`. +export const StrictSet = Action.make("StrictSet", { + payload: { value: Schema.Number }, + success: Schema.Literals(["ok", "rejected"]), +}); + +// Lets the `SchemaError` propagate. The registry's catch-encode-die +// path converts it to a `RivetError` on the wire — same shape an +// unhandled defect would have produced before this change. +export const StrictSetUnhandled = Action.make("StrictSetUnhandled", { + payload: { value: Schema.Number }, + success: Schema.Number, +}); + +export const StrictGet = Action.make("StrictGet", { + success: Schema.Number, +}); + +export const Strict = Actor.make("Strict", { + actions: [StrictSet, StrictSetUnhandled, StrictGet], +}); + +export const StrictLive = Strict.toLayer( + ({ state }) => + Effect.gen(function* () { + return Strict.of({ + StrictSet: ({ payload }) => + State.set(state, payload.value).pipe( + Effect.match({ + onFailure: () => "rejected" as const, + onSuccess: () => "ok" as const, + }), + ), + StrictSetUnhandled: ({ payload }) => + State.set(state, payload.value).pipe( + Effect.as(payload.value), + ), + StrictGet: () => State.get(state), + }); + }), + { + state: { + // State schema that rejects negative values. Used to exercise the + // typed-error channel on `State` writes: encoding a negative through + // `State.set` fails with `SchemaError`, which now flows through the + // handler effect instead of dying as a defect. + schema: Schema.Number.pipe( + Schema.check(Schema.isGreaterThanOrEqualTo(0)), + ), + initialValue: () => 0, + }, + }, +); + +// --- Pinger --- + +// Minimal second actor used solely to assert that the registry serves +// more than one actor side-by-side. +export const Ping = Action.make("Ping", { success: Schema.String }); + +export const Pinger = Actor.make("Pinger", { actions: [Ping] }); + +export const PingerLive = Pinger.toLayer({ + Ping: () => Effect.succeed("pong"), +}); + +// --- FailingActor --- + +export const FailingActor = Actor.make("FailingBuild", { + actions: [Ping], +}); + +export const FailingActorLive = FailingActor.toLayer( + Effect.die("build effect failed"), +); + +// --- Unregistered --- + +// Used solely to test the failure shape when calling an actor whose +// `*Live` layer was never provided to the runner. No `UnregisteredLive` +// is exported on purpose — the test relies on this actor being absent +// from the registry at runtime. +export const Echo = Action.make("Echo", { success: Schema.String }); + +export const Unregistered = Actor.make("Unregistered", { actions: [Echo] }); + +// --- WakeDecodeFail --- + +export const WakeDecodeFail = Actor.make("WakeDecodeFail", { + actions: [Ping], +}); + +export const WakeDecodeFailLive = WakeDecodeFail.toLayer( + () => + Effect.gen(function* () { + return WakeDecodeFail.of({ + Ping: () => Effect.succeed("never reached"), + }); + }), + { + state: { + // Schema whose encode is permissive (identity) but whose decode rejects + // negatives. Used to seed invalid persisted actor state so + // `state` construction rejects on first wake. + schema: Schema.Number.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transformOrFail({ + decode: (n: number) => + n >= 0 + ? Effect.succeed(n) + : Effect.fail( + new SchemaIssue.InvalidValue( + Option.some(n), + { + message: + "decode rejects negative", + }, + ), + ), + encode: (n: number) => Effect.succeed(n), + }), + ), + ), + // `-1` encodes successfully (encode is identity) so registry setup + // passes, but the wake-time decode rejects before handlers are built. + initialValue: () => -1, + }, + }, +); + +// --- BuildSetRejected --- + +export const BuildOutcome = Action.make("BuildOutcome", { + success: Schema.Literals(["wrote", "rejected"]), +}); + +export const BuildSetRejected = Actor.make("BuildSetRejected", { + actions: [BuildOutcome], +}); + +export const BuildSetRejectedLive = BuildSetRejected.toLayer( + ({ state }) => + Effect.gen(function* () { + const wrote = yield* State.set(state, -1).pipe( + Effect.match({ + onFailure: () => false, + onSuccess: () => true, + }), + ); + return BuildSetRejected.of({ + BuildOutcome: () => + Effect.succeed(wrote ? "wrote" : "rejected"), + }); + }), + { + state: { + // Strict schema rejecting negatives on encode. The build effect deliberately + // calls `State.set` against `state` with a value the schema + // rejects, catches the resulting `SchemaError` via `Effect.match`, and + // exposes the outcome via `BuildOutcome`. + schema: Schema.Number.pipe( + Schema.check(Schema.isGreaterThanOrEqualTo(0)), + ), + initialValue: () => 0, + }, + }, +); diff --git a/rivetkit-typescript/packages/effect/test/fixtures/tracer.ts b/rivetkit-typescript/packages/effect/test/fixtures/tracer.ts new file mode 100644 index 0000000000..1b84e87b12 --- /dev/null +++ b/rivetkit-typescript/packages/effect/test/fixtures/tracer.ts @@ -0,0 +1,46 @@ +import { Context, Effect, Layer, Tracer } from "effect"; + +/** + * Test-only tracer service: tests yield it to inspect spans recorded + * during a call (`spans`) and reset between runs (`clear`). + * + * `TestTracer.layer()` overrides the active `Tracer.Tracer` Reference + * with a wrapper around `Effect.tracer` that pushes every created span + * into a buffer local to the layer closure. Because `Tracer.Tracer` is + * a `Context.Reference` (always available via its default), the override + * does not surface in the layer's output type; only the read-side + * `TestTracer` service does. + */ +export class TestTracer extends Context.Service< + TestTracer, + { + readonly spans: Effect.Effect>; + readonly clear: Effect.Effect; + } +>()("test/TestTracer") { + static layer() { + return Layer.effectContext( + Effect.gen(function* () { + const buffer: Tracer.Span[] = []; + const currentTracer = yield* Effect.tracer; + const tracer = Tracer.make({ + span(options) { + const span = currentTracer.span(options); + buffer.push(span); + return span; + }, + context: currentTracer.context, + }); + return Context.make( + TestTracer, + TestTracer.of({ + spans: Effect.sync(() => buffer.slice()), + clear: Effect.sync(() => { + buffer.length = 0; + }), + }), + ).pipe(Context.add(Tracer.Tracer, tracer)); + }), + ); + } +} diff --git a/rivetkit-typescript/packages/effect/test/global-setup.ts b/rivetkit-typescript/packages/effect/test/global-setup.ts new file mode 100644 index 0000000000..3a3535b5f1 --- /dev/null +++ b/rivetkit-typescript/packages/effect/test/global-setup.ts @@ -0,0 +1,43 @@ +import type { TestProject } from "vitest/node"; +import { + getOrStartSharedTestEngine, + releaseSharedTestEngine, + TEST_ENGINE_TOKEN, +} from "./shared-engine"; + +declare module "vitest" { + export interface ProvidedContext { + rivetEngine: { + endpoint: string; + token: string; + }; + } +} + +/** + * Spawns a single rivet-engine for the test run on random ports + * with an isolated tmpdir-backed db, then exposes its endpoint to + * test workers via vitest's `provide`/`inject`. The engine outlives + * a single test file but never two test runs: `globalTeardown` + * releases the refcount in `shared-engine.ts`, which kills the + * process and wipes its dbRoot. + * + * Each test file should create its own namespace + runner config + * against this endpoint so envoy registrations from one file can't + * pollute another. + */ +export default async function setup({ provide }: TestProject) { + // `test.env` in vitest.config only applies to test workers, not the + // main vitest process where this setup spawns the engine. Mirror it + // here so the engine inherits a quiet log level. + process.env.RIVET_LOG_LEVEL ??= "SILENT"; + + const engine = await getOrStartSharedTestEngine(); + provide("rivetEngine", { + endpoint: engine.endpoint, + token: TEST_ENGINE_TOKEN, + }); + return async () => { + await releaseSharedTestEngine(); + }; +} diff --git a/rivetkit-typescript/packages/effect/test/shared-engine.ts b/rivetkit-typescript/packages/effect/test/shared-engine.ts new file mode 100644 index 0000000000..fcfe5d0b97 --- /dev/null +++ b/rivetkit-typescript/packages/effect/test/shared-engine.ts @@ -0,0 +1,162 @@ +import { randomUUID } from "node:crypto"; +import { + getOrStartSharedTestEngine, + releaseSharedTestEngine, + type SharedTestEngine, + TEST_ENGINE_TOKEN, +} from "../../rivetkit/tests/shared-engine"; + +export { getOrStartSharedTestEngine, releaseSharedTestEngine, TEST_ENGINE_TOKEN }; +export type { SharedTestEngine }; + +export interface PreparedNamespace { + readonly endpoint: string; + readonly token: string; + readonly namespace: string; + readonly poolName: string; +} + +export async function prepareNamespace( + endpoint: string, + options: { namespace?: string; poolName?: string } = {}, +): Promise { + const namespace = options.namespace ?? `effect-e2e-${randomUUID()}`; + const poolName = options.poolName ?? "default"; + await createNamespace(endpoint, namespace); + await upsertNormalRunnerConfig(endpoint, namespace, poolName); + return { endpoint, token: TEST_ENGINE_TOKEN, namespace, poolName }; +} + +async function createNamespace( + endpoint: string, + namespace: string, +): Promise { + const response = await fetch(`${endpoint}/namespaces`, { + method: "POST", + headers: { + Authorization: `Bearer ${TEST_ENGINE_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: namespace, + display_name: `Effect e2e ${namespace}`, + }), + }); + + if (!response.ok) { + throw new Error( + `failed to create namespace ${namespace}: ${response.status} ${await response.text()}`, + ); + } +} + +export async function waitForEnvoy( + endpoint: string, + namespace: string, + poolName: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await fetch( + `${endpoint}/envoys?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(poolName)}`, + { + headers: { + Authorization: `Bearer ${TEST_ENGINE_TOKEN}`, + }, + }, + ); + + if (response.ok) { + const body = (await response.json()) as { + envoys: Array<{ envoy_key: string }>; + }; + if (body.envoys.length > 0) return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `timed out waiting for envoy in pool ${poolName} (namespace ${namespace})`, + ); +} + +async function upsertNormalRunnerConfig( + endpoint: string, + namespace: string, + poolName: string, +): Promise { + const datacentersResponse = await fetch( + `${endpoint}/datacenters?namespace=${encodeURIComponent(namespace)}`, + { + headers: { + Authorization: `Bearer ${TEST_ENGINE_TOKEN}`, + }, + }, + ); + + if (!datacentersResponse.ok) { + throw new Error( + `failed to list datacenters: ${datacentersResponse.status} ${await datacentersResponse.text()}`, + ); + } + + const datacentersBody = (await datacentersResponse.json()) as { + datacenters: Array<{ name: string }>; + }; + const datacenter = datacentersBody.datacenters[0]?.name; + + if (!datacenter) { + throw new Error("engine returned no datacenters"); + } + + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + const response = await fetch( + `${endpoint}/runner-configs/${encodeURIComponent(poolName)}?namespace=${encodeURIComponent(namespace)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${TEST_ENGINE_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + datacenters: { + [datacenter]: { + normal: {}, + }, + }, + }), + }, + ); + + if (response.ok) { + return; + } + + const responseBody = await response.text(); + // The engine briefly reports the just-created namespace as missing + // or returns a transient internal_error before the create write + // propagates. Match the driver harness pattern and retry both. + if ( + (response.status === 400 && + responseBody.includes('"group":"namespace"') && + responseBody.includes('"code":"not_found"')) || + (response.status === 500 && + responseBody.includes('"group":"core"') && + responseBody.includes('"code":"internal_error"')) + ) { + await new Promise((resolve) => setTimeout(resolve, 500)); + continue; + } + + throw new Error( + `failed to upsert runner config ${poolName}: ${response.status} ${responseBody}`, + ); + } + + throw new Error(`timed out upserting runner config ${poolName}`); +} diff --git a/rivetkit-typescript/packages/effect/tsconfig.json b/rivetkit-typescript/packages/effect/tsconfig.json new file mode 100644 index 0000000000..588bd72ffb --- /dev/null +++ b/rivetkit-typescript/packages/effect/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "types": [], + "verbatimModuleSyntax": true, + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": [ + "effect", + "@effect/*", + "@rivetkit/effect" + ] + } + ] + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/rivetkit-typescript/packages/effect/tsup.config.ts b/rivetkit-typescript/packages/effect/tsup.config.ts new file mode 100644 index 0000000000..e7d8e5f88d --- /dev/null +++ b/rivetkit-typescript/packages/effect/tsup.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "tsup"; +import defaultConfig from "../../../tsup.base"; + +export default defineConfig(defaultConfig); diff --git a/rivetkit-typescript/packages/effect/turbo.json b/rivetkit-typescript/packages/effect/turbo.json new file mode 100644 index 0000000000..29d4cb2625 --- /dev/null +++ b/rivetkit-typescript/packages/effect/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/rivetkit-typescript/packages/effect/vitest.config.ts b/rivetkit-typescript/packages/effect/vitest.config.ts new file mode 100644 index 0000000000..e786fac908 --- /dev/null +++ b/rivetkit-typescript/packages/effect/vitest.config.ts @@ -0,0 +1,31 @@ +// +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import defaultConfig from "../../../vitest.base"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const env = { + ...defaultConfig.test?.env, + RIVET_ENGINE_BINARY: join(here, "../../../target/debug/rivet-engine"), + // The shared vitest base sets RIVET_LOG_LEVEL=DEBUG, which floods the + // terminal with engine + runtime logs. Keep this suite quiet. + RIVET_LOG_LEVEL: "SILENT", +}; + +export default defineConfig({ + ...defaultConfig, + test: { + ...defaultConfig.test, + env, + // One rivet-engine is shared across all test files in this suite. + // Each file creates its own namespace + runner pool against it, so + // envoy registrations from one file can't pollute another. We + // still serialize files for now because `Registry.test` registers + // an in-process envoy that binds local ports. + fileParallelism: false, + sequence: { concurrent: false }, + globalSetup: ["./test/global-setup.ts"], + }, +}); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index 1db2f4f646..666e95a8c1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -1645,10 +1645,10 @@ export class ActorInstance< attributes?: Record, ): Record { return { - "rivet.actor.id": this.#actorId, - "rivet.actor.name": this.#name, - "rivet.actor.key": this.#actorKeyString, - "rivet.actor.region": this.#region, + "rivet.actors.actor.id": this.#actorId, + "rivet.actors.actor.name": this.#name, + "rivet.actors.actor.key": this.#actorKeyString, + "rivet.actors.actor.region": this.#region, ...(attributes ?? {}), }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts index a30445184c..daec5238b1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts @@ -32,6 +32,8 @@ export { export { ActorError, RivetError, + type RivetErrorLike, + type RivetErrorOptions, UserError, type UserErrorOptions, } from "./errors";