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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## 0.17.1 - 2026-10-08

- Check holds with `Object.hasOwn` in the README and guide ticket sale
examples. The `in` operator also matched inherited names, so a buyer named
`constructor` could not hold a free ticket, and `expire` could add a ticket
that no hold had taken. `pnpm run test:package` now holds a ticket for that
buyer.
- Override `sharp` with 0.35.5 and `source-map-js` with 1.2.2. Two high
advisories reach the repository only through the development tooling:
`sharp` through Miniflare and `source-map-js` through Vite and PostCSS. The
published package does not depend on either.
- Name the category in the package metadata and the README: Solid Objects is
a SQL-backed virtual actor library for TypeScript and Node.js. The package
now declares `https://solidobjects.dev/js` as its homepage and adds the
`virtual-actors` and `actor-model` keywords.
- Add `docs/virtual-actors.md`, a category guide with the definition, a small
example, fit and poor-fit criteria, comparisons, an Orleans concept map, and
separate compatibility statements for the Node runtime, the browser client,
the browser runtime, and the Cloudflare backend.
- Add `docs/agents.md`, a consumer guide for coding agents with setup,
authorization, effect idempotency, verification, and troubleshooting steps.
- Add Dapr actors, Temporal, and a comparison vocabulary to
`docs/comparisons.md`.
- Add `examples/ticket-sale.ts`. `pnpm run test:package` runs it against the
packed tarball, and the documentation check fails when the guide no longer
embeds it.
- The documentation check now reads every `docs/*.md` file instead of a fixed
list.
- Add `context7.json` so that Context7 indexes the consumer documentation and
skips maintainer files.
- Record the clean-install artifact proof in `docs/parity.md`. Ruby now has a
matching `rake quickstart` check.

## 0.17.0 - 2026-10-03

- **Breaking:** `solid_objects.activation.started` now fires before the actor's
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

**Open Source Durable Objects in your Node app.**

Solid Objects is a SQL-backed virtual actor library for TypeScript and Node.js,
with durable state, ordered operations, and automatic activation. Each actor
has a stable identity, and its state lives in the SQL database that your app
already uses. [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
explains the model and when to use it.

In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Node site could be restarting before recovery finishes.

To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
Expand Down Expand Up @@ -70,7 +76,7 @@ class TicketSale extends Actor {
holds = {}

hold({ buyer }) {
if (this.available === 0 || buyer in this.holds) {
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
return { held: false, available: this.available }
}

Expand All @@ -84,7 +90,7 @@ class TicketSale extends Actor {
}

expire({ buyer }) {
if (!(buyer in this.holds)) return this.available
if (!Object.hasOwn(this.holds, buyer)) return this.available

const remainingHolds = { ...this.holds }
delete remainingHolds[buyer]
Expand Down Expand Up @@ -254,6 +260,8 @@ There is no exactly-once delivery. Read the
## Read more

- [Five-minute Node guide](https://solidobjects.dev/5min/node)
- [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
- [Guide for coding agents](docs/agents.md)
- [Choosing Solid Objects](docs/fit.md)
- [Public API](docs/api.md)
- [Operations and recovery](docs/operations.md)
Expand Down
17 changes: 17 additions & 0 deletions context7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://context7.com/schema/context7.json",
"projectTitle": "Solid Objects for TypeScript and Node.js (solid-objects)",
"description": "SQL-backed virtual actor library for TypeScript and Node.js. Actors have stable identities, durable JSON state in SQLite, PostgreSQL, or MySQL, ordered per-identity mailboxes, fenced activation, durable reminders, and transactional effects. Requires Node.js 24.4+; ESM only.",
"folders": ["docs", "examples"],
"excludeFiles": ["CONTRIBUTING.md", "releasing.md", "parity.md"],
"rules": [
"Solid Objects requires Node.js 24.4 or newer and is ESM only. TypeScript applications need TypeScript 5.9 or newer.",
"Install with npm install solid-objects. Add pg for PostgreSQL or mysql2 for MySQL; SQLite uses node:sqlite.",
"Call configure() with a database adapter, then await runtime.install() before the first actor call.",
"Authorization callbacks deny every operation by default. Production policies must bind actorType and actorId to the authenticated user or tenant, and callers pass authorizationContext through ref.with().",
"Call runtime.run(signal) in at least one process for reminders, effects, actor-to-actor messages, and realtime broadcasts. Direct calls do not need it.",
"Delivery is at least once. Effect handlers registered with runtime.registerEffect must use context.id as the idempotency key.",
"Actor operations must not write application tables directly. Use commitAction() for a short same-database write and emit() for external I/O.",
"There are no transactions across actor identities. One hot identity is sequential."
]
}
300 changes: 300 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
# Solid Objects for coding agents (TypeScript and Node.js)

This guide is for a coding agent that evaluates or adds Solid Objects in a
TypeScript or Node.js application. It gives the facts and steps in a fixed
order. Each step links to the maintained documentation for more detail.

## 1. Check the fit first

Use Solid Objects only if you can answer yes to most of these questions:

1. Does one identity, such as a room, cart, account, device, or document, own
the state?
2. Must writes for that identity happen one at a time across requests and
processes?
3. Must some work happen later or continue after a process exits?
4. Is the state a bounded JSON document, not a large relational dataset?
5. Can the application run `runtime.run(signal)` in at least one process for
reminders, effects, and realtime delivery?

Select a simpler tool in these cases:

| Requirement | Use instead |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| The invariant fits in one request | A transaction, a constraint, or `SELECT ... FOR UPDATE` |
| CPU-intensive or parallel work | `worker_threads` or a worker pool. An actor serializes work |
| Thousands of writes for each second to one key | A counter column or a cache. Solid Objects Pro covers this case for Rails; its Node build is in development |
| One atomic change across two actor identities | One SQL transaction on normal tables |
| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
| Replay of named workflow steps from a step log | A durable execution engine, such as DBOS or Temporal |

The full list is in [Choosing Solid Objects](fit.md). The category guide is
[Virtual actors in TypeScript and Node.js](virtual-actors.md).

## 2. Package identity

| Item | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| npm package | `solid-objects` |
| Repository | <https://github.com/cardmagic/solid-objects-js> |
| Website | <https://solidobjects.dev/js> |
| Ruby on Rails gem | `solid_objects`, a separate implementation of the same model |
| Commercial package | `@solidobjects/pro`, a separate paid package in development. The open-source package does not need it |

Solid Objects is not SolidJS and has no relation to it. It is not affiliated
with Cloudflare.

## 3. Requirements

- Node.js 24.4 or newer. Prefer 24.15 or newer, where `node:sqlite` is no
longer experimental.
- The package is ESM only. TypeScript applications need TypeScript 5.9 or
newer.
- SQLite through the `node:sqlite` module, PostgreSQL 14 or newer with the
`pg` package, or MySQL 8.0 or newer with InnoDB and the `mysql2` package.
- Redis is optional. It only shortens wake-up latency and holds no durable
state.

[Supported versions](support.md) lists the CI matrix.

## 4. Install

```bash
npm install solid-objects
npx solid-objects quickstart --yes
```

The quickstart runs 25 concurrent calls against one identity on a temporary
SQLite database. It proves that no update is lost. Add `pg` or `mysql2` when
you use PostgreSQL or MySQL.

Create the runtime and its tables at startup:

```typescript
import { configure } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"

const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
})

await runtime.install()
```

[Configuration](configuration.md) lists each option and database adapter.

## 5. Authorize

All authorization callbacks deny by default. A new runtime answers no actor
call until you write a policy. Do not remove this behavior.

For a local demonstration only, grant messages and queries:

```typescript
const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
authorizeMessage: () => true,
authorizeQuery: () => true,
})
```

Keep `authorizeDestroy`, `authorizeSubscription`, and
`authorizeAdministration` denied in a demonstration.

A production policy must bind the actor type and ID to the authenticated user
or tenant. An actor ID is not a permission:

```typescript
const ownsCart = ({
actorType,
actorId,
authorizationContext,
}: {
actorType: string
actorId: string
authorizationContext: unknown
}) => {
if (actorType !== "ShoppingCart") return false
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
if (!("userId" in authorizationContext)) return false
return typeof authorizationContext.userId === "string" && actorId === authorizationContext.userId
}

const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
authorizeMessage: ownsCart,
authorizeQuery: ownsCart,
})
```

Pass the context on each call:

```typescript
await ShoppingCart.ref(subject.userId)
.with({ authorizationContext: subject })
.addItem({ productId: "shirt-123" })
```

[Authorization](authorization.md) covers each entry point.

## 6. Define an actor

This actor is the example from the [virtual actor guide](virtual-actors.md):

```typescript
import { Actor } from "solid-objects"

const HOLD_MILLISECONDS = 10 * 60 * 1000

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

available = 1
holds: Record<string, number> = {}

hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
return { held: false, available: this.available }
}

this.available -= 1
this.holds = { ...this.holds, [buyer]: Date.now() }
this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
return { held: true, available: this.available }
}

expire({ buyer }: { buyer: string }): number {
if (!Object.hasOwn(this.holds, buyer)) return this.available

const remainingHolds = { ...this.holds }
delete remainingHolds[buyer]
this.holds = remainingHolds
this.available += 1
return this.available
}
}
```

Obey these rules in actor code:

- Give each actor class a stable `static actorType`. The database stores it.
- Enumerable public fields are the durable state. They must be
JSON-compatible.
- Public methods are ordered operations. Each one takes no argument or one
object argument.
- Use `this.schedule({ at, key })` for delayed work. A new `schedule` with the
same key moves the alarm.
- Use `this.reject(code, message)` for a business rule failure that must not
retry.
- Do not write application tables directly from an operation. Use
`this.commitAction()` for a short write in the same database.
- Do not call an external API in an operation. Use `this.emit()` and an effect
handler.
- Write each operation so that it can run again. Delivery is at least once.

[State and lifecycle](state-and-lifecycle.md) and the
[public API](api.md) give the full rules.

## 7. Run the background roles

A direct call, such as `TicketSale.ref("event-42").hold({ buyer: "ada" })`,
runs in the caller. These features need a process that calls
`runtime.run(signal)`:

- Reminders from `schedule()`.
- Effects from `emit()` and their callbacks.
- Messages to other actors.
- Realtime broadcasts.

```typescript
const controller = new AbortController()
process.once("SIGTERM", () => controller.abort())
await runtime.run(controller.signal)
await runtime.close()
```

The packaged `solid-objects start` command does the same for a runtime that
`solid-objects.config.js` exports. When no process runs, committed work waits
in SQL and runs later. [Operations](operations.md) covers roles and shutdown.

## 8. Make external effects idempotent

Register an effect handler at startup. Use `context.id` as the provider
idempotency key:

```typescript
runtime.registerEffect("charge_payment", async (argumentsValue, context) => {
return payments.charge({
idempotencyKey: context.id,
paymentId: argumentsValue.paymentId,
})
})
```

Stage it from an operation:

```typescript
this.emit("charge_payment", {
arguments: { paymentId: this.paymentId },
onSuccess: "charged",
})
```

The effect can run more than once after a crash. The `context.id` value is the
same each time. [Effect recovery](effect-recovery.md) explains how to retire
abandoned work.

## 9. Verify the implementation

Do these checks before you report that the work is complete:

1. Call `await runtime.doctor.run()`. The report must contain no failed check.
2. Send concurrent calls to one identity with `Promise.all`, and from two
processes if the application runs more than one. Assert the final state.
3. Test delayed work without sleeps. `runDueReminders({ now })` enqueues the
reminders that are due at the `Date` you pass. `drain()` then runs the
enqueued actor work:

```typescript
await runtime.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS) })
await runtime.testing.drain({ roles: ["actors"] })
```

4. Start a process that calls `runtime.run(signal)`, schedule a short
reminder, and stop the process. Start it again after the deadline and
confirm that the reminder ran.
5. Confirm that each effect handler deduplicates with `context.id`.
6. Confirm that production policies do not grant access to every caller.

The repository runs the same proofs. `pnpm run test:recovery` stops a worker
process and recovers its work. `pnpm run test:at-least-once` shows a repeated
effect and its deduplication.

## 10. Troubleshooting

| Symptom | Cause and fix |
| --------------------------------------- | ------------------------------------------------------------------------------------ |
| `Unauthorized` | A policy denied the call. Write the policy, and pass `authorizationContext` |
| A reminder or effect does not run | No process calls `runtime.run(signal)`. Start one |
| `SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction |
| `SyncTimeout` | The call did not finish in time. The message is still durable. Wait on its reference |
| `ApplicationWriteForbidden` | An operation wrote an application table. Use `commitAction()` or `emit()` |
| `Rejected` | The actor called `reject()`. This is a business result, not a retry |
| Wrong integer values or SQLite warnings | Node.js is older than 24.4, or older than 24.15 for the warning. Upgrade Node.js |
| `ERR_REQUIRE_ESM` or import errors | The package is ESM only. Use `import` and an ES module file |

## 11. Guarantees to state correctly

When you explain Solid Objects to a user, state these limits:

- Delivery is at least once, not exactly once.
- Calls for one identity are ordered. Different identities run concurrently.
- There are no transactions across actor identities.
- Fencing stops a stale activation from a commit, but its code can continue to
run.
- The package is pre-1.0. It has no measured scale and no known third-party
production use.
- The browser client is not the SQL runtime. The Cloudflare backend is
experimental.

The [correctness contract](correctness.md) is the source for each guarantee.
Loading
Loading