diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a5bd8..451debf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 51f1724..a35ef91 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 } } @@ -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] @@ -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) diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..7c7ef58 --- /dev/null +++ b/context7.json @@ -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." + ] +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..c05a38e --- /dev/null +++ b/docs/agents.md @@ -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 | | +| Website | | +| 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 = {} + + 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. diff --git a/docs/comparisons.md b/docs/comparisons.md index 628cb36..7fc247c 100644 --- a/docs/comparisons.md +++ b/docs/comparisons.md @@ -2,6 +2,9 @@ This guide compares coordination models so an application can choose the smallest mechanism that meets its requirements. It does not rank the projects. +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. | Approach | State and serialization unit | Deployment and durable substrate | Separate service | Replay versus state | Realtime and edge placement | Cross-identity transaction | Data access | | --------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------- | @@ -12,7 +15,25 @@ smallest mechanism that meets its requirements. It does not rank the projects. | 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 | | 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 | +| Temporal | Workflow execution ID | Worker processes plus a Temporal Service, self-hosted or Temporal Cloud | Yes, the Temporal Service | Deterministic workflow code replays its event history | Workflow signals and queries; application placement | No shared SQL transaction across workflows | Temporal SDK, CLI, and web UI | + +## Comparison vocabulary + +Each row above differs on the same dimensions. For Solid Objects they are: + +| Dimension | Solid Objects | +| ---------------- | ---------------------------------------------------------------------------------- | +| Identity | An actor class and an application-defined ID | +| Activation | On demand, behind a fenced lease, released when idle | +| State storage | A JSON document in SQLite, PostgreSQL, or MySQL | +| Serialization | One durable mailbox for each identity | +| Delivery | At least once, in order for each identity | +| Reminders | Durable and keyed for each actor | +| Recovery | Another process claims the work after a lease expires; committed work waits in SQL | +| Deployment | A library inside the application's Node.js processes | +| Operational cost | One retained message row for each call, plus database load | ## Primary references @@ -37,5 +58,20 @@ smallest mechanism that meets its requirements. It does not rank the projects. and its storage requirements in the [self-hosted server overview](https://docs.restate.dev/server/overview). +- Dapr documents virtual actors, the actor state store, and Scheduler-backed + reminders in [Actors overview](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-overview/) + and [Actor timers and reminders](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-timers-reminders/). + The [JavaScript SDK](https://docs.dapr.io/developing-applications/sdks/js/js-actors/) + supports actors. +- Temporal documents deterministic replay in + [Workflow definition](https://docs.temporal.io/workflow-definition) and its + service in [What is Temporal?](https://docs.temporal.io/temporal). +- Orleans documents virtual actors, turn-based scheduling, and reminders in its + [overview](https://learn.microsoft.com/en-us/dotnet/orleans/overview), + [Request scheduling](https://learn.microsoft.com/en-us/dotnet/orleans/grains/request-scheduling), + and [Timers and reminders](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders). +- Node.js documents CPU-bound parallelism in + [Worker threads](https://nodejs.org/api/worker_threads.html). + External systems evolve. Recheck these primary sources before relying on one row as a procurement or architecture decision. diff --git a/docs/parity.md b/docs/parity.md index 27e6ba4..5324f70 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem. Parity preserves a capability and its correctness or security boundary. It does not copy a Rails API into Node. -Reference: Ruby `solid_objects` 0.17.0. The JavaScript package began at the Ruby +Reference: Ruby `solid_objects` 0.17.1. The JavaScript package began at the Ruby design's `0.12` capability generation; that version number did not imply earlier JavaScript releases. @@ -92,6 +92,7 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change | Structured instrumentation | Native | Versioned events, metric samples, isolated observers, and bounded authorized diagnostics match Ruby. Both test suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add their turn's message fields. Both runtimes log exporter and observer failures, require observer callbacks, and cap local observers at 1,000. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer attributes, and does not send `sync.timeout`. | | Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from `0.14.3`, as `warn_state_bytes`. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. | | Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. | +| Clean-install artifact proof | Native | `pnpm run test:package` installs the packed tarball in a new project. It runs the packaged quickstart, which sends 25 concurrent calls to one identity, and the `examples/ticket-sale.ts` guide example. `pnpm run test:recovery` proves recovery after a worker process stops. Ruby's `rake quickstart` builds the gem, installs it in a new Rails application, and proves ordering and reminder recovery after a runtime restart. | ## Databases and wake-up diff --git a/docs/releasing.md b/docs/releasing.md index 4cba3ad..e9d1055 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,8 +35,13 @@ npm trust github solid-objects \ `pnpm run test:recovery`, `pnpm run test:browser`, and `pnpm audit --audit-level=high`. Run the PostgreSQL, MySQL, and Redis jobs against the versions in [the support matrix](support.md). -3. Commit and push `main`. -4. Create and push an annotated tag matching the package version: +3. Read `docs/virtual-actors.md` and `docs/agents.md` against the release. + Correct any requirement, compatibility, or guarantee statement that the + release changed. `pnpm run check:documentation` fails when the category + guide no longer embeds `examples/ticket-sale.ts`, and `pnpm run +test:package` runs that example against the packed tarball. +4. Commit and push `main`. +5. Create and push an annotated tag matching the package version: ```shell git tag -a v0.13.3 -m "Version 0.13.3" @@ -52,3 +57,8 @@ The job then builds the release notes with `scripts/release-notes.mjs`. That script prints the `CHANGELOG.md` section for the tagged version. The job then creates the GitHub release for the tag. If you run the job again on a tag that npm already holds, it still creates a missing release. + +After the tag publishes, refresh the solidobjects.dev documentation snapshot +from the tag and redeploy the site. The site's `check:release` step refuses a +snapshot that is not the latest published tag. Then trigger a Context7 refresh +for this repository. diff --git a/docs/virtual-actors.md b/docs/virtual-actors.md new file mode 100644 index 0000000..895d9b4 --- /dev/null +++ b/docs/virtual-actors.md @@ -0,0 +1,242 @@ +# Virtual actors in TypeScript and Node.js + +## Short answer + +Yes. Solid Objects is a SQL-backed virtual actor library for TypeScript and +Node.js. The npm package is `solid-objects`. It gives each actor a stable +identity, durable state, ordered operations, and automatic activation. + +The actor runtime runs in your Node.js processes. State and mailboxes live in +SQLite, PostgreSQL, or MySQL. It needs no broker, no daemon, no Cloudflare +account, and no new datastore. Redis is optional and only shortens wake-up +latency. + +Solid Objects is a pre-1.0 release. Read +[Compatibility and maturity](#compatibility-and-maturity) before you choose it. + +## What a virtual actor is + +A virtual actor is a logical object that always exists by name. The caller +does not create it, start it, or stop it. The runtime loads it when a message +arrives and releases it when it is idle. Microsoft Orleans made this model +known as "virtual actors". + +Solid Objects implements four properties of that model: + +| Property | What it means | How Solid Objects does it | +| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Stable identity | An actor is addressed by type and ID, for example one room for each game. | `Room.ref("room-7")` returns a reference. The reference does not load the actor. | +| Automatic activation | The first message activates the actor. An idle actor is released. | A process claims a fenced activation lease when work arrives. A worker releases it after `idleDeactivationTimeoutMilliseconds`. | +| Durable state | State survives process exits and deploys. | Enumerable public fields are a JSON document in SQL. | +| Ordered turns | One identity runs one operation at a time, in a fixed order. | Each call is a durable mailbox message with a per-actor sequence number. | + +Different identities run concurrently across processes. One identity is a +serialization point on purpose. + +## A small example + +This program holds one ticket for a buyer and releases the hold after ten +minutes. It uses the SQLite driver that Node.js includes. Save it as +`ticket-sale.mts`, so that Node.js loads it as an ES module: + +```typescript +import { Actor, configure } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" + +const HOLD_MILLISECONDS = 10 * 60 * 1000 + +export class TicketSale extends Actor { + static override readonly actorType = "TicketSale" + + available = 1 + holds: Record = {} + + 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 + } +} + +const runtime = configure({ + database: sqlite({ path: process.env.TICKET_DATABASE ?? "tickets.sqlite3" }), + authorizeMessage: () => true, + authorizeQuery: () => true, +}) + +await runtime.install() + +try { + const sale = TicketSale.ref("event-42") + + if (process.argv[2] === "work") { + const controller = new AbortController() + process.once("SIGINT", () => controller.abort()) + process.once("SIGTERM", () => controller.abort()) + await runtime.run(controller.signal) + } else { + const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"] + const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer }))) + console.log(JSON.stringify(results)) + } +} finally { + await runtime.close() +} +``` + +Run the background roles in one terminal. Place two concurrent holds in a +second terminal: + +```bash +npm install solid-objects +node ticket-sale.mts work +node ticket-sale.mts hold +``` + +The two holds enter the same mailbox and commit one at a time, so only one +buyer gets the ticket. The hold and its reminder commit in one transaction. If +the worker stops, the reminder stays in `tickets.sqlite3`. It runs when the +worker starts again. + +The authorization callbacks above allow every caller. Use them only for a +local example. The package release check runs this file against the packed +npm tarball. The source is [`examples/ticket-sale.ts`](../examples/ticket-sale.ts). + +For more setup, use one of these guides: + +- `npx solid-objects quickstart --yes` runs a packaged proof of concurrent + calls to one identity. +- [The README](../README.md#installation) has the installation steps. +- [The agent guide](agents.md) gives setup and verification steps for coding + agents. + +## When to use it + +Solid Objects is a good candidate when most of these conditions are true: + +- Concurrent requests can change the same room, cart, account, device, + document, or session. +- Each identity needs its own ordering boundary and durable mailbox. +- Work must happen later or continue after a Node.js process exits. +- A state change must stage reminders, effects, messages to other actors, or + realtime updates in the same commit. +- The application already runs SQLite, PostgreSQL, or MySQL and should keep + durable coordination there. + +## When to use something else + +Do not use an actor when a simpler tool enforces the invariant: + +- One short transaction, a constraint, or `SELECT ... FOR UPDATE` is enough. +- The work is CPU-intensive. Use `worker_threads` or a worker pool. An actor + serializes work. It does not add CPU parallelism. +- One global identity must accept more writes than one sequential mailbox can + commit, for example a request-path rate limiter. +- The state is a large document or a relational dataset. +- The operation must change two actor identities in one atomic transaction. + Solid Objects has no cross-actor transactions. +- You need exactly-once calls to an external API. No actor library can promise + that through every network failure. Solid Objects gives at-least-once + delivery and stable effect IDs for idempotency keys. +- You need replay of named workflow steps from a step log. That is a durable + execution engine, not an actor. +- State must be placed automatically near clients at the network edge. + +[Choosing Solid Objects](fit.md) has the full list. + +## How it compares + +This table compares coordination models for a Node.js application. It does +not rank the projects. Facts about other projects were checked on +October 7, 2026, against the sources in [System comparisons](comparisons.md). + +| Approach | Unit of order | Durable state | Delayed work | Extra service | +| ------------------------------------------ | -------------------------------- | ------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| SQL transaction or row lock | Rows in one transaction | Application tables | None | No | +| Job queue, such as BullMQ or pg-boss | A job or a queue | Owned by the application | Scheduled jobs | Redis for BullMQ; PostgreSQL for pg-boss | +| `worker_threads` or a pool such as Piscina | None | None | None | No | +| Solid Objects | Actor class and ID | JSON state in SQLite, PostgreSQL, or MySQL | Durable per-actor reminders | No | +| Cloudflare Durable Objects | Object class and ID | Per-object storage | One alarm for each object | Cloudflare's network. The open-source `workerd` runtime hosts objects on one instance only | +| Dapr actors (`@dapr/dapr`) | Actor type and ID | A transactional Dapr state store | Durable reminders through the Dapr Scheduler service | A Dapr sidecar, plus the placement and Scheduler services | +| Rivet Actors | Actor key | Actor state, plus per-actor SQLite and KV storage | Scheduled actions | The Rivet Engine, self-hosted or Rivet Cloud | +| Restate virtual objects | Object key, one writer at a time | Restate's state store | Durable timers | A Restate server | +| DBOS | A workflow and its steps | Checkpoints in PostgreSQL | Durable sleeps | No server; PostgreSQL is required | +| Temporal | A workflow execution | Temporal event history | Durable timers | A Temporal Service, self-hosted or Temporal Cloud | + +[System comparisons](comparisons.md) has the full table, with primary +references for each row. + +### Orleans concept map + +Orleans is the reference design for virtual actors on .NET. Solid Objects +uses the same programming model on a SQL database. It does not copy the +Orleans cluster, placement, or feature set. + +| Orleans | Solid Objects | Difference | +| ---------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Grain class | `Actor` subclass with a static `actorType` | None in concept | +| Grain identity (key) | Actor type and actor ID | None in concept | +| Activation on first call | Activation lease on first claimed message | Solid Objects fences each activation with a database generation | +| Turn-based execution | Ordered mailbox, one turn at a time | Orleans can enable reentrancy. Solid Objects turns for one identity never interleave | +| Grain persistence | Public fields stored as JSON in SQL | An Orleans grain calls `WriteStateAsync`. Solid Objects persists state with the turn that changed it | +| Reminders | `schedule()` | Both are durable. Orleans skips a tick that falls due while the cluster is down. A due Solid Objects reminder runs when a runtime process starts. Solid Objects has no non-durable timers | +| Silos and cluster membership | Any Node.js process that calls `runtime.run(signal)` | Solid Objects has no placement, directory, or cluster membership. The database is the coordination point | +| Streams | Observables and realtime sessions | Solid Objects publishes committed revisions through an application-owned transport | + +Solid Objects delivery is at least once. Write each operation so that it can +run again without harm. + +## Guarantees and boundaries + +- Calls are durably ordered per identity. Different identities can run + concurrently. +- Delivery is at least once, not exactly once. An operation can start again + after a crash or a lost lease. +- Ordered turns do not cancel stale JavaScript. Fencing stops a stale + activation from a commit, but that code can continue to run. +- External effects can run more than once. Use the stable effect ID, or + another durable key, as the idempotency key at the provider. +- There are no transactions across actor identities, and there is no replay of + durable function steps. +- Reminders, effects, and realtime delivery need a process that calls + `runtime.run(signal)`. When no process runs, committed work waits in SQL. + The package does not supply a hosted worker. +- One hot identity is sequential. The core package is not a high-throughput + request-path rate limiter. +- The guarantees apply only to changes made through the actor APIs. Actor + fencing does not protect direct writes to the same data or other external + requests. + +The [correctness contract](correctness.md) states each guarantee and its +limits. + +## Compatibility and maturity + +These runtimes have different compatibility statements: + +| Surface | Status | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Node.js SQL runtime | Node.js 24.4 or newer, ESM only, TypeScript 5.9 or newer. SQLite through `node:sqlite`, PostgreSQL 14 or newer, MySQL 8.0 or newer with InnoDB | +| Browser client (`solid-objects/browser`) | A subscription client for a server runtime. It is not the SQL actor runtime | +| Browser runtime (`solid-objects/browser/host`) | The actor runtime in a browser module worker on SQLite WASM. Tested in Chromium | +| Cloudflare backend (`solid-objects/cloudflare`) | Experimental. It runs the actor API on Cloudflare Durable Objects, with different capability limits | + +The package is pre-1.0. It has one deployed first-party reference +application, no measured scale, and no known third-party production use. +[Supported versions](support.md) lists the CI matrix. For Ruby on Rails, use +the [solid_objects](https://github.com/cardmagic/solid-objects-ruby) gem. diff --git a/examples/ticket-sale.ts b/examples/ticket-sale.ts new file mode 100644 index 0000000..141eb89 --- /dev/null +++ b/examples/ticket-sale.ts @@ -0,0 +1,57 @@ +import { Actor, configure } from "solid-objects" +import { sqlite } from "solid-objects/database/sqlite" + +const HOLD_MILLISECONDS = 10 * 60 * 1000 + +export class TicketSale extends Actor { + static override readonly actorType = "TicketSale" + + available = 1 + holds: Record = {} + + 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 + } +} + +const runtime = configure({ + database: sqlite({ path: process.env.TICKET_DATABASE ?? "tickets.sqlite3" }), + authorizeMessage: () => true, + authorizeQuery: () => true, +}) + +await runtime.install() + +try { + const sale = TicketSale.ref("event-42") + + if (process.argv[2] === "work") { + const controller = new AbortController() + process.once("SIGINT", () => controller.abort()) + process.once("SIGTERM", () => controller.abort()) + await runtime.run(controller.signal) + } else { + const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"] + const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer }))) + console.log(JSON.stringify(results)) + } +} finally { + await runtime.close() +} diff --git a/package.json b/package.json index 8dd7ce9..201836e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "solid-objects", - "version": "0.17.0", - "description": "Race-free realtime state per application identity, backed by your SQL database", + "version": "0.17.1", + "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", "type": "module", "license": "MIT", "author": "Lucas Carlson", + "homepage": "https://solidobjects.dev/js", "keywords": [ "concurrency", "durable-state", @@ -16,7 +17,9 @@ "state-management", "typescript", "actors", - "durable-objects" + "durable-objects", + "virtual-actors", + "actor-model" ], "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d19ca..a7fbd13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,8 @@ settings: overrides: undici@7.29.0: 7.29.1 - sharp@<0.35.4: ^0.35.4 + sharp@<0.35.5: ^0.35.5 + source-map-js@<1.2.2: ^1.2.2 importers: @@ -307,160 +308,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1016,8 +1017,8 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1031,8 +1032,8 @@ packages: signal-polyfill@0.2.2: resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} split2@4.2.0: @@ -1378,108 +1379,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -1829,7 +1830,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -1838,7 +1839,7 @@ snapshots: miniflare@5.20260903.0-alpha(@types/node@24.13.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.4(@types/node@24.13.3) + sharp: 0.35.5(@types/node@24.13.3) undici: 7.29.1 workerd: 1.20260903.1 ws: 8.21.0 @@ -1922,7 +1923,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -1971,44 +1972,44 @@ snapshots: semver@7.8.5: {} - sharp@0.35.4(@types/node@24.13.3): + sharp@0.35.5(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 24.13.3 siginfo@2.0.0: {} signal-polyfill@0.2.2: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} split2@4.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 61f7a64..8e29cca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ minimumReleaseAgeExclude: - vitest@4.1.11 - "@vitest/mocker@4.1.11" - sharp@0.35.4 + - source-map-js@1.2.2 overrides: undici@7.29.0: 7.29.1 - sharp@<0.35.4: ^0.35.4 + sharp@<0.35.5: ^0.35.5 + source-map-js@<1.2.2: ^1.2.2 diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index eb4586d..fc91e66 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -1,4 +1,4 @@ -import { readFile, stat } from "node:fs/promises" +import { readdir, readFile, stat } from "node:fs/promises" import { dirname, resolve } from "node:path" const repositoryRoot = resolve(import.meta.dirname, "..") @@ -6,23 +6,10 @@ const documentationPaths = [ "README.md", "CONTRIBUTING.md", "SECURITY.md", - "docs/api.md", - "docs/architecture.md", - "docs/authorization.md", - "docs/browser-protocol.md", - "docs/configuration.md", - "docs/cloudflare.md", - "docs/correctness.md", - "docs/dashboard.md", - "docs/errors-and-recovery.md", - "docs/fit.md", - "docs/benchmarks.md", - "docs/comparisons.md", - "docs/operations.md", - "docs/parity.md", - "docs/releasing.md", - "docs/state-and-lifecycle.md", - "docs/support.md", + ...(await readdir(resolve(repositoryRoot, "docs"))) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => `docs/${name}`), ] for (const documentationPath of documentationPaths) { @@ -48,6 +35,17 @@ for (const documentationPath of documentationPaths) { } } +const embeddedExamples = [ + { documentation: "docs/virtual-actors.md", example: "examples/ticket-sale.ts" }, +] +for (const { documentation, example } of embeddedExamples) { + const documentationSource = await readFile(resolve(repositoryRoot, documentation), "utf8") + const exampleSource = await readFile(resolve(repositoryRoot, example), "utf8") + if (!documentationSource.includes(`\`\`\`typescript\n${exampleSource}\`\`\``)) { + throw new Error(`${documentation} does not embed the current ${example}`) + } +} + const publicApi = await readFile(resolve(repositoryRoot, "docs/api.md"), "utf8") const configurationReference = await readFile( resolve(repositoryRoot, "docs/configuration.md"), diff --git a/scripts/release-artifact-smoke.mjs b/scripts/release-artifact-smoke.mjs index cb8b8e5..764a307 100644 --- a/scripts/release-artifact-smoke.mjs +++ b/scripts/release-artifact-smoke.mjs @@ -35,6 +35,9 @@ try { "dist/examples/sqlite-quickstart.js", "examples/sqlite-quickstart.ts", "docs/correctness.md", + "docs/agents.md", + "docs/virtual-actors.md", + "examples/ticket-sale.ts", "README.md", ]) { assert(packagedPaths.has(expectedPath), `package is missing ${expectedPath}`) @@ -118,6 +121,38 @@ try { assert(resolvedModule.includes("/node_modules/solid-objects/dist/index.js")) assert.equal(resolvedModule.startsWith(`file://${repositoryRoot}`), false) + const ticketSaleExample = join(projectDirectory, "ticket-sale.mts") + await writeFile( + ticketSaleExample, + await readFile(join(projectDirectory, "node_modules/solid-objects/examples/ticket-sale.ts")), + ) + const ticketSaleHolds = JSON.parse( + await run(process.execPath, [ticketSaleExample, "hold"], { + cwd: projectDirectory, + env: { TICKET_DATABASE: join(projectDirectory, "tickets.sqlite3") }, + }), + ) + assert.deepEqual( + ticketSaleHolds.map((result) => result.held).sort(), + [false, true], + "exactly one concurrent hold must win the only ticket", + ) + assert.deepEqual( + ticketSaleHolds.map((result) => result.available), + [0, 0], + ) + const inheritedNameHold = JSON.parse( + await run(process.execPath, [ticketSaleExample, "hold", "constructor"], { + cwd: projectDirectory, + env: { TICKET_DATABASE: join(projectDirectory, "inherited-name.sqlite3") }, + }), + ) + assert.deepEqual( + inheritedNameHold, + [{ held: true, available: 0 }], + "a buyer named after an Object.prototype property must get the free ticket", + ) + const quickstartJson = await run( join(projectDirectory, "node_modules/.bin/solid-objects"), ["quickstart", "--json"], @@ -179,7 +214,7 @@ async function run(command, argumentsValue, options) { return new Promise((resolvePromise, reject) => { const child = spawn(command, argumentsValue, { ...options, - env: { ...process.env, NO_COLOR: "1" }, + env: { ...process.env, NO_COLOR: "1", ...options.env }, stdio: ["ignore", "pipe", "pipe"], }) let stdout = "" diff --git a/src/version.ts b/src/version.ts index 8a29276..f92d2a2 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.0" +export const VERSION = "0.17.1" diff --git a/test/package-metadata.test.ts b/test/package-metadata.test.ts new file mode 100644 index 0000000..9647508 --- /dev/null +++ b/test/package-metadata.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +const packageDefinition = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { description: string; keywords: string[]; homepage?: string; files: string[] } + +describe("package metadata", () => { + it("names the virtual actor category for TypeScript and Node.js", () => { + expect(packageDefinition.description).toMatch( + /SQL-backed virtual actor library for TypeScript and Node\.js/, + ) + }) + + it("keeps the existing keywords and adds the actor categories", () => { + expect(packageDefinition.keywords).toEqual( + expect.arrayContaining(["actors", "durable-objects", "virtual-actors", "actor-model"]), + ) + }) + + it("links the homepage to the Node page", () => { + expect(packageDefinition.homepage).toBe("https://solidobjects.dev/js") + }) + + it("ships the documentation directory that holds the agent guides", () => { + expect(packageDefinition.files).toContain("docs") + }) +})