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,282 @@
1
+ # RivetKit actors and Solid Objects: chat rooms with ordered state on PostgreSQL in Node.js
2
+
3
+ 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.
4
+
5
+ Solid Objects is an independent project. It is not affiliated with Rivet.
6
+
7
+ ## The short answer
8
+
9
+ | Question | RivetKit | Solid Objects |
10
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
11
+ | What has state | An actor and its key | An actor class and an actor ID |
12
+ | 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 |
13
+ | 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 |
14
+ | 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 |
15
+ | Later work | `c.schedule.after` and `c.schedule.at`. "Schedules survive actor sleep, restarts, upgrades, and crashes" | `this.schedule({ at, key }).operation(input)` |
16
+ | Realtime | Events over WebSockets with `c.broadcast` | Committed broadcasts. Your app owns the WebSocket transport |
17
+ | 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 |
18
+ | 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 |
19
+ | Node.js | Node.js 22 or newer | Node.js 24.4 or newer |
20
+ | Licence | Apache-2.0. The FoundationDB storage backend "requires an enterprise license" | MIT. The optional Solid Objects Pro package is commercial |
21
+
22
+ The RivetKit facts come from the [Rivet documentation](https://rivet.dev/docs/) (checked October 15, 2026).
23
+
24
+ ## Choose RivetKit when
25
+
26
+ - You want realtime WebSocket connections to each actor, built in.
27
+ - You want state in memory with fast access, and a throttled save is acceptable.
28
+ - You want a per-actor SQLite database, or you want Rivet to run the actors for you.
29
+ - You expect very many active actors and can use the storage backend that suits that scale.
30
+
31
+ ## Choose Solid Objects when
32
+
33
+ - Each change must be saved before the call returns, together with its effects and reminders.
34
+ - Changes to one key must apply in order without a separate queue loop.
35
+ - A failed change must hold its place and run again.
36
+ - You do not want to depend on a control plane, self-hosted or on Rivet Cloud.
37
+ - You want the state in the SQL database that you already back up and query.
38
+
39
+ ## The same terms in each
40
+
41
+ | RivetKit | Solid Objects |
42
+ | ------------------------------------ | ---------------------------------------- |
43
+ | `actor({ ... })` and its key | An `Actor` class and an actor ID |
44
+ | An action | An actor method (an operation) |
45
+ | A queue and a `run` loop | The actor mailbox |
46
+ | `c.state` | Actor fields |
47
+ | `c.schedule.after` | `schedule({ at, key })` and an operation |
48
+ | `c.broadcast` | `observables()` and the realtime layer |
49
+ | `client.chatRoom.getOrCreate([key])` | `runtime.ref(ChatRoom, id)` |
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 RivetKit
56
+
57
+ 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.
58
+
59
+ ```typescript
60
+ import { actor, event, queue, setup } from "rivetkit"
61
+
62
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
63
+ type Message = { seq: number; from: string; text: string; at: number }
64
+
65
+ export const chatRoom = actor({
66
+ createState: () => ({
67
+ members: [] as string[],
68
+ unread: {} as Record<string, number>,
69
+ pinned: null as Message | null,
70
+ recent: [] as Message[],
71
+ seq: 0,
72
+ lastActivityAt: Date.now(),
73
+ archiveId: null as string | null,
74
+ archived: false,
75
+ }),
76
+ queues: { post: queue<{ from: string; text: string }>() },
77
+ events: { newMessage: event<Message>(), archived: event<{ at: number }>() },
78
+ run: async (c) => {
79
+ for await (const m of c.queue.iter()) {
80
+ const msg: Message = { seq: ++c.state.seq, ...m.body, at: Date.now() }
81
+ c.state.recent = [...c.state.recent, msg].slice(-100)
82
+ for (const user of c.state.members.filter((u) => u !== msg.from)) {
83
+ c.state.unread[user] = (c.state.unread[user] ?? 0) + 1
84
+ }
85
+ c.state.lastActivityAt = msg.at
86
+ await c.saveState({ immediate: true })
87
+ c.broadcast("newMessage", msg)
88
+ }
89
+ },
90
+ actions: {
91
+ join: (c, user: string) => {
92
+ c.state.members = [...new Set([...c.state.members, user])]
93
+ },
94
+ pin: (c, seq: number) => {
95
+ c.state.pinned = c.state.recent.find((m) => m.seq === seq) ?? null
96
+ },
97
+ sendMessage: async (c, from: string, text: string) => {
98
+ await c.queue.send("post", { from, text })
99
+ if (c.state.archiveId) await c.schedule.cancel(c.state.archiveId)
100
+ c.state.archiveId = await c.schedule.after(SEVEN_DAYS, "archive")
101
+ },
102
+ archive: (c) => {
103
+ if (Date.now() - c.state.lastActivityAt < SEVEN_DAYS) return
104
+ c.state.archived = true
105
+ c.broadcast("archived", { at: Date.now() })
106
+ },
107
+ },
108
+ })
109
+
110
+ export const registry = setup({ use: { chatRoom } })
111
+ registry.start()
112
+ ```
113
+
114
+ What this code gets from RivetKit:
115
+
116
+ - The `run` loop takes queue messages one at a time, so messages get one order.
117
+ - `c.broadcast` sends each new message to connected clients over WebSockets.
118
+ - The archive schedule survives a restart.
119
+
120
+ What to know:
121
+
122
+ - 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.
123
+ - 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.
124
+ - 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.
125
+ - The state is saved on a throttle. The example calls `c.saveState({ immediate: true })` before the broadcast.
126
+ - Two `sendMessage` calls can run at the same time, so the example also checks `lastActivityAt` before it archives.
127
+
128
+ ### With Solid Objects
129
+
130
+ There is one `ChatRoom` actor for each room ID.
131
+
132
+ ```typescript
133
+ import { Actor } from "solid-objects"
134
+
135
+ export const ARCHIVE_AFTER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000
136
+
137
+ const KEPT_MESSAGES = 200
138
+
139
+ export type ChatMessage = { sequence: number; authorId: string; text: string }
140
+
141
+ export class ChatRoom extends Actor {
142
+ static override readonly actorType = "ChatRoom"
143
+
144
+ members: string[] = []
145
+ messages: ChatMessage[] = []
146
+ nextSequence = 1
147
+ lastRead: Record<string, number> = {}
148
+ pinned: ChatMessage | null = null
149
+ activity = 0
150
+ archived = false
151
+
152
+ get unreadCounts(): Record<string, number> {
153
+ const latest = this.nextSequence - 1
154
+ return Object.fromEntries(
155
+ this.members.map((memberId) => [memberId, latest - (this.lastRead[memberId] ?? 0)]),
156
+ )
157
+ }
158
+
159
+ join({ memberId }: { memberId: string }): void {
160
+ this.#requireOpen()
161
+ if (this.members.includes(memberId)) return
162
+ this.members = [...this.members, memberId]
163
+ this.lastRead = { ...this.lastRead, [memberId]: this.nextSequence - 1 }
164
+ this.#armArchive()
165
+ }
166
+
167
+ post({ authorId, text }: { authorId: string; text: string }): { sequence: number } {
168
+ this.#requireMember(authorId)
169
+ const sequence = this.nextSequence
170
+ this.nextSequence += 1
171
+ this.messages = [...this.messages, { sequence, authorId, text }].slice(-KEPT_MESSAGES)
172
+ this.lastRead = { ...this.lastRead, [authorId]: sequence }
173
+ this.#armArchive()
174
+ return { sequence }
175
+ }
176
+
177
+ markRead({ memberId, sequence }: { memberId: string; sequence: number }): void {
178
+ this.#requireMember(memberId)
179
+ const current = this.lastRead[memberId] ?? 0
180
+ this.lastRead = {
181
+ ...this.lastRead,
182
+ [memberId]: Math.min(Math.max(current, sequence), this.nextSequence - 1),
183
+ }
184
+ }
185
+
186
+ pin({ memberId, sequence }: { memberId: string; sequence: number }): void {
187
+ this.#requireMember(memberId)
188
+ const message = this.messages.find((candidate) => candidate.sequence === sequence)
189
+ if (!message) this.reject("unknown_message", { message: "That message is not in the room" })
190
+ this.pinned = message
191
+ this.#armArchive()
192
+ }
193
+
194
+ archiveIfIdle({ activity }: { activity: number }): void {
195
+ if (activity !== this.activity) return
196
+ this.archived = true
197
+ }
198
+
199
+ #requireMember(memberId: string): void {
200
+ this.#requireOpen()
201
+ if (!this.members.includes(memberId)) {
202
+ this.reject("not_a_member", { message: "Join the room first" })
203
+ }
204
+ }
205
+
206
+ #requireOpen(): void {
207
+ if (this.archived) this.reject("archived", { message: "The room is archived" })
208
+ }
209
+
210
+ #armArchive(): void {
211
+ this.activity += 1
212
+ this.schedule({ at: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS) }).archiveIfIdle({
213
+ activity: this.activity,
214
+ })
215
+ }
216
+ }
217
+ ```
218
+
219
+ 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.
220
+
221
+ How it works:
222
+
223
+ - 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.
224
+ - `lastRead` keeps the last message that each member read. `unreadCounts` is a query that computes the counts from it.
225
+ - 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`.
226
+ - `pin` keeps a copy of the message, so the pinned text stays after newer messages push it out of the last 200.
227
+ - 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.
228
+ - 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.
229
+
230
+ ### What the tests prove
231
+
232
+ [The tests](../../test/compare-chat-room.test.ts) show these results:
233
+
234
+ - Twenty messages from two members that arrive at the same time get the sequence numbers 1 to 20, with no gap.
235
+ - Unread counts are correct for each member after one member marks messages read.
236
+ - After a restart and seven idle days, the room is archived, keeps its pinned message, and refuses a new post.
237
+ - An archive reminder that was queued behind a newer post does nothing.
238
+ - A pinned message keeps its text after 200 newer messages.
239
+ - A member cannot post in the name of another member.
240
+ - A caller with no credentials cannot join or post, even with a `null` member ID.
241
+ - A caller who did not join cannot post.
242
+
243
+ ## Questions that teams ask
244
+
245
+ **Is Solid Objects mature enough?** Here are the facts:
246
+
247
+ - First public release of the npm package: August 13, 2026.
248
+ - Version 1.0.0: October 13, 2026. The public API follows [semantic versioning](../versioning.md).
249
+ - One maintainer.
250
+ - 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.
251
+ - The [benchmarks](../benchmarks.md) are development measurements. Longer soak tests are open work.
252
+
253
+ **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).
254
+
255
+ **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).
256
+
257
+ **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.
258
+
259
+ **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.
260
+
261
+ ## Sources
262
+
263
+ Checked October 15, 2026:
264
+
265
+ - [Rivet overview](https://rivet.dev/docs/)
266
+ - [Rivet actor actions](https://rivet.dev/actors/docs/actions/)
267
+ - [Rivet actor queues](https://rivet.dev/actors/docs/queues/)
268
+ - [Rivet actor state](https://rivet.dev/actors/docs/state/)
269
+ - [Rivet actor scheduling](https://rivet.dev/actors/docs/schedule/)
270
+ - [Rivet actor events](https://rivet.dev/actors/docs/events/)
271
+ - [Rivet architecture](https://rivet.dev/docs/architecture/)
272
+ - [Rivet control plane storage](https://rivet.dev/docs/deploy/self-host/control-plane/storage/)
273
+ - [Rivet chat room guide](https://rivet.dev/guides/chat-room/)
274
+ - [Rivet Workflows: failure and recovery](https://rivet.dev/workflows/docs/failure-and-recovery)
275
+ - [rivetkit on npm](https://www.npmjs.com/package/rivetkit)
276
+
277
+ ## More information
278
+
279
+ - [All comparisons](../comparisons.md)
280
+ - [Graphile Worker named queues and Solid Objects](graphile-worker.md)
281
+ - [Persistent rooms in Node.js](../guides/persistent-rooms.md)
282
+ - [Guide for coding agents](../agents.md)
@@ -2,6 +2,22 @@
2
2
 
3
3
  This guide compares coordination models so an application can choose the
4
4
  smallest mechanism that meets its requirements. It does not rank the projects.
5
+
6
+ ## Comparison pages
7
+
8
+ Each page compares Solid Objects with one system on one real task. Each page
9
+ says when the other system is the better choice. The Solid Objects actor and
10
+ runtime code on each page runs in the test suite.
11
+
12
+ | Page | The task |
13
+ | ---------------------------------------------------------------------------- | ---------------------- |
14
+ | [Restate virtual objects and Solid Objects](compare/restate.md) | AI agent sessions |
15
+ | [DBOS Transact and Solid Objects](compare/dbos.md) | Shopping cart checkout |
16
+ | [RivetKit actors and Solid Objects](compare/rivetkit.md) | Chat rooms |
17
+ | [Graphile Worker named queues and Solid Objects](compare/graphile-worker.md) | Chat rooms |
18
+
19
+ ## Coordination models
20
+
5
21
  Solid Objects is a virtual actor library; the
6
22
  [virtual actor guide](virtual-actors.md) defines the term. Facts about other
7
23
  projects were checked on October 7, 2026.
@@ -13,7 +29,7 @@ projects were checked on October 7, 2026.
13
29
  | 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 |
14
30
  | 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 |
15
31
  | 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 |
16
- | 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 |
32
+ | 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 |
17
33
  | 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 |
18
34
  | 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 |
19
35
  | 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 |
@@ -0,0 +1,46 @@
1
+ import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+ import { AgentSession, type Entry } from "./agent-session.js"
4
+
5
+ export type ModelClient = {
6
+ reply(options: { idempotencyKey: string; history: Entry[] }): Promise<{ content: string }>
7
+ }
8
+
9
+ const CALLER_OPERATIONS = new Set(["receive", "followUpLater"])
10
+
11
+ export function agentRuntime({
12
+ path,
13
+ model,
14
+ retryDelayMilliseconds,
15
+ }: {
16
+ path: string
17
+ model: ModelClient
18
+ retryDelayMilliseconds?: (attempt: number) => number
19
+ }): SolidObjectsRuntime {
20
+ const runtime = createRuntime({
21
+ database: sqlite({ path }),
22
+ ...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
23
+ authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
24
+ actorType === AgentSession.actorType &&
25
+ CALLER_OPERATIONS.has(operation) &&
26
+ ownsSession({ actorId, authorizationContext }),
27
+ authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
28
+ actorType === AgentSession.actorType && ownsSession({ actorId, authorizationContext }),
29
+ })
30
+ runtime.register(AgentSession)
31
+ runtime.registerEffect("model_reply", (argumentsValue, context) =>
32
+ model.reply({ idempotencyKey: context.id, history: argumentsValue.history as Entry[] }),
33
+ )
34
+ return runtime
35
+ }
36
+
37
+ function ownsSession({
38
+ actorId,
39
+ authorizationContext,
40
+ }: {
41
+ actorId: string
42
+ authorizationContext: unknown
43
+ }): boolean {
44
+ if (typeof authorizationContext !== "object" || authorizationContext === null) return false
45
+ return "userId" in authorizationContext && authorizationContext.userId === actorId
46
+ }
@@ -0,0 +1,50 @@
1
+ import { Actor, type EffectSuccessPayload } from "solid-objects"
2
+
3
+ export const FOLLOW_UP_MILLISECONDS = 2 * 60 * 60 * 1000
4
+
5
+ const KEPT_ENTRIES = 50
6
+
7
+ export type Entry = { role: "user" | "tool" | "assistant"; content: string }
8
+
9
+ type ReplyArguments = { turn: number; history: Entry[] }
10
+ type ReplyResult = { content: string }
11
+
12
+ export class AgentSession extends Actor {
13
+ static override readonly actorType = "AgentSession"
14
+
15
+ history: Entry[] = []
16
+ turn = 0
17
+ followUps: string[] = []
18
+
19
+ receive({ role, content }: { role: "user" | "tool"; content: string }): { turn: number } {
20
+ this.#append({ role, content })
21
+ return { turn: this.turn }
22
+ }
23
+
24
+ followUpLater({ topic }: { topic: string }): void {
25
+ this.schedule({ at: new Date(Date.now() + FOLLOW_UP_MILLISECONDS), key: topic }).followUp({
26
+ topic,
27
+ })
28
+ }
29
+
30
+ followUp({ topic }: { topic: string }): void {
31
+ this.followUps = [...this.followUps, topic]
32
+ this.#append({ role: "tool", content: `Follow-up due: ${topic}` })
33
+ }
34
+
35
+ replied({ arguments: reply, result }: EffectSuccessPayload<ReplyArguments, ReplyResult>): void {
36
+ if (reply.turn !== this.turn) return
37
+ this.history = [...this.history, { role: "assistant" as const, content: result.content }].slice(
38
+ -KEPT_ENTRIES,
39
+ )
40
+ }
41
+
42
+ #append(entry: Entry): void {
43
+ this.history = [...this.history, entry].slice(-KEPT_ENTRIES)
44
+ this.turn += 1
45
+ this.emit("model_reply", {
46
+ arguments: { turn: this.turn, history: this.history },
47
+ onSuccess: "replied",
48
+ })
49
+ }
50
+ }
@@ -0,0 +1,80 @@
1
+ import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+ import { ShoppingCart } from "./shopping-cart.js"
4
+
5
+ export type PaymentProvider = {
6
+ charge(options: {
7
+ idempotencyKey: string
8
+ cartId: string
9
+ amountCents: number
10
+ }): Promise<{ status: "paid"; paymentId: string } | { status: "declined" }>
11
+ lookup(options: {
12
+ idempotencyKey: string
13
+ }): Promise<{ status: "paid"; paymentId: string } | { status: "not_found" }>
14
+ }
15
+
16
+ export type Inventory = {
17
+ release(options: {
18
+ idempotencyKey: string
19
+ cartId: string
20
+ items: Record<string, number>
21
+ }): Promise<void>
22
+ }
23
+
24
+ const CALLER_OPERATIONS = new Set(["addItem", "applyCoupon", "checkOut"])
25
+
26
+ export function cartRuntime({
27
+ path,
28
+ payments,
29
+ inventory,
30
+ retryDelayMilliseconds,
31
+ maxAttempts,
32
+ }: {
33
+ path: string
34
+ payments: PaymentProvider
35
+ inventory: Inventory
36
+ retryDelayMilliseconds?: (attempt: number) => number
37
+ maxAttempts?: number
38
+ }): SolidObjectsRuntime {
39
+ const runtime = createRuntime({
40
+ database: sqlite({ path }),
41
+ ...(retryDelayMilliseconds ? { retryDelayMilliseconds } : {}),
42
+ ...(maxAttempts ? { maxAttempts } : {}),
43
+ authorizeMessage: ({ actorType, actorId, operation, authorizationContext }) =>
44
+ actorType === ShoppingCart.actorType &&
45
+ CALLER_OPERATIONS.has(operation) &&
46
+ ownsCart({ actorId, authorizationContext }),
47
+ authorizeQuery: ({ actorType, actorId, authorizationContext }) =>
48
+ actorType === ShoppingCart.actorType && ownsCart({ actorId, authorizationContext }),
49
+ })
50
+ runtime.register(ShoppingCart)
51
+ runtime.registerEffect("charge_payment", (argumentsValue, context) =>
52
+ payments.charge({
53
+ idempotencyKey: context.id,
54
+ cartId: String(argumentsValue.cartId),
55
+ amountCents: Number(argumentsValue.amountCents),
56
+ }),
57
+ )
58
+ runtime.registerEffect("check_payment", (argumentsValue) =>
59
+ payments.lookup({ idempotencyKey: String(argumentsValue.paymentKey) }),
60
+ )
61
+ runtime.registerEffect("release_stock", (argumentsValue, context) =>
62
+ inventory.release({
63
+ idempotencyKey: context.id,
64
+ cartId: String(argumentsValue.cartId),
65
+ items: argumentsValue.items as Record<string, number>,
66
+ }),
67
+ )
68
+ return runtime
69
+ }
70
+
71
+ function ownsCart({
72
+ actorId,
73
+ authorizationContext,
74
+ }: {
75
+ actorId: string
76
+ authorizationContext: unknown
77
+ }): boolean {
78
+ if (typeof authorizationContext !== "object" || authorizationContext === null) return false
79
+ return "userId" in authorizationContext && authorizationContext.userId === actorId
80
+ }
@@ -0,0 +1,84 @@
1
+ import { Actor } from "solid-objects"
2
+
3
+ export const ARCHIVE_AFTER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000
4
+
5
+ const KEPT_MESSAGES = 200
6
+
7
+ export type ChatMessage = { sequence: number; authorId: string; text: string }
8
+
9
+ export class ChatRoom extends Actor {
10
+ static override readonly actorType = "ChatRoom"
11
+
12
+ members: string[] = []
13
+ messages: ChatMessage[] = []
14
+ nextSequence = 1
15
+ lastRead: Record<string, number> = {}
16
+ pinned: ChatMessage | null = null
17
+ activity = 0
18
+ archived = false
19
+
20
+ get unreadCounts(): Record<string, number> {
21
+ const latest = this.nextSequence - 1
22
+ return Object.fromEntries(
23
+ this.members.map((memberId) => [memberId, latest - (this.lastRead[memberId] ?? 0)]),
24
+ )
25
+ }
26
+
27
+ join({ memberId }: { memberId: string }): void {
28
+ this.#requireOpen()
29
+ if (this.members.includes(memberId)) return
30
+ this.members = [...this.members, memberId]
31
+ this.lastRead = { ...this.lastRead, [memberId]: this.nextSequence - 1 }
32
+ this.#armArchive()
33
+ }
34
+
35
+ post({ authorId, text }: { authorId: string; text: string }): { sequence: number } {
36
+ this.#requireMember(authorId)
37
+ const sequence = this.nextSequence
38
+ this.nextSequence += 1
39
+ this.messages = [...this.messages, { sequence, authorId, text }].slice(-KEPT_MESSAGES)
40
+ this.lastRead = { ...this.lastRead, [authorId]: sequence }
41
+ this.#armArchive()
42
+ return { sequence }
43
+ }
44
+
45
+ markRead({ memberId, sequence }: { memberId: string; sequence: number }): void {
46
+ this.#requireMember(memberId)
47
+ const current = this.lastRead[memberId] ?? 0
48
+ this.lastRead = {
49
+ ...this.lastRead,
50
+ [memberId]: Math.min(Math.max(current, sequence), this.nextSequence - 1),
51
+ }
52
+ }
53
+
54
+ pin({ memberId, sequence }: { memberId: string; sequence: number }): void {
55
+ this.#requireMember(memberId)
56
+ const message = this.messages.find((candidate) => candidate.sequence === sequence)
57
+ if (!message) this.reject("unknown_message", { message: "That message is not in the room" })
58
+ this.pinned = message
59
+ this.#armArchive()
60
+ }
61
+
62
+ archiveIfIdle({ activity }: { activity: number }): void {
63
+ if (activity !== this.activity) return
64
+ this.archived = true
65
+ }
66
+
67
+ #requireMember(memberId: string): void {
68
+ this.#requireOpen()
69
+ if (!this.members.includes(memberId)) {
70
+ this.reject("not_a_member", { message: "Join the room first" })
71
+ }
72
+ }
73
+
74
+ #requireOpen(): void {
75
+ if (this.archived) this.reject("archived", { message: "The room is archived" })
76
+ }
77
+
78
+ #armArchive(): void {
79
+ this.activity += 1
80
+ this.schedule({ at: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS) }).archiveIfIdle({
81
+ activity: this.activity,
82
+ })
83
+ }
84
+ }
@@ -0,0 +1,38 @@
1
+ import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+ import { ChatRoom } from "./chat-room.js"
4
+
5
+ const MEMBER_FIELDS: Record<string, string> = {
6
+ join: "memberId",
7
+ post: "authorId",
8
+ markRead: "memberId",
9
+ pin: "memberId",
10
+ }
11
+
12
+ export function chatRuntime({ path }: { path: string }): SolidObjectsRuntime {
13
+ const runtime = createRuntime({
14
+ database: sqlite({ path }),
15
+ authorizeMessage: ({
16
+ actorType,
17
+ operation,
18
+ arguments: argumentsValue,
19
+ authorizationContext,
20
+ }) => {
21
+ const field = MEMBER_FIELDS[operation]
22
+ if (actorType !== ChatRoom.actorType || !field) return false
23
+ const memberId = memberIdOf(authorizationContext)
24
+ if (memberId === null) return false
25
+ return argumentsValue[field] === memberId
26
+ },
27
+ authorizeQuery: ({ actorType, authorizationContext }) =>
28
+ actorType === ChatRoom.actorType && memberIdOf(authorizationContext) !== null,
29
+ })
30
+ runtime.register(ChatRoom)
31
+ return runtime
32
+ }
33
+
34
+ function memberIdOf(authorizationContext: unknown): string | null {
35
+ if (typeof authorizationContext !== "object" || authorizationContext === null) return null
36
+ if (!("memberId" in authorizationContext)) return null
37
+ return typeof authorizationContext.memberId === "string" ? authorizationContext.memberId : null
38
+ }