solid-objects 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/agents.md +12 -1
- package/docs/compare/dbos.md +391 -0
- package/docs/compare/graphile-worker.md +296 -0
- package/docs/compare/restate.md +290 -0
- package/docs/compare/rivetkit.md +282 -0
- package/docs/comparisons.md +17 -1
- package/examples/compare/agent-runtime.ts +46 -0
- package/examples/compare/agent-session.ts +50 -0
- package/examples/compare/cart-runtime.ts +80 -0
- package/examples/compare/chat-room.ts +84 -0
- package/examples/compare/chat-runtime.ts +38 -0
- package/examples/compare/shopping-cart.ts +100 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.2 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
- Add four comparison pages in `docs/compare/`: Restate virtual objects, DBOS
|
|
6
|
+
Transact, RivetKit actors, and Graphile Worker named queues. Each page
|
|
7
|
+
compares one real task, says when the other system is the better choice, and
|
|
8
|
+
links the primary sources that were checked on October 15, 2026. The Solid
|
|
9
|
+
Objects code on each page is in `examples/compare/` and runs in the test
|
|
10
|
+
suite. `docs/comparisons.md` and the README link the pages. An evaluation on
|
|
11
|
+
October 15, 2026 found that Claude Code, with web search, searched only for
|
|
12
|
+
these names and never found Solid Objects.
|
|
13
|
+
- `pnpm run check:documentation` now checks the comparison pages. Each page
|
|
14
|
+
must embed its current example files, link only to files and headings that
|
|
15
|
+
exist, and use no em dash or en dash. `docs/comparisons.md` must link each
|
|
16
|
+
page.
|
|
17
|
+
|
|
18
|
+
## 1.0.1 - 2026-10-14
|
|
19
|
+
|
|
20
|
+
- The agent guide now lists the import path, the call, and the driver of each
|
|
21
|
+
SQL database adapter, and it shows how to cancel a reminder with
|
|
22
|
+
`unschedule()`. Agents in the Track B evaluation invented the names
|
|
23
|
+
`solid-objects/database/postgres` and `this.cancelReminder()`.
|
|
24
|
+
|
|
3
25
|
## 1.0.0 - 2026-10-13
|
|
4
26
|
|
|
5
27
|
- Version 1.0 freezes the public API under semantic versioning.
|
package/README.md
CHANGED
|
@@ -288,6 +288,7 @@ then shows a tested Solid Objects version with its limits:
|
|
|
288
288
|
- [Virtual actors in TypeScript and Node.js](docs/virtual-actors.md)
|
|
289
289
|
- [Guide for coding agents](docs/agents.md)
|
|
290
290
|
- [Choosing Solid Objects](docs/fit.md)
|
|
291
|
+
- [Comparisons with Restate, DBOS, RivetKit, and Graphile Worker](docs/comparisons.md)
|
|
291
292
|
- [Public API](docs/api.md)
|
|
292
293
|
- [Versioning](docs/versioning.md)
|
|
293
294
|
- [Operations and recovery](docs/operations.md)
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "1.0.
|
|
1
|
+
export declare const VERSION = "1.0.2";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "1.0.
|
|
1
|
+
export const VERSION = "1.0.2";
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/docs/agents.md
CHANGED
|
@@ -95,6 +95,14 @@ const runtime = configure({
|
|
|
95
95
|
await runtime.install()
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
Each SQL database has its own adapter. Import the adapter from its own path:
|
|
99
|
+
|
|
100
|
+
| Database | Import | Call | Driver |
|
|
101
|
+
| ---------- | ---------------------------------------------------------------- | ---------------------------------- | ------------------------------- |
|
|
102
|
+
| SQLite | `import { sqlite } from "solid-objects/database/sqlite"` | `sqlite({ path: "app.sqlite3" })` | None. Node.js has `node:sqlite` |
|
|
103
|
+
| PostgreSQL | `import { postgresql } from "solid-objects/database/postgresql"` | `postgresql({ connectionString })` | `npm install pg` |
|
|
104
|
+
| MySQL | `import { mysql } from "solid-objects/database/mysql"` | `mysql({ connectionString })` | `npm install mysql2` |
|
|
105
|
+
|
|
98
106
|
Every authorization callback denies by default. Do step 5 before you call an
|
|
99
107
|
actor. [Configuration](configuration.md) lists each option and database
|
|
100
108
|
adapter.
|
|
@@ -199,7 +207,8 @@ Obey these rules in actor code:
|
|
|
199
207
|
- Public methods are ordered operations. Each one takes no argument or one
|
|
200
208
|
object argument.
|
|
201
209
|
- Use `this.schedule({ at, key })` for delayed work. A new `schedule` with the
|
|
202
|
-
same key moves the alarm.
|
|
210
|
+
same key moves the alarm. `this.unschedule("expire", { key: buyer })` cancels
|
|
211
|
+
it.
|
|
203
212
|
- Use `this.reject(code, { message })` for a business rule failure that must
|
|
204
213
|
not retry. The second argument is an object, for example
|
|
205
214
|
`this.reject("room_full", { message: "The room is full" })`.
|
|
@@ -221,6 +230,8 @@ Avoid these mistakes:
|
|
|
221
230
|
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
222
231
|
| `this.schedule({ at, key })` with no operation after it | `this.schedule({ at, key }).expire({ buyer })`. A `schedule()` call with no operation after it fails the turn with `ScheduleWithoutOperation` |
|
|
223
232
|
| `this.reject("room full")` or `this.reject(code, message)` | `this.reject("room_full", { message: "The room is full" })` |
|
|
233
|
+
| `postgres()` from `solid-objects/database/postgres` | `postgresql({ connectionString })` from `solid-objects/database/postgresql`. Step 4 lists each adapter |
|
|
234
|
+
| A guessed cancel method, such as `this.cancelReminder()` | `this.unschedule("expire", { key: buyer })`. `this.unscheduleAll("expire")` cancels every key of one operation |
|
|
224
235
|
| `registerEffect(name, (context, argumentsValue) => ...)` | `registerEffect(name, (argumentsValue, context) => ...)`. The staged arguments come first |
|
|
225
236
|
| A worker process that never names the actor class | `runtime.register(TicketSale)` before `runtime.run(signal)`. See step 7 |
|
|
226
237
|
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# DBOS Transact and Solid Objects: durable checkout and state for each cart on PostgreSQL
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
Solid Objects is an independent project. It is not affiliated with DBOS.
|
|
6
|
+
|
|
7
|
+
## The short answer
|
|
8
|
+
|
|
9
|
+
| Question | DBOS Transact | Solid Objects |
|
|
10
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| What has state | A workflow and its steps. Long-lived state goes in your own tables | An actor class and an actor ID |
|
|
12
|
+
| One at a time for each key | A partitioned queue with `partitionConcurrency: 1` | One turn at a time for each actor |
|
|
13
|
+
| 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 |
|
|
14
|
+
| 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 |
|
|
15
|
+
| Idempotency | "An assigned workflow ID acts as an idempotency key" | An idempotency key on each message, and the effect ID for each external call |
|
|
16
|
+
| Later work | `DBOS.sleep` in a workflow, `delaySeconds` on enqueue, or `Debouncer` | `this.schedule({ at, key }).operation(input)` |
|
|
17
|
+
| Database | PostgreSQL | SQLite, PostgreSQL, MySQL |
|
|
18
|
+
| 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)` |
|
|
19
|
+
| 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 |
|
|
20
|
+
| Node.js | Node.js 20 or newer | Node.js 24.4 or newer |
|
|
21
|
+
| Licence | Transact: MIT. Self-hosted Conductor: proprietary. Hosted Conductor: from $99 a month | MIT. The optional Solid Objects Pro package is commercial |
|
|
22
|
+
|
|
23
|
+
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).
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
## Choose DBOS when
|
|
28
|
+
|
|
29
|
+
- The work is a sequence of steps, and a completed step must not run again after a crash.
|
|
30
|
+
- You want a workflow ID as the idempotency key for the whole sequence.
|
|
31
|
+
- You want a workflow dashboard and managed recovery from Conductor.
|
|
32
|
+
- An agent loop waits for days for a person, with `recv` and `setEvent`.
|
|
33
|
+
|
|
34
|
+
## Choose Solid Objects when
|
|
35
|
+
|
|
36
|
+
- One thing, such as a cart, has state that many requests change, and the requests must apply in order.
|
|
37
|
+
- The idle timer must move in the same transaction as the state change, for example to release stock after 30 minutes with no activity.
|
|
38
|
+
- You want the state change and its effects and reminders to commit in one SQL transaction.
|
|
39
|
+
- You use SQLite or MySQL, or you do not want DBOS system tables in PostgreSQL.
|
|
40
|
+
|
|
41
|
+
## The same terms in each
|
|
42
|
+
|
|
43
|
+
| DBOS Transact | Solid Objects |
|
|
44
|
+
| ----------------------------------- | -------------------------------------------- |
|
|
45
|
+
| Partition key (`queuePartitionKey`) | Actor ID |
|
|
46
|
+
| Workflow function | Actor method (an operation) |
|
|
47
|
+
| Step (`DBOS.runStep`) | Effect (`emit` and `runtime.registerEffect`) |
|
|
48
|
+
| Workflow ID as idempotency key | `idempotencyKey` on `send.with(...)` |
|
|
49
|
+
| `DBOS.sleep` or `delaySeconds` | `schedule({ at, key })` and an operation |
|
|
50
|
+
| `DBOS.send` and `DBOS.recv` | A message to the actor |
|
|
51
|
+
| `DBOS.setEvent` and `DBOS.getEvent` | Actor queries and `snapshot()` |
|
|
52
|
+
| Datasource transaction | `commitAction` |
|
|
53
|
+
|
|
54
|
+
## A shopping cart checkout in each
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
### With DBOS Transact
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { DBOS } from "@dbos-inc/dbos-sdk"
|
|
64
|
+
|
|
65
|
+
const cartQueue = (cartId: string, delaySeconds?: number) => ({
|
|
66
|
+
queueName: "carts",
|
|
67
|
+
enqueueOptions: { queuePartitionKey: cartId, delaySeconds },
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const releaseIfIdle = DBOS.registerWorkflow(
|
|
71
|
+
(cartId: string, version: number) => releaseIfIdleInSql(cartId, version),
|
|
72
|
+
{ name: "releaseIfIdle" },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const addItem = DBOS.registerWorkflow(
|
|
76
|
+
async (cartId: string, sku: string) => {
|
|
77
|
+
const version = await reserveItem(cartId, sku)
|
|
78
|
+
await DBOS.startWorkflow(releaseIfIdle, cartQueue(cartId, 30 * 60))(cartId, version)
|
|
79
|
+
},
|
|
80
|
+
{ name: "addItem" },
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const checkout = DBOS.registerWorkflow(
|
|
84
|
+
async (cartId: string) => {
|
|
85
|
+
const cart = await lockCart(cartId)
|
|
86
|
+
if (!cart) return "not-open"
|
|
87
|
+
const charge = await DBOS.runStep(
|
|
88
|
+
() =>
|
|
89
|
+
payments.charge({
|
|
90
|
+
cartId,
|
|
91
|
+
amountCents: cart.totalCents,
|
|
92
|
+
idempotencyKey: `${DBOS.workflowID}-charge`,
|
|
93
|
+
}),
|
|
94
|
+
{ name: "charge", retriesAllowed: true, maxAttempts: 5, timeoutMS: 15_000 },
|
|
95
|
+
)
|
|
96
|
+
await markPaid(cartId, charge.id)
|
|
97
|
+
return "paid"
|
|
98
|
+
},
|
|
99
|
+
{ name: "checkout" },
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await DBOS.registerQueue("carts", { partitionConcurrency: 1, workerConcurrency: 50 })
|
|
103
|
+
|
|
104
|
+
await DBOS.startWorkflow(checkout, {
|
|
105
|
+
workflowID: `checkout-${requestIdempotencyKey}`,
|
|
106
|
+
...cartQueue(cartId),
|
|
107
|
+
})(cartId)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
What this code gets from DBOS:
|
|
111
|
+
|
|
112
|
+
- The partitioned queue runs one workflow at a time for each cart.
|
|
113
|
+
- The workflow ID stops a second checkout with the same request key.
|
|
114
|
+
- A charge step that completed does not run again after a crash.
|
|
115
|
+
- The delayed `releaseIfIdle` workflow is stored in PostgreSQL and runs after a restart. The version check in SQL skips it if the cart changed.
|
|
116
|
+
|
|
117
|
+
What to know:
|
|
118
|
+
|
|
119
|
+
- 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**".
|
|
120
|
+
- The cart state lives in your own tables. Each workflow reads and writes them in datasource transactions.
|
|
121
|
+
- 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.
|
|
122
|
+
- DBOS also documents a `Debouncer`, which can replace the delayed `releaseIfIdle` workflow.
|
|
123
|
+
|
|
124
|
+
### With Solid Objects
|
|
125
|
+
|
|
126
|
+
There is one `ShoppingCart` actor for each cart ID.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects"
|
|
130
|
+
|
|
131
|
+
export const IDLE_MILLISECONDS = 30 * 60 * 1000
|
|
132
|
+
|
|
133
|
+
type ChargeArguments = { cartId: string; amountCents: number }
|
|
134
|
+
type ChargeResult = { status: "paid"; paymentId: string } | { status: "declined" }
|
|
135
|
+
type CheckArguments = { paymentKey: string }
|
|
136
|
+
type CheckResult = { status: "paid"; paymentId: string } | { status: "not_found" }
|
|
137
|
+
|
|
138
|
+
export class ShoppingCart extends Actor {
|
|
139
|
+
static override readonly actorType = "ShoppingCart"
|
|
140
|
+
|
|
141
|
+
items: Record<string, number> = {}
|
|
142
|
+
couponCode: string | null = null
|
|
143
|
+
status: "open" | "paying" | "checking" | "paid" | "released" = "open"
|
|
144
|
+
paymentId: string | null = null
|
|
145
|
+
activity = 0
|
|
146
|
+
|
|
147
|
+
addItem({ productId, quantity }: { productId: string; quantity: number }): {
|
|
148
|
+
items: Record<string, number>
|
|
149
|
+
} {
|
|
150
|
+
this.#requireOpen()
|
|
151
|
+
this.items = { ...this.items, [productId]: (this.items[productId] ?? 0) + quantity }
|
|
152
|
+
this.#armIdleRelease()
|
|
153
|
+
return { items: this.items }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
applyCoupon({ code }: { code: string }): void {
|
|
157
|
+
this.#requireOpen()
|
|
158
|
+
this.couponCode = code
|
|
159
|
+
this.#armIdleRelease()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
checkOut({ amountCents }: { amountCents: number }): { status: string } {
|
|
163
|
+
if (this.status === "paying" || this.status === "checking" || this.status === "paid") {
|
|
164
|
+
return { status: this.status }
|
|
165
|
+
}
|
|
166
|
+
this.#requireOpen()
|
|
167
|
+
this.status = "paying"
|
|
168
|
+
this.emit("charge_payment", {
|
|
169
|
+
arguments: { cartId: this.actorId, amountCents },
|
|
170
|
+
onSuccess: "paymentSettled",
|
|
171
|
+
onFailure: "paymentUnknown",
|
|
172
|
+
})
|
|
173
|
+
return { status: this.status }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
paymentSettled({ result }: EffectSuccessPayload<ChargeArguments, ChargeResult>): void {
|
|
177
|
+
if (result.status === "paid") {
|
|
178
|
+
this.#markPaid(result.paymentId)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
this.#reopen()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
paymentUnknown({ effectId }: EffectFailurePayload<ChargeArguments>): void {
|
|
185
|
+
this.status = "checking"
|
|
186
|
+
this.emit("check_payment", {
|
|
187
|
+
arguments: { paymentKey: effectId },
|
|
188
|
+
onSuccess: "paymentChecked",
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
paymentChecked({ result }: EffectSuccessPayload<CheckArguments, CheckResult>): void {
|
|
193
|
+
if (result.status === "paid") {
|
|
194
|
+
this.#markPaid(result.paymentId)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
this.#reopen()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
releaseIfIdle({ activity }: { activity: number }): void {
|
|
201
|
+
if (activity !== this.activity || this.status !== "open") return
|
|
202
|
+
this.status = "released"
|
|
203
|
+
this.emit("release_stock", { arguments: { cartId: this.actorId, items: this.items } })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#markPaid(paymentId: string): void {
|
|
207
|
+
this.status = "paid"
|
|
208
|
+
this.paymentId = paymentId
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#reopen(): void {
|
|
212
|
+
this.status = "open"
|
|
213
|
+
this.#armIdleRelease()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#requireOpen(): void {
|
|
217
|
+
if (this.status !== "open") {
|
|
218
|
+
this.reject("cart_closed", { message: `The cart is ${this.status}` })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#armIdleRelease(): void {
|
|
223
|
+
this.activity += 1
|
|
224
|
+
this.schedule({ at: new Date(Date.now() + IDLE_MILLISECONDS) }).releaseIfIdle({
|
|
225
|
+
activity: this.activity,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The runtime registers the actor and the three effects:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
|
|
235
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
236
|
+
import { ShoppingCart } from "./shopping-cart.js"
|
|
237
|
+
|
|
238
|
+
export type PaymentProvider = {
|
|
239
|
+
charge(options: {
|
|
240
|
+
idempotencyKey: string
|
|
241
|
+
cartId: string
|
|
242
|
+
amountCents: number
|
|
243
|
+
}): Promise<{ status: "paid"; paymentId: string } | { status: "declined" }>
|
|
244
|
+
lookup(options: {
|
|
245
|
+
idempotencyKey: string
|
|
246
|
+
}): Promise<{ status: "paid"; paymentId: string } | { status: "not_found" }>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type Inventory = {
|
|
250
|
+
release(options: {
|
|
251
|
+
idempotencyKey: string
|
|
252
|
+
cartId: string
|
|
253
|
+
items: Record<string, number>
|
|
254
|
+
}): Promise<void>
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const CALLER_OPERATIONS = new Set(["addItem", "applyCoupon", "checkOut"])
|
|
258
|
+
|
|
259
|
+
export function cartRuntime({
|
|
260
|
+
path,
|
|
261
|
+
payments,
|
|
262
|
+
inventory,
|
|
263
|
+
retryDelayMilliseconds,
|
|
264
|
+
maxAttempts,
|
|
265
|
+
}: {
|
|
266
|
+
path: string
|
|
267
|
+
payments: PaymentProvider
|
|
268
|
+
inventory: Inventory
|
|
269
|
+
retryDelayMilliseconds?: (attempt: number) => number
|
|
270
|
+
maxAttempts?: number
|
|
271
|
+
}): SolidObjectsRuntime {
|
|
272
|
+
const runtime = createRuntime({
|
|
273
|
+
database: sqlite({ path }),
|
|
274
|
+
...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
|
|
275
|
+
...(maxAttempts ? { maxAttempts } : {}),
|
|
276
|
+
authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
|
|
277
|
+
actorType === ShoppingCart.actorType &&
|
|
278
|
+
CALLER_OPERATIONS.has(operation) &&
|
|
279
|
+
ownsCart({ actorId, authorizationContext }),
|
|
280
|
+
authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
|
|
281
|
+
actorType === ShoppingCart.actorType && ownsCart({ actorId, authorizationContext }),
|
|
282
|
+
})
|
|
283
|
+
runtime.register(ShoppingCart)
|
|
284
|
+
runtime.registerEffect("charge_payment", (argumentsValue, context) =>
|
|
285
|
+
payments.charge({
|
|
286
|
+
idempotencyKey: context.id,
|
|
287
|
+
cartId: String(argumentsValue.cartId),
|
|
288
|
+
amountCents: Number(argumentsValue.amountCents),
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
runtime.registerEffect("check_payment", (argumentsValue) =>
|
|
292
|
+
payments.lookup({ idempotencyKey: String(argumentsValue.paymentKey) }),
|
|
293
|
+
)
|
|
294
|
+
runtime.registerEffect("release_stock", (argumentsValue, context) =>
|
|
295
|
+
inventory.release({
|
|
296
|
+
idempotencyKey: context.id,
|
|
297
|
+
cartId: String(argumentsValue.cartId),
|
|
298
|
+
items: argumentsValue.items as Record<string, number>,
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
return runtime
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function ownsCart({
|
|
305
|
+
actorId,
|
|
306
|
+
authorizationContext,
|
|
307
|
+
}: {
|
|
308
|
+
actorId: string
|
|
309
|
+
authorizationContext: unknown
|
|
310
|
+
}): boolean {
|
|
311
|
+
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
|
|
312
|
+
return "userId" in authorizationContext && authorizationContext.userId === actorId
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Each process sends calls to the cart with the reference:
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
await runtime
|
|
320
|
+
.ref(ShoppingCart, cartId)
|
|
321
|
+
.with({ authorizationContext: { userId } })
|
|
322
|
+
.checkOut({ amountCents: totalFromYourPriceTable })
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
At least one process calls `runtime.run(signal)`. It runs the effects and the reminders.
|
|
326
|
+
|
|
327
|
+
How it works:
|
|
328
|
+
|
|
329
|
+
- 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.
|
|
330
|
+
- 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.
|
|
331
|
+
- `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.
|
|
332
|
+
- 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.
|
|
333
|
+
- A declined card is an answer, so `paymentSettled` opens the cart again.
|
|
334
|
+
- 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.
|
|
335
|
+
- Compute the amount on the server from your own prices. Do not take it from the browser.
|
|
336
|
+
|
|
337
|
+
### What the tests prove
|
|
338
|
+
|
|
339
|
+
[The tests](../../test/compare-shopping-cart.test.ts) show these results:
|
|
340
|
+
|
|
341
|
+
- Ten concurrent `addItem` calls for one cart keep all ten items.
|
|
342
|
+
- When the payment response is lost, the effect runs again with the same idempotency key, and the provider charges once.
|
|
343
|
+
- A second checkout while the first one runs does not charge again.
|
|
344
|
+
- A declined card opens the cart again.
|
|
345
|
+
- When every payment response is lost after a charge, the cart checks the charge, ends as paid, and the provider charges once.
|
|
346
|
+
- When every payment call fails with no charge, the check finds no charge, and the cart opens again.
|
|
347
|
+
- A release reminder that was queued behind a newer change does nothing.
|
|
348
|
+
- After a restart, an idle cart releases its reserved stock and refuses a checkout.
|
|
349
|
+
|
|
350
|
+
## Questions that teams ask
|
|
351
|
+
|
|
352
|
+
**Is Solid Objects mature enough?** Here are the facts:
|
|
353
|
+
|
|
354
|
+
- First public release of the npm package: August 13, 2026.
|
|
355
|
+
- Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
|
|
356
|
+
- One maintainer.
|
|
357
|
+
- 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.
|
|
358
|
+
- The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
|
|
359
|
+
|
|
360
|
+
DBOS has a company, a hosted service, and a longer history. If those matter more than state for each cart, choose DBOS.
|
|
361
|
+
|
|
362
|
+
**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).
|
|
363
|
+
|
|
364
|
+
**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.
|
|
365
|
+
|
|
366
|
+
**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).
|
|
367
|
+
|
|
368
|
+
**Can I use both?** Yes. Code in a DBOS step can call an actor, and an actor effect handler can call any API.
|
|
369
|
+
|
|
370
|
+
## Sources
|
|
371
|
+
|
|
372
|
+
Checked October 15, 2026:
|
|
373
|
+
|
|
374
|
+
- [DBOS workflows and steps](https://docs.dbos.dev/typescript/reference/workflows-steps)
|
|
375
|
+
- [DBOS workflow tutorial](https://docs.dbos.dev/typescript/tutorials/workflow-tutorial)
|
|
376
|
+
- [DBOS queue tutorial](https://docs.dbos.dev/typescript/tutorials/queue-tutorial)
|
|
377
|
+
- [DBOS queue reference](https://docs.dbos.dev/typescript/reference/queues)
|
|
378
|
+
- [DBOS architecture](https://docs.dbos.dev/architecture)
|
|
379
|
+
- [DBOS datasource transactions](https://docs.dbos.dev/typescript/tutorials/transaction-tutorial)
|
|
380
|
+
- [DBOS FAQ](https://docs.dbos.dev/faq)
|
|
381
|
+
- [DBOS workflow recovery](https://docs.dbos.dev/production/workflow-recovery)
|
|
382
|
+
- [DBOS upgrading workflows](https://docs.dbos.dev/typescript/tutorials/upgrading-workflows)
|
|
383
|
+
- [Migrating from Temporal to DBOS](https://docs.dbos.dev/explanations/migrating-from-temporal)
|
|
384
|
+
- [DBOS pricing](https://www.dbos.dev/dbos-pricing)
|
|
385
|
+
|
|
386
|
+
## More information
|
|
387
|
+
|
|
388
|
+
- [All comparisons](../comparisons.md)
|
|
389
|
+
- [Restate virtual objects and Solid Objects](restate.md)
|
|
390
|
+
- [Virtual actors in TypeScript](../virtual-actors.md)
|
|
391
|
+
- [Guide for coding agents](../agents.md)
|