diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6d61b..95a2991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 08dd4db..c948db9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/compare/dbos.md b/docs/compare/dbos.md new file mode 100644 index 0000000..8b2f2b1 --- /dev/null +++ b/docs/compare/dbos.md @@ -0,0 +1,391 @@ +# DBOS Transact and Solid Objects: durable checkout and state for each cart on PostgreSQL + +DBOS Transact is a TypeScript library that checkpoints workflows and steps in a PostgreSQL system database. It has no object with its own state for each key. Its docs recommend "storing long-lived objects directly in your database" and using partitioned queues for concurrency and order. Solid Objects (the `solid-objects` npm package) gives each key, for example each shopping cart, an actor with an ordered mailbox, durable state, and reminders, in the SQLite, PostgreSQL, or MySQL database that the app already uses. Choose DBOS when the work is a workflow of steps that must not repeat after they complete. Choose Solid Objects when one thing has state that many requests change in order, and that thing must wake up later. + +Solid Objects is an independent project. It is not affiliated with DBOS. + +## The short answer + +| Question | DBOS Transact | Solid Objects | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| What has state | A workflow and its steps. Long-lived state goes in your own tables | An actor class and an actor ID | +| One at a time for each key | A partitioned queue with `partitionConcurrency: 1` | One turn at a time for each actor | +| Order | "Queued tasks are started in first-in, first-out (FIFO) order." The docs call each partition a "subqueue" | Messages run in the order that the runtime stores them | +| Side effects | "Steps are tried _at least once_ but are never re-executed after they complete" | Effects run at least once with a stable effect ID. A callback updates the state | +| Idempotency | "An assigned workflow ID acts as an idempotency key" | An idempotency key on each message, and the effect ID for each external call | +| Later work | `DBOS.sleep` in a workflow, `delaySeconds` on enqueue, or `Debouncer` | `this.schedule({ at, key }).operation(input)` | +| Database | PostgreSQL | SQLite, PostgreSQL, MySQL | +| What you run | The library in your process and a PostgreSQL system database. For more than one process, see the note below | The library in your process. A process calls `runtime.run(signal)` | +| New code and old work | A workflow continues only on "the same application version that started the workflow", unless you turn on patching or set `applicationVersion` | Stored messages and reminders run on the code that is deployed when they run. Your [state migrations](../state-and-lifecycle.md) must accept old state | +| Node.js | Node.js 20 or newer | Node.js 24.4 or newer | +| Licence | Transact: MIT. Self-hosted Conductor: proprietary. Hosted Conductor: from $99 a month | MIT. The optional Solid Objects Pro package is commercial | + +The DBOS facts come from the [DBOS documentation](https://docs.dbos.dev) and the [DBOS pricing page](https://www.dbos.dev/dbos-pricing) (checked October 15, 2026). + +Two DBOS pages differ on more than one process. The FAQ says "Conductor is required for correct workflow recovery in applications that use more than one process". The recovery page describes recovery without Conductor: each process has an executor ID, and a restarted process recovers its own pending workflows. + +## Choose DBOS when + +- The work is a sequence of steps, and a completed step must not run again after a crash. +- You want a workflow ID as the idempotency key for the whole sequence. +- You want a workflow dashboard and managed recovery from Conductor. +- An agent loop waits for days for a person, with `recv` and `setEvent`. + +## Choose Solid Objects when + +- One thing, such as a cart, has state that many requests change, and the requests must apply in order. +- The idle timer must move in the same transaction as the state change, for example to release stock after 30 minutes with no activity. +- You want the state change and its effects and reminders to commit in one SQL transaction. +- You use SQLite or MySQL, or you do not want DBOS system tables in PostgreSQL. + +## The same terms in each + +| DBOS Transact | Solid Objects | +| ----------------------------------- | -------------------------------------------- | +| Partition key (`queuePartitionKey`) | Actor ID | +| Workflow function | Actor method (an operation) | +| Step (`DBOS.runStep`) | Effect (`emit` and `runtime.registerEffect`) | +| Workflow ID as idempotency key | `idempotencyKey` on `send.with(...)` | +| `DBOS.sleep` or `delaySeconds` | `schedule({ at, key })` and an operation | +| `DBOS.send` and `DBOS.recv` | A message to the actor | +| `DBOS.setEvent` and `DBOS.getEvent` | Actor queries and `snapshot()` | +| Datasource transaction | `commitAction` | + +## A shopping cart checkout in each + +The task: a Node.js TypeScript API runs as four processes behind a load balancer, with PostgreSQL. Each shopping cart can get concurrent requests (add item, apply coupon, check out) on different processes. Checkout calls a payment provider that sometimes times out, so a crash in the middle of checkout must recover without charging twice. Carts with no activity for 30 minutes must release their reserved stock, even across restarts. + +### With DBOS Transact + +This code is shortened from a design that follows the DBOS documentation. It was not compiled or run. `lockCart`, `markPaid`, `reserveItem`, and `releaseIfIdleInSql` are DBOS datasource transactions (`runTransaction`) on your own tables. Each one commits with its DBOS checkpoint, so it runs once. + +```typescript +import { DBOS } from "@dbos-inc/dbos-sdk" + +const cartQueue = (cartId: string, delaySeconds?: number) => ({ + queueName: "carts", + enqueueOptions: { queuePartitionKey: cartId, delaySeconds }, +}) + +const releaseIfIdle = DBOS.registerWorkflow( + (cartId: string, version: number) => releaseIfIdleInSql(cartId, version), + { name: "releaseIfIdle" }, +) + +const addItem = DBOS.registerWorkflow( + async (cartId: string, sku: string) => { + const version = await reserveItem(cartId, sku) + await DBOS.startWorkflow(releaseIfIdle, cartQueue(cartId, 30 * 60))(cartId, version) + }, + { name: "addItem" }, +) + +const checkout = DBOS.registerWorkflow( + async (cartId: string) => { + const cart = await lockCart(cartId) + if (!cart) return "not-open" + const charge = await DBOS.runStep( + () => + payments.charge({ + cartId, + amountCents: cart.totalCents, + idempotencyKey: `${DBOS.workflowID}-charge`, + }), + { name: "charge", retriesAllowed: true, maxAttempts: 5, timeoutMS: 15_000 }, + ) + await markPaid(cartId, charge.id) + return "paid" + }, + { name: "checkout" }, +) + +await DBOS.registerQueue("carts", { partitionConcurrency: 1, workerConcurrency: 50 }) + +await DBOS.startWorkflow(checkout, { + workflowID: `checkout-${requestIdempotencyKey}`, + ...cartQueue(cartId), +})(cartId) +``` + +What this code gets from DBOS: + +- The partitioned queue runs one workflow at a time for each cart. +- The workflow ID stops a second checkout with the same request key. +- A charge step that completed does not run again after a crash. +- The delayed `releaseIfIdle` workflow is stored in PostgreSQL and runs after a restart. The version check in SQL skips it if the cart changed. + +What to know: + +- A crash after the payment provider charges and before DBOS records the step makes the step run again. The provider must deduplicate the idempotency key. DBOS says that steps "should be **idempotent**". +- The cart state lives in your own tables. Each workflow reads and writes them in datasource transactions. +- A queued workflow starts only when a process polls the queue, at least 1,000 milliseconds apart by default. A request that waits for the result waits for that poll. +- DBOS also documents a `Debouncer`, which can replace the delayed `releaseIfIdle` workflow. + +### With Solid Objects + +There is one `ShoppingCart` actor for each cart ID. + +```typescript +import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects" + +export const IDLE_MILLISECONDS = 30 * 60 * 1000 + +type ChargeArguments = { cartId: string; amountCents: number } +type ChargeResult = { status: "paid"; paymentId: string } | { status: "declined" } +type CheckArguments = { paymentKey: string } +type CheckResult = { status: "paid"; paymentId: string } | { status: "not_found" } + +export class ShoppingCart extends Actor { + static override readonly actorType = "ShoppingCart" + + items: Record = {} + couponCode: string | null = null + status: "open" | "paying" | "checking" | "paid" | "released" = "open" + paymentId: string | null = null + activity = 0 + + addItem({ productId, quantity }: { productId: string; quantity: number }): { + items: Record + } { + this.#requireOpen() + this.items = { ...this.items, [productId]: (this.items[productId] ?? 0) + quantity } + this.#armIdleRelease() + return { items: this.items } + } + + applyCoupon({ code }: { code: string }): void { + this.#requireOpen() + this.couponCode = code + this.#armIdleRelease() + } + + checkOut({ amountCents }: { amountCents: number }): { status: string } { + if (this.status === "paying" || this.status === "checking" || this.status === "paid") { + return { status: this.status } + } + this.#requireOpen() + this.status = "paying" + this.emit("charge_payment", { + arguments: { cartId: this.actorId, amountCents }, + onSuccess: "paymentSettled", + onFailure: "paymentUnknown", + }) + return { status: this.status } + } + + paymentSettled({ result }: EffectSuccessPayload): void { + if (result.status === "paid") { + this.#markPaid(result.paymentId) + return + } + this.#reopen() + } + + paymentUnknown({ effectId }: EffectFailurePayload): void { + this.status = "checking" + this.emit("check_payment", { + arguments: { paymentKey: effectId }, + onSuccess: "paymentChecked", + }) + } + + paymentChecked({ result }: EffectSuccessPayload): void { + if (result.status === "paid") { + this.#markPaid(result.paymentId) + return + } + this.#reopen() + } + + releaseIfIdle({ activity }: { activity: number }): void { + if (activity !== this.activity || this.status !== "open") return + this.status = "released" + this.emit("release_stock", { arguments: { cartId: this.actorId, items: this.items } }) + } + + #markPaid(paymentId: string): void { + this.status = "paid" + this.paymentId = paymentId + } + + #reopen(): void { + this.status = "open" + this.#armIdleRelease() + } + + #requireOpen(): void { + if (this.status !== "open") { + this.reject("cart_closed", { message: `The cart is ${this.status}` }) + } + } + + #armIdleRelease(): void { + this.activity += 1 + this.schedule({ at: new Date(Date.now() + IDLE_MILLISECONDS) }).releaseIfIdle({ + activity: this.activity, + }) + } +} +``` + +The runtime registers the actor and the three effects: + +```typescript +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 + }): Promise +} + +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, + }), + ) + 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 +} +``` + +Each process sends calls to the cart with the reference: + +```typescript +await runtime + .ref(ShoppingCart, cartId) + .with({ authorizationContext: { userId } }) + .checkOut({ amountCents: totalFromYourPriceTable }) +``` + +At least one process calls `runtime.run(signal)`. It runs the effects and the reminders. + +How it works: + +- Calls for one cart run one at a time on any of the four processes. Each call reads the state that the previous call committed, so no item is lost. +- Each change moves the one `releaseIfIdle` reminder 30 minutes ahead and counts up `activity`. The reminder is a row in the database, so it runs after a restart. When it runs, it releases the stock only if the cart is still open and no newer change came after it. +- `checkOut` sets the status to `paying` and stages the `charge_payment` effect in the same transaction. A second `checkOut` while the payment runs changes nothing. +- The effect handler uses `context.id` as the idempotency key. If the payment response is lost, the runtime runs the effect again with the same ID, and the provider charges once. +- A declined card is an answer, so `paymentSettled` opens the cart again. +- If every attempt fails with no answer, the charge may have happened. `paymentUnknown` sets the status to `checking` and stages `check_payment` with the same payment key, the effect ID of the charge. The cart opens again only if the provider has no charge for that key. A new checkout can never get a new key while the first charge is in doubt. +- Compute the amount on the server from your own prices. Do not take it from the browser. + +### What the tests prove + +[The tests](../../test/compare-shopping-cart.test.ts) show these results: + +- Ten concurrent `addItem` calls for one cart keep all ten items. +- When the payment response is lost, the effect runs again with the same idempotency key, and the provider charges once. +- A second checkout while the first one runs does not charge again. +- A declined card opens the cart again. +- When every payment response is lost after a charge, the cart checks the charge, ends as paid, and the provider charges once. +- When every payment call fails with no charge, the check finds no charge, and the cart opens again. +- A release reminder that was queued behind a newer change does nothing. +- After a restart, an idle cart releases its reserved stock and refuses a checkout. + +## Questions that teams ask + +**Is Solid Objects mature enough?** Here are the facts: + +- First public release of the npm package: August 13, 2026. +- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md). +- One maintainer. +- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known. +- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work. + +DBOS has a company, a hosted service, and a longer history. If those matter more than state for each cart, choose DBOS. + +**Why Node.js 24.4 or newer?** The SQLite adapter reads integer columns as `BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the built-in SQLite module. See [support](../support.md). + +**Does it need another process?** No new server. A process of your app calls `runtime.run(signal)`. While no process runs, pending work stays in SQL. + +**Is delivery exactly once?** No. Delivery is at least once. Use the effect ID as the idempotency key for each external call. See [correctness](../correctness.md) and [effect recovery](../effect-recovery.md). + +**Can I use both?** Yes. Code in a DBOS step can call an actor, and an actor effect handler can call any API. + +## Sources + +Checked October 15, 2026: + +- [DBOS workflows and steps](https://docs.dbos.dev/typescript/reference/workflows-steps) +- [DBOS workflow tutorial](https://docs.dbos.dev/typescript/tutorials/workflow-tutorial) +- [DBOS queue tutorial](https://docs.dbos.dev/typescript/tutorials/queue-tutorial) +- [DBOS queue reference](https://docs.dbos.dev/typescript/reference/queues) +- [DBOS architecture](https://docs.dbos.dev/architecture) +- [DBOS datasource transactions](https://docs.dbos.dev/typescript/tutorials/transaction-tutorial) +- [DBOS FAQ](https://docs.dbos.dev/faq) +- [DBOS workflow recovery](https://docs.dbos.dev/production/workflow-recovery) +- [DBOS upgrading workflows](https://docs.dbos.dev/typescript/tutorials/upgrading-workflows) +- [Migrating from Temporal to DBOS](https://docs.dbos.dev/explanations/migrating-from-temporal) +- [DBOS pricing](https://www.dbos.dev/dbos-pricing) + +## More information + +- [All comparisons](../comparisons.md) +- [Restate virtual objects and Solid Objects](restate.md) +- [Virtual actors in TypeScript](../virtual-actors.md) +- [Guide for coding agents](../agents.md) diff --git a/docs/compare/graphile-worker.md b/docs/compare/graphile-worker.md new file mode 100644 index 0000000..90a5b95 --- /dev/null +++ b/docs/compare/graphile-worker.md @@ -0,0 +1,296 @@ +# Graphile Worker named queues and Solid Objects: ordered work for each room on PostgreSQL + +Graphile Worker is a job queue that stores jobs in PostgreSQL and runs them in Node.js. Jobs in the same named queue (`queueName`) run one at a time. Solid Objects (the `solid-objects` npm package) gives each key an actor with an ordered mailbox, durable state, and reminders, in the SQLite, PostgreSQL, or MySQL database that the app already uses. Choose Graphile Worker for background jobs, cron tasks, and work that does not belong to one key. Choose Solid Objects when one key, such as a chat room, has state that each change reads, and the order must hold after a failure. + +Solid Objects is an independent project. It is not affiliated with Graphile. + +## The short answer + +| Question | Graphile Worker | Solid Objects | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| What runs one at a time | Jobs in the same named queue. "Adding jobs to same named queue runs them in series" | Calls to the same actor ID | +| Order after a failure | The docs say "order is only maintained within each named queue", but not what happens after a failure. In the source code, a failed job gets a later `run_at`, so later jobs in the queue can run first | The failed message runs again. Later messages wait until it succeeds or becomes a dead letter | +| State for each key | Your own tables | Actor fields | +| A worker crashes | "each job will execute at least once". By default, a crashed worker's jobs "remain locked for at least 4 hours". `forceUnlockWorkers` can release them sooner | The lease expires and the turn runs again. Delivery is at least once | +| Later work | `runAt` with a `jobKey` | `this.schedule({ at, key }).operation(input)` | +| Recurring work | A crontab | No crontab. A reminder can arm itself again for one actor | +| Queue names | "Avoid using high cardinality values (e.g., random strings, UUIDs, timestamps) for queue names" | One actor ID for each key, at any count | +| What you run | Worker processes (or the worker inside your server process) and PostgreSQL | Your Node.js process and your database | +| Database | PostgreSQL 12 or newer | SQLite, PostgreSQL 14 or newer, MySQL 8.0 or newer | +| Node.js | Node.js 22.18 or newer | Node.js 24.4 or newer | +| Status | "Production ready (and used in production)", with 0.x version numbers | Version 1.0 | +| Licence | MIT. The Worker Pro add-on is proprietary | MIT. The optional Solid Objects Pro package is commercial | + +The Graphile Worker facts come from the [Graphile Worker documentation](https://worker.graphile.org/docs) and its [source code](https://github.com/graphile/worker) (checked October 15, 2026). + +## Choose Graphile Worker when + +- The work is background jobs, such as emails, webhooks, or reports. +- You need cron tasks. +- You want low latency from PostgreSQL `LISTEN` and `NOTIFY`. +- The state already lives in your own normalized tables, and each job is a short transaction on them. + +## Choose Solid Objects when + +- One key has state that each change reads and writes, and the changes must apply in order. +- The order must hold when one change fails. +- The keys are many, for example one for each room or each user. Graphile Worker advises against queue names with high cardinality. +- You use SQLite or MySQL. + +## The same terms in each + +| Graphile Worker | Solid Objects | +| ------------------------------------------------- | ------------------------------------------ | +| `queueName` | Actor ID | +| A task | An actor method (an operation) | +| `addJob` | A call on `runtime.ref(ActorClass, id)` | +| `runAt` and `jobKey` with `jobKeyMode: "replace"` | `schedule({ at, key })` and an operation | +| `maxAttempts` and the backoff formula | `maxAttempts` and `retryDelayMilliseconds` | +| A crontab entry | No equivalent | + +## A chat room in each + +The task: chat rooms in a Node.js TypeScript server with PostgreSQL. Several Node.js processes run. Messages in one room must get a strict order. Room state (members, unread counts, pinned message) must survive restarts. A room archives itself after 7 days with no activity. Rooms are independent of each other. + +### With Graphile Worker + +This code follows the Graphile Worker documentation. It was not compiled or run. The room state lives in `rooms`, `messages`, and `room_members` tables. + +```typescript +import { makeWorkerUtils, run, type Task } from "graphile-worker" + +const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000 +const connectionString = process.env.DATABASE_URL! +const workerUtils = await makeWorkerUtils({ connectionString }) + +export async function sendMessage(roomId: string, from: string, text: string) { + await workerUtils.addJob("post_message", { roomId, from, text }, { queueName: `room:${roomId}` }) + await workerUtils.addJob( + "archive_room", + { roomId }, + { + jobKey: `archive_room:${roomId}`, + jobKeyMode: "replace", + runAt: new Date(Date.now() + SEVEN_DAYS), + }, + ) +} + +const post_message: Task = async (payload, helpers) => { + const { roomId, from, text } = payload as { roomId: string; from: string; text: string } + await helpers.withPgClient(async (pg) => { + await pg.query("begin") + try { + const { + rows: [room], + } = await pg.query( + "update rooms set seq = seq + 1, last_activity_at = now() where id = $1 returning seq", + [roomId], + ) + await pg.query("insert into messages (room_id, seq, sender, body) values ($1, $2, $3, $4)", [ + roomId, + room.seq, + from, + text, + ]) + await pg.query( + "update room_members set unread = unread + 1 where room_id = $1 and user_id <> $2", + [roomId, from], + ) + await pg.query("commit") + } catch (error) { + await pg.query("rollback") + throw error + } + }) +} + +const archive_room: Task = async (payload, helpers) => { + const { roomId } = payload as { roomId: string } + await helpers.query( + "update rooms set archived_at = now() where id = $1 and last_activity_at <= now() - interval '7 days'", + [roomId], + ) +} + +const runner = await run({ + connectionString, + concurrency: 5, + taskList: { post_message, archive_room }, +}) +await runner.promise +``` + +What this code gets from Graphile Worker: + +- Jobs in the queue `room:` run one at a time. +- Each message job runs one SQL transaction, so the sequence number and the unread counts change together. +- The archive job with `jobKeyMode: "replace"` moves to 7 days after the newest message. + +What to know: + +- A failed job does not hold its place. In the source code, a failed job gets a later `run_at` from the backoff, and the next job in the queue runs. The documentation says that "order is only maintained within each named queue", but it does not say what happens after a failure. +- By default, a crashed worker's jobs stay locked for at least 4 hours, and the named queue waits with them. `workerUtils.forceUnlockWorkers` or Worker Pro can release them sooner. +- The sample does not refuse a post to an archived room. +- `withPgClient` does not open a transaction, so the task opens one. +- Delivery is at least once. A crash after `commit` can insert a message twice. Add a client message ID with a unique index. +- Graphile Worker has no realtime push. The app sends updates itself. +- One queue name for each room is a high-cardinality queue name. Graphile Worker advises against it and documents a `GC_JOB_QUEUES` cleanup task. + +### With Solid Objects + +There is one `ChatRoom` actor for each room ID. + +```typescript +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 = {} + pinned: ChatMessage | null = null + activity = 0 + archived = false + + get unreadCounts(): Record { + 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, + }) + } +} +``` + +The runtime policy lets a member act only in their own name. See [the runtime file](../../examples/compare/chat-runtime.ts). Its query policy only checks that the caller signed in. A production policy also checks room membership from your own data. + +How it works: + +- Calls for one room run one at a time, on any process. Each `post` gets the next sequence number, and the number commits with the message. +- If a turn throws, the runtime runs the same message again. Later messages for the room wait, so the order holds. See [errors and recovery](../errors-and-recovery.md). +- `lastRead` keeps the last message that each member read. `unreadCounts` is a query that computes the counts from it. +- Each join, post, or pin moves the one `archiveIfIdle` reminder 7 days ahead and counts up `activity`. The reminder is a row in the database, so it runs after a restart. A reminder that was already queued behind a newer change carries an old count, so it does nothing. A post to an archived room gets `archived`. +- `pin` keeps a copy of the message, so the pinned text stays after newer messages push it out of the last 200. +- The actor keeps the last 200 messages in its state. Keep the full history in your own table with a commit action if you need it. + +### What the tests prove + +[The tests](../../test/compare-chat-room.test.ts) show these results: + +- Twenty messages from two members that arrive at the same time get the sequence numbers 1 to 20, with no gap. +- Unread counts are correct for each member after one member marks messages read. +- After a restart and seven idle days, the room is archived, keeps its pinned message, and refuses a new post. +- An archive reminder that was queued behind a newer post does nothing. +- A pinned message keeps its text after 200 newer messages. +- A member cannot post in the name of another member. +- A caller with no credentials cannot join or post, even with a `null` member ID. +- A caller who did not join cannot post. + +## Questions that teams ask + +**Is Solid Objects mature enough?** Here are the facts: + +- First public release of the npm package: August 13, 2026. +- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md). +- One maintainer. +- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known. +- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work. + +**Why Node.js 24.4 or newer?** The SQLite adapter reads integer columns as `BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the built-in SQLite module. See [support](../support.md). + +**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Use the effect ID as the idempotency key for each external call. See [correctness](../correctness.md). + +**What if one room is very busy?** One actor runs one turn at a time. A busy room handles its messages in sequence. Different rooms run at the same time. + +**Does it need another process?** Both run in your Node.js processes. Graphile Worker runs `run(...)`. Solid Objects runs `runtime.run(signal)` for reminders and effects. While no process runs, pending work stays in SQL. + +**Can I use both?** Yes. Keep background jobs and cron in Graphile Worker, and keep state for each room in Solid Objects. + +## Sources + +Checked October 15, 2026: + +- [Graphile Worker introduction](https://worker.graphile.org/docs) +- [Graphile Worker `addJob`](https://worker.graphile.org/docs/library/add-job) +- [Graphile Worker job keys](https://worker.graphile.org/docs/job-key) +- [Graphile Worker error handling](https://worker.graphile.org/docs/error-handling) +- [Graphile Worker exponential backoff](https://worker.graphile.org/docs/exponential-backoff) +- [Graphile Worker requirements](https://worker.graphile.org/docs/requirements) +- [Graphile Worker project status](https://worker.graphile.org/docs/project-status) +- [Graphile Worker techniques](https://worker.graphile.org/docs/techniques) +- [Graphile Worker admin functions](https://worker.graphile.org/docs/admin-functions) +- [Graphile Worker Pro](https://worker.graphile.org/docs/pro) +- [Graphile Worker `failJobs.ts`](https://github.com/graphile/worker/blob/main/src/sql/failJobs.ts) and [`getJobs.ts`](https://github.com/graphile/worker/blob/main/src/sql/getJobs.ts) + +## More information + +- [All comparisons](../comparisons.md) +- [RivetKit actors and Solid Objects](rivetkit.md) +- [Persistent rooms in Node.js](../guides/persistent-rooms.md) +- [Guide for coding agents](../agents.md) diff --git a/docs/compare/restate.md b/docs/compare/restate.md new file mode 100644 index 0000000..a36e3b8 --- /dev/null +++ b/docs/compare/restate.md @@ -0,0 +1,290 @@ +# Restate virtual objects and Solid Objects: state and delayed calls for each key in TypeScript + +Restate is a durable execution runtime. A Restate virtual object keeps state for each key, runs one write handler at a time for each key, and records each step in a journal on the Restate server. Solid Objects (the `solid-objects` npm package) gives each key an actor with an ordered mailbox, durable state, and reminders, stored in the SQLite, PostgreSQL, or MySQL database that the app already uses. It needs no separate server. Choose Restate when you want a journal of every step and you can run the Restate server or use Restate Cloud. Choose Solid Objects when you want the state and the order in your own SQL database, with no new server to operate. + +Solid Objects is an independent project. It is not affiliated with Restate. + +## The short answer + +| Question | Restate virtual object | Solid Objects | +| --------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| What has state | An object name and a key | An actor class and an actor ID | +| One writer at a time | "At most one handler with write access can run at a time per object key" | One turn at a time for each actor | +| Order | "Calls to a Virtual Object execute in order of arrival, serially" | Messages run in the order that the runtime stores them | +| Where the state lives | The embedded key-value store of the Restate server | Tables in your SQL database | +| Delayed call | `ctx.objectSendClient(...).handler(input, restate.rpc.sendOpts({ delay }))` | `this.schedule({ at, key }).operation(input)` | +| Side effects | `ctx.run` stores the result in the journal. After the result commits, the step does not run again | `emit` stages an effect with a stable ID. The effect runs at least once, and a callback updates the state | +| What you run | The Restate server (one binary or a cluster) and your service | Your Node.js process, which calls `runtime.run(signal)` | +| Node.js | Node.js 22 or newer for the SDK | Node.js 24.4 or newer | +| Licence | SDK: MIT. Server: Business Source License 1.1, which changes to Apache 2.0 four years after each release | MIT. The optional Solid Objects Pro package is commercial | + +The Restate facts come from the [Restate documentation](https://docs.restate.dev) and the [Restate server licence](https://github.com/restatedev/restate/blob/main/LICENSE) (checked October 15, 2026). + +## Choose Restate when + +- You want a journal of each step, so that a retry resumes after the last completed step. +- You can run the Restate server, or you can use Restate Cloud. +- You want workflows, service calls, and virtual objects in one runtime. +- You use an agent framework that Restate integrates with in TypeScript, such as the Vercel AI SDK. + +## Choose Solid Objects when + +- You want the state and the mailbox in the SQL database that you already operate, back up, and query. +- You do not want to run another server. +- You want a state change and its effects, reminders, and broadcasts to commit in one SQL transaction. +- You also have a Rails app. The `solid_objects` gem has the same model. + +## The same terms in each + +| Restate | Solid Objects | +| ----------------------------------------- | ---------------------------------------------------- | +| Virtual object key | Actor ID | +| Exclusive handler (`ObjectContext`) | Actor method (an operation) | +| Shared handler | Query (a getter) or `snapshot()` | +| `ctx.get` and `ctx.set` | Actor fields | +| Delayed call with `sendOpts({ delay })` | `schedule({ at, key })` and an operation | +| `ctx.cancel(invocationId)` | [Cancel a reminder](../api.md#cancelling-a-reminder) | +| `ctx.run` | `emit` and an effect handler | +| `ctx.rand.uuidv4()` as an idempotency key | The effect ID, `context.id` | +| `restate deployments register` | `runtime.register(ActorClass)` | + +## An AI agent session in each + +The task: a Node.js TypeScript backend runs a long-lived AI agent session for each user. A session holds conversation state. It receives user messages and tool results that must apply in order. It can schedule its own follow-up, for example "check the order status again in 2 hours". Sessions must survive deploys and process crashes. The app uses PostgreSQL and runs several Node.js processes. + +### With Restate + +This code follows the Restate documentation and type-checks against `@restatedev/restate-sdk` 1.17.3. It was not run against a Restate server. + +```typescript +import * as restate from "@restatedev/restate-sdk" + +type Message = { role: "user" | "assistant" | "tool"; content: string } +type LlmReply = { text: string; followUpOrderId?: string } + +declare function callLlm(messages: Message[]): Promise +declare function fetchOrderStatus(orderId: string): Promise + +async function appendAndAsk(ctx: restate.ObjectContext, message: Message) { + const messages = (await ctx.get("messages")) ?? [] + messages.push(message) + const reply = await ctx.run("LLM call", () => callLlm(messages)) + messages.push({ role: "assistant", content: reply.text }) + ctx.set("messages", messages) + return reply +} + +export const agentSession = restate.object({ + name: "AgentSession", + handlers: { + message: async (ctx: restate.ObjectContext, message: Message) => { + const reply = await appendAndAsk(ctx, message) + if (!reply.followUpOrderId) return reply.text + ctx + .objectSendClient(agentSession, ctx.key) + .checkOrder(reply.followUpOrderId, restate.rpc.sendOpts({ delay: { hours: 2 } })) + return reply.text + }, + checkOrder: async (ctx: restate.ObjectContext, orderId: string) => { + const status = await ctx.run("order status", () => fetchOrderStatus(orderId)) + await appendAndAsk(ctx, { role: "tool", content: `Order ${orderId}: ${status}` }) + }, + }, +}) + +restate.serve({ services: [agentSession], port: 9080 }) +``` + +What this code gets from Restate: + +- Calls for one session run one at a time, in order of arrival. +- The model reply is in the journal. Restate says that model calls are "Persisted so responses are not re-fetched on recovery". +- The delayed call to `checkOrder` is durable. + +What to know: + +- As in the Restate session examples, the model call runs inside the exclusive handler. A new message for the same session waits until the model call and the handler finish. +- The Restate pages differ on side effects. The agent page says "Side effects are executed exactly once". The saga guide tells you to register compensation "in case action succeeded but confirmation was lost", so a `ctx.run` action can run again. +- For long model calls, Restate tells you to raise the inactivity timeout (default 1 minute) and the abort timeout (default 10 minutes). +- The state is a key-value store inside the Restate server. Restate says "SQL is supported only for analytics / introspection". + +### With Solid Objects + +There is one `AgentSession` actor for each user ID. + +```typescript +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): 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", + }) + } +} +``` + +The runtime registers the actor and the effect that calls the model: + +```typescript +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 +} +``` + +The app sends each message with `send`, so that the message is stored before the call returns: + +```typescript +await runtime + .ref(AgentSession, user.id) + .send.with({ authorizationContext: { userId: user.id }, idempotencyKey: message.id }) + .receive({ role: "user", content: message.text }) +``` + +At least one process calls `runtime.run(signal)`. It runs the stored messages, the effects, and the reminders. + +How it works: + +- Messages for one session run one at a time, in the order that the runtime stores them. +- `receive` appends the entry, moves `turn` forward, and stages a `model_reply` effect. The turn ends at once. The model call runs outside the turn, so a new message does not wait for it. +- The effect handler uses `context.id` as the idempotency key. If the effect fails, the runtime retries it with the same ID, up to `maxAttempts` (5 by default). `AgentSession` has no `onFailure` callback, so if every attempt fails, that turn gets no reply. +- `replied` adds the model reply only if no newer message arrived. A reply for an older turn is dropped, because the newer turn has its own reply. +- `followUpLater` schedules a keyed reminder. The reminder is a row in the database, so it runs after a deploy or a restart. One key holds one alarm, so a second call with the same topic moves the alarm. +- The policy lets each user call only their own session, and only `receive` and `followUpLater`. Callbacks and reminders do not go through the caller policy. + +A model call can run again after a crash, because effects run at least once. If the model API does not accept an idempotency key, the repeated call can cost tokens again. A step in Restate or DBOS that crashes before its result is recorded runs again too. + +### What the tests prove + +[The tests](../../test/compare-agent-session.test.ts) show these results: + +- Two user messages and a tool result apply in the order that they were sent. +- A model call that fails and runs again uses the same idempotency key, and the session keeps one reply. +- When two messages arrive before the model replies, the session keeps only the reply for the newest turn. +- A follow-up runs after a runtime restart. +- A caller who does not own the session gets `Unauthorized`. + +## Questions that teams ask + +**Is Solid Objects mature enough?** Here are the facts: + +- First public release of the npm package: August 13, 2026. +- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md). +- One maintainer. +- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known. +- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work. + +Restate reached 1.0 in June 2024. If a longer history matters more than keeping the state in your own database, choose Restate. + +**Why Node.js 24.4 or newer?** The SQLite adapter reads integer columns as `BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the built-in SQLite module. See [support](../support.md). + +**Does it need another process?** No new server. A process of your app calls `runtime.run(signal)`. It can be the web process or a separate worker. While no process runs, pending work stays in SQL. + +**Is delivery exactly once?** No. Delivery is at least once. Use the effect ID as the idempotency key for each external call. See [correctness](../correctness.md) and [effect recovery](../effect-recovery.md). + +**What if one session is very busy?** One actor runs one turn at a time. The turns are short, because the model call runs as an effect outside the turn. + +## Sources + +Checked October 15, 2026: + +- [Restate services and virtual objects](https://docs.restate.dev/foundations/services) +- [Restate service communication and delayed calls](https://docs.restate.dev/develop/ts/service-communication) +- [Restate durable steps](https://docs.restate.dev/develop/ts/durable-steps) +- [Restate durable agents](https://docs.restate.dev/ai/patterns/durable-agents) +- [Restate agent sessions](https://docs.restate.dev/ai/patterns/sessions) +- [Restate sagas](https://docs.restate.dev/guides/sagas) +- [Restate service configuration](https://docs.restate.dev/services/configuration) +- [Restate server overview](https://docs.restate.dev/server/overview) +- [Restate server licence](https://github.com/restatedev/restate/blob/main/LICENSE) + +## More information + +- [All comparisons](../comparisons.md) +- [DBOS Transact and Solid Objects](dbos.md) +- [Virtual actors in TypeScript](../virtual-actors.md) +- [Guide for coding agents](../agents.md) diff --git a/docs/compare/rivetkit.md b/docs/compare/rivetkit.md new file mode 100644 index 0000000..b66cd4b --- /dev/null +++ b/docs/compare/rivetkit.md @@ -0,0 +1,282 @@ +# RivetKit actors and Solid Objects: chat rooms with ordered state on PostgreSQL in Node.js + +RivetKit is the TypeScript SDK for Rivet Actors: long-lived processes with durable state, queues, realtime connections, and scheduling. A Rivet control plane schedules the actors and stores their state. Solid Objects (the `solid-objects` npm package) gives each key an actor with an ordered mailbox, durable state, and reminders, in the SQLite, PostgreSQL, or MySQL database that the app already uses. It has no control plane. Choose RivetKit when you want built-in WebSocket connections, many actors that keep state in memory, and a platform that runs them for you. Choose Solid Objects when each change must commit to your own SQL database with its follow-up work, in order, without a new service to operate. + +Solid Objects is an independent project. It is not affiliated with Rivet. + +## The short answer + +| Question | RivetKit | Solid Objects | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| What has state | An actor and its key | An actor class and an actor ID | +| One at a time | "By default, actions run in parallel. If you need advanced control over concurrency, use queues" | One turn at a time for each actor | +| When the state is saved | "Rapid mutations are batched into a single write on a throttle (`stateSaveInterval`, default 1 second)… saves aren't tied to action or handler boundaries" | Each turn commits the state, effects, and reminders in one SQL transaction | +| A failed queue message | "If processing fails before `message.complete()`, the message is not redelivered" | The message runs again. It holds back later messages until it succeeds or becomes a dead letter | +| Later work | `c.schedule.after` and `c.schedule.at`. "Schedules survive actor sleep, restarts, upgrades, and crashes" | `this.schedule({ at, key }).operation(input)` | +| Realtime | Events over WebSockets with `c.broadcast` | Committed broadcasts. Your app owns the WebSocket transport | +| What you run | Your workers and a Rivet control plane. Rivet Cloud or BYOC can run it for you. Self-hosted, it is a Rust binary with RocksDB, PostgreSQL, or FoundationDB storage, plus NATS for more than one node | Your Node.js process and your database | +| PostgreSQL | A control plane storage backend, "production-ready for light-to-moderate workloads, up to roughly 1,000 concurrent actors" | The application database, with your own tables beside it | +| Node.js | Node.js 22 or newer | Node.js 24.4 or newer | +| Licence | Apache-2.0. The FoundationDB storage backend "requires an enterprise license" | MIT. The optional Solid Objects Pro package is commercial | + +The RivetKit facts come from the [Rivet documentation](https://rivet.dev/docs/) (checked October 15, 2026). + +## Choose RivetKit when + +- You want realtime WebSocket connections to each actor, built in. +- You want state in memory with fast access, and a throttled save is acceptable. +- You want a per-actor SQLite database, or you want Rivet to run the actors for you. +- You expect very many active actors and can use the storage backend that suits that scale. + +## Choose Solid Objects when + +- Each change must be saved before the call returns, together with its effects and reminders. +- Changes to one key must apply in order without a separate queue loop. +- A failed change must hold its place and run again. +- You do not want to depend on a control plane, self-hosted or on Rivet Cloud. +- You want the state in the SQL database that you already back up and query. + +## The same terms in each + +| RivetKit | Solid Objects | +| ------------------------------------ | ---------------------------------------- | +| `actor({ ... })` and its key | An `Actor` class and an actor ID | +| An action | An actor method (an operation) | +| A queue and a `run` loop | The actor mailbox | +| `c.state` | Actor fields | +| `c.schedule.after` | `schedule({ at, key })` and an operation | +| `c.broadcast` | `observables()` and the realtime layer | +| `client.chatRoom.getOrCreate([key])` | `runtime.ref(ChatRoom, id)` | + +## A chat room in each + +The task: chat rooms in a Node.js TypeScript server with PostgreSQL. Several Node.js processes run. Messages in one room must get a strict order. Room state (members, unread counts, pinned message) must survive restarts. A room archives itself after 7 days with no activity. Rooms are independent of each other. + +### With RivetKit + +This code follows the Rivet documentation. It was not compiled or run. The docs show `c.saveState` in actions but not inside `run`, and they do not give the type of the schedule ID. + +```typescript +import { actor, event, queue, setup } from "rivetkit" + +const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000 +type Message = { seq: number; from: string; text: string; at: number } + +export const chatRoom = actor({ + createState: () => ({ + members: [] as string[], + unread: {} as Record, + pinned: null as Message | null, + recent: [] as Message[], + seq: 0, + lastActivityAt: Date.now(), + archiveId: null as string | null, + archived: false, + }), + queues: { post: queue<{ from: string; text: string }>() }, + events: { newMessage: event(), archived: event<{ at: number }>() }, + run: async (c) => { + for await (const m of c.queue.iter()) { + const msg: Message = { seq: ++c.state.seq, ...m.body, at: Date.now() } + c.state.recent = [...c.state.recent, msg].slice(-100) + for (const user of c.state.members.filter((u) => u !== msg.from)) { + c.state.unread[user] = (c.state.unread[user] ?? 0) + 1 + } + c.state.lastActivityAt = msg.at + await c.saveState({ immediate: true }) + c.broadcast("newMessage", msg) + } + }, + actions: { + join: (c, user: string) => { + c.state.members = [...new Set([...c.state.members, user])] + }, + pin: (c, seq: number) => { + c.state.pinned = c.state.recent.find((m) => m.seq === seq) ?? null + }, + sendMessage: async (c, from: string, text: string) => { + await c.queue.send("post", { from, text }) + if (c.state.archiveId) await c.schedule.cancel(c.state.archiveId) + c.state.archiveId = await c.schedule.after(SEVEN_DAYS, "archive") + }, + archive: (c) => { + if (Date.now() - c.state.lastActivityAt < SEVEN_DAYS) return + c.state.archived = true + c.broadcast("archived", { at: Date.now() }) + }, + }, +}) + +export const registry = setup({ use: { chatRoom } }) +registry.start() +``` + +What this code gets from RivetKit: + +- The `run` loop takes queue messages one at a time, so messages get one order. +- `c.broadcast` sends each new message to connected clients over WebSockets. +- The archive schedule survives a restart. + +What to know: + +- Actions run in parallel by default. That is why the messages go through a queue. The Rivet chat room guide calls one actor "a single serialized writer", and the actions page says that actions run in parallel. This design follows the actions page. +- A queue message is removed when the loop receives it. If a plain `run` loop fails before it completes the message, the message is not delivered again. Rivet Workflows record each message and retry failed steps. +- The `join`, `pin`, and `archive` actions change state directly. The queues page says "Actions are for getting data, queue entries are for mutating data". The sample also does not refuse a post to an archived room. +- The state is saved on a throttle. The example calls `c.saveState({ immediate: true })` before the broadcast. +- Two `sendMessage` calls can run at the same time, so the example also checks `lastActivityAt` before it archives. + +### With Solid Objects + +There is one `ChatRoom` actor for each room ID. + +```typescript +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 = {} + pinned: ChatMessage | null = null + activity = 0 + archived = false + + get unreadCounts(): Record { + 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, + }) + } +} +``` + +The runtime policy lets a member act only in their own name. See [the runtime file](../../examples/compare/chat-runtime.ts). Its query policy only checks that the caller signed in. A production policy also checks room membership from your own data. + +How it works: + +- Calls for one room run one at a time, on any process. Each `post` gets the next sequence number, and the number commits with the message. +- `lastRead` keeps the last message that each member read. `unreadCounts` is a query that computes the counts from it. +- Each join, post, or pin moves the one `archiveIfIdle` reminder 7 days ahead and counts up `activity`. The reminder is a row in the database, so it runs after a restart. A reminder that was already queued behind a newer change carries an old count, so it does nothing. A post to an archived room gets `archived`. +- `pin` keeps a copy of the message, so the pinned text stays after newer messages push it out of the last 200. +- The actor keeps the last 200 messages in its state. Keep the full history in your own table with a commit action if you need it. +- For realtime updates, return the values that clients need from `observables()`, and connect clients through [the realtime layer](../api.md). Your app owns the WebSocket server. + +### What the tests prove + +[The tests](../../test/compare-chat-room.test.ts) show these results: + +- Twenty messages from two members that arrive at the same time get the sequence numbers 1 to 20, with no gap. +- Unread counts are correct for each member after one member marks messages read. +- After a restart and seven idle days, the room is archived, keeps its pinned message, and refuses a new post. +- An archive reminder that was queued behind a newer post does nothing. +- A pinned message keeps its text after 200 newer messages. +- A member cannot post in the name of another member. +- A caller with no credentials cannot join or post, even with a `null` member ID. +- A caller who did not join cannot post. + +## Questions that teams ask + +**Is Solid Objects mature enough?** Here are the facts: + +- First public release of the npm package: August 13, 2026. +- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md). +- One maintainer. +- One first-party application with more than 100,000 users runs it in production. That is a count of users, not a measure of load. No production use by other people is known. +- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work. + +**Why Node.js 24.4 or newer?** The SQLite adapter reads integer columns as `BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the built-in SQLite module. See [support](../support.md). + +**Is delivery exactly once?** No. Delivery is at least once. A turn can run again after a crash. Use the effect ID as the idempotency key for each external call. See [correctness](../correctness.md). + +**What if one room is very busy?** One actor runs one turn at a time. A busy room handles its messages in sequence. Different rooms run at the same time. + +**Does it need another process?** No new service. A process of your app calls `runtime.run(signal)` to run reminders and effects. While no process runs, pending work stays in SQL. + +## Sources + +Checked October 15, 2026: + +- [Rivet overview](https://rivet.dev/docs/) +- [Rivet actor actions](https://rivet.dev/actors/docs/actions/) +- [Rivet actor queues](https://rivet.dev/actors/docs/queues/) +- [Rivet actor state](https://rivet.dev/actors/docs/state/) +- [Rivet actor scheduling](https://rivet.dev/actors/docs/schedule/) +- [Rivet actor events](https://rivet.dev/actors/docs/events/) +- [Rivet architecture](https://rivet.dev/docs/architecture/) +- [Rivet control plane storage](https://rivet.dev/docs/deploy/self-host/control-plane/storage/) +- [Rivet chat room guide](https://rivet.dev/guides/chat-room/) +- [Rivet Workflows: failure and recovery](https://rivet.dev/workflows/docs/failure-and-recovery) +- [rivetkit on npm](https://www.npmjs.com/package/rivetkit) + +## More information + +- [All comparisons](../comparisons.md) +- [Graphile Worker named queues and Solid Objects](graphile-worker.md) +- [Persistent rooms in Node.js](../guides/persistent-rooms.md) +- [Guide for coding agents](../agents.md) diff --git a/docs/comparisons.md b/docs/comparisons.md index 7fc247c..3f80288 100644 --- a/docs/comparisons.md +++ b/docs/comparisons.md @@ -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. @@ -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 | diff --git a/examples/compare/agent-runtime.ts b/examples/compare/agent-runtime.ts new file mode 100644 index 0000000..d9d9309 --- /dev/null +++ b/examples/compare/agent-runtime.ts @@ -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 +} diff --git a/examples/compare/agent-session.ts b/examples/compare/agent-session.ts new file mode 100644 index 0000000..b76f6d2 --- /dev/null +++ b/examples/compare/agent-session.ts @@ -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): 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", + }) + } +} diff --git a/examples/compare/cart-runtime.ts b/examples/compare/cart-runtime.ts new file mode 100644 index 0000000..7252fca --- /dev/null +++ b/examples/compare/cart-runtime.ts @@ -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 + }): Promise +} + +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, + }), + ) + 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 +} diff --git a/examples/compare/chat-room.ts b/examples/compare/chat-room.ts new file mode 100644 index 0000000..e5828d6 --- /dev/null +++ b/examples/compare/chat-room.ts @@ -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 = {} + pinned: ChatMessage | null = null + activity = 0 + archived = false + + get unreadCounts(): Record { + 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, + }) + } +} diff --git a/examples/compare/chat-runtime.ts b/examples/compare/chat-runtime.ts new file mode 100644 index 0000000..0c4a15c --- /dev/null +++ b/examples/compare/chat-runtime.ts @@ -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 = { + 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 +} diff --git a/examples/compare/shopping-cart.ts b/examples/compare/shopping-cart.ts new file mode 100644 index 0000000..0dce136 --- /dev/null +++ b/examples/compare/shopping-cart.ts @@ -0,0 +1,100 @@ +import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects" + +export const IDLE_MILLISECONDS = 30 * 60 * 1000 + +type ChargeArguments = { cartId: string; amountCents: number } +type ChargeResult = { status: "paid"; paymentId: string } | { status: "declined" } +type CheckArguments = { paymentKey: string } +type CheckResult = { status: "paid"; paymentId: string } | { status: "not_found" } + +export class ShoppingCart extends Actor { + static override readonly actorType = "ShoppingCart" + + items: Record = {} + couponCode: string | null = null + status: "open" | "paying" | "checking" | "paid" | "released" = "open" + paymentId: string | null = null + activity = 0 + + addItem({ productId, quantity }: { productId: string; quantity: number }): { + items: Record + } { + this.#requireOpen() + this.items = { ...this.items, [productId]: (this.items[productId] ?? 0) + quantity } + this.#armIdleRelease() + return { items: this.items } + } + + applyCoupon({ code }: { code: string }): void { + this.#requireOpen() + this.couponCode = code + this.#armIdleRelease() + } + + checkOut({ amountCents }: { amountCents: number }): { status: string } { + if (this.status === "paying" || this.status === "checking" || this.status === "paid") { + return { status: this.status } + } + this.#requireOpen() + this.status = "paying" + this.emit("charge_payment", { + arguments: { cartId: this.actorId, amountCents }, + onSuccess: "paymentSettled", + onFailure: "paymentUnknown", + }) + return { status: this.status } + } + + paymentSettled({ result }: EffectSuccessPayload): void { + if (result.status === "paid") { + this.#markPaid(result.paymentId) + return + } + this.#reopen() + } + + paymentUnknown({ effectId }: EffectFailurePayload): void { + this.status = "checking" + this.emit("check_payment", { + arguments: { paymentKey: effectId }, + onSuccess: "paymentChecked", + }) + } + + paymentChecked({ result }: EffectSuccessPayload): void { + if (result.status === "paid") { + this.#markPaid(result.paymentId) + return + } + this.#reopen() + } + + releaseIfIdle({ activity }: { activity: number }): void { + if (activity !== this.activity || this.status !== "open") return + this.status = "released" + this.emit("release_stock", { arguments: { cartId: this.actorId, items: this.items } }) + } + + #markPaid(paymentId: string): void { + this.status = "paid" + this.paymentId = paymentId + } + + #reopen(): void { + this.status = "open" + this.#armIdleRelease() + } + + #requireOpen(): void { + if (this.status !== "open") { + this.reject("cart_closed", { message: `The cart is ${this.status}` }) + } + } + + #armIdleRelease(): void { + this.activity += 1 + this.schedule({ at: new Date(Date.now() + IDLE_MILLISECONDS) }).releaseIfIdle({ + activity: this.activity, + }) + } +} diff --git a/package.json b/package.json index feb70f6..6b045ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "1.0.1", + "version": "1.0.2", "description": "Fix race conditions across Node.js processes: ordered operations, expiring holds, and state that survives a restart, in your SQL database. A SQL-backed virtual actor library for TypeScript and Node.js.", "type": "module", "license": "MIT", diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index a034da5..72f6faf 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -14,6 +14,16 @@ const documentationPaths = [ .filter((name) => name.endsWith(".md")) .sort() .map((name) => `docs/guides/${name}`), + ...(await readdir(resolve(repositoryRoot, "docs/compare"))) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => `docs/compare/${name}`), +] +const comparisonPages = [ + "docs/compare/restate.md", + "docs/compare/dbos.md", + "docs/compare/rivetkit.md", + "docs/compare/graphile-worker.md", ] for (const documentationPath of documentationPaths) { @@ -131,6 +141,36 @@ const embeddedExamples = [ example: "examples/guides/offline-first/sync-route.ts", language: "typescript", }, + { + documentation: "docs/compare/restate.md", + example: "examples/compare/agent-session.ts", + language: "typescript", + }, + { + documentation: "docs/compare/restate.md", + example: "examples/compare/agent-runtime.ts", + language: "typescript", + }, + { + documentation: "docs/compare/dbos.md", + example: "examples/compare/shopping-cart.ts", + language: "typescript", + }, + { + documentation: "docs/compare/dbos.md", + example: "examples/compare/cart-runtime.ts", + language: "typescript", + }, + { + documentation: "docs/compare/rivetkit.md", + example: "examples/compare/chat-room.ts", + language: "typescript", + }, + { + documentation: "docs/compare/graphile-worker.md", + example: "examples/compare/chat-room.ts", + language: "typescript", + }, ] for (const { documentation, example, language } of embeddedExamples) { const documentationSource = await readFile(resolve(repositoryRoot, documentation), "utf8") @@ -140,11 +180,22 @@ for (const { documentation, example, language } of embeddedExamples) { } } -for (const documentationPath of documentationPaths.filter((path) => - path.startsWith("docs/guides/"), +for (const documentationPath of documentationPaths.filter( + (path) => + path.startsWith("docs/guides/") || + path.startsWith("docs/compare/") || + path === "docs/comparisons.md", )) { const source = await readFile(resolve(repositoryRoot, documentationPath), "utf8") if (source.includes("\u2014")) throw new Error(`${documentationPath} contains an em dash`) + if (source.includes("\u2013")) throw new Error(`${documentationPath} contains an en dash`) +} + +const comparisonsIndex = await readFile(resolve(repositoryRoot, "docs/comparisons.md"), "utf8") +for (const comparisonPage of comparisonPages) { + if (!comparisonsIndex.includes(`(${comparisonPage.replace("docs/", "")})`)) { + throw new Error(`docs/comparisons.md does not link ${comparisonPage}`) + } } const publicApi = await readFile(resolve(repositoryRoot, "docs/api.md"), "utf8") diff --git a/src/version.ts b/src/version.ts index 4dbae34..d688532 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.0.1" +export const VERSION = "1.0.2" diff --git a/test/compare-agent-session.test.ts b/test/compare-agent-session.test.ts new file mode 100644 index 0000000..497c85d --- /dev/null +++ b/test/compare-agent-session.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { Unauthorized } from "../src/errors.js" +import type { SolidObjectsRuntime } from "../src/runtime.js" +import { agentRuntime, type ModelClient } from "../examples/compare/agent-runtime.js" +import { + AgentSession, + FOLLOW_UP_MILLISECONDS, + type Entry, +} from "../examples/compare/agent-session.js" + +let directory: string +const runtimes: SolidObjectsRuntime[] = [] + +class FakeModel implements ModelClient { + readonly idempotencyKeys: string[] = [] + failNextCall = false + + async reply({ + idempotencyKey, + history, + }: { + idempotencyKey: string + history: Entry[] + }): Promise<{ content: string }> { + this.idempotencyKeys.push(idempotencyKey) + if (this.failNextCall) { + this.failNextCall = false + throw new Error("the model call timed out") + } + return { content: `answer to ${history.at(-1)?.content ?? "nothing"}` } + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "compare-agent-")) +}) + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() + await rm(directory, { recursive: true, force: true }) +}) + +async function startRuntime(model: ModelClient): Promise { + const runtime = agentRuntime({ + path: join(directory, "agents.sqlite3"), + model, + retryDelayMilliseconds: () => 0, + }) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +async function stopRuntime(runtime: SolidObjectsRuntime): Promise { + await runtime.close() + runtimes.splice(runtimes.indexOf(runtime), 1) +} + +const asAda = { authorizationContext: { userId: "ada" } } + +describe("the AgentSession actor", () => { + it("applies user messages and tool results in the order they were sent", async () => { + const runtime = await startRuntime(new FakeModel()) + const session = runtime.ref(AgentSession, "ada") + + await session.send.with({ ...asAda, idempotencyKey: "m1" }).receive({ + role: "user", + content: "Where is order 42?", + }) + await session.send.with({ ...asAda, idempotencyKey: "m2" }).receive({ + role: "tool", + content: "order 42: packed", + }) + await session.send.with({ ...asAda, idempotencyKey: "m3" }).receive({ + role: "user", + content: "Tell me when it ships", + }) + await runtime.testing.drain({ roles: ["actors"] }) + + const snapshot = await session.snapshot(asAda) + expect(snapshot.history.map((entry) => entry.content)).toEqual([ + "Where is order 42?", + "order 42: packed", + "Tell me when it ships", + ]) + expect(snapshot.turn).toBe(3) + }) + + it("sends a retried model call with the same idempotency key", async () => { + const model = new FakeModel() + const runtime = await startRuntime(model) + const session = runtime.ref(AgentSession, "ada").with(asAda) + model.failNextCall = true + + await session.receive({ role: "user", content: "Hello" }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + expect(model.idempotencyKeys).toHaveLength(2) + expect(new Set(model.idempotencyKeys).size).toBe(1) + const snapshot = await runtime.ref(AgentSession, "ada").snapshot(asAda) + expect(snapshot.history.at(-1)).toEqual({ role: "assistant", content: "answer to Hello" }) + expect(snapshot.history.filter((entry) => entry.role === "assistant")).toHaveLength(1) + }) + + it("keeps only the reply for the newest turn", async () => { + const runtime = await startRuntime(new FakeModel()) + const session = runtime.ref(AgentSession, "ada").with(asAda) + + await session.receive({ role: "user", content: "First" }) + await session.receive({ role: "user", content: "Second" }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + const snapshot = await runtime.ref(AgentSession, "ada").snapshot(asAda) + expect(snapshot.history.filter((entry) => entry.role === "assistant")).toEqual([ + { role: "assistant", content: "answer to Second" }, + ]) + }) + + it("runs a follow-up after a restart", async () => { + const beforeRestart = await startRuntime(new FakeModel()) + await beforeRestart + .ref(AgentSession, "ada") + .with(asAda) + .followUpLater({ topic: "order 42 shipped?" }) + await stopRuntime(beforeRestart) + + const afterRestart = await startRuntime(new FakeModel()) + await afterRestart.testing.runDueReminders({ + now: new Date(Date.now() + FOLLOW_UP_MILLISECONDS + 1_000), + }) + await afterRestart.testing.drain({ roles: ["actors"] }) + + const snapshot = await afterRestart.ref(AgentSession, "ada").snapshot(asAda) + expect(snapshot.followUps).toEqual(["order 42 shipped?"]) + expect(snapshot.history.at(-1)).toEqual({ + role: "tool", + content: "Follow-up due: order 42 shipped?", + }) + }) + + it("refuses a caller who does not own the session", async () => { + const runtime = await startRuntime(new FakeModel()) + + await expect( + runtime + .ref(AgentSession, "ada") + .with({ authorizationContext: { userId: "mallory" } }) + .receive({ role: "user", content: "Hi" }), + ).rejects.toBeInstanceOf(Unauthorized) + }) +}) diff --git a/test/compare-chat-room.test.ts b/test/compare-chat-room.test.ts new file mode 100644 index 0000000..11f76ea --- /dev/null +++ b/test/compare-chat-room.test.ts @@ -0,0 +1,180 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { Unauthorized } from "../src/errors.js" +import type { SolidObjectsRuntime } from "../src/runtime.js" +import { ARCHIVE_AFTER_MILLISECONDS, ChatRoom } from "../examples/compare/chat-room.js" +import { chatRuntime } from "../examples/compare/chat-runtime.js" + +let directory: string +const runtimes: SolidObjectsRuntime[] = [] + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "compare-chat-")) +}) + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() + await rm(directory, { recursive: true, force: true }) +}) + +async function startRuntime(): Promise { + const runtime = chatRuntime({ path: join(directory, "chat.sqlite3") }) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +async function stopRuntime(runtime: SolidObjectsRuntime): Promise { + await runtime.close() + runtimes.splice(runtimes.indexOf(runtime), 1) +} + +function asMember(memberId: string): { authorizationContext: { memberId: string } } { + return { authorizationContext: { memberId } } +} + +async function roomWithMembers(runtime: SolidObjectsRuntime): Promise { + await runtime.ref(ChatRoom, "general").with(asMember("ada")).join({ memberId: "ada" }) + await runtime.ref(ChatRoom, "general").with(asMember("grace")).join({ memberId: "grace" }) +} + +describe("the ChatRoom actor", () => { + it("gives messages that arrive at the same time one order with no gaps", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + const authors = ["ada", "grace"] + + const results = await Promise.all( + Array.from({ length: 20 }, (_, index) => { + const authorId = authors[index % 2] ?? "ada" + return runtime + .ref(ChatRoom, "general") + .with(asMember(authorId)) + .post({ authorId, text: `message ${index}` }) + }), + ) + + expect(results.map((result) => result.sequence).sort((left, right) => left - right)).toEqual( + Array.from({ length: 20 }, (_, index) => index + 1), + ) + const snapshot = await runtime.ref(ChatRoom, "general").snapshot(asMember("ada")) + expect(snapshot.messages.map((message) => message.sequence)).toEqual( + Array.from({ length: 20 }, (_, index) => index + 1), + ) + }) + + it("counts unread messages for each member", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + const ada = runtime.ref(ChatRoom, "general").with(asMember("ada")) + await ada.post({ authorId: "ada", text: "one" }) + await ada.post({ authorId: "ada", text: "two" }) + await ada.post({ authorId: "ada", text: "three" }) + + await runtime + .ref(ChatRoom, "general") + .with(asMember("grace")) + .markRead({ memberId: "grace", sequence: 2 }) + + expect(await runtime.ref(ChatRoom, "general").with(asMember("grace")).unreadCounts).toEqual({ + ada: 0, + grace: 1, + }) + }) + + it("keeps the pinned message and archives the room after seven idle days and a restart", async () => { + const beforeRestart = await startRuntime() + await roomWithMembers(beforeRestart) + const ada = beforeRestart.ref(ChatRoom, "general").with(asMember("ada")) + const { sequence } = await ada.post({ authorId: "ada", text: "Read the rules" }) + await ada.pin({ memberId: "ada", sequence }) + await stopRuntime(beforeRestart) + + const afterRestart = await startRuntime() + await afterRestart.testing.runDueReminders({ + now: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS + 1_000), + }) + await afterRestart.testing.drain({ roles: ["actors"] }) + + const snapshot = await afterRestart.ref(ChatRoom, "general").snapshot(asMember("ada")) + expect(snapshot).toMatchObject({ archived: true, pinned: { sequence, text: "Read the rules" } }) + await expect( + afterRestart + .ref(ChatRoom, "general") + .with(asMember("ada")) + .post({ authorId: "ada", text: "Anyone here?" }), + ).rejects.toMatchObject({ code: "archived" }) + }) + + it("refuses a post in the name of another member", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + + await expect( + runtime + .ref(ChatRoom, "general") + .with(asMember("grace")) + .post({ authorId: "ada", text: "Not mine" }), + ).rejects.toBeInstanceOf(Unauthorized) + }) + + it("refuses a caller with no credentials, even with a null member ID", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + const anonymous = runtime.ref(ChatRoom, "general").with({ authorizationContext: undefined }) + + await expect(anonymous.join({ memberId: null as unknown as string })).rejects.toBeInstanceOf( + Unauthorized, + ) + await expect( + anonymous.post({ authorId: null as unknown as string, text: "Hello" }), + ).rejects.toBeInstanceOf(Unauthorized) + }) + + it("keeps the text of a pinned message after newer messages push it out", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + const ada = runtime.ref(ChatRoom, "general").with(asMember("ada")) + const { sequence } = await ada.post({ authorId: "ada", text: "Read the rules" }) + await ada.pin({ memberId: "ada", sequence }) + for (let index = 0; index < 200; index += 1) { + await ada.post({ authorId: "ada", text: `message ${index}` }) + } + + const snapshot = await runtime.ref(ChatRoom, "general").snapshot(asMember("ada")) + expect(snapshot.messages.some((message) => message.sequence === sequence)).toBe(false) + expect(snapshot.pinned).toEqual({ sequence, authorId: "ada", text: "Read the rules" }) + }) + + it("ignores an archive reminder that was queued behind newer activity", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + await runtime + .ref(ChatRoom, "general") + .send.with({ ...asMember("ada"), idempotencyKey: "late-post" }) + .post({ authorId: "ada", text: "Still here" }) + + await runtime.testing.runDueReminders({ + now: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS + 1_000), + }) + await runtime.testing.drain({ roles: ["actors"] }) + + const snapshot = await runtime.ref(ChatRoom, "general").snapshot(asMember("ada")) + expect(snapshot.archived).toBe(false) + expect(snapshot.messages.map((message) => message.text)).toEqual(["Still here"]) + }) + + it("refuses a post from someone who did not join", async () => { + const runtime = await startRuntime() + await roomWithMembers(runtime) + + await expect( + runtime + .ref(ChatRoom, "general") + .with(asMember("mallory")) + .post({ authorId: "mallory", text: "Hello" }), + ).rejects.toMatchObject({ code: "not_a_member" }) + }) +}) diff --git a/test/compare-shopping-cart.test.ts b/test/compare-shopping-cart.test.ts new file mode 100644 index 0000000..1f5f492 --- /dev/null +++ b/test/compare-shopping-cart.test.ts @@ -0,0 +1,249 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import type { SolidObjectsRuntime } from "../src/runtime.js" +import { + cartRuntime, + type Inventory, + type PaymentProvider, +} from "../examples/compare/cart-runtime.js" +import { IDLE_MILLISECONDS, ShoppingCart } from "../examples/compare/shopping-cart.js" + +let directory: string +const runtimes: SolidObjectsRuntime[] = [] + +class FakePayments implements PaymentProvider { + readonly calls: string[] = [] + readonly charges = new Map() + loseNextResponse = false + loseEveryResponse = false + failWithoutCharging = false + declineEveryCall = false + + async charge({ + idempotencyKey, + amountCents, + }: { + idempotencyKey: string + cartId: string + amountCents: number + }): Promise<{ status: "paid"; paymentId: string } | { status: "declined" }> { + this.calls.push(idempotencyKey) + if (this.failWithoutCharging) throw new Error("the provider did not answer") + if (this.declineEveryCall) return { status: "declined" } + const charge = this.charges.get(idempotencyKey) ?? { + paymentId: `payment-${this.charges.size + 1}`, + amountCents, + } + this.charges.set(idempotencyKey, charge) + if (this.loseEveryResponse) throw new Error("the response was lost") + if (this.loseNextResponse) { + this.loseNextResponse = false + throw new Error("the worker stopped before it recorded the result") + } + return { status: "paid", paymentId: charge.paymentId } + } + + async lookup({ + idempotencyKey, + }: { + idempotencyKey: string + }): Promise<{ status: "paid"; paymentId: string } | { status: "not_found" }> { + const charge = this.charges.get(idempotencyKey) + if (!charge) return { status: "not_found" } + return { status: "paid", paymentId: charge.paymentId } + } +} + +class FakeInventory implements Inventory { + readonly releases = new Map>() + + async release({ + idempotencyKey, + items, + }: { + idempotencyKey: string + cartId: string + items: Record + }): Promise { + this.releases.set(idempotencyKey, items) + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "compare-cart-")) +}) + +afterEach(async () => { + for (const runtime of runtimes.splice(0)) await runtime.close() + await rm(directory, { recursive: true, force: true }) +}) + +async function startRuntime({ + payments = new FakePayments(), + inventory = new FakeInventory(), +}: { + payments?: PaymentProvider + inventory?: Inventory +} = {}): Promise { + const runtime = cartRuntime({ + path: join(directory, "carts.sqlite3"), + payments, + inventory, + retryDelayMilliseconds: () => 0, + maxAttempts: 2, + }) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +async function stopRuntime(runtime: SolidObjectsRuntime): Promise { + await runtime.close() + runtimes.splice(runtimes.indexOf(runtime), 1) +} + +const asOwner = { authorizationContext: { userId: "ada" } } + +describe("the ShoppingCart actor", () => { + it("keeps every item when requests for one cart arrive at the same time", async () => { + const runtime = await startRuntime() + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + + await Promise.all( + Array.from({ length: 10 }, () => cart.addItem({ productId: "shirt", quantity: 1 })), + ) + + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot.items).toEqual({ shirt: 10 }) + }) + + it("charges once when the payment response is lost and the charge runs again", async () => { + const payments = new FakePayments() + const runtime = await startRuntime({ payments }) + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + await cart.addItem({ productId: "shirt", quantity: 1 }) + payments.loseNextResponse = true + + await cart.checkOut({ amountCents: 2_500 }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + expect(payments.calls).toHaveLength(2) + expect(new Set(payments.calls).size).toBe(1) + expect(payments.charges.size).toBe(1) + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot).toMatchObject({ status: "paid", paymentId: "payment-1" }) + }) + + it("ignores a second checkout while the first one is in progress", async () => { + const payments = new FakePayments() + const runtime = await startRuntime({ payments }) + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + await cart.addItem({ productId: "shirt", quantity: 1 }) + + await cart.checkOut({ amountCents: 2_500 }) + await cart.checkOut({ amountCents: 2_500 }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + expect(payments.charges.size).toBe(1) + }) + + it("opens the cart again when the card is declined", async () => { + const payments = new FakePayments() + payments.declineEveryCall = true + const runtime = await startRuntime({ payments }) + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + await cart.addItem({ productId: "shirt", quantity: 1 }) + + await cart.checkOut({ amountCents: 2_500 }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot.status).toBe("open") + await expect(cart.addItem({ productId: "socks", quantity: 1 })).resolves.toBeDefined() + }) + + it("checks the charge before it opens the cart when every payment response is lost", async () => { + const payments = new FakePayments() + payments.loseEveryResponse = true + const runtime = await startRuntime({ payments }) + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + await cart.addItem({ productId: "shirt", quantity: 1 }) + + await cart.checkOut({ amountCents: 2_500 }) + for (let pass = 0; pass < 4; pass += 1) { + await runtime.testing.drain({ roles: ["effects", "actors"] }) + } + await cart.checkOut({ amountCents: 2_500 }) + await runtime.testing.drain({ roles: ["effects", "actors"] }) + + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot).toMatchObject({ status: "paid", paymentId: "payment-1" }) + expect(payments.charges.size).toBe(1) + }) + + it("opens the cart again when the check finds no charge", async () => { + const payments = new FakePayments() + payments.failWithoutCharging = true + const runtime = await startRuntime({ payments }) + const cart = runtime.ref(ShoppingCart, "ada").with(asOwner) + await cart.addItem({ productId: "shirt", quantity: 1 }) + + await cart.checkOut({ amountCents: 2_500 }) + for (let pass = 0; pass < 4; pass += 1) { + await runtime.testing.drain({ roles: ["effects", "actors"] }) + } + + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot.status).toBe("open") + expect(payments.charges.size).toBe(0) + }) + + it("ignores a release reminder that was queued behind newer activity", async () => { + const inventory = new FakeInventory() + const runtime = await startRuntime({ inventory }) + await runtime + .ref(ShoppingCart, "ada") + .send.with({ ...asOwner, idempotencyKey: "late-add" }) + .addItem({ productId: "shirt", quantity: 1 }) + await runtime.testing.drain({ roles: ["actors"] }) + await runtime + .ref(ShoppingCart, "ada") + .send.with({ ...asOwner, idempotencyKey: "later-add" }) + .addItem({ productId: "socks", quantity: 1 }) + + await runtime.testing.runDueReminders({ + now: new Date(Date.now() + IDLE_MILLISECONDS + 1_000), + }) + await runtime.testing.drain({ roles: ["actors", "effects"] }) + + const snapshot = await runtime.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot.status).toBe("open") + expect(inventory.releases.size).toBe(0) + }) + + it("releases the reserved stock of an idle cart after a restart", async () => { + const beforeRestart = await startRuntime() + await beforeRestart + .ref(ShoppingCart, "ada") + .with(asOwner) + .addItem({ productId: "shirt", quantity: 2 }) + await stopRuntime(beforeRestart) + + const inventory = new FakeInventory() + const afterRestart = await startRuntime({ inventory }) + await afterRestart.testing.runDueReminders({ + now: new Date(Date.now() + IDLE_MILLISECONDS + 1_000), + }) + await afterRestart.testing.drain({ roles: ["actors", "effects"] }) + + const snapshot = await afterRestart.ref(ShoppingCart, "ada").snapshot(asOwner) + expect(snapshot.status).toBe("released") + expect([...inventory.releases.values()]).toEqual([{ shirt: 2 }]) + await expect( + afterRestart.ref(ShoppingCart, "ada").with(asOwner).checkOut({ amountCents: 5_000 }), + ).rejects.toMatchObject({ code: "cart_closed" }) + }) +}) diff --git a/tsconfig.examples.json b/tsconfig.examples.json index b719e41..0bdf958 100644 --- a/tsconfig.examples.json +++ b/tsconfig.examples.json @@ -15,6 +15,7 @@ "examples/**/*.ts", "benchmarks/**/*.ts", "test/guide-*.test.ts", + "test/compare-*.test.ts", "test/fixtures/event-hold-process.ts" ], "exclude": ["examples/cloudflare"] diff --git a/tsconfig.json b/tsconfig.json index e6e9890..9601202 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "src/cloudflare", "test/cloudflare", "test/guide-*.test.ts", + "test/compare-*.test.ts", "test/fixtures/event-hold-process.ts" ] }