diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index b8855eb..328255a 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -4,13 +4,12 @@ name: Sync docs # docs/ bundle into the website's vendor/workflows and opens a PR there, which is # also what triggers the site rebuild. # -# No extra-paths: this product owns no examples/ tree. Its paths -# resolve against another product's repo via snippetFrom in the website's -# product-metadata.ts, so nothing outside docs/ needs to travel with the bundle. +# examples/ travels with the bundle because Workflows docs embed snippets from +# this repository by repo-root-relative path. on: push: branches: [main] - paths: ["docs/**"] + paths: ["docs/**", "examples/**", ".github/workflows/docs-sync.yml"] workflow_dispatch: jobs: @@ -22,3 +21,4 @@ jobs: with: product: workflows token: ${{ secrets.RIVET_WEBSITE_TOKEN }} + extra-paths: examples diff --git a/examples/docs/actors-workflows/approval-gate.ts b/examples/docs/actors-workflows/approval-gate.ts new file mode 100644 index 0000000..0f77921 --- /dev/null +++ b/examples/docs/actors-workflows/approval-gate.ts @@ -0,0 +1,48 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; +export const approvalGateActor = workflow({ + state: { status: "pending" as string }, + queues: { + approval: queue<{ + approved: boolean; + }>(), + }, + run: async (ctx) => { + await ctx.step("validate-order", async (step) => { + await validateOrder("order-123"); + step.state.status = "awaiting_approval"; + }); + const decision = await ctx.queue.next("wait-approval"); + if (decision.body.approved) { + await ctx.step("fulfill-order", async (step) => { + await fulfillOrder("order-123"); + step.state.status = "fulfilled"; + }); + } else { + await ctx.step("cancel-order", async (step) => { + await cancelOrder("order-123"); + step.state.status = "cancelled"; + }); + } + }, + actions: { + getState: (c) => c.state, + }, +}); +async function validateOrder(orderId: string): Promise { + const res = await fetch( + `https://api.example.com/orders/${orderId}/validate`, + { method: "POST" }, + ); + if (!res.ok) throw new Error("Order validation failed"); +} +async function fulfillOrder(orderId: string): Promise { + await fetch(`https://api.example.com/orders/${orderId}/fulfill`, { + method: "POST", + }); +} +async function cancelOrder(orderId: string): Promise { + await fetch(`https://api.example.com/orders/${orderId}/cancel`, { + method: "POST", + }); +} +export const registry = setup({ use: { approvalGateActor } }); diff --git a/examples/docs/actors-workflows/batch-drainer.ts b/examples/docs/actors-workflows/batch-drainer.ts new file mode 100644 index 0000000..b93b434 --- /dev/null +++ b/examples/docs/actors-workflows/batch-drainer.ts @@ -0,0 +1,49 @@ +import { + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type MetricMessage = { + value: number; +}; +export const batchDrainerActor = workflow({ + state: { + pending: [] as number[], + flushedBatches: 0, + lastBatchTotal: 0, + }, + run: async (ctx) => { + await ctx.loop("drain-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-metric", { + timeout: 5000, + }); + const pendingCount = await loopCtx.step( + "buffer-message", + async (step) => { + if (message) { + step.state.pending.push((message.body as MetricMessage).value); + } + return step.state.pending.length; + }, + ); + if (pendingCount < 5) return; + await loopCtx.step("flush-batch", async (step) => flushBatch(step)); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +function flushBatch( + ctx: WorkflowStepContextOf, +): void { + const total = ctx.state.pending.reduce( + (sum: number, value: number) => sum + value, + 0, + ); + ctx.state.lastBatchTotal = total; + ctx.state.flushedBatches += 1; + ctx.state.pending = []; +} +export const registry = setup({ use: { batchDrainerActor } }); diff --git a/examples/docs/actors-workflows/bounded-drain.ts b/examples/docs/actors-workflows/bounded-drain.ts new file mode 100644 index 0000000..b036b63 --- /dev/null +++ b/examples/docs/actors-workflows/bounded-drain.ts @@ -0,0 +1,70 @@ +import { + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type WorkMessage = { + id: string; + value: number; +}; +const MAX_PER_ITERATION = 10; +const CONCURRENCY_LIMIT = 3; +async function processWork(value: number): Promise { + return value * 2; +} +async function runWithLimit( + limit: number, + items: T[], + fn: (item: T) => Promise, +): Promise { + let nextIndex = 0; + const workers = Array.from({ length: limit }, async () => { + while (nextIndex < items.length) { + const current = items[nextIndex]; + nextIndex += 1; + await fn(current); + } + }); + await Promise.all(workers); +} +export const boundedDrainActor = workflow({ + state: { + processed: 0, + lastWindowSize: 0, + lastWindowTotal: 0, + }, + run: async (ctx) => { + await ctx.loop("bounded-drain-loop", async (loopCtx) => { + const window: WorkMessage[] = []; + for (let i = 0; i < MAX_PER_ITERATION; i += 1) { + const [message] = await loopCtx.queue.nextBatch("wait-work", { + timeout: i === 0 ? 30000 : 10, + }); + if (!message) break; + window.push(message.body as WorkMessage); + } + if (window.length === 0) return; + await loopCtx.step("process-window", async (step) => + processWindow(step, window), + ); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function processWindow( + ctx: WorkflowStepContextOf, + window: WorkMessage[], +): Promise { + let windowTotal = 0; + await runWithLimit(CONCURRENCY_LIMIT, window, async (work) => { + const result = await processWork(work.value); + windowTotal += result; + }); + ctx.state.processed += window.length; + ctx.state.lastWindowSize = window.length; + ctx.state.lastWindowTotal = windowTotal; +} +export const registry = setup({ use: { boundedDrainActor } }); diff --git a/examples/docs/actors-workflows/checkpoint-friendly.ts b/examples/docs/actors-workflows/checkpoint-friendly.ts new file mode 100644 index 0000000..2ee648b --- /dev/null +++ b/examples/docs/actors-workflows/checkpoint-friendly.ts @@ -0,0 +1,47 @@ +import { setup, workflow } from "@rivet-dev/workflows"; + +type PaymentMessage = { + id: string; + amount: number; +}; +export const checkpointFriendlyActor = workflow({ + state: { + appliedCount: 0, + totalAmount: 0, + lastPaymentId: null as string | null, + }, + run: async (ctx) => { + await ctx.loop("payment-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-payment", { + timeout: 30000, + }); + if (!message) return; + const payment = message.body as PaymentMessage; + await loopCtx.rollbackCheckpoint("apply-payment-checkpoint"); + const plan = (await loopCtx.step("build-plan", async (_loopCtx) => + buildPaymentPlan(payment), + )) as { + paymentId: string; + amount: number; + }; + await loopCtx.step("apply-side-effects", async (step) => { + step.state.appliedCount += 1; + step.state.totalAmount += plan.amount; + step.state.lastPaymentId = plan.paymentId; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +function buildPaymentPlan(payment: PaymentMessage): { + paymentId: string; + amount: number; +} { + return { + paymentId: payment.id, + amount: payment.amount, + }; +} +export const registry = setup({ use: { checkpointFriendlyActor } }); diff --git a/examples/docs/actors-workflows/child-worker-orchestration.ts b/examples/docs/actors-workflows/child-worker-orchestration.ts new file mode 100644 index 0000000..496cff7 --- /dev/null +++ b/examples/docs/actors-workflows/child-worker-orchestration.ts @@ -0,0 +1,73 @@ +import { + actor, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type BatchMessage = { + payload: number; +}; +export const childWorkerActor = actor({ + actions: { + process: async (_c, payload: number) => payload * 3, + }, +}); +export const orchestratorActor = workflow({ + state: { + lastTotal: 0, + }, + run: async (ctx) => { + await ctx.step("start-children", async (step) => startChildren(step)); + await ctx.loop("orchestrate-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-batch", { + timeout: 30000, + }); + if (!message) return; + const batch = message.body as BatchMessage; + const results = await loopCtx.join("collect-updates", { + a: { + run: async (joinCtx) => + await joinCtx.step("run-child-a", async (step) => + runChildWorker(step, "child-a", batch.payload), + ), + }, + b: { + run: async (joinCtx) => + await joinCtx.step("run-child-b", async (step) => + runChildWorker(step, "child-b", batch.payload), + ), + }, + c: { + run: async (joinCtx) => + await joinCtx.step("run-child-c", async (step) => + runChildWorker(step, "child-c", batch.payload), + ), + }, + }); + await loopCtx.step("reconcile", async (step) => { + step.state.lastTotal = results.a + results.b + results.c; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function startChildren( + ctx: WorkflowStepContextOf, +): Promise { + const client = ctx.client(); + await client.childWorkerActor.getOrCreate(["child-a"]).process(0); + await client.childWorkerActor.getOrCreate(["child-b"]).process(0); + await client.childWorkerActor.getOrCreate(["child-c"]).process(0); +} +async function runChildWorker( + ctx: WorkflowStepContextOf, + workerId: "child-a" | "child-b" | "child-c", + payload: number, +): Promise { + const client = ctx.client(); + return await client.childWorkerActor.getOrCreate([workerId]).process(payload); +} +export const registry = setup({ use: { orchestratorActor, childWorkerActor } }); diff --git a/examples/docs/actors-workflows/coordinator-worker.ts b/examples/docs/actors-workflows/coordinator-worker.ts new file mode 100644 index 0000000..07995f7 --- /dev/null +++ b/examples/docs/actors-workflows/coordinator-worker.ts @@ -0,0 +1,51 @@ +import { + actor, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type TaskMessage = { + taskId: string; + workerId: string; + value: number; +}; +export const workerActor = actor({ + actions: { + runTask: async (_c, value: number) => value * 2, + }, +}); +export const coordinatorActor = workflow({ + state: { + lastTaskId: null as string | null, + lastResult: 0, + }, + run: async (ctx) => { + await ctx.loop("orchestrator-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-task", { + timeout: 30000, + }); + if (!message) return; + const task = message.body as TaskMessage; + const result = await loopCtx.step("dispatch-rpc", async (step) => + dispatchTask(step, task), + ); + await loopCtx.step("record-result", async (step) => { + step.state.lastTaskId = task.taskId; + step.state.lastResult = result as number; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function dispatchTask( + ctx: WorkflowStepContextOf, + task: TaskMessage, +): Promise { + const client = ctx.client(); + const worker = client.workerActor.getOrCreate([task.workerId]); + return await worker.runTask(task.value); +} +export const registry = setup({ use: { coordinatorActor, workerActor } }); diff --git a/examples/docs/actors-workflows/cron.ts b/examples/docs/actors-workflows/cron.ts new file mode 100644 index 0000000..70e37cd --- /dev/null +++ b/examples/docs/actors-workflows/cron.ts @@ -0,0 +1,42 @@ +import { + queue, + type ScheduledFireInfo, + setup, + workflow, +} from "@rivet-dev/workflows"; +export const cronActor = workflow({ + state: { + runs: 0, + lastRunAt: null as number | null, + }, + queues: { + "cron-tick": queue<{ + scheduledAt: number; + }>(), + }, + onCreate: async (c) => { + await c.cron.every({ + name: "workflow-tick", + interval: 60000, + action: "enqueueCronTick", + args: [], + maxHistory: 100, + }); + }, + actions: { + enqueueCronTick: async (c, fire: ScheduledFireInfo) => { + await c.queue.send("cron-tick", { scheduledAt: fire.scheduledAt }); + }, + getState: (c) => c.state, + }, + run: async (ctx) => { + await ctx.loop("cron-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-cron-tick"); + await loopCtx.step("run-cron-job", async (step) => { + step.state.runs += 1; + step.state.lastRunAt = message.body.scheduledAt; + }); + }); + }, +}); +export const registry = setup({ use: { cronActor } }); diff --git a/examples/docs/actors-workflows/cross-actor-saga.ts b/examples/docs/actors-workflows/cross-actor-saga.ts new file mode 100644 index 0000000..a237ff0 --- /dev/null +++ b/examples/docs/actors-workflows/cross-actor-saga.ts @@ -0,0 +1,98 @@ +import { + actor, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type CheckoutMessage = { + orderId: string; + amount: number; +}; +export const inventoryActor = actor({ + actions: { + reserve: async (_c, orderId: string) => `reserve-${orderId}`, + release: async (_c, reservationId: string) => reservationId, + }, +}); +export const billingActor = actor({ + actions: { + charge: async (_c, amount: number) => `charge-${amount}`, + refund: async (_c, chargeId: string) => chargeId, + }, +}); +export const checkoutSagaActor = workflow({ + state: { + completedOrders: 0, + }, + run: async (ctx) => { + await ctx.loop("checkout-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-order", { + timeout: 30000, + }); + if (!message) return; + const checkout = message.body as CheckoutMessage; + await loopCtx.rollbackCheckpoint("checkout-saga"); + await loopCtx.step({ + name: "reserve-inventory", + run: async (ctx) => reserveInventoryForCheckout(ctx, checkout.orderId), + // Rollback callbacks only receive a rollback context, not actor + // APIs like client(). Compensate with direct external calls. + rollback: async (_rollbackCtx, output) => { + await releaseInventoryForCheckout(output as string); + }, + }); + await loopCtx.step({ + name: "charge-card", + run: async (ctx) => chargeCheckout(ctx, checkout.amount), + rollback: async (_rollbackCtx, output) => { + await refundCheckout(output as string); + }, + }); + await loopCtx.step("mark-complete", async (step) => + markOrderComplete(step), + ); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function reserveInventoryForCheckout( + ctx: WorkflowStepContextOf, + orderId: string, +): Promise { + const client = ctx.client(); + const inventory = client.inventoryActor.getOrCreate(["main"]); + return await inventory.reserve(orderId); +} +async function releaseInventoryForCheckout( + reservationId: string, +): Promise { + await fetch("https://api.example.com/inventory/release", { + method: "POST", + body: JSON.stringify({ reservationId }), + }); +} +async function chargeCheckout( + ctx: WorkflowStepContextOf, + amount: number, +): Promise { + const client = ctx.client(); + const billing = client.billingActor.getOrCreate(["main"]); + return await billing.charge(amount); +} +async function refundCheckout(chargeId: string): Promise { + await fetch("https://api.example.com/billing/refund", { + method: "POST", + body: JSON.stringify({ chargeId }), + }); +} +function markOrderComplete( + ctx: WorkflowStepContextOf, +): void { + ctx.state.completedOrders += 1; +} +export const registry = setup({ + use: { checkoutSagaActor, inventoryActor, billingActor }, +}); diff --git a/examples/docs/actors-workflows/error-hooks.ts b/examples/docs/actors-workflows/error-hooks.ts new file mode 100644 index 0000000..e2555f7 --- /dev/null +++ b/examples/docs/actors-workflows/error-hooks.ts @@ -0,0 +1,37 @@ +import { + event, + setup, + type WorkflowErrorEvent, + workflow, +} from "@rivet-dev/workflows"; +export const errorHookActor = workflow( + { + state: { + lastError: null as WorkflowErrorEvent | null, + }, + events: { + workflowError: event<[WorkflowErrorEvent]>(), + }, + run: async (ctx) => { + await ctx.step({ + name: "sync-ledger", + maxRetries: 3, + retryBackoffBase: 250, + retryBackoffMax: 1000, + run: async (_ctx) => { + throw new Error("ledger unavailable"); + }, + }); + }, + actions: { + getState: (c) => c.state, + }, + }, + { + onError: (c, event) => { + c.state.lastError = event; + c.broadcast("workflowError", event); + }, + }, +); +export const registry = setup({ use: { errorHookActor } }); diff --git a/examples/docs/actors-workflows/fan-in-out.ts b/examples/docs/actors-workflows/fan-in-out.ts new file mode 100644 index 0000000..1938b36 --- /dev/null +++ b/examples/docs/actors-workflows/fan-in-out.ts @@ -0,0 +1,50 @@ +import { setup, workflow } from "@rivet-dev/workflows"; +export const fanInOutActor = workflow({ + state: { + total: 0, + }, + run: async (ctx) => { + await ctx.loop("join-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-refresh", { + timeout: 30000, + }); + if (!message) return; + const joined = await loopCtx.join("parallel-work", { + users: { + run: async (branchCtx) => + await branchCtx.step("fetch-users", (_branchCtx) => + fetchCount("/users"), + ), + }, + orders: { + run: async (branchCtx) => + await branchCtx.step("fetch-orders", (_branchCtx) => + fetchCount("/orders"), + ), + }, + invoices: { + run: async (branchCtx) => + await branchCtx.step("fetch-invoices", (_branchCtx) => + fetchCount("/invoices"), + ), + }, + }); + await loopCtx.step("merge-results", async (step) => { + step.state.total = joined.users + joined.orders + joined.invoices; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function fetchCount(path: string): Promise { + const res = await fetch(`https://api.example.com${path}`); + if (!res.ok) throw new Error(`fetch ${path} failed: ${res.status}`); + return ( + (await res.json()) as { + count: number; + } + ).count; +} +export const registry = setup({ use: { fanInOutActor } }); diff --git a/examples/docs/actors-workflows/get-version.ts b/examples/docs/actors-workflows/get-version.ts new file mode 100644 index 0000000..005dc5a --- /dev/null +++ b/examples/docs/actors-workflows/get-version.ts @@ -0,0 +1,32 @@ +import { setup, workflow } from "@rivet-dev/workflows"; +export const versionGateActor = workflow({ + state: { + processed: 0, + }, + run: async (ctx) => { + await ctx.loop("process-loop", async (loopCtx) => { + // Gate the changed code path. Each loop iteration resolves its version + // independently: an iteration that already ran under the old code + // (in-flight across the deploy) resolves to version 1, while a fresh + // iteration resolves to `latest` (2 here). The resolved value is pinned + // in history, so replays stay deterministic. + const version = await loopCtx.getVersion("process-message", 2); + if (version === 1) { + // Preserve the original behavior for in-flight iterations. + await loopCtx.step("process-v1", async (step) => { + step.state.processed += 1; + }); + } else { + // New behavior for iterations that begin after this deploy. + await loopCtx.step("process-v2", async (step) => { + step.state.processed += 1; + }); + } + await loopCtx.sleep("idle", 1000); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { versionGateActor } }); diff --git a/examples/docs/actors-workflows/join/client.ts b/examples/docs/actors-workflows/join/client.ts new file mode 100644 index 0000000..361ed4c --- /dev/null +++ b/examples/docs/actors-workflows/join/client.ts @@ -0,0 +1,7 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.dashboardActor.getOrCreate(["main"]); +await handle.send("refresh", {}); +console.log(await handle.getState()); diff --git a/examples/docs/actors-workflows/join/index.ts b/examples/docs/actors-workflows/join/index.ts new file mode 100644 index 0000000..1f15a5b --- /dev/null +++ b/examples/docs/actors-workflows/join/index.ts @@ -0,0 +1,57 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; +export const dashboardActor = workflow({ + state: { + summary: null as null | { + users: number; + orders: number; + revenue: number; + }, + }, + queues: { + refresh: queue>(), + }, + run: async (ctx) => { + await ctx.loop("dashboard-loop", async (loopCtx) => { + await loopCtx.queue.next("wait-refresh"); + const summary = await loopCtx.join("fetch-summary", { + users: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-users", (_branchCtx) => + fetchCount("/users"), + ); + }, + }, + orders: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-orders", (_branchCtx) => + fetchCount("/orders"), + ); + }, + }, + revenue: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-revenue", (_branchCtx) => + fetchCount("/revenue"), + ); + }, + }, + }); + await loopCtx.step("save-summary", async (step) => { + step.state.summary = summary; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function fetchCount(path: string): Promise { + const res = await fetch(`https://api.example.com${path}`); + if (!res.ok) throw new Error(`fetch ${path} failed: ${res.status}`); + return ( + (await res.json()) as { + count: number; + } + ).count; +} +export const registry = setup({ use: { dashboardActor } }); diff --git a/examples/docs/actors-workflows/loops/client.ts b/examples/docs/actors-workflows/loops/client.ts new file mode 100644 index 0000000..3145199 --- /dev/null +++ b/examples/docs/actors-workflows/loops/client.ts @@ -0,0 +1,9 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.workflowCounter.getOrCreate(["main"]); +await handle.send("counter", { delta: 1 }); +await handle.send("counter", { delta: 2 }); +const state = await handle.getState(); +console.log(state.value, state.processed); diff --git a/examples/docs/actors-workflows/loops/index.ts b/examples/docs/actors-workflows/loops/index.ts new file mode 100644 index 0000000..a324962 --- /dev/null +++ b/examples/docs/actors-workflows/loops/index.ts @@ -0,0 +1,52 @@ +import { + queue, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; +export const workflowCounter = workflow({ + state: { + value: 0, + processed: 0, + lastOperationId: null as string | null, + }, + queues: { + counter: queue<{ + delta: number; + }>(), + }, + run: async (ctx) => { + await ctx.loop("counter-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-counter-command"); + await loopCtx.step("apply-counter-command", async (step) => + applyCounterCommand(step, message.body.delta), + ); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function applyCounterCommand( + ctx: WorkflowStepContextOf, + delta: number, +): Promise { + const response = await fetch("https://api.example.com/counter/apply", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ delta }), + }); + if (!response.ok) { + throw new Error(`counter apply failed: ${response.status}`); + } + const result = (await response.json()) as { + nextValue: number; + operationId: string; + }; + ctx.state.value = result.nextValue; + ctx.state.lastOperationId = result.operationId; + ctx.state.processed += 1; +} +export const registry = setup({ use: { workflowCounter } }); diff --git a/examples/docs/actors-workflows/poll-backoff.ts b/examples/docs/actors-workflows/poll-backoff.ts new file mode 100644 index 0000000..195013d --- /dev/null +++ b/examples/docs/actors-workflows/poll-backoff.ts @@ -0,0 +1,38 @@ +import { setup, workflow } from "@rivet-dev/workflows"; + +async function pollExternal(attempt: number): Promise { + return attempt % 3 === 0; +} +export const pollBackoffActor = workflow({ + state: { + attempts: 0, + backoffMs: 100, + status: "unknown" as "unknown" | "healthy" | "retrying", + }, + run: async (ctx) => { + await ctx.loop("poll-loop", async (loopCtx) => { + const success = await loopCtx.step("poll-target", async (step) => { + step.state.attempts += 1; + return pollExternal(step.state.attempts); + }); + if (success) { + await loopCtx.step("reset-backoff", async (step) => { + step.state.status = "healthy"; + step.state.backoffMs = 100; + }); + await loopCtx.sleep("healthy-interval", 1000); + return; + } + const retryDelay = await loopCtx.step("grow-backoff", async (ctx) => { + ctx.state.status = "retrying"; + ctx.state.backoffMs = Math.min(ctx.state.backoffMs * 2, 5000); + return ctx.state.backoffMs; + }); + await loopCtx.sleep("retry-delay", retryDelay); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { pollBackoffActor } }); diff --git a/examples/docs/actors-workflows/queue-worker.ts b/examples/docs/actors-workflows/queue-worker.ts new file mode 100644 index 0000000..203369a --- /dev/null +++ b/examples/docs/actors-workflows/queue-worker.ts @@ -0,0 +1,29 @@ +import { setup, workflow } from "@rivet-dev/workflows"; + +type Job = { + id: string; + amount: number; +}; +export const queueWorkerActor = workflow({ + state: { + processed: 0, + totalAmount: 0, + }, + run: async (ctx) => { + await ctx.loop("worker-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-job", { + timeout: 30000, + }); + if (!message) return; + const job = message.body as Job; + await loopCtx.step("process-job", async (step) => { + step.state.processed += 1; + step.state.totalAmount += job.amount; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { queueWorkerActor } }); diff --git a/examples/docs/actors-workflows/race/client.ts b/examples/docs/actors-workflows/race/client.ts new file mode 100644 index 0000000..46f02f8 --- /dev/null +++ b/examples/docs/actors-workflows/race/client.ts @@ -0,0 +1,7 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.auctionActor.getOrCreate(["item-123"]); +await handle.send("bids", { amount: 100 }); +console.log(await handle.getState()); diff --git a/examples/docs/actors-workflows/race/index.ts b/examples/docs/actors-workflows/race/index.ts new file mode 100644 index 0000000..73cb822 --- /dev/null +++ b/examples/docs/actors-workflows/race/index.ts @@ -0,0 +1,47 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; +export const auctionActor = workflow({ + state: { result: null as "sold" | "expired" | null }, + queues: { + bids: queue<{ + amount: number; + }>(), + }, + run: async (ctx) => { + await ctx.step("list-item", (_ctx) => listItem("item-123")); + const { winner } = await ctx.race("bid-or-expire", [ + { + name: "bid", + run: async (branchCtx) => { + const bid = await branchCtx.queue.next("wait-bid"); + return bid.body.amount; + }, + }, + { + name: "expire", + run: async (branchCtx) => { + await branchCtx.sleep("auction-timeout", 24 * 60 * 60 * 1000); + return 0; + }, + }, + ]); + await ctx.step("finalize", async (step) => { + await finalizeAuction("item-123", winner); + step.state.result = winner === "bid" ? "sold" : "expired"; + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function listItem(itemId: string): Promise { + await fetch(`https://api.example.com/auctions/${itemId}`, { + method: "POST", + }); +} +async function finalizeAuction(itemId: string, outcome: string): Promise { + await fetch(`https://api.example.com/auctions/${itemId}/finalize`, { + method: "POST", + body: JSON.stringify({ outcome }), + }); +} +export const registry = setup({ use: { auctionActor } }); diff --git a/examples/docs/actors-workflows/request-response-queue/client.ts b/examples/docs/actors-workflows/request-response-queue/client.ts new file mode 100644 index 0000000..52e8cac --- /dev/null +++ b/examples/docs/actors-workflows/request-response-queue/client.ts @@ -0,0 +1,12 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.requestResponseActor.getOrCreate(["main"]); +const result = await handle.send("requests", { value: 21 }, { wait: true }); +if (result.status === "completed") { + const response = result.response as { + doubled: number; + }; + console.log(response.doubled); +} diff --git a/examples/docs/actors-workflows/request-response-queue/index.ts b/examples/docs/actors-workflows/request-response-queue/index.ts new file mode 100644 index 0000000..0818d51 --- /dev/null +++ b/examples/docs/actors-workflows/request-response-queue/index.ts @@ -0,0 +1,32 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; + +type RequestMessage = { + value: number; +}; +export const requestResponseActor = workflow({ + state: { + handled: 0, + }, + queues: { + requests: queue< + RequestMessage, + { + doubled: number; + } + >(), + }, + run: async (ctx) => { + await ctx.loop("request-response-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-request", { + completable: true, + }); + if (!message.complete) return; + const doubled = await loopCtx.step("handle-request", async (step) => { + step.state.handled += 1; + return message.body.value * 2; + }); + await message.complete({ doubled }); + }); + }, +}); +export const registry = setup({ use: { requestResponseActor } }); diff --git a/examples/docs/actors-workflows/request-response/client.ts b/examples/docs/actors-workflows/request-response/client.ts new file mode 100644 index 0000000..9231d8c --- /dev/null +++ b/examples/docs/actors-workflows/request-response/client.ts @@ -0,0 +1,16 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.requestResponseActor.getOrCreate(["main"]); +const result = await handle.send( + "requests", + { value: 21 }, + { wait: true, timeout: 1000 }, +); +if (result.status === "completed") { + const response = result.response as { + doubled: number; + }; + console.log(response.doubled); +} diff --git a/examples/docs/actors-workflows/request-response/index.ts b/examples/docs/actors-workflows/request-response/index.ts new file mode 100644 index 0000000..6c8cde2 --- /dev/null +++ b/examples/docs/actors-workflows/request-response/index.ts @@ -0,0 +1,30 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; +export const requestResponseActor = workflow({ + state: { + handled: 0, + }, + queues: { + requests: queue< + { + value: number; + }, + { + doubled: number; + } + >(), + }, + run: async (ctx) => { + await ctx.loop("request-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-request", { + completable: true, + }); + if (!message.complete) return; + const doubled = await loopCtx.step("handle-request", async (step) => { + step.state.handled += 1; + return message.body.value * 2; + }); + await message.complete({ doubled }); + }); + }, +}); +export const registry = setup({ use: { requestResponseActor } }); diff --git a/examples/docs/actors-workflows/rollback.ts b/examples/docs/actors-workflows/rollback.ts new file mode 100644 index 0000000..d640ae5 --- /dev/null +++ b/examples/docs/actors-workflows/rollback.ts @@ -0,0 +1,71 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; +export const checkoutActor = workflow({ + state: { status: "pending" as string }, + queues: { + orders: queue<{ + orderId: string; + }>(), + }, + run: async (ctx) => { + await ctx.loop("checkout-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-order"); + await loopCtx.rollbackCheckpoint("checkout-checkpoint"); + await loopCtx.step({ + name: "reserve-inventory", + run: (_loopCtx) => reserveInventory(message.body.orderId), + rollback: async (_rollbackCtx, id) => { + await releaseInventory(id as string); + }, + }); + await loopCtx.step({ + name: "charge-card", + run: (_loopCtx) => chargeCard(message.body.orderId), + rollback: async (_rollbackCtx, chargeId) => { + await refundCharge(chargeId as string); + }, + }); + await loopCtx.step("confirm", async (step) => { + step.state.status = "confirmed"; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function reserveInventory(orderId: string): Promise { + const res = await fetch("https://api.example.com/inventory/reserve", { + method: "POST", + body: JSON.stringify({ orderId }), + }); + return ( + (await res.json()) as { + reservationId: string; + } + ).reservationId; +} +async function releaseInventory(reservationId: string): Promise { + await fetch(`https://api.example.com/inventory/${reservationId}/release`, { + method: "POST", + }); +} +async function chargeCard(orderId: string): Promise { + const res = await fetch("https://api.stripe.com/v1/charges", { + method: "POST", + headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` }, + body: JSON.stringify({ orderId }), + }); + return ( + (await res.json()) as { + id: string; + } + ).id; +} +async function refundCharge(chargeId: string): Promise { + await fetch("https://api.stripe.com/v1/refunds", { + method: "POST", + headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` }, + body: JSON.stringify({ charge: chargeId }), + }); +} +export const registry = setup({ use: { checkoutActor } }); diff --git a/examples/docs/actors-workflows/scatter-gather.ts b/examples/docs/actors-workflows/scatter-gather.ts new file mode 100644 index 0000000..8235235 --- /dev/null +++ b/examples/docs/actors-workflows/scatter-gather.ts @@ -0,0 +1,66 @@ +import { + actor, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type ScatterMessage = { + input: number; +}; +export const shardActor = actor({ + actions: { + compute: async (_c, input: number) => input * 10, + }, +}); +export const scatterGatherActor = workflow({ + state: { + lastSum: 0, + }, + run: async (ctx) => { + await ctx.loop("scatter-gather-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-scatter", { + timeout: 30000, + }); + if (!message) return; + const scatter = message.body as ScatterMessage; + const gathered = await loopCtx.join("gather", { + shardA: { + run: async (joinCtx) => + await joinCtx.step("call-shard-a", async (step) => + callShard(step, "a", scatter.input), + ), + }, + shardB: { + run: async (joinCtx) => + await joinCtx.step("call-shard-b", async (step) => + callShard(step, "b", scatter.input), + ), + }, + shardC: { + run: async (joinCtx) => + await joinCtx.step("call-shard-c", async (step) => + callShard(step, "c", scatter.input), + ), + }, + }); + await loopCtx.step("aggregate", async (step) => { + step.state.lastSum = + gathered.shardA + gathered.shardB + gathered.shardC; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function callShard( + ctx: WorkflowStepContextOf, + shardId: "a" | "b" | "c", + input: number, +): Promise { + const client = ctx.client(); + const handle = client.shardActor.getOrCreate([shardId]); + return await handle.compute(input); +} +export const registry = setup({ use: { scatterGatherActor, shardActor } }); diff --git a/examples/docs/actors-workflows/setup-teardown-pattern.ts b/examples/docs/actors-workflows/setup-teardown-pattern.ts new file mode 100644 index 0000000..c9245be --- /dev/null +++ b/examples/docs/actors-workflows/setup-teardown-pattern.ts @@ -0,0 +1,39 @@ +import { setup, workflow } from "@rivet-dev/workflows"; + +function openResource(): string { + return "connected"; +} +function closeResource(_resource: string): void {} +export const setupRunTeardownActor = workflow({ + vars: { + resource: null as string | null, + }, + state: { + initialized: false, + ticks: 0, + }, + onWake: (c) => { + c.vars.resource = openResource(); + }, + onSleep: (c) => { + if (!c.vars.resource) return; + closeResource(c.vars.resource); + c.vars.resource = null; + }, + run: async (ctx) => { + await ctx.step("setup", async (step) => { + if (!step.vars.resource) step.vars.resource = openResource(); + step.state.initialized = true; + }); + await ctx.loop("main-loop", async (loopCtx) => { + await loopCtx.sleep("tick", 1000); + await loopCtx.step("tick-step", async (step) => { + step.state.ticks += 1; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { setupRunTeardownActor } }); diff --git a/examples/docs/actors-workflows/setup-teardown/client.ts b/examples/docs/actors-workflows/setup-teardown/client.ts new file mode 100644 index 0000000..b61b446 --- /dev/null +++ b/examples/docs/actors-workflows/setup-teardown/client.ts @@ -0,0 +1,10 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.setupRunTeardownActor.getOrCreate(["main"]); +await handle.send("work", { amount: 5 }); +await handle.send("work", { amount: 3 }); +await handle.send("control", { type: "stop", reason: "maintenance" }); +const state = await handle.getState(); +console.log(state.phase, state.total, state.stopReason); diff --git a/examples/docs/actors-workflows/setup-teardown/index.ts b/examples/docs/actors-workflows/setup-teardown/index.ts new file mode 100644 index 0000000..c92eacb --- /dev/null +++ b/examples/docs/actors-workflows/setup-teardown/index.ts @@ -0,0 +1,109 @@ +import { + Loop, + queue, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type WorkMessage = { + amount: number; +}; +type ControlMessage = { + type: "stop"; + reason: string; +}; +export const setupRunTeardownActor = workflow({ + state: { + phase: "idle" as "idle" | "running" | "stopped", + total: 0, + processed: 0, + stopReason: null as string | null, + workerSessionId: null as string | null, + }, + queues: { + work: queue(), + control: queue(), + }, + run: async (ctx) => { + await ctx.step("setup", async (step) => setupWorkerSession(step)); + const stopReason = await ctx.loop("worker-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-command", { + names: ["work", "control"], + }); + if (message.name === "work") { + const work = message.body as WorkMessage; + await loopCtx.step("apply-work", async (step) => + applyWorkerMessage(step, work), + ); + return; + } + const control = message.body as ControlMessage; + if (control.type === "stop") { + return Loop.break(control.reason); + } + }); + await ctx.step("teardown", async (step) => + teardownWorkerSession(step, stopReason), + ); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function setupWorkerSession( + ctx: WorkflowStepContextOf, +): Promise { + const response = await fetch("https://api.example.com/workers/session", { + method: "POST", + }); + if (!response.ok) { + throw new Error(`worker setup failed: ${response.status}`); + } + const session = (await response.json()) as { + sessionId: string; + }; + ctx.state.workerSessionId = session.sessionId; + ctx.state.phase = "running"; + ctx.state.stopReason = null; +} +async function applyWorkerMessage( + ctx: WorkflowStepContextOf, + work: WorkMessage, +): Promise { + const response = await fetch("https://api.example.com/workers/process", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + sessionId: ctx.state.workerSessionId, + amount: work.amount, + }), + }); + if (!response.ok) { + throw new Error(`worker process failed: ${response.status}`); + } + const result = (await response.json()) as { + appliedAmount: number; + }; + ctx.state.total += result.appliedAmount; + ctx.state.processed += 1; +} +async function teardownWorkerSession( + ctx: WorkflowStepContextOf, + stopReason: string, +): Promise { + if (ctx.state.workerSessionId) { + const response = await fetch( + `https://api.example.com/workers/session/${ctx.state.workerSessionId}`, + { method: "DELETE" }, + ); + if (!response.ok) { + throw new Error(`worker teardown failed: ${response.status}`); + } + } + ctx.state.phase = "stopped"; + ctx.state.stopReason = stopReason; +} +export const registry = setup({ use: { setupRunTeardownActor } }); diff --git a/examples/docs/actors-workflows/signal-control-loop.ts b/examples/docs/actors-workflows/signal-control-loop.ts new file mode 100644 index 0000000..662b3cd --- /dev/null +++ b/examples/docs/actors-workflows/signal-control-loop.ts @@ -0,0 +1,40 @@ +import { + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type ControlSignal = { + kind: "pause" | "resume" | "stop"; +}; +export const controlLoopActor = workflow({ + state: { + mode: "running" as "running" | "paused" | "stopped", + handledSignals: 0, + }, + run: async (ctx) => { + await ctx.loop("control-loop", async (loopCtx) => { + const [message] = await loopCtx.queue.nextBatch("wait-signal", { + timeout: 30000, + }); + if (!message) return; + const signal = message.body as ControlSignal; + await loopCtx.step("apply-signal", async (step) => + applyControlSignal(step, signal.kind), + ); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +function applyControlSignal( + ctx: WorkflowStepContextOf, + kind: ControlSignal["kind"], +): void { + ctx.state.handledSignals += 1; + if (kind === "pause") ctx.state.mode = "paused"; + if (kind === "resume") ctx.state.mode = "running"; + if (kind === "stop") ctx.state.mode = "stopped"; +} +export const registry = setup({ use: { controlLoopActor } }); diff --git a/examples/docs/actors-workflows/simple-workflow/client.ts b/examples/docs/actors-workflows/simple-workflow/client.ts new file mode 100644 index 0000000..b95ed2e --- /dev/null +++ b/examples/docs/actors-workflows/simple-workflow/client.ts @@ -0,0 +1,7 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.invoiceActor.getOrCreate(["main"]); +const state = await handle.getState(); +console.log(state.status, state.total); diff --git a/examples/docs/actors-workflows/simple-workflow/index.ts b/examples/docs/actors-workflows/simple-workflow/index.ts new file mode 100644 index 0000000..78fa826 --- /dev/null +++ b/examples/docs/actors-workflows/simple-workflow/index.ts @@ -0,0 +1,80 @@ +import { + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; +export const invoiceActor = workflow({ + state: { + invoiceId: null as string | null, + subtotal: 0, + tax: 0, + total: 0, + status: "idle" as "idle" | "complete", + }, + run: async (ctx) => { + const subtotal = await ctx.step("load-subtotal", async (_ctx) => + loadSubtotal(), + ); + const tax = await ctx.step("calculate-tax", async (_ctx) => + calculateTax(subtotal), + ); + await ctx.step("save-invoice", async (step) => + saveInvoice(step, subtotal, tax), + ); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function loadSubtotal(): Promise { + const response = await fetch("https://api.example.com/carts/main"); + if (!response.ok) { + throw new Error(`load subtotal failed: ${response.status}`); + } + const cart = (await response.json()) as { + subtotal: number; + }; + return cart.subtotal; +} +async function calculateTax(subtotal: number): Promise { + const response = await fetch("https://api.example.com/tax/quote", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ subtotal }), + }); + if (!response.ok) { + throw new Error(`tax quote failed: ${response.status}`); + } + const quote = (await response.json()) as { + tax: number; + }; + return quote.tax; +} +async function saveInvoice( + ctx: WorkflowStepContextOf, + subtotal: number, + tax: number, +): Promise { + const total = subtotal + tax; + const response = await fetch("https://api.example.com/invoices", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ subtotal, tax, total }), + }); + if (!response.ok) { + throw new Error(`save invoice failed: ${response.status}`); + } + const invoice = (await response.json()) as { + id: string; + }; + ctx.state.invoiceId = invoice.id; + ctx.state.subtotal = subtotal; + ctx.state.tax = tax; + ctx.state.total = total; + ctx.state.status = "complete"; +} +export const registry = setup({ use: { invoiceActor } }); diff --git a/examples/docs/actors-workflows/store-progress/client.ts b/examples/docs/actors-workflows/store-progress/client.ts new file mode 100644 index 0000000..a23570c --- /dev/null +++ b/examples/docs/actors-workflows/store-progress/client.ts @@ -0,0 +1,12 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.progressActor.getOrCreate(["main"]); +const conn = handle.connect(); +conn.on("progressUpdated", (progress) => { + console.log("progress", progress); +}); +await handle.send("jobs", { value: 5 }); +await handle.send("jobs", { value: 7 }); +console.log(await handle.getState()); diff --git a/examples/docs/actors-workflows/store-progress/index.ts b/examples/docs/actors-workflows/store-progress/index.ts new file mode 100644 index 0000000..8c385aa --- /dev/null +++ b/examples/docs/actors-workflows/store-progress/index.ts @@ -0,0 +1,68 @@ +import { + event, + queue, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; + +type Progress = { + stage: "idle" | "running" | "completed"; + completed: number; + total: number; +}; +export const progressActor = workflow({ + state: { + progress: { + stage: "idle", + completed: 0, + total: 0, + } as Progress, + sum: 0, + }, + events: { + progressUpdated: event(), + }, + queues: { + jobs: queue<{ + value: number; + }>(), + }, + run: async (ctx) => { + await ctx.loop("progress-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-job"); + await loopCtx.step("mark-running", async (step) => + markProgressRunning(step), + ); + await loopCtx.step("apply-job", async (step) => + applyProgressJob(step, message.body.value), + ); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +function markProgressRunning( + ctx: WorkflowStepContextOf, +): void { + ctx.state.progress = { + stage: "running", + completed: ctx.state.progress.completed, + total: ctx.state.progress.total + 1, + }; + ctx.broadcast("progressUpdated", ctx.state.progress); +} +function applyProgressJob( + ctx: WorkflowStepContextOf, + value: number, +): void { + ctx.state.sum += value; + ctx.state.progress = { + stage: "completed", + completed: ctx.state.progress.completed + 1, + total: ctx.state.progress.total, + }; + ctx.broadcast("progressUpdated", ctx.state.progress); +} +export const registry = setup({ use: { progressActor } }); diff --git a/examples/docs/actors-workflows/timeout-fallback.ts b/examples/docs/actors-workflows/timeout-fallback.ts new file mode 100644 index 0000000..ced6a8e --- /dev/null +++ b/examples/docs/actors-workflows/timeout-fallback.ts @@ -0,0 +1,80 @@ +import { + actor, + setup, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; +export const primaryServiceActor = actor({ + actions: { + fetchValue: async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + return "primary"; + }, + }, +}); +export const fallbackServiceActor = actor({ + actions: { + fetchValue: async () => "fallback", + }, +}); +export const timeoutFallbackActor = workflow({ + state: { + lastSource: "none" as "none" | "primary" | "fallback", + lastValue: "", + }, + run: async (ctx) => { + await ctx.loop("timeout-loop", async (loopCtx) => { + await loopCtx.queue.nextBatch("wait-request", { + timeout: 30000, + }); + const winner = await loopCtx.race("primary-vs-timeout", [ + { + name: "primary", + run: async (raceCtx) => + await raceCtx.step("call-primary", async (step) => + callPrimaryValue(step), + ), + }, + { + name: "timeout", + run: async (raceCtx) => { + await raceCtx.sleep("primary-timeout", 200); + return "timeout"; + }, + }, + ]); + let value = winner.value as string; + let source: "primary" | "fallback" = "primary"; + if (winner.winner === "timeout") { + value = (await loopCtx.step("fallback-call", async (step) => + callFallbackValue(step), + )) as string; + source = "fallback"; + } + await loopCtx.step("record-choice", async (step) => { + step.state.lastSource = source; + step.state.lastValue = value; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function callPrimaryValue( + ctx: WorkflowStepContextOf, +): Promise { + const client = ctx.client(); + const primary = client.primaryServiceActor.getOrCreate(["main"]); + return await primary.fetchValue(); +} +async function callFallbackValue( + ctx: WorkflowStepContextOf, +): Promise { + const client = ctx.client(); + const fallback = client.fallbackServiceActor.getOrCreate(["main"]); + return await fallback.fetchValue(); +} +export const registry = setup({ + use: { timeoutFallbackActor, primaryServiceActor, fallbackServiceActor }, +}); diff --git a/examples/docs/actors-workflows/timeouts.ts b/examples/docs/actors-workflows/timeouts.ts new file mode 100644 index 0000000..5584209 --- /dev/null +++ b/examples/docs/actors-workflows/timeouts.ts @@ -0,0 +1,33 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; + +async function chargeCard(orderId: string): Promise { + return `charge-${orderId}`; +} +export const timeoutActor = workflow({ + state: { + lastChargeId: null as string | null, + }, + queues: { + charge: queue<{ + orderId: string; + }>(), + }, + run: async (ctx) => { + await ctx.loop("charge-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-charge"); + const chargeId = await loopCtx.step({ + name: "charge-card", + timeout: 5000, + retryOnTimeout: true, + maxRetries: 5, + retryBackoffBase: 200, + retryBackoffMax: 2000, + run: async (_loopCtx) => await chargeCard(message.body.orderId), + }); + await loopCtx.step("save-charge", async (step) => { + step.state.lastChargeId = chargeId; + }); + }); + }, +}); +export const registry = setup({ use: { timeoutActor } }); diff --git a/examples/docs/actors-workflows/timers/client.ts b/examples/docs/actors-workflows/timers/client.ts new file mode 100644 index 0000000..510f0a1 --- /dev/null +++ b/examples/docs/actors-workflows/timers/client.ts @@ -0,0 +1,11 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); +const handle = client.reminderActor.getOrCreate(["main"]); +await handle.send("reminders", { + text: "send weekly report", + at: Date.now() + 1000, +}); +await new Promise((resolve) => setTimeout(resolve, 1300)); +console.log(await handle.getState()); diff --git a/examples/docs/actors-workflows/timers/index.ts b/examples/docs/actors-workflows/timers/index.ts new file mode 100644 index 0000000..93dc3aa --- /dev/null +++ b/examples/docs/actors-workflows/timers/index.ts @@ -0,0 +1,28 @@ +import { queue, setup, workflow } from "@rivet-dev/workflows"; + +type Reminder = { + text: string; + at: number; +}; +export const reminderActor = workflow({ + state: { + fired: [] as string[], + }, + queues: { + reminders: queue(), + }, + run: async (ctx) => { + await ctx.loop("reminder-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-reminder"); + const runAt = Math.max(Date.now(), message.body.at); + await loopCtx.sleepUntil("wait-until-reminder", runAt); + await loopCtx.step("record-reminder", async (step) => { + step.state.fired.push(message.body.text); + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { reminderActor } }); diff --git a/examples/docs/actors-workflows/try-step.ts b/examples/docs/actors-workflows/try-step.ts new file mode 100644 index 0000000..e514b14 --- /dev/null +++ b/examples/docs/actors-workflows/try-step.ts @@ -0,0 +1,30 @@ +import { setup, workflow } from "@rivet-dev/workflows"; +export const paymentActor = workflow({ + state: { + status: "pending" as "pending" | "manual-review" | "paid", + reason: null as string | null, + }, + run: async (ctx) => { + const charge = await ctx.tryStep({ + name: "charge-card", + maxRetries: 3, + run: async (_ctx) => await chargeCard("order-123"), + }); + await ctx.step("store-charge-result", async (step) => { + if (!charge.ok) { + step.state.status = "manual-review"; + step.state.reason = charge.failure.error.message; + return; + } + step.state.status = "paid"; + step.state.reason = null; + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +async function chargeCard(orderId: string): Promise { + return `charge-${orderId}`; +} +export const registry = setup({ use: { paymentActor } }); diff --git a/examples/docs/actors-workflows/versioned-workflow.ts b/examples/docs/actors-workflows/versioned-workflow.ts new file mode 100644 index 0000000..3bcba1d --- /dev/null +++ b/examples/docs/actors-workflows/versioned-workflow.ts @@ -0,0 +1,22 @@ +import { setup, workflow } from "@rivet-dev/workflows"; +export const versionedWorkflowActor = workflow({ + state: { + runs: 0, + }, + run: async (ctx) => { + await ctx.step("validate-v2", async (step) => { + step.state.runs += 1; + }); + await ctx.removed("validate-v1", "step"); + await ctx.loop("main-loop-v2", async (loopCtx) => { + await loopCtx.sleep("idle", 500); + await loopCtx.step("heartbeat-v2", async (step) => { + step.state.runs += 1; + }); + }); + }, + actions: { + getState: (c) => c.state, + }, +}); +export const registry = setup({ use: { versionedWorkflowActor } }); diff --git a/examples/docs/package.json b/examples/docs/package.json new file mode 100644 index 0000000..8ad2611 --- /dev/null +++ b/examples/docs/package.json @@ -0,0 +1,16 @@ +{ + "name": "@rivet-dev/workflows-docs-examples", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@rivet-dev/workflows": "workspace:*", + "rivetkit": "2.3.11" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "typescript": "^5.7.3" + } +} diff --git a/examples/docs/tsconfig.json b/examples/docs/tsconfig.json new file mode 100644 index 0000000..52656dd --- /dev/null +++ b/examples/docs/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": "../..", + "paths": { + "@rivet-dev/workflows": ["packages/workflows/src/mod.ts"] + }, + "types": ["node"] + }, + "include": ["actors-workflows/**/*.ts"] +} diff --git a/package.json b/package.json index 42172c2..610214d 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ }, "scripts": { "build": "pnpm --filter @rivet-dev/workflows build", - "check-types": "pnpm --filter @rivet-dev/workflows check-types", + "check-types": "pnpm --filter @rivet-dev/workflows check-types && pnpm --filter @rivet-dev/workflows-docs-examples check-types", "test": "pnpm --filter @rivet-dev/workflows test", "test:e2e": "pnpm --filter @rivet-dev/workflows test:e2e", "lint": "biome lint .", - "format:check": "biome check packages/workflows/src/rivetkit packages/workflows/src/mod.ts packages/workflows/tests/rivetkit packages/workflows/tests/e2e packages/workflows/tests/compat packages/workflows/tests/fixtures packages/workflows/vitest.config.ts packages/workflows/vitest.e2e.config.ts scripts", + "format:check": "biome check packages/workflows/src/rivetkit packages/workflows/src/mod.ts packages/workflows/tests/rivetkit packages/workflows/tests/e2e packages/workflows/tests/compat packages/workflows/tests/fixtures packages/workflows/vitest.config.ts packages/workflows/vitest.e2e.config.ts scripts examples/docs", "check:boundaries": "tsx scripts/check-boundaries.ts", "verify:pack": "tsx scripts/verify-pack.ts", "ci": "pnpm lint && pnpm format:check && pnpm check:boundaries && pnpm check-types && pnpm test && pnpm test:e2e && pnpm build && pnpm verify:pack" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d1b7a7..f7255c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,22 @@ importers: specifier: ^4.20.6 version: 4.23.12 + examples/docs: + dependencies: + '@rivet-dev/workflows': + specifier: workspace:* + version: link:../../packages/workflows + rivetkit: + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1) + devDependencies: + '@types/node': + specifier: ^22.13.1 + version: 22.20.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/workflows: dependencies: '@rivetkit/bare-ts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7dd1213..0d57224 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - examples/* onlyBuiltDependencies: - '@biomejs/biome'