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.
@@ -0,0 +1,296 @@
1
+ # Graphile Worker named queues and Solid Objects: ordered work for each room on PostgreSQL
2
+
3
+ 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.
4
+
5
+ Solid Objects is an independent project. It is not affiliated with Graphile.
6
+
7
+ ## The short answer
8
+
9
+ | Question | Graphile Worker | Solid Objects |
10
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
11
+ | 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 |
12
+ | 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 |
13
+ | State for each key | Your own tables | Actor fields |
14
+ | 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 |
15
+ | Later work | `runAt` with a `jobKey` | `this.schedule({ at, key }).operation(input)` |
16
+ | Recurring work | A crontab | No crontab. A reminder can arm itself again for one actor |
17
+ | 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 |
18
+ | What you run | Worker processes (or the worker inside your server process) and PostgreSQL | Your Node.js process and your database |
19
+ | Database | PostgreSQL 12 or newer | SQLite, PostgreSQL 14 or newer, MySQL 8.0 or newer |
20
+ | Node.js | Node.js 22.18 or newer | Node.js 24.4 or newer |
21
+ | Status | "Production ready (and used in production)", with 0.x version numbers | Version 1.0 |
22
+ | Licence | MIT. The Worker Pro add-on is proprietary | MIT. The optional Solid Objects Pro package is commercial |
23
+
24
+ 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).
25
+
26
+ ## Choose Graphile Worker when
27
+
28
+ - The work is background jobs, such as emails, webhooks, or reports.
29
+ - You need cron tasks.
30
+ - You want low latency from PostgreSQL `LISTEN` and `NOTIFY`.
31
+ - The state already lives in your own normalized tables, and each job is a short transaction on them.
32
+
33
+ ## Choose Solid Objects when
34
+
35
+ - One key has state that each change reads and writes, and the changes must apply in order.
36
+ - The order must hold when one change fails.
37
+ - The keys are many, for example one for each room or each user. Graphile Worker advises against queue names with high cardinality.
38
+ - You use SQLite or MySQL.
39
+
40
+ ## The same terms in each
41
+
42
+ | Graphile Worker | Solid Objects |
43
+ | ------------------------------------------------- | ------------------------------------------ |
44
+ | `queueName` | Actor ID |
45
+ | A task | An actor method (an operation) |
46
+ | `addJob` | A call on `runtime.ref(ActorClass, id)` |
47
+ | `runAt` and `jobKey` with `jobKeyMode: "replace"` | `schedule({ at, key })` and an operation |
48
+ | `maxAttempts` and the backoff formula | `maxAttempts` and `retryDelayMilliseconds` |
49
+ | A crontab entry | No equivalent |
50
+
51
+ ## A chat room in each
52
+
53
+ 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.
54
+
55
+ ### With Graphile Worker
56
+
57
+ This code follows the Graphile Worker documentation. It was not compiled or run. The room state lives in `rooms`, `messages`, and `room_members` tables.
58
+
59
+ ```typescript
60
+ import { makeWorkerUtils, run, type Task } from "graphile-worker"
61
+
62
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
63
+ const connectionString = process.env.DATABASE_URL!
64
+ const workerUtils = await makeWorkerUtils({ connectionString })
65
+
66
+ export async function sendMessage(roomId: string, from: string, text: string) {
67
+ await workerUtils.addJob("post_message", { roomId, from, text }, { queueName: `room:${roomId}` })
68
+ await workerUtils.addJob(
69
+ "archive_room",
70
+ { roomId },
71
+ {
72
+ jobKey: `archive_room:${roomId}`,
73
+ jobKeyMode: "replace",
74
+ runAt: new Date(Date.now() + SEVEN_DAYS),
75
+ },
76
+ )
77
+ }
78
+
79
+ const post_message: Task = async (payload, helpers) => {
80
+ const { roomId, from, text } = payload as { roomId: string; from: string; text: string }
81
+ await helpers.withPgClient(async (pg) => {
82
+ await pg.query("begin")
83
+ try {
84
+ const {
85
+ rows: [room],
86
+ } = await pg.query(
87
+ "update rooms set seq = seq + 1, last_activity_at = now() where id = $1 returning seq",
88
+ [roomId],
89
+ )
90
+ await pg.query("insert into messages (room_id, seq, sender, body) values ($1, $2, $3, $4)", [
91
+ roomId,
92
+ room.seq,
93
+ from,
94
+ text,
95
+ ])
96
+ await pg.query(
97
+ "update room_members set unread = unread + 1 where room_id = $1 and user_id <> $2",
98
+ [roomId, from],
99
+ )
100
+ await pg.query("commit")
101
+ } catch (error) {
102
+ await pg.query("rollback")
103
+ throw error
104
+ }
105
+ })
106
+ }
107
+
108
+ const archive_room: Task = async (payload, helpers) => {
109
+ const { roomId } = payload as { roomId: string }
110
+ await helpers.query(
111
+ "update rooms set archived_at = now() where id = $1 and last_activity_at <= now() - interval '7 days'",
112
+ [roomId],
113
+ )
114
+ }
115
+
116
+ const runner = await run({
117
+ connectionString,
118
+ concurrency: 5,
119
+ taskList: { post_message, archive_room },
120
+ })
121
+ await runner.promise
122
+ ```
123
+
124
+ What this code gets from Graphile Worker:
125
+
126
+ - Jobs in the queue `room:<id>` run one at a time.
127
+ - Each message job runs one SQL transaction, so the sequence number and the unread counts change together.
128
+ - The archive job with `jobKeyMode: "replace"` moves to 7 days after the newest message.
129
+
130
+ What to know:
131
+
132
+ - 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.
133
+ - 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.
134
+ - The sample does not refuse a post to an archived room.
135
+ - `withPgClient` does not open a transaction, so the task opens one.
136
+ - Delivery is at least once. A crash after `commit` can insert a message twice. Add a client message ID with a unique index.
137
+ - Graphile Worker has no realtime push. The app sends updates itself.
138
+ - 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.
139
+
140
+ ### With Solid Objects
141
+
142
+ There is one `ChatRoom` actor for each room ID.
143
+
144
+ ```typescript
145
+ import { Actor } from "solid-objects"
146
+
147
+ export const ARCHIVE_AFTER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000
148
+
149
+ const KEPT_MESSAGES = 200
150
+
151
+ export type ChatMessage = { sequence: number; authorId: string; text: string }
152
+
153
+ export class ChatRoom extends Actor {
154
+ static override readonly actorType = "ChatRoom"
155
+
156
+ members: string[] = []
157
+ messages: ChatMessage[] = []
158
+ nextSequence = 1
159
+ lastRead: Record<string, number> = {}
160
+ pinned: ChatMessage | null = null
161
+ activity = 0
162
+ archived = false
163
+
164
+ get unreadCounts(): Record<string, number> {
165
+ const latest = this.nextSequence - 1
166
+ return Object.fromEntries(
167
+ this.members.map((memberId) => [memberId, latest - (this.lastRead[memberId] ?? 0)]),
168
+ )
169
+ }
170
+
171
+ join({ memberId }: { memberId: string }): void {
172
+ this.#requireOpen()
173
+ if (this.members.includes(memberId)) return
174
+ this.members = [...this.members, memberId]
175
+ this.lastRead = { ...this.lastRead, [memberId]: this.nextSequence - 1 }
176
+ this.#armArchive()
177
+ }
178
+
179
+ post({ authorId, text }: { authorId: string; text: string }): { sequence: number } {
180
+ this.#requireMember(authorId)
181
+ const sequence = this.nextSequence
182
+ this.nextSequence += 1
183
+ this.messages = [...this.messages, { sequence, authorId, text }].slice(-KEPT_MESSAGES)
184
+ this.lastRead = { ...this.lastRead, [authorId]: sequence }
185
+ this.#armArchive()
186
+ return { sequence }
187
+ }
188
+
189
+ markRead({ memberId, sequence }: { memberId: string; sequence: number }): void {
190
+ this.#requireMember(memberId)
191
+ const current = this.lastRead[memberId] ?? 0
192
+ this.lastRead = {
193
+ ...this.lastRead,
194
+ [memberId]: Math.min(Math.max(current, sequence), this.nextSequence - 1),
195
+ }
196
+ }
197
+
198
+ pin({ memberId, sequence }: { memberId: string; sequence: number }): void {
199
+ this.#requireMember(memberId)
200
+ const message = this.messages.find((candidate) => candidate.sequence === sequence)
201
+ if (!message) this.reject("unknown_message", { message: "That message is not in the room" })
202
+ this.pinned = message
203
+ this.#armArchive()
204
+ }
205
+
206
+ archiveIfIdle({ activity }: { activity: number }): void {
207
+ if (activity !== this.activity) return
208
+ this.archived = true
209
+ }
210
+
211
+ #requireMember(memberId: string): void {
212
+ this.#requireOpen()
213
+ if (!this.members.includes(memberId)) {
214
+ this.reject("not_a_member", { message: "Join the room first" })
215
+ }
216
+ }
217
+
218
+ #requireOpen(): void {
219
+ if (this.archived) this.reject("archived", { message: "The room is archived" })
220
+ }
221
+
222
+ #armArchive(): void {
223
+ this.activity += 1
224
+ this.schedule({ at: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS) }).archiveIfIdle({
225
+ activity: this.activity,
226
+ })
227
+ }
228
+ }
229
+ ```
230
+
231
+ 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.
232
+
233
+ How it works:
234
+
235
+ - 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.
236
+ - 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).
237
+ - `lastRead` keeps the last message that each member read. `unreadCounts` is a query that computes the counts from it.
238
+ - 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`.
239
+ - `pin` keeps a copy of the message, so the pinned text stays after newer messages push it out of the last 200.
240
+ - 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.
241
+
242
+ ### What the tests prove
243
+
244
+ [The tests](../../test/compare-chat-room.test.ts) show these results:
245
+
246
+ - Twenty messages from two members that arrive at the same time get the sequence numbers 1 to 20, with no gap.
247
+ - Unread counts are correct for each member after one member marks messages read.
248
+ - After a restart and seven idle days, the room is archived, keeps its pinned message, and refuses a new post.
249
+ - An archive reminder that was queued behind a newer post does nothing.
250
+ - A pinned message keeps its text after 200 newer messages.
251
+ - A member cannot post in the name of another member.
252
+ - A caller with no credentials cannot join or post, even with a `null` member ID.
253
+ - A caller who did not join cannot post.
254
+
255
+ ## Questions that teams ask
256
+
257
+ **Is Solid Objects mature enough?** Here are the facts:
258
+
259
+ - First public release of the npm package: August 13, 2026.
260
+ - Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
261
+ - One maintainer.
262
+ - 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.
263
+ - The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
264
+
265
+ **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).
266
+
267
+ **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).
268
+
269
+ **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.
270
+
271
+ **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.
272
+
273
+ **Can I use both?** Yes. Keep background jobs and cron in Graphile Worker, and keep state for each room in Solid Objects.
274
+
275
+ ## Sources
276
+
277
+ Checked October 15, 2026:
278
+
279
+ - [Graphile Worker introduction](https://worker.graphile.org/docs)
280
+ - [Graphile Worker `addJob`](https://worker.graphile.org/docs/library/add-job)
281
+ - [Graphile Worker job keys](https://worker.graphile.org/docs/job-key)
282
+ - [Graphile Worker error handling](https://worker.graphile.org/docs/error-handling)
283
+ - [Graphile Worker exponential backoff](https://worker.graphile.org/docs/exponential-backoff)
284
+ - [Graphile Worker requirements](https://worker.graphile.org/docs/requirements)
285
+ - [Graphile Worker project status](https://worker.graphile.org/docs/project-status)
286
+ - [Graphile Worker techniques](https://worker.graphile.org/docs/techniques)
287
+ - [Graphile Worker admin functions](https://worker.graphile.org/docs/admin-functions)
288
+ - [Graphile Worker Pro](https://worker.graphile.org/docs/pro)
289
+ - [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)
290
+
291
+ ## More information
292
+
293
+ - [All comparisons](../comparisons.md)
294
+ - [RivetKit actors and Solid Objects](rivetkit.md)
295
+ - [Persistent rooms in Node.js](../guides/persistent-rooms.md)
296
+ - [Guide for coding agents](../agents.md)
@@ -0,0 +1,290 @@
1
+ # Restate virtual objects and Solid Objects: state and delayed calls for each key in TypeScript
2
+
3
+ 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.
4
+
5
+ Solid Objects is an independent project. It is not affiliated with Restate.
6
+
7
+ ## The short answer
8
+
9
+ | Question | Restate virtual object | Solid Objects |
10
+ | --------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
11
+ | What has state | An object name and a key | An actor class and an actor ID |
12
+ | 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 |
13
+ | Order | "Calls to a Virtual Object execute in order of arrival, serially" | Messages run in the order that the runtime stores them |
14
+ | Where the state lives | The embedded key-value store of the Restate server | Tables in your SQL database |
15
+ | Delayed call | `ctx.objectSendClient(...).handler(input, restate.rpc.sendOpts({ delay }))` | `this.schedule({ at, key }).operation(input)` |
16
+ | 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 |
17
+ | What you run | The Restate server (one binary or a cluster) and your service | Your Node.js process, which calls `runtime.run(signal)` |
18
+ | Node.js | Node.js 22 or newer for the SDK | Node.js 24.4 or newer |
19
+ | 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 |
20
+
21
+ 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).
22
+
23
+ ## Choose Restate when
24
+
25
+ - You want a journal of each step, so that a retry resumes after the last completed step.
26
+ - You can run the Restate server, or you can use Restate Cloud.
27
+ - You want workflows, service calls, and virtual objects in one runtime.
28
+ - You use an agent framework that Restate integrates with in TypeScript, such as the Vercel AI SDK.
29
+
30
+ ## Choose Solid Objects when
31
+
32
+ - You want the state and the mailbox in the SQL database that you already operate, back up, and query.
33
+ - You do not want to run another server.
34
+ - You want a state change and its effects, reminders, and broadcasts to commit in one SQL transaction.
35
+ - You also have a Rails app. The `solid_objects` gem has the same model.
36
+
37
+ ## The same terms in each
38
+
39
+ | Restate | Solid Objects |
40
+ | ----------------------------------------- | ---------------------------------------------------- |
41
+ | Virtual object key | Actor ID |
42
+ | Exclusive handler (`ObjectContext`) | Actor method (an operation) |
43
+ | Shared handler | Query (a getter) or `snapshot()` |
44
+ | `ctx.get` and `ctx.set` | Actor fields |
45
+ | Delayed call with `sendOpts({ delay })` | `schedule({ at, key })` and an operation |
46
+ | `ctx.cancel(invocationId)` | [Cancel a reminder](../api.md#cancelling-a-reminder) |
47
+ | `ctx.run` | `emit` and an effect handler |
48
+ | `ctx.rand.uuidv4()` as an idempotency key | The effect ID, `context.id` |
49
+ | `restate deployments register` | `runtime.register(ActorClass)` |
50
+
51
+ ## An AI agent session in each
52
+
53
+ 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.
54
+
55
+ ### With Restate
56
+
57
+ This code follows the Restate documentation and type-checks against `@restatedev/restate-sdk` 1.17.3. It was not run against a Restate server.
58
+
59
+ ```typescript
60
+ import * as restate from "@restatedev/restate-sdk"
61
+
62
+ type Message = { role: "user" | "assistant" | "tool"; content: string }
63
+ type LlmReply = { text: string; followUpOrderId?: string }
64
+
65
+ declare function callLlm(messages: Message[]): Promise<LlmReply>
66
+ declare function fetchOrderStatus(orderId: string): Promise<string>
67
+
68
+ async function appendAndAsk(ctx: restate.ObjectContext, message: Message) {
69
+ const messages = (await ctx.get<Message[]>("messages")) ?? []
70
+ messages.push(message)
71
+ const reply = await ctx.run("LLM call", () => callLlm(messages))
72
+ messages.push({ role: "assistant", content: reply.text })
73
+ ctx.set("messages", messages)
74
+ return reply
75
+ }
76
+
77
+ export const agentSession = restate.object({
78
+ name: "AgentSession",
79
+ handlers: {
80
+ message: async (ctx: restate.ObjectContext, message: Message) => {
81
+ const reply = await appendAndAsk(ctx, message)
82
+ if (!reply.followUpOrderId) return reply.text
83
+ ctx
84
+ .objectSendClient(agentSession, ctx.key)
85
+ .checkOrder(reply.followUpOrderId, restate.rpc.sendOpts({ delay: { hours: 2 } }))
86
+ return reply.text
87
+ },
88
+ checkOrder: async (ctx: restate.ObjectContext, orderId: string) => {
89
+ const status = await ctx.run("order status", () => fetchOrderStatus(orderId))
90
+ await appendAndAsk(ctx, { role: "tool", content: `Order ${orderId}: ${status}` })
91
+ },
92
+ },
93
+ })
94
+
95
+ restate.serve({ services: [agentSession], port: 9080 })
96
+ ```
97
+
98
+ What this code gets from Restate:
99
+
100
+ - Calls for one session run one at a time, in order of arrival.
101
+ - The model reply is in the journal. Restate says that model calls are "Persisted so responses are not re-fetched on recovery".
102
+ - The delayed call to `checkOrder` is durable.
103
+
104
+ What to know:
105
+
106
+ - 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.
107
+ - 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.
108
+ - For long model calls, Restate tells you to raise the inactivity timeout (default 1 minute) and the abort timeout (default 10 minutes).
109
+ - The state is a key-value store inside the Restate server. Restate says "SQL is supported only for analytics / introspection".
110
+
111
+ ### With Solid Objects
112
+
113
+ There is one `AgentSession` actor for each user ID.
114
+
115
+ ```typescript
116
+ import { Actor, type EffectSuccessPayload } from "solid-objects"
117
+
118
+ export const FOLLOW_UP_MILLISECONDS = 2 * 60 * 60 * 1000
119
+
120
+ const KEPT_ENTRIES = 50
121
+
122
+ export type Entry = { role: "user" | "tool" | "assistant"; content: string }
123
+
124
+ type ReplyArguments = { turn: number; history: Entry[] }
125
+ type ReplyResult = { content: string }
126
+
127
+ export class AgentSession extends Actor {
128
+ static override readonly actorType = "AgentSession"
129
+
130
+ history: Entry[] = []
131
+ turn = 0
132
+ followUps: string[] = []
133
+
134
+ receive({ role, content }: { role: "user" | "tool"; content: string }): { turn: number } {
135
+ this.#append({ role, content })
136
+ return { turn: this.turn }
137
+ }
138
+
139
+ followUpLater({ topic }: { topic: string }): void {
140
+ this.schedule({ at: new Date(Date.now() + FOLLOW_UP_MILLISECONDS), key: topic }).followUp({
141
+ topic,
142
+ })
143
+ }
144
+
145
+ followUp({ topic }: { topic: string }): void {
146
+ this.followUps = [...this.followUps, topic]
147
+ this.#append({ role: "tool", content: `Follow-up due: ${topic}` })
148
+ }
149
+
150
+ replied({ arguments: reply, result }: EffectSuccessPayload<ReplyArguments, ReplyResult>): void {
151
+ if (reply.turn !== this.turn) return
152
+ this.history = [...this.history, { role: "assistant" as const, content: result.content }].slice(
153
+ -KEPT_ENTRIES,
154
+ )
155
+ }
156
+
157
+ #append(entry: Entry): void {
158
+ this.history = [...this.history, entry].slice(-KEPT_ENTRIES)
159
+ this.turn += 1
160
+ this.emit("model_reply", {
161
+ arguments: { turn: this.turn, history: this.history },
162
+ onSuccess: "replied",
163
+ })
164
+ }
165
+ }
166
+ ```
167
+
168
+ The runtime registers the actor and the effect that calls the model:
169
+
170
+ ```typescript
171
+ import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
172
+ import { sqlite } from "solid-objects/database/sqlite"
173
+ import { AgentSession, type Entry } from "./agent-session.js"
174
+
175
+ export type ModelClient = {
176
+ reply(options: { idempotencyKey: string; history: Entry[] }): Promise<{ content: string }>
177
+ }
178
+
179
+ const CALLER_OPERATIONS = new Set(["receive", "followUpLater"])
180
+
181
+ export function agentRuntime({
182
+ path,
183
+ model,
184
+ retryDelayMilliseconds,
185
+ }: {
186
+ path: string
187
+ model: ModelClient
188
+ retryDelayMilliseconds?: (attempt: number) => number
189
+ }): SolidObjectsRuntime {
190
+ const runtime = createRuntime({
191
+ database: sqlite({ path }),
192
+ ...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
193
+ authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
194
+ actorType === AgentSession.actorType &&
195
+ CALLER_OPERATIONS.has(operation) &&
196
+ ownsSession({ actorId, authorizationContext }),
197
+ authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
198
+ actorType === AgentSession.actorType && ownsSession({ actorId, authorizationContext }),
199
+ })
200
+ runtime.register(AgentSession)
201
+ runtime.registerEffect("model_reply", (argumentsValue, context) =>
202
+ model.reply({ idempotencyKey: context.id, history: argumentsValue.history as Entry[] }),
203
+ )
204
+ return runtime
205
+ }
206
+
207
+ function ownsSession({
208
+ actorId,
209
+ authorizationContext,
210
+ }: {
211
+ actorId: string
212
+ authorizationContext: unknown
213
+ }): boolean {
214
+ if (typeof authorizationContext !== "object" || authorizationContext === null) return false
215
+ return "userId" in authorizationContext && authorizationContext.userId === actorId
216
+ }
217
+ ```
218
+
219
+ The app sends each message with `send`, so that the message is stored before the call returns:
220
+
221
+ ```typescript
222
+ await runtime
223
+ .ref(AgentSession, user.id)
224
+ .send.with({ authorizationContext: { userId: user.id }, idempotencyKey: message.id })
225
+ .receive({ role: "user", content: message.text })
226
+ ```
227
+
228
+ At least one process calls `runtime.run(signal)`. It runs the stored messages, the effects, and the reminders.
229
+
230
+ How it works:
231
+
232
+ - Messages for one session run one at a time, in the order that the runtime stores them.
233
+ - `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.
234
+ - 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.
235
+ - `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.
236
+ - `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.
237
+ - 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.
238
+
239
+ 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.
240
+
241
+ ### What the tests prove
242
+
243
+ [The tests](../../test/compare-agent-session.test.ts) show these results:
244
+
245
+ - Two user messages and a tool result apply in the order that they were sent.
246
+ - A model call that fails and runs again uses the same idempotency key, and the session keeps one reply.
247
+ - When two messages arrive before the model replies, the session keeps only the reply for the newest turn.
248
+ - A follow-up runs after a runtime restart.
249
+ - A caller who does not own the session gets `Unauthorized`.
250
+
251
+ ## Questions that teams ask
252
+
253
+ **Is Solid Objects mature enough?** Here are the facts:
254
+
255
+ - First public release of the npm package: August 13, 2026.
256
+ - Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
257
+ - One maintainer.
258
+ - 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.
259
+ - The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
260
+
261
+ Restate reached 1.0 in June 2024. If a longer history matters more than keeping the state in your own database, choose Restate.
262
+
263
+ **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).
264
+
265
+ **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.
266
+
267
+ **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).
268
+
269
+ **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.
270
+
271
+ ## Sources
272
+
273
+ Checked October 15, 2026:
274
+
275
+ - [Restate services and virtual objects](https://docs.restate.dev/foundations/services)
276
+ - [Restate service communication and delayed calls](https://docs.restate.dev/develop/ts/service-communication)
277
+ - [Restate durable steps](https://docs.restate.dev/develop/ts/durable-steps)
278
+ - [Restate durable agents](https://docs.restate.dev/ai/patterns/durable-agents)
279
+ - [Restate agent sessions](https://docs.restate.dev/ai/patterns/sessions)
280
+ - [Restate sagas](https://docs.restate.dev/guides/sagas)
281
+ - [Restate service configuration](https://docs.restate.dev/services/configuration)
282
+ - [Restate server overview](https://docs.restate.dev/server/overview)
283
+ - [Restate server licence](https://github.com/restatedev/restate/blob/main/LICENSE)
284
+
285
+ ## More information
286
+
287
+ - [All comparisons](../comparisons.md)
288
+ - [DBOS Transact and Solid Objects](dbos.md)
289
+ - [Virtual actors in TypeScript](../virtual-actors.md)
290
+ - [Guide for coding agents](../agents.md)