diff --git a/.github/media/icons/bolt-regular.svg b/.github/media/icons/bolt-regular.svg
new file mode 100644
index 000000000..4c2dfcf8f
--- /dev/null
+++ b/.github/media/icons/bolt-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/icons/cloud-regular.svg b/.github/media/icons/cloud-regular.svg
new file mode 100644
index 000000000..b4ced7e1f
--- /dev/null
+++ b/.github/media/icons/cloud-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/icons/database-regular.svg b/.github/media/icons/database-regular.svg
new file mode 100644
index 000000000..24532d94c
--- /dev/null
+++ b/.github/media/icons/database-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/icons/globe-regular.svg b/.github/media/icons/globe-regular.svg
new file mode 100644
index 000000000..5ec55387c
--- /dev/null
+++ b/.github/media/icons/globe-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/icons/microchip-regular.svg b/.github/media/icons/microchip-regular.svg
new file mode 100644
index 000000000..6588f197e
--- /dev/null
+++ b/.github/media/icons/microchip-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/icons/tower-broadcast-regular.svg b/.github/media/icons/tower-broadcast-regular.svg
new file mode 100644
index 000000000..bf5863619
--- /dev/null
+++ b/.github/media/icons/tower-broadcast-regular.svg
@@ -0,0 +1 @@
+
diff --git a/.github/media/studio-video-demo5.png b/.github/media/studio-video-demo5.png
new file mode 100644
index 000000000..56ad44d93
Binary files /dev/null and b/.github/media/studio-video-demo5.png differ
diff --git a/TESTING.md b/CONTRIBUTING.md
similarity index 96%
rename from TESTING.md
rename to CONTRIBUTING.md
index d69da5965..b16bad821 100644
--- a/TESTING.md
+++ b/CONTRIBUTING.md
@@ -1,4 +1,6 @@
-# Testing
+# Contributing
+
+## Testing
For now, only testing the outcome of running the CLI is supported. Before running tests, make sure that you have installed and started Docker.
@@ -25,4 +27,4 @@ $ ./scripts/e2e-publish.ts
5. ```bash
$ yarn workspace @actor-core/cli run test
```
- - Runs the tests for the CLI, which includes running the CLI (inside Docker) with different arguments and checking the output.
\ No newline at end of file
+ - Runs the tests for the CLI, which includes running the CLI (inside Docker) with different arguments and checking the output.
diff --git a/README.md b/README.md
index c4d606bd0..cd2222019 100644
--- a/README.md
+++ b/README.md
@@ -1,138 +1,268 @@
-
+
-
+
-
+
Stateful Serverless That Runs Anywhere
+
+ The easiest way to build stateful , AI agent , collaborative , or local-first applications.
+ Deploy to Rivet, Cloudflare, Bun, Node.js, and more.
+
+
+
+ Documentation β’
+ Discord β’
+ X β’
+ Bluesky
+
+
-Stateful, Scalable, Realtime Backend Framework
-
-
-
-
-
-
-
-
-
-
+## Quickstart
-## Intro
+**Start By Framework**
-The modern way to build multiplayer, realtime, or AI agent backends.
+- [React](https://actorcore.org/frameworks/react)
+- [Node.js & Bun](https://actorcore.org/clients/javascript)
+- [Rust](https://actorcore.org/clients/rust)
-Runs on [Rivet](https://actorcore.org/platforms/rivet), [Cloudflare Workers](https://actorcore.org/platforms/cloudflare-workers), [Bun](https://actorcore.org/platforms/bun), and [Node.js](https://actorcore.org/platforms/nodejs). Integrates with [Hono](https://actorcore.org/integrations/hono) and [Redis](https://actorcore.org/drivers/redis).
+**Start With Studio**
-### Architecture
+ [Open Studio](https://studio.rivet.gg)
-- πΎ **Persistent, In-Memory State**: Fast in-memory access with built-in durability β no external databases or caches needed.
-- β‘ **Ultra-Fast State Updates**: Real-time state updates with ultra-low latency, powered by co-locating compute and data.
-- π **Batteries Included**: Integrated support for state, actions, events, scheduling, and multiplayer β no extra boilerplate code needed.
-- π₯οΈ **Serverless & Scalable**: Effortless scaling, scale-to-zero, and easy deployments on any serverless runtime.
+**Start With Template**
-### Features
+```bash
+npx create-actor@latest
+```
-- πΎ [**State**](https://actorcore.org/concepts/state): Fast in-memory access with built-in durability.
-- π» [**Actions**](https://actorcore.org/concepts/actions): Callable functions for seamless client-server communication.
-- π‘ [**Events**](https://actorcore.org/concepts/events): Real-time event handling and broadcasting.
-- β° [**Scheduling**](https://actorcore.org/concepts/schedule): Timed tasks and operations management.
-- π [**Connections & Multiplayer**](https://actorcore.org/concepts/connections): Manage connections and multiplayer interactions.
-- π·οΈ [**Metadata**](https://actorcore.org/concepts/metadata): Store and manage additional data attributes.
+## What is Stateful Serverless?
-### Everything you need to build realtime, stateful backends
+
-ActorCore provides a solid foundation with the features you'd expect for modern apps.
+** Long-Lived, Stateful Compute**
-| Feature | ActorCore | Durable Objects | Socket.io | Redis | AWS Lambda |
-| --------------- | --------- | --------------- | --------- | ----- | ---------- |
-| In-Memory State | β | β | β | β | |
-| Persisted State | β | β | | | |
-| Actions (RPC) | β | β | β | | β |
-| Events (Pub/Sub)| β | - | β | β | |
-| Scheduling | β | - | | | - |
-| Edge Computing | β β | β | | | β |
-| No Vendor Lock | β | | β | β | |
+Each unit of compute is like a tiny server that remembers things between requests β no need to reload data or worry about timeouts. Like AWS Lambda, but with memory and no timeouts.
-_\- = requires significant boilerplate code or external service_
+
-_β = on supported platforms_
+ **Durable State Without a Database**
-## Quickstart
+Your code's state is saved automaticallyβno database, ORM, or config needed. Just use regular JavaScript objects or SQLite (available in April).
-Run this command:
+
-```
-npx create-actor@latest
-```
+ **Blazing-Fast Reads & Writes**
-## Supported Platforms
+State is stored on the same machine as your compute, so reads and writes are ultra-fast. No database round trips, no latency spikes.
-- [**Rivet**](https://actorcore.org/platforms/rivet)
-- [**Cloudflare Workers**](https://actorcore.org/platforms/cloudflare-workers)
-- [**Bun**](https://actorcore.org/platforms/bun)
-- [**Node.js**](https://actorcore.org/platforms/nodejs)
+
-## Overview
+ **Realtime, Made Simple**
-**Create Actor**
+Update state and broadcast changes in realtime. No external pub/sub systems, no polling β just built-in low-latency events.
-```typescript
-import { actor, setup } from "actor-core";
+
-const chatRoom = actor({
- state: { messages: [] },
- actions: {
- // receive an action call from the client
- sendMessage: (c, username: string, message: string) => {
- // save message to persistent storage
- c.state.messages.push({ username, message });
+ **Store Data Near Your Users**
- // broadcast message to all clients
- c.broadcast("newMessage", username, message);
- },
- // allow client to request message history
- getMessages: (c) => c.state.messages
- },
-});
+Your state lives close to your users on the edge β not in a faraway data center β so every interaction feels instant.
-export const app = setup({
- actors: { chatRoom },
- cors: { origin: "http://localhost:8080" }
-});
+
-export type App = typeof app;
-```
+ **Serverless & Scalable**
-**Connect to Actor**
+No servers to manage. Your code runs on-demand and scales automatically with usage.
-```typescript
-import { createClient } from "actor-core/client";
-import type { App } from "../src/index";
+
-const client = createClient(/* manager endpoint */);
+*[Have more questions? Jump to our FAQ β](#frequently-asked-questions)*
-// connect to chat room
-const chatRoom = await client.chatRoom.get({ channel: "random" });
+## Examples
-// listen for new messages
-chatRoom.on("newMessage", (username: string, message: string) =>
- console.log(`Message from ${username}: ${message}`),
-);
+Browse snippets for how to use ActorCore with different use cases.
-// send message to room
-await chatRoom.sendMessage("william", "All the world's a stage.");
-```
+| Example | Actor (JavaScript) | Actor (SQLite) | Frontend (React) |
+|---------|------------|--------|-------|
+| Chat Room | [actor.ts](/examples/snippets/chat-room/actor-json.ts) | [actor.ts](/examples/snippets/chat-room/actor-sqlite.ts) | [App.tsx](/examples/snippets/chat-room/App.tsx) |
+| AI Agent | [actor.ts](/examples/snippets/ai-agent/actor-json.ts) | [actor.ts](/examples/snippets/ai-agent/actor-sqlite.ts) | [App.tsx](/examples/snippets/ai-agent/App.tsx) |
+| Local-First Sync | [actor.ts](/examples/snippets/sync/actor-json.ts) | [actor.ts](/examples/snippets/sync/actor-sqlite.ts) | [App.tsx](/examples/snippets/sync/App.tsx) |
+| Per-Tenant SaaS | [actor.ts](/examples/snippets/tenant/actor-json.ts) | [actor.ts](/examples/snippets/tenant/actor-sqlite.ts) | [App.tsx](/examples/snippets/tenant/App.tsx) |
+| Per-User Databases | [actor.ts](/examples/snippets/database/actor-json.ts) | [actor.ts](/examples/snippets/database/actor-sqlite.ts) | [App.tsx](/examples/snippets/database/App.tsx) |
+| Yjs CRDT | [actor.ts](/examples/snippets/crdt/actor-json.ts) | [actor.ts](/examples/snippets/crdt/actor-sqlite.ts) | [App.tsx](/examples/snippets/crdt/App.tsx) |
+| Collaborative Document | [actor.ts](/examples/snippets/document/actor-json.ts) | [actor.ts](/examples/snippets/document/actor-sqlite.ts) | [App.tsx](/examples/snippets/document/App.tsx) |
+| Stream Processing | [actor.ts](/examples/snippets/stream/actor-json.ts) | [actor.ts](/examples/snippets/stream/actor-sqlite.ts) | [App.tsx](/examples/snippets/stream/App.tsx) |
+| Multiplayer Game | [actor.ts](/examples/snippets/game/actor-json.ts) | [actor.ts](/examples/snippets/game/actor-sqlite.ts) | [App.tsx](/examples/snippets/game/App.tsx) |
+| Rate Limiter | [actor.ts](/examples/snippets/rate/actor-json.ts) | [actor.ts](/examples/snippets/rate/actor-sqlite.ts) | [App.tsx](/examples/snippets/rate/App.tsx) |
+
+_SQLite will be available in late April. Weβre working on publishing full examples related to these snippets. If you find an error, please create an issue._
+
+## Runs On Your Stack
+
+Deploy ActorCore anywhere - from serverless platforms to your own infrastructure. Don't see the runtime you want? [Add your own](http://localhost:3000/drivers/build).
+
+### All-In-One
+- [Rivet](/platforms/rivet)
+- [Cloudflare Workers](/platforms/cloudflare-workers)
+
+### Compute
+- [Vercel](https://github.com/rivet-gg/actor-core/issues/897) *(On The Roadmap)*
+- [AWS Lambda](https://github.com/rivet-gg/actor-core/issues/898) *(On The Roadmap)*
+- [Supabase](https://github.com/rivet-gg/actor-core/issues/905) *(Help Wanted)*
+- [Bun](/platforms/bun)
+- [Node.js](/platforms/nodejs)
+
+### Storage
+- [Redis](/drivers/redis)
+- [Postgres](https://github.com/rivet-gg/actor-core/issues/899) *(Help Wanted)*
+- [File System](/drivers/file-system)
+- [Memory](/drivers/memory)
+
+## Works With Your Tools
+
+Seamlessly integrate ActorCore with your favorite frameworks, languages, and tools. Don't see what you need? [Request an integration](https://github.com/rivet-gg/actor-core/issues/new).
+
+### Frameworks
+- [React](/frameworks/react)
+- [Next.js](https://github.com/rivet-gg/actor-core/issues/904) *(Help Wanted)*
+- [Vue](https://github.com/rivet-gg/actor-core/issues/903) *(Help Wanted)*
+
+### Clients
+- [JavaScript](/clients/javascript)
+- [TypeScript](/clients/javascript)
+- [Python](https://github.com/rivet-gg/actor-core/issues/900) *(Available In April)*
+- [Rust](/clients/rust)
+
+### Integrations
+- [Hono](/integrations/hono)
+- [Resend](/integrations/resend)
+- [Better Auth](https://github.com/rivet-gg/actor-core/issues/906) *(On The Roadmap)*
+- [AI SDK](https://github.com/rivet-gg/actor-core/issues/907) *(On The Roadmap)*
+
+### Local-First Sync
+- [LiveStore](https://github.com/rivet-gg/actor-core/issues/908) *(Available In May)*
+- [ZeroSync](https://github.com/rivet-gg/actor-core/issues/909) *(Help Wanted)*
+- [TinyBase](https://github.com/rivet-gg/actor-core/issues/910) *(Help Wanted)*
+- [Yjs](https://github.com/rivet-gg/actor-core/issues/911) *(Help Wanted)*
+
+## Local Development with the Studio
+
+Like Postman, but for all of your stateful serverless needs.
+
+Visit The Studio β
+
+
+
+## Join the Community
+
+Help make ActorCore the universal way to build & scale stateful serverless applications.
-## Community & Support
+- [Discord](https://rivet.gg/discord)
+- [X](https://x.com/ActorCore_org)
+- [Bluesky](https://bsky.app/profile/rivet.gg)
+- [Discussions](https://github.com/rivet-gg/actor-core/discussions)
+- [Issues](https://github.com/rivet-gg/actor-core/issues)
-- Join our [**Discord**](https://rivet.gg/discord)
-- Follow us on [**X**](https://x.com/rivet_gg)
-- Follow us on [**Bluesky**](https://bsky.app/profile/rivet.gg)
-- File bug reports in [**GitHub Issues**](https://github.com/rivet-gg/actor-core/issues)
-- Post questions & ideas in [**GitHub Discussions**](https://github.com/rivet-gg/actor-core/discussions)
+## Frequently Asked Questions
+
+
+How is ActorCore different than Rivet Actors?
+
+ActorCore is a framework written in TypeScript that provides high-level functionality. Rivet is an open-source serverless platform written in Rust with features tailored for stateful serverless.
+
+You can think of it as ActorCore is to Rivet as Next.js is to Vercel.
+
+While Rivet is the primary maintainer of ActorCore, we intend for this to be community driven.
+
+
+
+How does stateful serverless compare to the traditional actor model?
+
+Stateful serverless is very similar to actors: it's essentially actors with persistence, and usually doesn't have as rigid constraints on message handling. This makes it more flexible while maintaining the core benefits of the actor model.
+
+
+
+How do stateful and stateless serverless work together?
+
+Stateless serverless works well when you have an external resource that maintains state. Stateful serverless, on the other hand, is almost like a mini-database.
+
+Sometimes it makes sense to use stateless serverless to make requests to multiple stateful serverless instances, orchestrating complex operations across multiple state boundaries.
+
+
+
+How does ActorCore achieve huge performance gains?
+
+By storing state in memory and flushing to a persistence layer, we can serve requests instantly instead of waiting for a round trip to the database. There are additional optimizations that can be made around your state to tune the durability of it.
+
+Additionally, data is stored near your users at the edge, ensuring round-trip times of less than 50ms when they request it. This edge-first approach eliminates the latency typically associated with centralized databases.
+
+
+
+Isn't well-designed software supposed to separate compute and storage?
+
+Some software makes sense to separate β e.g., for data lakes or highly relational data. But at the end of the day, data has to be partitioned somewhere at some point.
+
+Usually "faster" databases like Cassandra, DynamoDB, or Vitess make consistency tradeoffs to get better performance. Stateful serverless forces you to think about how your data is sharded for better performance, better scalability, and less consistency footguns.
+
+
+
+What is stateful serverless not good for?
+
+OLAP, data lakes, graph databases, and highly relational data are currently not ideal use cases for stateful serverless, though it will get better at handling these use cases over time.
+
+
+
+Can this create a single bottleneck?
+
+Yes, but only as much as storing data in a single database row does. We're working on building out read replicas to allow you to perform read-only actions on actors.
+
+
+
+Stateless serverless is standardized under WinterTC. Is there any intention to standardize stateful serverless?
+
+Things are cooking! Check out our [blog post](https://rivet.gg/blog/2025-03-23-what-would-a-w3c-standard-look-like-for-stateful-serverless-) about what a W3C standard for stateful serverless might look like and [the awesome people who are collaborating on this](https://x.com/threepointone/status/1903579571028390038).
+
+
+Have more questions? Join our [Discord](https://discord.gg/rivet) or go to [GitHub Discussions](https://github.com/rivet-gg/actor-core/discussions).
+
+## Roadmap For 2025
+
+We ship fast, so we want to share what you can expect to see before the end of the year.
+Help shape our roadmap by [creating issues](https://github.com/rivet-gg/actor-core/issues) and [joining our Discord](https://rivet.gg/discord).
+
+- [ ] SQLite Support
+- [ ] Oodles Of Examples
+- [ ] SQLite in Studio
+- [ ] Local-First Extensions
+- [ ] Auth Extensions
+- [ ] Workflows
+- [ ] Queues
+- [ ] MCP
+- [ ] Actor-Actor Actions
+- [ ] Cancellable Schedules
+- [ ] Cron Jobs
+- [ ] Drizzle Support
+- [ ] Prisma v7 Support
+- [ ] Read Replicas
+- [ ] Middleware
+- [ ] Schema Validation
+- [ ] Vite Integration
+- [ ] OpenTelemetry
+- [X] Studio
+- [X] File system driver
+- [X] React client
+- [X] Rust client
+- [X] Resend Integration
+- [X] Vitest Integration
+- [X] Non-serialized state
+- [X] `create-actor`
+- [X] `actor-core dev` command
+- [X] Hono Integration
## License
Apache 2.0
+
+_Scale without drama β only with ActorCore._
+
diff --git a/docs/images/clients/react.svg b/docs/images/clients/react.svg
new file mode 100644
index 000000000..001b82e80
--- /dev/null
+++ b/docs/images/clients/react.svg
@@ -0,0 +1 @@
+
diff --git a/docs/images/clients/vue.svg b/docs/images/clients/vue.svg
new file mode 100644
index 000000000..1c509d9d8
--- /dev/null
+++ b/docs/images/clients/vue.svg
@@ -0,0 +1 @@
+
diff --git a/docs/images/platforms/file-system.svg b/docs/images/platforms/file-system.svg
new file mode 100644
index 000000000..ce0539fc8
--- /dev/null
+++ b/docs/images/platforms/file-system.svg
@@ -0,0 +1 @@
+
diff --git a/docs/images/platforms/memory.svg b/docs/images/platforms/memory.svg
new file mode 100644
index 000000000..484a48220
--- /dev/null
+++ b/docs/images/platforms/memory.svg
@@ -0,0 +1 @@
+
diff --git a/docs/snippets/landing-tech.mdx b/docs/snippets/landing-tech.mdx
index fdd524af5..5bff0fc53 100644
--- a/docs/snippets/landing-tech.mdx
+++ b/docs/snippets/landing-tech.mdx
@@ -30,9 +30,7 @@
On The Roadmap
-
-
-
+
AWS Lambda
On The Roadmap
@@ -65,15 +63,11 @@
Help Wanted
-
-
-
+
File System
-
-
-
+
Memory
@@ -95,9 +89,7 @@
Frameworks
diff --git a/examples/snippets/README.md b/examples/snippets/README.md
new file mode 100644
index 000000000..c2a3ae120
--- /dev/null
+++ b/examples/snippets/README.md
@@ -0,0 +1,11 @@
+# ActorCore Snippets
+
+This directory contains the full source code for examples shown in the documentation.
+
+These snippets are not intended to be complete examples.
+
+Each example has these files in a single folder:
+- `actor-json.ts` - Server implementation with JavaScript state
+- `actor-sqlite.ts` - Server implementation with SQLite state
+- `App.tsx` - React client implementation
+
diff --git a/examples/snippets/ai-agent/App.tsx b/examples/snippets/ai-agent/App.tsx
new file mode 100644
index 000000000..93fc5106e
--- /dev/null
+++ b/examples/snippets/ai-agent/App.tsx
@@ -0,0 +1,85 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+import type { App } from "../actors/app";
+import type { Message } from "./actor";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function AIAssistant() {
+ const [{ actor }] = useActor("aiAgent", { tags: { conversationId: "default" } });
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Load initial messages
+ useEffect(() => {
+ if (actor) {
+ actor.getMessages().then(setMessages);
+ }
+ }, [actor]);
+
+ // Listen for real-time messages
+ useActorEvent({ actor, event: "messageReceived" }, (message) => {
+ setMessages(prev => [...prev, message as Message]);
+ setIsLoading(false);
+ });
+
+ const handleSendMessage = async () => {
+ if (actor && input.trim()) {
+ setIsLoading(true);
+
+ // Add user message to UI immediately
+ const userMessage = { role: "user", content: input } as Message;
+ setMessages(prev => [...prev, userMessage]);
+
+ // Send to actor (AI response will come through the event)
+ await actor.sendMessage(input);
+ setInput("");
+ }
+ };
+
+ return (
+
+
+ {messages.length === 0 ? (
+
+ Ask the AI assistant a question to get started
+
+ ) : (
+ messages.map((msg, i) => (
+
+
+ {msg.role === "user" ? "π€" : "π€"}
+
+
{msg.content}
+
+ ))
+ )}
+ {isLoading && (
+
+ )}
+
+
+
+ setInput(e.target.value)}
+ onKeyPress={e => e.key === "Enter" && handleSendMessage()}
+ placeholder="Ask the AI assistant..."
+ disabled={isLoading}
+ />
+
+ Send
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/ai-agent/actor-json.ts b/examples/snippets/ai-agent/actor-json.ts
new file mode 100644
index 000000000..b7ce7d606
--- /dev/null
+++ b/examples/snippets/ai-agent/actor-json.ts
@@ -0,0 +1,65 @@
+import { actor } from "actor-core";
+import { generateText, tool } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { getWeather } from "./my-utils";
+
+export type Message = { role: "user" | "assistant"; content: string; timestamp: number; }
+
+const aiAgent = actor({
+ // State is automatically persisted
+ state: {
+ messages: [] as Message[]
+ },
+
+ actions: {
+ // Get conversation history
+ getMessages: (c) => c.state.messages,
+
+ // Send a message to the AI and get a response
+ sendMessage: async (c, userMessage: string) => {
+ // Add user message to conversation
+ const userMsg: Message = {
+ role: "user",
+ content: userMessage,
+ timestamp: Date.now()
+ };
+ c.state.messages.push(userMsg);
+
+ // Generate AI response using Vercel AI SDK with tools
+ const { text } = await generateText({
+ model: openai("o3-mini"),
+ prompt: userMessage,
+ messages: c.state.messages,
+ tools: {
+ weather: tool({
+ description: 'Get the weather in a location',
+ parameters: {
+ location: {
+ type: 'string',
+ description: 'The location to get the weather for',
+ },
+ },
+ execute: async ({ location }) => {
+ return await getWeather(location);
+ },
+ }),
+ },
+ });
+
+ // Add AI response to conversation
+ const assistantMsg: Message = {
+ role: "assistant",
+ content: text,
+ timestamp: Date.now()
+ };
+ c.state.messages.push(assistantMsg);
+
+ // Broadcast the new message to all connected clients
+ c.broadcast("messageReceived", assistantMsg);
+
+ return assistantMsg;
+ },
+ }
+});
+
+export default aiAgent;
\ No newline at end of file
diff --git a/examples/snippets/ai-agent/actor-sqlite.ts b/examples/snippets/ai-agent/actor-sqlite.ts
new file mode 100644
index 000000000..b61b19eb1
--- /dev/null
+++ b/examples/snippets/ai-agent/actor-sqlite.ts
@@ -0,0 +1,86 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { generateText, tool } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { getWeather } from "./my-utils";
+import { messages } from "./schema";
+
+export type Message = { role: "user" | "assistant"; content: string; timestamp: number; }
+
+const aiAgent = actor({
+ sql: drizzle(),
+
+ actions: {
+ // Get conversation history
+ getMessages: async (c) => {
+ const result = await c.db
+ .select()
+ .from(messages)
+ .orderBy(messages.timestamp.asc());
+
+ return result;
+ },
+
+ // Send a message to the AI and get a response
+ sendMessage: async (c, userMessage: string) => {
+ const now = Date.now();
+
+ // Add user message to conversation
+ const userMsg = {
+ conversationId: c.actorId, // Use the actor instance ID
+ role: "user",
+ content: userMessage,
+ };
+
+ // Store user message
+ await c.db
+ .insert(messages)
+ .values(userMsg);
+
+ // Get all messages
+ const allMessages = await c.db
+ .select()
+ .from(messages)
+ .orderBy(messages.timestamp.asc());
+
+ // Generate AI response using Vercel AI SDK with tools
+ const { text } = await generateText({
+ model: openai("o3-mini"),
+ prompt: userMessage,
+ messages: allMessages,
+ tools: {
+ weather: tool({
+ description: 'Get the weather in a location',
+ parameters: {
+ location: {
+ type: 'string',
+ description: 'The location to get the weather for',
+ },
+ },
+ execute: async ({ location }) => {
+ return await getWeather(location);
+ },
+ }),
+ },
+ });
+
+ // Add AI response to conversation
+ const assistantMsg = {
+ role: "assistant",
+ content: text,
+ };
+
+ // Store assistant message
+ await c.db
+ .insert(messages)
+ .values(assistantMsg);
+
+ // Broadcast the new message to all connected clients
+ c.broadcast("messageReceived", assistantMsg);
+
+ return assistantMsg;
+ },
+ }
+});
+
+export default aiAgent;
\ No newline at end of file
diff --git a/examples/snippets/chat-room/App.tsx b/examples/snippets/chat-room/App.tsx
new file mode 100644
index 000000000..6b7e631f2
--- /dev/null
+++ b/examples/snippets/chat-room/App.tsx
@@ -0,0 +1,71 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+import type { App } from "../actors/app";
+import type { Message } from "./actor";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function ChatRoom({ roomId = "general" }) {
+ // Connect to specific chat room using tags
+ const [{ actor }] = useActor("chatRoom", {
+ tags: { roomId }
+ });
+
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+
+ // Load initial state
+ useEffect(() => {
+ if (actor) {
+ // Load chat history
+ actor.getHistory().then(setMessages);
+ }
+ }, [actor]);
+
+ // Listen for real-time updates from the server
+ useActorEvent({ actor, event: "newMessage" }, (message) => {
+ setMessages(prev => [...prev, message]);
+ });
+
+ const sendMessage = () => {
+ if (actor && input.trim()) {
+ actor.sendMessage("User", input);
+ setInput("");
+ }
+ };
+
+ return (
+
+
+
Chat Room: {roomId}
+
+
+
+ {messages.length === 0 ? (
+
No messages yet. Start the conversation!
+ ) : (
+ messages.map((msg, i) => (
+
+ {msg.sender}: {msg.text}
+
+ {new Date(msg.timestamp).toLocaleTimeString()}
+
+
+ ))
+ )}
+
+
+
+ setInput(e.target.value)}
+ onKeyPress={e => e.key === "Enter" && sendMessage()}
+ placeholder="Type a message..."
+ />
+ Send
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/chat-room/actor-json.ts b/examples/snippets/chat-room/actor-json.ts
new file mode 100644
index 000000000..60f486c6a
--- /dev/null
+++ b/examples/snippets/chat-room/actor-json.ts
@@ -0,0 +1,31 @@
+import { actor } from "actor-core";
+
+export type Message = { sender: string; text: string; timestamp: number; }
+
+const chatRoom = actor({
+ // State is automatically persisted
+ state: {
+ messages: [] as Message[]
+ },
+
+ // Initialize the room
+ createState: () => ({
+ messages: []
+ }),
+
+ actions: {
+ sendMessage: (c, sender: string, text: string) => {
+ const message = { sender, text, timestamp: Date.now() };
+
+ // Any changes to state are automatically saved
+ c.state.messages.push(message);
+
+ // Broadcast events trigger real-time updates in connected clients
+ c.broadcast("newMessage", message);
+ },
+
+ getHistory: (c) => c.state.messages
+ }
+});
+
+export default chatRoom;
\ No newline at end of file
diff --git a/examples/snippets/chat-room/actor-sqlite.ts b/examples/snippets/chat-room/actor-sqlite.ts
new file mode 100644
index 000000000..fe244d3cd
--- /dev/null
+++ b/examples/snippets/chat-room/actor-sqlite.ts
@@ -0,0 +1,40 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { messages } from "./schema";
+
+export type Message = { sender: string; text: string; timestamp: number; }
+
+const chatRoom = actor({
+ sql: drizzle(),
+
+ actions: {
+ sendMessage: async (c, sender: string, text: string) => {
+ const message = {
+ sender,
+ text,
+ timestamp: Date.now(),
+ };
+
+ // Insert the message into SQLite
+ await c.db.insert(messages).values(message);
+
+ // Broadcast to all connected clients
+ c.broadcast("newMessage", message);
+
+ // Return the created message (matches JS memory version)
+ return message;
+ },
+
+ getHistory: async (c) => {
+ // Query all messages ordered by timestamp
+ const result = await c.db
+ .select()
+ .from(messages)
+ .orderBy(messages.timestamp);
+
+ return result as Message[];
+ }
+ }
+});
+
+export default chatRoom;
\ No newline at end of file
diff --git a/examples/snippets/crdt/App.tsx b/examples/snippets/crdt/App.tsx
new file mode 100644
index 000000000..1692fb23f
--- /dev/null
+++ b/examples/snippets/crdt/App.tsx
@@ -0,0 +1,178 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect, useRef } from "react";
+import * as Y from 'yjs';
+import { applyUpdate, encodeStateAsUpdate } from 'yjs';
+import type { App } from "../actors/app";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function YjsEditor({ documentId = "shared-doc" }) {
+ // Connect to specific document using tags
+ const { actor } = useActor("yjsDocument", {
+ tags: { documentId }
+ });
+
+ // Document state
+ const [isLoading, setIsLoading] = useState(true);
+ const [text, setText] = useState("");
+
+ // Local Yjs document
+ const yDocRef = useRef(null);
+ // Flag to prevent infinite update loops
+ const updatingFromServer = useRef(false);
+ const updatingFromLocal = useRef(false);
+ // Track if we've initialized observation
+ const observationInitialized = useRef(false);
+
+ // Initialize local Yjs document and connect
+ useEffect(() => {
+ // Create Yjs document
+ const yDoc = new Y.Doc();
+ yDocRef.current = yDoc;
+ setIsLoading(false);
+
+ return () => {
+ // Clean up Yjs document
+ yDoc.destroy();
+ };
+ }, [actor]);
+
+ // Set up text observation
+ useEffect(() => {
+ const yDoc = yDocRef.current;
+ if (!yDoc || observationInitialized.current) return;
+
+ // Get the Yjs Text type from the document
+ const yText = yDoc.getText('content');
+
+ // Observe changes to the text
+ yText.observe(() => {
+ // Only update UI if change wasn't from server
+ if (!updatingFromServer.current) {
+ // Update React state
+ setText(yText.toString());
+
+ if (actor && !updatingFromLocal.current) {
+ // Set flag to prevent loops
+ updatingFromLocal.current = true;
+
+ // Convert update to base64 and send to server
+ const update = encodeStateAsUpdate(yDoc);
+ const base64 = bufferToBase64(update);
+ actor.applyUpdate(base64).finally(() => {
+ updatingFromLocal.current = false;
+ });
+ }
+ }
+ });
+
+ observationInitialized.current = true;
+ }, [actor]);
+
+ // Handle initial state from server
+ useActorEvent({ actor, event: "initialState" }, ({ update }) => {
+ const yDoc = yDocRef.current;
+ if (!yDoc) return;
+
+ // Set flag to prevent update loops
+ updatingFromServer.current = true;
+
+ try {
+ // Convert base64 to binary and apply to document
+ const binary = atob(update);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ // Apply update to Yjs document
+ applyUpdate(yDoc, bytes);
+
+ // Update React state
+ const yText = yDoc.getText('content');
+ setText(yText.toString());
+ } catch (error) {
+ console.error("Error applying initial update:", error);
+ } finally {
+ updatingFromServer.current = false;
+ }
+ });
+
+ // Handle updates from other clients
+ useActorEvent({ actor, event: "update" }, ({ update }) => {
+ const yDoc = yDocRef.current;
+ if (!yDoc) return;
+
+ // Set flag to prevent update loops
+ updatingFromServer.current = true;
+
+ try {
+ // Convert base64 to binary and apply to document
+ const binary = atob(update);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ // Apply update to Yjs document
+ applyUpdate(yDoc, bytes);
+
+ // Update React state
+ const yText = yDoc.getText('content');
+ setText(yText.toString());
+ } catch (error) {
+ console.error("Error applying update:", error);
+ } finally {
+ updatingFromServer.current = false;
+ }
+ });
+
+ // Handle text changes from user
+ const handleTextChange = (e: React.ChangeEvent) => {
+ if (!yDocRef.current) return;
+
+ const newText = e.target.value;
+ const yText = yDocRef.current.getText('content');
+
+ // Only update if text actually changed
+ if (newText !== yText.toString()) {
+ // Set flag to avoid loops
+ updatingFromLocal.current = true;
+
+ // Update Yjs document (this will trigger observe callback)
+ yDocRef.current.transact(() => {
+ yText.delete(0, yText.length);
+ yText.insert(0, newText);
+ });
+
+ updatingFromLocal.current = false;
+ }
+ };
+
+ if (isLoading) {
+ return Loading collaborative document...
;
+ }
+
+ return (
+
+
Collaborative Document: {documentId}
+
+
+ );
+}
+
+// Helper to convert ArrayBuffer to base64
+function bufferToBase64(buffer: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < buffer.byteLength; i++) {
+ binary += String.fromCharCode(buffer[i]);
+ }
+ return btoa(binary);
+}
\ No newline at end of file
diff --git a/examples/snippets/crdt/actor-json.ts b/examples/snippets/crdt/actor-json.ts
new file mode 100644
index 000000000..049368208
--- /dev/null
+++ b/examples/snippets/crdt/actor-json.ts
@@ -0,0 +1,72 @@
+import { actor } from "actor-core";
+import * as Y from 'yjs';
+import { encodeStateAsUpdate, applyUpdate } from 'yjs';
+
+const yjsDocument = actor({
+ // State: just the serialized Yjs document data
+ state: {
+ docData: "", // Base64 encoded Yjs document
+ lastModified: 0
+ },
+
+ // In-memory Yjs objects (not serialized)
+ createVars: () => ({
+ doc: new Y.Doc()
+ }),
+
+ // Initialize document from state when actor starts
+ onStart: (c) => {
+ if (c.state.docData) {
+ const binary = atob(c.state.docData);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ applyUpdate(c.vars.doc, bytes);
+ }
+ },
+
+ // Handle client connections
+ onConnect: (c) => {
+ // Send initial document state to client
+ const update = encodeStateAsUpdate(c.vars.doc);
+ const base64 = bufferToBase64(update);
+
+ c.conn.send("initialState", { update: base64 });
+ },
+
+
+ actions: {
+ // Apply a Yjs update from a client
+ applyUpdate: (c, updateBase64: string) => {
+ // Convert base64 to binary
+ const binary = atob(updateBase64);
+ const update = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ update[i] = binary.charCodeAt(i);
+ }
+
+ // Apply update to Yjs document
+ applyUpdate(c.vars.doc, update);
+
+ // Save document state
+ const fullState = encodeStateAsUpdate(c.vars.doc);
+ c.state.docData = bufferToBase64(fullState);
+ c.state.lastModified = Date.now();
+
+ // Broadcast to all clients
+ c.broadcast("update", { update: updateBase64 });
+ }
+ }
+});
+
+// Helper to convert ArrayBuffer to base64
+function bufferToBase64(buffer: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < buffer.byteLength; i++) {
+ binary += String.fromCharCode(buffer[i]);
+ }
+ return btoa(binary);
+}
+
+export default yjsDocument;
\ No newline at end of file
diff --git a/examples/snippets/crdt/actor-sqlite.ts b/examples/snippets/crdt/actor-sqlite.ts
new file mode 100644
index 000000000..366a70b07
--- /dev/null
+++ b/examples/snippets/crdt/actor-sqlite.ts
@@ -0,0 +1,96 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import * as Y from 'yjs';
+import { encodeStateAsUpdate, applyUpdate } from 'yjs';
+import { documents } from "./schema";
+
+const yjsDocument = actor({
+ sql: drizzle(),
+
+ // In-memory Yjs objects (not serialized)
+ createVars: () => ({
+ doc: new Y.Doc()
+ }),
+
+ // Initialize document from state when actor starts
+ onStart: async (c) => {
+ // Get document data from database
+ const documentData = await c.db
+ .select()
+ .from(documents)
+ .get();
+
+ if (documentData?.docData) {
+ try {
+ // Parse the docData from string to binary
+ const binary = atob(documentData.docData);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ applyUpdate(c.vars.doc, bytes);
+ } catch (error) {
+ console.error("Failed to load document", error);
+ }
+ }
+ },
+
+ // Handle client connections
+ onConnect: (c) => {
+ // Send initial document state to client
+ const update = encodeStateAsUpdate(c.vars.doc);
+ const base64 = bufferToBase64(update);
+
+ c.conn.send("initialState", { update: base64 });
+ },
+
+ actions: {
+ // Apply a Yjs update from a client
+ applyUpdate: async (c, updateBase64: string) => {
+ try {
+ // Convert base64 to binary
+ const binary = atob(updateBase64);
+ const update = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ update[i] = binary.charCodeAt(i);
+ }
+
+ // Apply update to Yjs document
+ applyUpdate(c.vars.doc, update);
+
+ // Save document state to database
+ const fullState = encodeStateAsUpdate(c.vars.doc);
+ const docData = bufferToBase64(fullState);
+
+ // Store in database
+ await c.db
+ .insert(documents)
+ .values({
+ docData
+ })
+ .onConflictDoUpdate({
+ target: documents.id,
+ set: {
+ docData
+ }
+ });
+
+ // Broadcast to all clients
+ c.broadcast("update", { update: updateBase64 });
+ } catch (error) {
+ console.error("Failed to apply update", error);
+ }
+ }
+ }
+});
+
+// Helper to convert ArrayBuffer to base64
+function bufferToBase64(buffer: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < buffer.byteLength; i++) {
+ binary += String.fromCharCode(buffer[i]);
+ }
+ return btoa(binary);
+}
+
+export default yjsDocument;
\ No newline at end of file
diff --git a/examples/snippets/database/App.tsx b/examples/snippets/database/App.tsx
new file mode 100644
index 000000000..03bd364c2
--- /dev/null
+++ b/examples/snippets/database/App.tsx
@@ -0,0 +1,77 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function NotesApp({ userId }: { userId: string }) {
+ const [notes, setNotes] = useState>([]);
+ const [newNote, setNewNote] = useState("");
+
+ // Connect to actor with auth token
+ const [{ actor }] = useActor("notes", {
+ params: { userId, token: "demo-token" }
+ });
+
+ // Load initial notes
+ useEffect(() => {
+ if (actor) {
+ actor.getNotes().then(setNotes);
+ }
+ }, [actor]);
+
+ // Add a new note
+ const addNote = async () => {
+ if (actor && newNote.trim()) {
+ await actor.updateNote({ id: `note-${Date.now()}`, content: newNote });
+ setNewNote("");
+ }
+ };
+
+ // Delete a note
+ const deleteNote = (id: string) => {
+ if (actor) {
+ actor.deleteNote({ id });
+ }
+ };
+
+ // Listen for realtime updates
+ useActorEvent({ actor, event: "noteAdded" }, (note) => {
+ setNotes(notes => [...notes, note]);
+ });
+
+ useActorEvent({ actor, event: "noteUpdated" }, (updatedNote) => {
+ setNotes(notes => notes.map(note =>
+ note.id === updatedNote.id ? updatedNote : note
+ ));
+ });
+
+ useActorEvent({ actor, event: "noteDeleted" }, ({ id }) => {
+ setNotes(notes => notes.filter(note => note.id !== id));
+ });
+
+ return (
+
+
My Notes
+
+
+ setNewNote(e.target.value)}
+ placeholder="Enter a new note"
+ />
+ Add
+
+
+
+ {notes.map(note => (
+
+ {note.content}
+ deleteNote(note.id)}>Delete
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/database/actor-json.ts b/examples/snippets/database/actor-json.ts
new file mode 100644
index 000000000..25a6258c7
--- /dev/null
+++ b/examples/snippets/database/actor-json.ts
@@ -0,0 +1,59 @@
+import { actor } from "actor-core";
+import { authenticate } from "./my-utils";
+
+export type Note = { id: string; content: string; updatedAt: number };
+
+// User notes actor
+const notes = actor({
+ state: {
+ notes: [] as Note[]
+ },
+
+ // Authenticate
+ createConnState: async (c, { params }) => {
+ const token = params.token;
+ const userId = await authenticate(token);
+ return { userId };
+ },
+
+ actions: {
+ // Get all notes
+ getNotes: (c) => c.state.notes,
+
+ // Update note or create if it doesn't exist
+ updateNote: (c, { id, content }) => {
+ const noteIndex = c.state.notes.findIndex(note => note.id === id);
+ let note;
+
+ if (noteIndex >= 0) {
+ // Update existing note
+ note = c.state.notes[noteIndex];
+ note.content = content;
+ note.updatedAt = Date.now();
+ c.broadcast("noteUpdated", note);
+ } else {
+ // Create new note
+ note = {
+ id: id || `note-${Date.now()}`,
+ content,
+ updatedAt: Date.now()
+ };
+ c.state.notes.push(note);
+ c.broadcast("noteAdded", note);
+ }
+
+ return note;
+ },
+
+ // Delete note
+ deleteNote: (c, { id }) => {
+ const noteIndex = c.state.notes.findIndex(note => note.id === id);
+ if (noteIndex >= 0) {
+ c.state.notes.splice(noteIndex, 1);
+ c.broadcast("noteDeleted", { id });
+ }
+ }
+ }
+});
+
+export default notes;
\ No newline at end of file
diff --git a/examples/snippets/database/actor-sqlite.ts b/examples/snippets/database/actor-sqlite.ts
new file mode 100644
index 000000000..e0074963c
--- /dev/null
+++ b/examples/snippets/database/actor-sqlite.ts
@@ -0,0 +1,85 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { notes } from "./schema";
+import { authenticate } from "./my-utils";
+
+export type Note = { id: string; content: string; updatedAt: number };
+
+// User notes actor
+const userNotes = actor({
+ sql: drizzle(),
+
+ // Authenticate
+ createConnState: async (c, { params }) => {
+ const token = params.token;
+ const userId = await authenticate(token);
+ return { userId };
+ },
+
+ actions: {
+ // Get all notes
+ getNotes: async (c) => {
+ const result = await c.db
+ .select()
+ .from(notes);
+
+ return result;
+ },
+
+ // Update note or create if it doesn't exist
+ updateNote: async (c, { id, content }) => {
+ // Ensure the note ID exists or create a new one
+ const noteId = id || `note-${Date.now()}`;
+
+ // Check if note exists
+ const existingNote = await c.db
+ .select()
+ .from(notes)
+ .where(notes.id.equals(noteId))
+ .get();
+
+ if (existingNote) {
+ // Update existing note
+ await c.db
+ .update(notes)
+ .set({
+ content
+ })
+ .where(notes.id.equals(noteId));
+
+ const updatedNote = {
+ id: noteId,
+ content
+ };
+
+ c.broadcast("noteUpdated", updatedNote);
+ return updatedNote;
+ } else {
+ // Create new note
+ const newNote = {
+ id: noteId,
+ content
+ };
+
+ await c.db
+ .insert(notes)
+ .values(newNote);
+
+ c.broadcast("noteAdded", newNote);
+ return newNote;
+ }
+ },
+
+ // Delete note
+ deleteNote: async (c, { id }) => {
+ // Delete the note
+ await c.db
+ .delete(notes)
+ .where(notes.id.equals(id));
+
+ c.broadcast("noteDeleted", { id });
+ }
+ }
+});
+
+export default userNotes;
\ No newline at end of file
diff --git a/examples/snippets/document/App.tsx b/examples/snippets/document/App.tsx
new file mode 100644
index 000000000..ef66ffbde
--- /dev/null
+++ b/examples/snippets/document/App.tsx
@@ -0,0 +1,91 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+import type { App } from "../actors/app";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function DocumentEditor() {
+ // Connect to actor for this document ID from URL
+ const documentId = new URLSearchParams(window.location.search).get('id') || 'default-doc';
+ const [{ actor, connectionId }] = useActor("document", { tags: { id: documentId } });
+
+ // Local state
+ const [text, setText] = useState("");
+ const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
+ const [otherCursors, setOtherCursors] = useState({});
+
+ // Load initial document state
+ useEffect(() => {
+ if (actor) {
+ actor.getText().then(setText);
+ actor.getCursors().then(setOtherCursors);
+ }
+ }, [actor]);
+
+ // Listen for updates from other users
+ useActorEvent({ actor, event: "textUpdated" }, ({ text: newText, userId: senderId }) => {
+ if (senderId !== connectionId) setText(newText);
+ });
+
+ useActorEvent({ actor, event: "cursorUpdated" }, ({ userId: cursorUserId, x, y }) => {
+ if (cursorUserId !== connectionId) {
+ setOtherCursors(prev => ({
+ ...prev,
+ [cursorUserId]: { x, y, userId: cursorUserId }
+ }));
+ }
+ });
+
+ // Update cursor position
+ const updateCursor = (e) => {
+ if (!actor) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (x !== cursorPos.x || y !== cursorPos.y) {
+ setCursorPos({ x, y });
+ actor.updateCursor(x, y);
+ }
+ };
+
+ return (
+
+
Document: {documentId}
+
+
+
+
+
Connected users: You and {Object.keys(otherCursors).length} others
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/document/actor-json.ts b/examples/snippets/document/actor-json.ts
new file mode 100644
index 000000000..039d73a78
--- /dev/null
+++ b/examples/snippets/document/actor-json.ts
@@ -0,0 +1,43 @@
+import { actor } from "actor-core";
+
+export type Cursor = { x: number, y: number, userId: string };
+
+const document = actor({
+ state: {
+ text: "",
+ cursors: {} as Record,
+ },
+
+ actions: {
+ getText: (c) => c.state.text,
+
+ // Update the document (real use case has better conflict resolution)
+ setText: (c, text: string) => {
+ // Save document state
+ c.state.text = text;
+
+ // Broadcast update
+ c.broadcast("textUpdated", {
+ text,
+ userId: c.conn.id
+ });
+ },
+
+ getCursors: (c) => c.state.cursors,
+
+ updateCursor: (c, x: number, y: number) => {
+ // Update user location
+ const userId = c.conn.id;
+ c.state.cursors[userId] = { x, y, userId };
+
+ // Broadcast location
+ c.broadcast("cursorUpdated", {
+ userId,
+ x,
+ y
+ });
+ },
+ }
+});
+
+export default document;
\ No newline at end of file
diff --git a/examples/snippets/document/actor-sqlite.ts b/examples/snippets/document/actor-sqlite.ts
new file mode 100644
index 000000000..dc7c73a0e
--- /dev/null
+++ b/examples/snippets/document/actor-sqlite.ts
@@ -0,0 +1,87 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { documents, cursors } from "./schema";
+
+export type Cursor = { x: number, y: number, userId: string };
+
+const document = actor({
+ sql: drizzle(),
+
+ actions: {
+ getText: async (c) => {
+ const doc = await c.db
+ .select()
+ .from(documents)
+ .get();
+
+ return doc?.text || "";
+ },
+
+ // Update the document (real use case has better conflict resolution)
+ setText: async (c, text: string) => {
+ // Save document state
+ await c.db
+ .insert(documents)
+ .values({
+ text
+ })
+ .onConflictDoUpdate({
+ target: documents.id,
+ set: {
+ text
+ }
+ });
+
+ // Broadcast update
+ c.broadcast("textUpdated", {
+ text,
+ userId: c.conn.id
+ });
+ },
+
+ getCursors: async (c) => {
+ const result = await c.db
+ .select()
+ .from(cursors);
+
+ // Convert array to record object keyed by userId
+ return result.reduce((acc, cursor) => {
+ acc[cursor.userId] = {
+ x: cursor.x,
+ y: cursor.y,
+ userId: cursor.userId
+ };
+ return acc;
+ }, {} as Record);
+ },
+
+ updateCursor: async (c, x: number, y: number) => {
+ // Update user location
+ const userId = c.conn.id;
+
+ await c.db
+ .insert(cursors)
+ .values({
+ userId,
+ x,
+ y
+ })
+ .onConflictDoUpdate({
+ target: cursors.userId,
+ set: {
+ x,
+ y
+ }
+ });
+
+ // Broadcast location
+ c.broadcast("cursorUpdated", {
+ userId,
+ x,
+ y
+ });
+ },
+ }
+});
+
+export default document;
\ No newline at end of file
diff --git a/examples/snippets/game/App.tsx b/examples/snippets/game/App.tsx
new file mode 100644
index 000000000..4b6265542
--- /dev/null
+++ b/examples/snippets/game/App.tsx
@@ -0,0 +1,91 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect, useRef } from "react";
+import type { Player } from "./actor";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function MultiplayerGame() {
+ const [{ actor, connectionId }] = useActor("gameRoom");
+ const [players, setPlayers] = useState([]);
+ const canvasRef = useRef(null);
+ const keysPressed = useRef>({});
+
+ // Set up game
+ useEffect(() => {
+ if (!actor) return;
+
+ // Set up keyboard handlers
+ const handleKeyDown = (e: KeyboardEvent) => {
+ keysPressed.current[e.key.toLowerCase()] = true;
+ };
+
+ const handleKeyUp = (e: KeyboardEvent) => {
+ keysPressed.current[e.key.toLowerCase()] = false;
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+
+ // Input update loop
+ const inputInterval = setInterval(() => {
+ const input = { x: 0, y: 0 };
+
+ if (keysPressed.current["w"] || keysPressed.current["arrowup"]) input.y = -1;
+ if (keysPressed.current["s"] || keysPressed.current["arrowdown"]) input.y = 1;
+ if (keysPressed.current["a"] || keysPressed.current["arrowleft"]) input.x = -1;
+ if (keysPressed.current["d"] || keysPressed.current["arrowright"]) input.x = 1;
+
+ actor.setInput(input);
+ }, 50);
+
+ // Rendering loop
+ const renderLoop = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Use for loop instead of forEach
+ for (let i = 0; i < players.length; i++) {
+ const player = players[i];
+ ctx.fillStyle = player.id === connectionId ? "blue" : "gray";
+ ctx.beginPath();
+ ctx.arc(player.position.x, player.position.y, 10, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ requestAnimationFrame(renderLoop);
+ };
+
+ const animationId = requestAnimationFrame(renderLoop);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ clearInterval(inputInterval);
+ cancelAnimationFrame(animationId);
+ };
+ }, [actor, connectionId, players]);
+
+ // Listen for world updates
+ useActorEvent({ actor, event: "worldUpdate" }, ({ playerList }) => {
+ setPlayers(playerList);
+ });
+
+ return (
+
+
+
Move: WASD or Arrow Keys
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/game/actor-json.ts b/examples/snippets/game/actor-json.ts
new file mode 100644
index 000000000..fd66fc744
--- /dev/null
+++ b/examples/snippets/game/actor-json.ts
@@ -0,0 +1,66 @@
+import { actor } from "actor-core";
+
+export type Position = { x: number; y: number };
+export type Input = { x: number; y: number };
+export type Player = { id: string; position: Position; input: Input };
+
+const gameRoom = actor({
+ state: {
+ players: {} as Record,
+ mapSize: 800
+ },
+
+ onStart: (c) => {
+ // Set up game update loop
+ setInterval(() => {
+ const worldUpdate = { playerList: [] };
+
+ for (const id in c.state.players) {
+ const player = c.state.players[id];
+ const speed = 5;
+
+ // Update position based on input
+ player.position.x += player.input.x * speed;
+ player.position.y += player.input.y * speed;
+
+ // Keep player in bounds
+ player.position.x = Math.max(0, Math.min(player.position.x, c.state.mapSize));
+ player.position.y = Math.max(0, Math.min(player.position.y, c.state.mapSize));
+
+ // Add to list for broadcast
+ worldUpdate.playerList.push(player);
+ }
+
+ // Broadcast world state
+ c.broadcast("worldUpdate", worldUpdate);
+ }, 50);
+ },
+
+ // Add player to game
+ onConnect: (c) => {
+ const id = c.conn.id;
+ c.state.players[id] = {
+ id,
+ position: {
+ x: Math.floor(Math.random() * c.state.mapSize),
+ y: Math.floor(Math.random() * c.state.mapSize)
+ },
+ input: { x: 0, y: 0 }
+ };
+ },
+
+ // Remove player from game
+ onDisconnect: (c) => {
+ delete c.state.players[c.conn.id];
+ },
+
+ actions: {
+ // Update movement
+ setInput: (c, input: Input) => {
+ const player = c.state.players[c.conn.id];
+ if (player) player.input = input;
+ }
+ }
+});
+
+export default gameRoom;
\ No newline at end of file
diff --git a/examples/snippets/game/actor-sqlite.ts b/examples/snippets/game/actor-sqlite.ts
new file mode 100644
index 000000000..5c178507f
--- /dev/null
+++ b/examples/snippets/game/actor-sqlite.ts
@@ -0,0 +1,169 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { players, gameSettings } from "./schema";
+
+export type Position = { x: number; y: number };
+export type Input = { x: number; y: number };
+export type Player = { id: string; position: Position; input: Input };
+
+const gameRoom = actor({
+ sql: drizzle(),
+
+ // Store game settings and player inputs in memory for performance
+ createVars: () => ({
+ playerCache: {} as Record,
+ mapSize: 800
+ }),
+
+ onStart: async (c) => {
+ // Get or initialize game settings
+ const settings = await c.db
+ .select()
+ .from(gameSettings)
+ .get();
+
+ if (settings) {
+ c.vars.mapSize = settings.mapSize;
+ } else {
+ await c.db
+ .insert(gameSettings)
+ .values({
+ mapSize: c.vars.mapSize
+ });
+ }
+
+ // Load existing players into memory
+ const existingPlayers = await c.db
+ .select()
+ .from(players);
+
+ for (const player of existingPlayers) {
+ c.vars.playerCache[player.id] = {
+ id: player.id,
+ position: {
+ x: player.positionX,
+ y: player.positionY
+ },
+ input: {
+ x: player.inputX,
+ y: player.inputY
+ }
+ };
+ }
+
+ // Set up game update loop
+ setInterval(async () => {
+ const worldUpdate = { playerList: [] };
+ let changed = false;
+
+ for (const id in c.vars.playerCache) {
+ const player = c.vars.playerCache[id];
+ const speed = 5;
+
+ // Update position based on input
+ player.position.x += player.input.x * speed;
+ player.position.y += player.input.y * speed;
+
+ // Keep player in bounds
+ player.position.x = Math.max(0, Math.min(player.position.x, c.vars.mapSize));
+ player.position.y = Math.max(0, Math.min(player.position.y, c.vars.mapSize));
+
+ // Add to list for broadcast
+ worldUpdate.playerList.push(player);
+ changed = true;
+ }
+
+ // Save player positions to database if changed
+ if (changed) {
+ for (const id in c.vars.playerCache) {
+ const player = c.vars.playerCache[id];
+
+ await c.db
+ .update(players)
+ .set({
+ positionX: player.position.x,
+ positionY: player.position.y
+ })
+ .where(players.id.equals(id));
+ }
+
+ // Broadcast world state
+ c.broadcast("worldUpdate", worldUpdate);
+ }
+ }, 50);
+ },
+
+ // Add player to game
+ onConnect: async (c) => {
+ const id = c.conn.id;
+ const randomX = Math.floor(Math.random() * c.vars.mapSize);
+ const randomY = Math.floor(Math.random() * c.vars.mapSize);
+
+ // Create player in memory cache
+ c.vars.playerCache[id] = {
+ id,
+ position: {
+ x: randomX,
+ y: randomY
+ },
+ input: { x: 0, y: 0 }
+ };
+
+ // Save player to database
+ await c.db
+ .insert(players)
+ .values({
+ id,
+ positionX: randomX,
+ positionY: randomY,
+ inputX: 0,
+ inputY: 0
+ })
+ .onConflictDoUpdate({
+ target: players.id,
+ set: {
+ positionX: randomX,
+ positionY: randomY,
+ inputX: 0,
+ inputY: 0
+ }
+ });
+ },
+
+ // Remove player from game
+ onDisconnect: async (c) => {
+ const id = c.conn.id;
+
+ // Remove from memory cache
+ delete c.vars.playerCache[id];
+
+ // Remove from database
+ await c.db
+ .delete(players)
+ .where(players.id.equals(id));
+ },
+
+ actions: {
+ // Update movement
+ setInput: async (c, input: Input) => {
+ const id = c.conn.id;
+ const player = c.vars.playerCache[id];
+
+ if (player) {
+ // Update in memory for fast response
+ player.input = input;
+
+ // Update in database
+ await c.db
+ .update(players)
+ .set({
+ inputX: input.x,
+ inputY: input.y
+ })
+ .where(players.id.equals(id));
+ }
+ }
+ }
+});
+
+export default gameRoom;
\ No newline at end of file
diff --git a/examples/snippets/rate/App.tsx b/examples/snippets/rate/App.tsx
new file mode 100644
index 000000000..2962ed5b0
--- /dev/null
+++ b/examples/snippets/rate/App.tsx
@@ -0,0 +1,41 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState } from "react";
+import type { App } from "../actors/app";
+
+const client = createClient("http://localhost:6420");
+const { useActor } = createReactActorCore(client);
+
+export function RateLimiter() {
+ // Connect to API rate limiter for user-123
+ const [{ actor }] = useActor("rateLimiter", { tags: { userId: "user-123" } });
+ const [result, setResult] = useState<{
+ allowed: boolean;
+ remaining: number;
+ resetsIn: number;
+ } | null>(null);
+
+ // Make a request
+ const makeRequest = async () => {
+ if (!actor) return;
+
+ const response = await actor.checkLimit();
+ setResult(response);
+ };
+
+ return (
+
+
Rate Limiter (5 req/min)
+
+
Make Request
+
+ {result && (
+
+
Status: {result.allowed ? "Allowed" : "Blocked"}
+
Remaining: {result.remaining}
+
Resets in: {result.resetsIn} seconds
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/rate/actor-json.ts b/examples/snippets/rate/actor-json.ts
new file mode 100644
index 000000000..acdba6d74
--- /dev/null
+++ b/examples/snippets/rate/actor-json.ts
@@ -0,0 +1,38 @@
+import { actor } from "actor-core";
+
+// Simple rate limiter - allows 5 requests per minute
+const rateLimiter = actor({
+ state: {
+ count: 0,
+ resetAt: 0
+ },
+
+ actions: {
+ // Check if request is allowed
+ checkLimit: (c) => {
+ const now = Date.now();
+
+ // Reset if expired
+ if (now > c.state.resetAt) {
+ c.state.count = 0;
+ c.state.resetAt = now + 60000; // 1 minute window
+ }
+
+ // Check if under limit
+ const allowed = c.state.count < 5;
+
+ // Increment if allowed
+ if (allowed) {
+ c.state.count++;
+ }
+
+ return {
+ allowed,
+ remaining: 5 - c.state.count,
+ resetsIn: Math.round((c.state.resetAt - now) / 1000)
+ };
+ }
+ }
+});
+
+export default rateLimiter;
\ No newline at end of file
diff --git a/examples/snippets/rate/actor-sqlite.ts b/examples/snippets/rate/actor-sqlite.ts
new file mode 100644
index 000000000..03d29ec6c
--- /dev/null
+++ b/examples/snippets/rate/actor-sqlite.ts
@@ -0,0 +1,67 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { limiters } from "./schema";
+
+// Simple rate limiter - allows 5 requests per minute
+const rateLimiter = actor({
+ sql: drizzle(),
+
+ actions: {
+ // Check if request is allowed
+ checkLimit: async (c) => {
+ const now = Date.now();
+
+ // Get the current limiter state from database
+ const limiterState = await c.db
+ .select()
+ .from(limiters)
+ .get();
+
+ // If no record exists, create one
+ if (!limiterState) {
+ await c.db.insert(limiters).values({
+ count: 1,
+ resetAt: now + 60000 // 1 minute window
+ });
+
+ return {
+ allowed: true,
+ remaining: 4,
+ resetsIn: 60
+ };
+ }
+
+ // Reset if expired
+ if (now > limiterState.resetAt) {
+ await c.db.update(limiters)
+ .set({
+ count: 1,
+ resetAt: now + 60000 // 1 minute window
+ });
+
+ return {
+ allowed: true,
+ remaining: 4,
+ resetsIn: 60
+ };
+ }
+
+ // Check if under limit
+ const allowed = limiterState.count < 5;
+
+ // Increment if allowed
+ if (allowed) {
+ await c.db.update(limiters)
+ .set({ count: limiterState.count + 1 });
+ }
+
+ return {
+ allowed,
+ remaining: 5 - (allowed ? limiterState.count + 1 : limiterState.count),
+ resetsIn: Math.round((limiterState.resetAt - now) / 1000)
+ };
+ }
+ }
+});
+
+export default rateLimiter;
\ No newline at end of file
diff --git a/examples/snippets/stream/App.tsx b/examples/snippets/stream/App.tsx
new file mode 100644
index 000000000..962fed488
--- /dev/null
+++ b/examples/snippets/stream/App.tsx
@@ -0,0 +1,53 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+import type { App } from "../actors/app";
+import type { StreamState } from "./actor"; // Import shared types from actor
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function StreamExample() {
+ const [{ actor }] = useActor("streamProcessor");
+ const [topValues, setTopValues] = useState([]);
+ const [newValue, setNewValue] = useState(0);
+
+ // Load initial values
+ useEffect(() => {
+ if (actor) {
+ actor.getTopValues().then(setTopValues);
+ }
+ }, [actor]);
+
+ // Listen for updates from other clients
+ useActorEvent({ actor, event: "updated" }, ({ topValues }) => {
+ setTopValues(topValues);
+ });
+
+ // Add a new value to the stream
+ const handleAddValue = () => {
+ if (actor) {
+ actor.addValue(newValue).then(setTopValues);
+ setNewValue(0);
+ }
+ };
+
+ return (
+
+
Top 3 Values
+
+
+ {topValues.map((value, i) => (
+ {value}
+ ))}
+
+
+
setNewValue(Number(e.target.value))}
+ />
+
Add Value
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/stream/actor-json.ts b/examples/snippets/stream/actor-json.ts
new file mode 100644
index 000000000..60e1719e7
--- /dev/null
+++ b/examples/snippets/stream/actor-json.ts
@@ -0,0 +1,39 @@
+import { actor } from "actor-core";
+
+export type StreamState = {
+ topValues: number[];
+};
+
+// Simple top-K stream processor example
+const streamProcessor = actor({
+ state: {
+ topValues: [] as number[]
+ },
+
+ actions: {
+ getTopValues: (c) => c.state.topValues,
+
+ // Add value and keep top 3
+ addValue: (c, value: number) => {
+ // Insert new value if needed
+ const insertAt = c.state.topValues.findIndex(v => value > v);
+ if (insertAt === -1) {
+ c.state.topValues.push(value);
+ } else {
+ c.state.topValues.splice(insertAt, 0, value);
+ }
+
+ // Keep only top 3
+ if (c.state.topValues.length > 3) {
+ c.state.topValues.length = 3;
+ }
+
+ // Broadcast update to all clients
+ c.broadcast("updated", { topValues: c.state.topValues });
+
+ return c.state.topValues;
+ },
+ }
+});
+
+export default streamProcessor;
\ No newline at end of file
diff --git a/examples/snippets/stream/actor-sqlite.ts b/examples/snippets/stream/actor-sqlite.ts
new file mode 100644
index 000000000..4a9874194
--- /dev/null
+++ b/examples/snippets/stream/actor-sqlite.ts
@@ -0,0 +1,56 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { streams, streamValues } from "./schema";
+
+export type StreamState = { topValues: number[]; };
+
+// Simple top-K stream processor example
+const streamProcessor = actor({
+ sql: drizzle(),
+
+ actions: {
+ getTopValues: async (c) => {
+ // Get the top 3 values sorted in descending order
+ const result = await c.db
+ .select()
+ .from(streamValues)
+ .orderBy(streamValues.value.desc())
+ .limit(3);
+
+ return result.map(r => r.value);
+ },
+
+ // Add value and keep top 3
+ addValue: async (c, value: number) => {
+ // Insert the new value
+ await c.db
+ .insert(streamValues)
+ .values({
+ value
+ });
+
+ // Get the updated top 3 values
+ const topValues = await c.db
+ .select()
+ .from(streamValues)
+ .orderBy(streamValues.value.desc())
+ .limit(3);
+
+ // Delete values that are no longer in the top 3
+ if (topValues.length === 3) {
+ await c.db
+ .delete(streamValues)
+ .where(streamValues.value.lt(topValues[2].value));
+ }
+
+ const topValuesArray = topValues.map(r => r.value);
+
+ // Broadcast update to all clients
+ c.broadcast("updated", { topValues: topValuesArray });
+
+ return topValuesArray;
+ },
+ }
+});
+
+export default streamProcessor;
\ No newline at end of file
diff --git a/examples/snippets/sync/App.tsx b/examples/snippets/sync/App.tsx
new file mode 100644
index 000000000..df2316e7d
--- /dev/null
+++ b/examples/snippets/sync/App.tsx
@@ -0,0 +1,210 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect, useRef } from "react";
+import type { Contact } from "./actor";
+
+const client = createClient("http://localhost:6420");
+const { useActor, useActorEvent } = createReactActorCore(client);
+
+export function ContactsApp() {
+ const { actor } = useActor("contacts");
+ const [contacts, setContacts] = useState([]);
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [syncStatus, setSyncStatus] = useState("Idle");
+
+ const lastSyncTime = useRef(0);
+
+ // Load initial contacts
+ useEffect(() => {
+ if (!actor) return;
+
+ actor.getChanges(0).then(data => {
+ setContacts(data.changes);
+ lastSyncTime.current = data.timestamp;
+ setSyncStatus("Synced");
+ });
+ }, [actor]);
+
+ // Handle contact events
+ useActorEvent({ actor, event: "contactsChanged" }, ({ contacts: updatedContacts }) => {
+ setContacts(prev => {
+ const contactMap = new Map(prev.map(c => [c.id, c]));
+
+ updatedContacts.forEach(contact => {
+ const existing = contactMap.get(contact.id);
+ if (!existing || existing.updatedAt < contact.updatedAt) {
+ contactMap.set(contact.id, contact);
+ }
+ });
+
+ return Array.from(contactMap.values());
+ });
+ });
+
+ // Sync periodically
+ useEffect(() => {
+ if (!actor) return;
+
+ const sync = async () => {
+ setSyncStatus("Syncing...");
+
+ try {
+ // Get remote changes
+ const changes = await actor.getChanges(lastSyncTime.current);
+
+ // Apply remote changes
+ if (changes.changes.length > 0) {
+ setContacts(prev => {
+ const contactMap = new Map(prev.map(c => [c.id, c]));
+
+ changes.changes.forEach(contact => {
+ const existing = contactMap.get(contact.id);
+ if (!existing || existing.updatedAt < contact.updatedAt) {
+ contactMap.set(contact.id, contact);
+ }
+ });
+
+ return Array.from(contactMap.values());
+ });
+ }
+
+ // Push local changes
+ const localChanges = contacts.filter(c => c.updatedAt > lastSyncTime.current);
+ if (localChanges.length > 0) {
+ await actor.pushChanges(localChanges);
+ }
+
+ lastSyncTime.current = changes.timestamp;
+ setSyncStatus("Synced");
+ } catch (error) {
+ setSyncStatus("Offline");
+ }
+ };
+
+ const intervalId = setInterval(sync, 5000);
+
+ return () => clearInterval(intervalId);
+ }, [actor, contacts]);
+
+ // Add new contact (local first)
+ const addContact = () => {
+ if (!name.trim()) return;
+
+ const newContact: Contact = {
+ id: Date.now().toString(),
+ name,
+ email,
+ phone,
+ updatedAt: Date.now()
+ };
+
+ setContacts(prev => [...prev, newContact]);
+
+ if (actor) {
+ actor.pushChanges([newContact]);
+ }
+
+ setName("");
+ setEmail("");
+ setPhone("");
+ };
+
+ // Delete contact (implemented as update with empty name)
+ const deleteContact = (id: string) => {
+ setContacts(prev => {
+ const updatedContacts = prev.map(c =>
+ c.id === id
+ ? { ...c, name: "", updatedAt: Date.now() }
+ : c
+ );
+
+ if (actor) {
+ const deleted = updatedContacts.find(c => c.id === id);
+ if (deleted) {
+ actor.pushChanges([deleted]);
+ }
+ }
+
+ return updatedContacts.filter(c => c.name !== "");
+ });
+ };
+
+ // Manual sync
+ const handleSync = async () => {
+ if (!actor) return;
+
+ setSyncStatus("Syncing...");
+
+ try {
+ // Push all contacts
+ await actor.pushChanges(contacts);
+
+ // Get all changes
+ const changes = await actor.getChanges(0);
+
+ setContacts(changes.changes);
+ lastSyncTime.current = changes.timestamp;
+ setSyncStatus("Synced");
+ } catch (error) {
+ setSyncStatus("Offline");
+ }
+ };
+
+ return (
+
+
+
Contacts
+
+ {syncStatus}
+
+ Sync Now
+
+
+
+
+
+ setName(e.target.value)}
+ />
+ setEmail(e.target.value)}
+ />
+ setPhone(e.target.value)}
+ />
+ Add Contact
+
+
+
+ {contacts.filter(c => c.name !== "").map(contact => (
+
+
+
{contact.name}
+
+
{contact.email}
+
{contact.phone}
+
+
+
deleteContact(contact.id)}
+ >
+ Delete
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/sync/actor-json.ts b/examples/snippets/sync/actor-json.ts
new file mode 100644
index 000000000..d78e09f49
--- /dev/null
+++ b/examples/snippets/sync/actor-json.ts
@@ -0,0 +1,47 @@
+import { actor } from "actor-core";
+
+export type Contact = { id: string; name: string; email: string; phone: string; updatedAt: number; }
+
+const contacts = actor({
+ // State is automatically persisted
+ state: {
+ contacts: {}
+ },
+
+ actions: {
+ // Gets changes after the last timestamp (when coming back online)
+ getChanges: (c, after: number = 0) => {
+ const changes = Object.values(c.state.contacts)
+ .filter(contact => contact.updatedAt > after);
+
+ return {
+ changes,
+ timestamp: Date.now()
+ };
+ },
+
+ // Pushes new changes from the client & handles conflicts
+ pushChanges: (c, contacts: Contact[]) => {
+ let changed = false;
+
+ contacts.forEach(contact => {
+ const existing = c.state.contacts[contact.id];
+
+ if (!existing || existing.updatedAt < contact.updatedAt) {
+ c.state.contacts[contact.id] = contact;
+ changed = true;
+ }
+ });
+
+ if (changed) {
+ c.broadcast("contactsChanged", {
+ contacts: Object.values(c.state.contacts)
+ });
+ }
+
+ return { timestamp: Date.now() };
+ }
+ }
+});
+
+export default contacts;
\ No newline at end of file
diff --git a/examples/snippets/sync/actor-sqlite.ts b/examples/snippets/sync/actor-sqlite.ts
new file mode 100644
index 000000000..234d1d85a
--- /dev/null
+++ b/examples/snippets/sync/actor-sqlite.ts
@@ -0,0 +1,66 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { contacts } from "./schema";
+
+export type Contact = { id: string; name: string; email: string; phone: string; updatedAt: number; }
+
+const contactSync = actor({
+ sql: drizzle(),
+
+ actions: {
+ // Gets changes after the last timestamp (when coming back online)
+ getChanges: async (c, after: number = 0) => {
+ const changes = await c.db
+ .select()
+ .from(contacts)
+ .where(contacts.updatedAt.gt(after));
+
+ return {
+ changes,
+ timestamp: Date.now()
+ };
+ },
+
+ // Pushes new changes from the client & handles conflicts
+ pushChanges: async (c, contactList: Contact[]) => {
+ let changed = false;
+
+ for (const contact of contactList) {
+ // Check if contact exists with a newer timestamp
+ const existing = await c.db
+ .select()
+ .from(contacts)
+ .where(contacts.id.equals(contact.id))
+ .get();
+
+ if (!existing || existing.updatedAt < contact.updatedAt) {
+ // Insert or update the contact
+ await c.db
+ .insert(contacts)
+ .values(contact)
+ .onConflictDoUpdate({
+ target: contacts.id,
+ set: contact
+ });
+
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ // Get all contacts to broadcast
+ const allContacts = await c.db
+ .select()
+ .from(contacts);
+
+ c.broadcast("contactsChanged", {
+ contacts: allContacts
+ });
+ }
+
+ return { timestamp: Date.now() };
+ }
+ }
+});
+
+export default contactSync;
\ No newline at end of file
diff --git a/examples/snippets/tenant/App.tsx b/examples/snippets/tenant/App.tsx
new file mode 100644
index 000000000..c5f203a8b
--- /dev/null
+++ b/examples/snippets/tenant/App.tsx
@@ -0,0 +1,130 @@
+import { createClient } from "actor-core/client";
+import { createReactActorCore } from "@actor-core/react";
+import { useState, useEffect } from "react";
+import type { App } from "../actors/app";
+
+// Create client and hooks
+const client = createClient("http://localhost:6420");
+const { useActor } = createReactActorCore(client);
+
+export function OrgDashboard({ orgId }: { orgId: string }) {
+ // State for data
+ const [members, setMembers] = useState([]);
+ const [invoices, setInvoices] = useState([]);
+ const [error, setError] = useState("");
+
+ // Login as admin or regular user
+ const loginAsAdmin = () => {
+ setToken("auth:user-1"); // Alice is admin
+ };
+
+ const loginAsMember = () => {
+ setToken("auth:user-2"); // Bob is member
+ };
+
+ // Authentication token
+ const [token, setToken] = useState("");
+
+ // Connect to tenant actor with authentication token
+ const [{ actor }] = useActor("tenant", {
+ params: { token },
+ tags: { orgId }
+ });
+
+ // Load data when actor is available
+ useEffect(() => {
+ if (!actor || !token) return;
+
+ const loadData = async () => {
+ try {
+ // Get members (available to all users)
+ const membersList = await actor.getMembers();
+ setMembers(membersList);
+
+ // Try to get invoices (only available to admins)
+ try {
+ const invoicesList = await actor.getInvoices();
+ setInvoices(invoicesList);
+ setError("");
+ } catch (err: any) {
+ setError(err.message);
+ }
+ } catch (err) {
+ console.error("Failed to load data");
+ }
+ };
+
+ loadData();
+ }, [actor, token]);
+
+ // Login screen when not authenticated
+ if (!token) {
+ return (
+
+
Organization Dashboard
+
Choose a login:
+
Login as Admin (Alice)
+
Login as Member (Bob)
+
+ );
+ }
+
+ return (
+
+
Organization Dashboard
+
Logged in as: {token.split(":")[1]}
+
+ {/* Members Section - available to all users */}
+
+
Members
+
+
+
+ Name
+ Email
+ Role
+
+
+
+ {members.map(member => (
+
+ {member.name}
+ {member.email}
+ {member.role}
+
+ ))}
+
+
+
+
+ {/* Invoices Section - only displayed to admins */}
+
+
Invoices
+ {error ? (
+
{error}
+ ) : (
+
+
+
+ Invoice #
+ Date
+ Amount
+ Status
+
+
+
+ {invoices.map(invoice => (
+
+ {invoice.id}
+ {new Date(invoice.date).toLocaleDateString()}
+ ${invoice.amount}
+ {invoice.paid ? "Paid" : "Unpaid"}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/examples/snippets/tenant/actor-json.ts b/examples/snippets/tenant/actor-json.ts
new file mode 100644
index 000000000..6411b4e34
--- /dev/null
+++ b/examples/snippets/tenant/actor-json.ts
@@ -0,0 +1,47 @@
+import { actor } from "actor-core";
+import { authenticate } from "./my-utils";
+
+// Simple tenant organization actor
+const tenant = actor({
+ // Example initial state
+ state: {
+ members: [
+ { id: "user-1", name: "Alice", email: "alice@example.com", role: "admin" },
+ { id: "user-2", name: "Bob", email: "bob@example.com", role: "member" }
+ ],
+ invoices: [
+ { id: "inv-1", amount: 100, date: Date.now(), paid: true },
+ { id: "inv-2", amount: 200, date: Date.now(), paid: false }
+ ]
+ },
+
+ // Authentication
+ createConnState: async (c, { params }) => {
+ const token = params.token;
+ const userId = await authenticate(token);
+ return { userId };
+ },
+
+ actions: {
+ // Get all members
+ getMembers: (c) => {
+ return c.state.members;
+ },
+
+ // Get all invoices (only admin can access)
+ getInvoices: (c) => {
+ // Find the user's role by their userId
+ const userId = c.conn.userId;
+ const user = c.state.members.find(m => m.id === userId);
+
+ // Only allow admins to see invoices
+ if (!user || user.role !== "admin") {
+ throw new UserError("Permission denied: requires admin role");
+ }
+
+ return c.state.invoices;
+ }
+ }
+});
+
+export default tenant;
\ No newline at end of file
diff --git a/examples/snippets/tenant/actor-sqlite.ts b/examples/snippets/tenant/actor-sqlite.ts
new file mode 100644
index 000000000..9c354611e
--- /dev/null
+++ b/examples/snippets/tenant/actor-sqlite.ts
@@ -0,0 +1,51 @@
+import { actor } from "actor-core";
+import { drizzle } from "@actor-core/drizzle";
+import { members, invoices } from "./schema";
+import { authenticate } from "./my-utils";
+
+// Simple tenant organization actor
+const tenant = actor({
+ sql: drizzle(),
+
+ // Authentication
+ createConnState: async (c, { params }) => {
+ const token = params.token;
+ const userId = await authenticate(token);
+ return { userId };
+ },
+
+ actions: {
+ // Get all members
+ getMembers: async (c) => {
+ const result = await c.db
+ .select()
+ .from(members);
+
+ return result;
+ },
+
+ // Get all invoices (only admin can access)
+ getInvoices: async (c) => {
+ // Find the user's role by their userId
+ const userId = c.conn.userId;
+ const user = await c.db
+ .select()
+ .from(members)
+ .where(members.id.equals(userId))
+ .get();
+
+ // Only allow admins to see invoices
+ if (!user || user.role !== "admin") {
+ throw new Error("Permission denied: requires admin role");
+ }
+
+ const result = await c.db
+ .select()
+ .from(invoices);
+
+ return result;
+ }
+ }
+});
+
+export default tenant;
\ No newline at end of file