Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 1.0.2 - 2026-10-15

- Add four comparison pages in `docs/compare/`: Restate virtual objects, DBOS
Transact, RivetKit actors, and Graphile Worker named queues. Each page
compares one real task, says when the other system is the better choice, and
links the primary sources that were checked on October 15, 2026. The Solid
Objects code on each page is in `examples/compare/` and runs in the test
suite. `docs/comparisons.md` and the README link the pages. An evaluation on
October 15, 2026 found that Claude Code, with web search, searched only for
these names and never found Solid Objects.
- `pnpm run check:documentation` now checks the comparison pages. Each page
must embed its current example files, link only to files and headings that
exist, and use no em dash or en dash. `docs/comparisons.md` must link each
page.

## 1.0.1 - 2026-10-14

- The agent guide now lists the import path, the call, and the driver of each
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ then shows a tested Solid Objects version with its limits:
- [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
- [Guide for coding agents](docs/agents.md)
- [Choosing Solid Objects](docs/fit.md)
- [Comparisons with Restate, DBOS, RivetKit, and Graphile Worker](docs/comparisons.md)
- [Public API](docs/api.md)
- [Versioning](docs/versioning.md)
- [Operations and recovery](docs/operations.md)
Expand Down
391 changes: 391 additions & 0 deletions docs/compare/dbos.md

Large diffs are not rendered by default.

296 changes: 296 additions & 0 deletions docs/compare/graphile-worker.md

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions docs/compare/restate.md

Large diffs are not rendered by default.

282 changes: 282 additions & 0 deletions docs/compare/rivetkit.md

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion docs/comparisons.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

This guide compares coordination models so an application can choose the
smallest mechanism that meets its requirements. It does not rank the projects.

## Comparison pages

Each page compares Solid Objects with one system on one real task. Each page
says when the other system is the better choice. The Solid Objects actor and
runtime code on each page runs in the test suite.

| Page | The task |
| ---------------------------------------------------------------------------- | ---------------------- |
| [Restate virtual objects and Solid Objects](compare/restate.md) | AI agent sessions |
| [DBOS Transact and Solid Objects](compare/dbos.md) | Shopping cart checkout |
| [RivetKit actors and Solid Objects](compare/rivetkit.md) | Chat rooms |
| [Graphile Worker named queues and Solid Objects](compare/graphile-worker.md) | Chat rooms |

## Coordination models

Solid Objects is a virtual actor library; the
[virtual actor guide](virtual-actors.md) defines the term. Facts about other
projects were checked on October 7, 2026.
Expand All @@ -13,7 +29,7 @@ projects were checked on October 7, 2026.
| Solid Objects | Actor class and application-defined ID | Node processes plus existing SQLite, PostgreSQL, or MySQL | No; Redis wake-up is optional | The operation retries against durable actor state | Committed projections; application-owned transport; no edge placement | No | Relational tables, typed administration, CLI, and dashboard |
| Cloudflare Durable Objects | Object class and globally unique ID | Cloudflare Workers plus per-object managed storage | Cloudflare platform | Object activation with durable state, not workflow-step replay | WebSockets and Cloudflare-selected object location | Storage transactions are scoped to one object | Object storage APIs and platform tooling |
| celld | Object class and object name | celld nodes plus one object-storage bucket; each object is its own SQLite database | Yes, the celld daemon on every node | The new owner restores the object's SQLite database from the bucket and resumes | Cloudflare Workers APIs; an object runs on one node of your fleet, not at an edge location | No | Per-object SQLite through the Workers storage APIs, plus the bucket |
| Rivet Actors | Addressable actor | Rivet Engine or managed compute with actor state, KV, or per-actor SQLite | Rivet Engine | Actor persistence and lifecycle; workflows add recorded steps | Actor events and deployment-dependent placement | No general transaction across actors | Actor APIs and selected persistence model |
| Rivet Actors | Addressable actor | Rivet control plane or managed compute with actor state, KV, or per-actor SQLite | Rivet control plane | Actor persistence and lifecycle; workflows add recorded steps | Actor events and deployment-dependent placement | No general transaction across actors | Actor APIs and selected persistence model |
| DBOS | Workflow ID and checkpointed steps | Application processes plus PostgreSQL system database | No orchestration server for the library; Conductor is recommended for distributed recovery | Deterministic workflow replay skips checkpointed steps | Workflow events; application placement | PostgreSQL transactions remain separate from workflow identity | PostgreSQL system database, client, CLI, and optional Conductor |
| Dapr actors | Actor type and ID, one turn at a time | Application services plus Dapr sidecars and a transactional actor state store | Yes: the Dapr sidecar plus the placement and Scheduler services | Actor state persists in the state store; actors do not replay workflow steps | Sidecar-routed actor placement; no edge placement | No transaction across actors | Dapr actor APIs and the configured state store |
| Restate | Service handler or keyed virtual object | Application services plus Restate's durable log and state store | Yes | Durable execution journals handler progress and object state | Service protocol and Restate deployment | No shared SQL transaction across object keys | Restate APIs, state tools, snapshots, and backups |
Expand Down
46 changes: 46 additions & 0 deletions examples/compare/agent-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import { AgentSession, type Entry } from "./agent-session.js"

export type ModelClient = {
reply(options: { idempotencyKey: string; history: Entry[] }): Promise<{ content: string }>
}

const CALLER_OPERATIONS = new Set(["receive", "followUpLater"])

export function agentRuntime({
path,
model,
retryDelayMilliseconds,
}: {
path: string
model: ModelClient
retryDelayMilliseconds?: (attempt: number) => number
}): SolidObjectsRuntime {
const runtime = createRuntime({
database: sqlite({ path }),
...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
actorType === AgentSession.actorType &&
CALLER_OPERATIONS.has(operation) &&
ownsSession({ actorId, authorizationContext }),
authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
actorType === AgentSession.actorType && ownsSession({ actorId, authorizationContext }),
})
runtime.register(AgentSession)
runtime.registerEffect("model_reply", (argumentsValue, context) =>
model.reply({ idempotencyKey: context.id, history: argumentsValue.history as Entry[] }),
)
return runtime
}

function ownsSession({
actorId,
authorizationContext,
}: {
actorId: string
authorizationContext: unknown
}): boolean {
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
return "userId" in authorizationContext && authorizationContext.userId === actorId
}
50 changes: 50 additions & 0 deletions examples/compare/agent-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Actor, type EffectSuccessPayload } from "solid-objects"

export const FOLLOW_UP_MILLISECONDS = 2 * 60 * 60 * 1000

const KEPT_ENTRIES = 50

export type Entry = { role: "user" | "tool" | "assistant"; content: string }

type ReplyArguments = { turn: number; history: Entry[] }
type ReplyResult = { content: string }

export class AgentSession extends Actor {
static override readonly actorType = "AgentSession"

history: Entry[] = []
turn = 0
followUps: string[] = []

receive({ role, content }: { role: "user" | "tool"; content: string }): { turn: number } {
this.#append({ role, content })
return { turn: this.turn }
}

followUpLater({ topic }: { topic: string }): void {
this.schedule({ at: new Date(Date.now() + FOLLOW_UP_MILLISECONDS), key: topic }).followUp({
topic,
})
}

followUp({ topic }: { topic: string }): void {
this.followUps = [...this.followUps, topic]
this.#append({ role: "tool", content: `Follow-up due: ${topic}` })
}

replied({ arguments: reply, result }: EffectSuccessPayload<ReplyArguments, ReplyResult>): void {
if (reply.turn !== this.turn) return
this.history = [...this.history, { role: "assistant" as const, content: result.content }].slice(
-KEPT_ENTRIES,
)
}

#append(entry: Entry): void {
this.history = [...this.history, entry].slice(-KEPT_ENTRIES)
this.turn += 1
this.emit("model_reply", {
arguments: { turn: this.turn, history: this.history },
onSuccess: "replied",
})
}
}
80 changes: 80 additions & 0 deletions examples/compare/cart-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import { ShoppingCart } from "./shopping-cart.js"

export type PaymentProvider = {
charge(options: {
idempotencyKey: string
cartId: string
amountCents: number
}): Promise<{ status: "paid"; paymentId: string } | { status: "declined" }>
lookup(options: {
idempotencyKey: string
}): Promise<{ status: "paid"; paymentId: string } | { status: "not_found" }>
}

export type Inventory = {
release(options: {
idempotencyKey: string
cartId: string
items: Record<string, number>
}): Promise<void>
}

const CALLER_OPERATIONS = new Set(["addItem", "applyCoupon", "checkOut"])

export function cartRuntime({
path,
payments,
inventory,
retryDelayMilliseconds,
maxAttempts,
}: {
path: string
payments: PaymentProvider
inventory: Inventory
retryDelayMilliseconds?: (attempt: number) => number
maxAttempts?: number
}): SolidObjectsRuntime {
const runtime = createRuntime({
database: sqlite({ path }),
...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
...(maxAttempts ? { maxAttempts } : {}),
authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
actorType === ShoppingCart.actorType &&
CALLER_OPERATIONS.has(operation) &&
ownsCart({ actorId, authorizationContext }),
authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
actorType === ShoppingCart.actorType && ownsCart({ actorId, authorizationContext }),
})
runtime.register(ShoppingCart)
runtime.registerEffect("charge_payment", (argumentsValue, context) =>
payments.charge({
idempotencyKey: context.id,
cartId: String(argumentsValue.cartId),
amountCents: Number(argumentsValue.amountCents),
}),
)
runtime.registerEffect("check_payment", (argumentsValue) =>
payments.lookup({ idempotencyKey: String(argumentsValue.paymentKey) }),
)
runtime.registerEffect("release_stock", (argumentsValue, context) =>
inventory.release({
idempotencyKey: context.id,
cartId: String(argumentsValue.cartId),
items: argumentsValue.items as Record<string, number>,
}),
)
return runtime
}

function ownsCart({
actorId,
authorizationContext,
}: {
actorId: string
authorizationContext: unknown
}): boolean {
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
return "userId" in authorizationContext && authorizationContext.userId === actorId
}
84 changes: 84 additions & 0 deletions examples/compare/chat-room.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Actor } from "solid-objects"

export const ARCHIVE_AFTER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000

const KEPT_MESSAGES = 200

export type ChatMessage = { sequence: number; authorId: string; text: string }

export class ChatRoom extends Actor {
static override readonly actorType = "ChatRoom"

members: string[] = []
messages: ChatMessage[] = []
nextSequence = 1
lastRead: Record<string, number> = {}
pinned: ChatMessage | null = null
activity = 0
archived = false

get unreadCounts(): Record<string, number> {
const latest = this.nextSequence - 1
return Object.fromEntries(
this.members.map((memberId) => [memberId, latest - (this.lastRead[memberId] ?? 0)]),
)
}

join({ memberId }: { memberId: string }): void {
this.#requireOpen()
if (this.members.includes(memberId)) return
this.members = [...this.members, memberId]
this.lastRead = { ...this.lastRead, [memberId]: this.nextSequence - 1 }
this.#armArchive()
}

post({ authorId, text }: { authorId: string; text: string }): { sequence: number } {
this.#requireMember(authorId)
const sequence = this.nextSequence
this.nextSequence += 1
this.messages = [...this.messages, { sequence, authorId, text }].slice(-KEPT_MESSAGES)
this.lastRead = { ...this.lastRead, [authorId]: sequence }
this.#armArchive()
return { sequence }
}

markRead({ memberId, sequence }: { memberId: string; sequence: number }): void {
this.#requireMember(memberId)
const current = this.lastRead[memberId] ?? 0
this.lastRead = {
...this.lastRead,
[memberId]: Math.min(Math.max(current, sequence), this.nextSequence - 1),
}
}

pin({ memberId, sequence }: { memberId: string; sequence: number }): void {
this.#requireMember(memberId)
const message = this.messages.find((candidate) => candidate.sequence === sequence)
if (!message) this.reject("unknown_message", { message: "That message is not in the room" })
this.pinned = message
this.#armArchive()
}

archiveIfIdle({ activity }: { activity: number }): void {
if (activity !== this.activity) return
this.archived = true
}

#requireMember(memberId: string): void {
this.#requireOpen()
if (!this.members.includes(memberId)) {
this.reject("not_a_member", { message: "Join the room first" })
}
}

#requireOpen(): void {
if (this.archived) this.reject("archived", { message: "The room is archived" })
}

#armArchive(): void {
this.activity += 1
this.schedule({ at: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS) }).archiveIfIdle({
activity: this.activity,
})
}
}
38 changes: 38 additions & 0 deletions examples/compare/chat-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import { ChatRoom } from "./chat-room.js"

const MEMBER_FIELDS: Record<string, string> = {
join: "memberId",
post: "authorId",
markRead: "memberId",
pin: "memberId",
}

export function chatRuntime({ path }: { path: string }): SolidObjectsRuntime {
const runtime = createRuntime({
database: sqlite({ path }),
authorizeMessage: ({
actorType,
operation,
arguments: argumentsValue,
authorizationContext,
}) => {
const field = MEMBER_FIELDS[operation]
if (actorType !== ChatRoom.actorType || !field) return false
const memberId = memberIdOf(authorizationContext)
if (memberId === null) return false
return argumentsValue[field] === memberId
},
authorizeQuery: ({ actorType, authorizationContext }) =>
actorType === ChatRoom.actorType && memberIdOf(authorizationContext) !== null,
})
runtime.register(ChatRoom)
return runtime
}

function memberIdOf(authorizationContext: unknown): string | null {
if (typeof authorizationContext !== "object" || authorizationContext === null) return null
if (!("memberId" in authorizationContext)) return null
return typeof authorizationContext.memberId === "string" ? authorizationContext.memberId : null
}
Loading
Loading