Guide · TypeScript and Node.js

DBOS Transact and Solid Objects: durable checkout and state for each cart on PostgreSQL

DBOS checkpoints workflows and steps in PostgreSQL. Compare partitioned queues and durable steps with a Solid Objects actor for each cart.

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 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 and the DBOS pricing page (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

Choose Solid Objects when

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.

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:

What to know:

With Solid Objects

There is one ShoppingCart actor for each cart ID.

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<string, number> = {}
  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<string, number>
  } {
    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<ChargeArguments, ChargeResult>): void {
    if (result.status === "paid") {
      this.#markPaid(result.paymentId)
      return
    }
    this.#reopen()
  }

  paymentUnknown({ effectId }: EffectFailurePayload<ChargeArguments>): void {
    this.status = "checking"
    this.emit("check_payment", {
      arguments: { paymentKey: effectId },
      onSuccess: "paymentChecked",
    })
  }

  paymentChecked({ result }: EffectSuccessPayload<CheckArguments, CheckResult>): 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:

import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import { ShoppingCart } from "./shopping-cart.js"

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

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

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

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

function ownsCart({
  actorId,
  authorizationContext,
}: {
  actorId: string
  authorizationContext: unknown
}): boolean {
  if (typeof authorizationContext !== "object" || authorizationContext === null) return false
  return "userId" in authorizationContext && authorizationContext.userId === actorId
}

Each process sends calls to the cart with the reference:

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:

What the tests prove

The tests show these results:

Questions that teams ask

Is Solid Objects mature enough? Here are the facts:

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.

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 and effect recovery.

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:

More information

Generated from docs/compare/dbos.md in cardmagic/solid-objects-js, v1.0.2, commit 0a26ebb. For Ruby on Rails, read Virtual actors in Ruby on Rails.