solid-objects 0.14.1 → 0.14.3
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 +51 -0
- package/README.md +249 -185
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/correctness.md +6 -1
- package/docs/operations.md +7 -0
- package/examples/at-least-once/actor.ts +13 -0
- package/examples/at-least-once/demo.ts +129 -0
- package/examples/at-least-once/effect-worker.ts +62 -0
- package/examples/at-least-once/sink.ts +34 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.3 - 2026-08-25
|
|
4
|
+
|
|
5
|
+
- Rewrite the first screen around the objection a reader actually has. The
|
|
6
|
+
README led with a shopping cart that appended to an array, which invites the
|
|
7
|
+
reply that one SQL statement already does it. It now leads with the ticket
|
|
8
|
+
sale from the homepage: 100 seats, a hold, a ten-minute expiry that frees the
|
|
9
|
+
seat, and a published count. That is the smallest example needing three
|
|
10
|
+
things from one number, and the three things are the argument.
|
|
11
|
+
- Answer "why not just use transactions?" in the first screen instead of
|
|
12
|
+
burying the fit sections. The section concedes the transaction and the row
|
|
13
|
+
lock first, including `navigator.locks` in the browser, then argues scope
|
|
14
|
+
rather than discipline: any `expiresAt` or `scheduledAt` column is evidence
|
|
15
|
+
the critical section already outlived the lock, and what follows it is a
|
|
16
|
+
sweeper and a race.
|
|
17
|
+
- Add "Is it worth installing here?", which names who should not install this,
|
|
18
|
+
and point readers with high-QPS reads or hot identities at
|
|
19
|
+
[Solid Objects Pro](https://solidobjects.pro/).
|
|
20
|
+
- Correct two claims. The realtime section said a value is published once per
|
|
21
|
+
change from the saving turn; the turn records the publication atomically,
|
|
22
|
+
while delivery is a separate worker and is at least once. Design provenance
|
|
23
|
+
said the API was redesigned around Web Components; there is no
|
|
24
|
+
`customElements` or `HTMLElement` in the package.
|
|
25
|
+
- Cut the README from 590 to about 540 lines by removing what `docs/` already
|
|
26
|
+
documented and what the page said three times, and add the table of contents
|
|
27
|
+
the standard-readme specification asks for above 100 lines.
|
|
28
|
+
|
|
29
|
+
## 0.14.2 - 2026-08-24
|
|
30
|
+
|
|
31
|
+
- State that background pickup needs `runtime.run(signal)`
|
|
32
|
+
([#22](https://github.com/cardmagic/solid-objects-js/issues/22)). The
|
|
33
|
+
README's programming-model example works without it because the caller's
|
|
34
|
+
own path executes the call, and nothing on that page said that a process
|
|
35
|
+
which installs and then waits claims nothing. An external prober built a
|
|
36
|
+
two-process harness from the README and read the unclaimed messages as
|
|
37
|
+
stranded. The README and `docs/operations.md` now state it, and
|
|
38
|
+
`test/background-pickup.test.ts` pins it: a sent message reads `ready`
|
|
39
|
+
after `install()`, and `completed` once `run(signal)` starts the roles.
|
|
40
|
+
- Build the same example with `configure()` and address the actor as
|
|
41
|
+
`Cart.ref("cart-123")`, matching every other reference example in the
|
|
42
|
+
documentation. `createRuntime()` deliberately leaves the process default
|
|
43
|
+
unset, so the static form needs `configure()`.
|
|
44
|
+
- Add `examples/at-least-once` and `pnpm run test:at-least-once`
|
|
45
|
+
([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an
|
|
46
|
+
executable proof that the at-least-once clause fires and that the
|
|
47
|
+
documented remedy absorbs it. An effect worker crashes between the
|
|
48
|
+
external sink write and the acknowledgement; after restart the sink
|
|
49
|
+
reads 2 with deduplication off, and 1 when a guard on the stable
|
|
50
|
+
effect id is in place. The state commit happens exactly once in both
|
|
51
|
+
runs, and both deliveries carry the same effect id. CI runs the demo
|
|
52
|
+
alongside the recovery demo.
|
|
53
|
+
|
|
3
54
|
## 0.14.1 - 2026-08-23
|
|
4
55
|
|
|
5
56
|
- Add `solid-objects/signals`, live signals on actor references
|
package/README.md
CHANGED
|
@@ -32,29 +32,69 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser).
|
|
|
32
32
|
> data.
|
|
33
33
|
|
|
34
34
|
> **Not a replacement for SQL transactions:** when one row update inside one
|
|
35
|
-
> transaction solves the problem, use that. Solid Objects
|
|
36
|
-
>
|
|
37
|
-
>
|
|
35
|
+
> transaction solves the problem, use that and install nothing. Solid Objects
|
|
36
|
+
> earns its cost when the critical section outlives the transaction: a hold that
|
|
37
|
+
> expires in ten minutes, work that must survive a restart, or a fan-in that
|
|
38
|
+
> spans many jobs. See
|
|
39
|
+
> [Why not just use transactions?](#why-not-just-use-transactions).
|
|
40
|
+
|
|
41
|
+
## Contents
|
|
42
|
+
|
|
43
|
+
- [The programming model](#the-programming-model)
|
|
44
|
+
- [Why not just use transactions?](#why-not-just-use-transactions)
|
|
45
|
+
- [Is it worth installing here?](#is-it-worth-installing-here)
|
|
46
|
+
- [Run it now with SQLite](#run-it-now-with-sqlite)
|
|
47
|
+
- [What Solid Objects is for](#what-solid-objects-is-for)
|
|
48
|
+
- [Measured behavior](#measured-behavior)
|
|
49
|
+
- [Running in a deployed application](#running-in-a-deployed-application)
|
|
50
|
+
- [How it works](#how-it-works)
|
|
51
|
+
- [Delivery boundaries](#delivery-boundaries)
|
|
52
|
+
- [Realtime committed state](#realtime-committed-state)
|
|
53
|
+
- [Solid Objects in the browser](#solid-objects-in-the-browser)
|
|
54
|
+
- [Comparison](#comparison)
|
|
55
|
+
- [Requirements and supported systems](#requirements-and-supported-systems)
|
|
56
|
+
- [Operations](#operations)
|
|
57
|
+
- [Design provenance](#design-provenance)
|
|
58
|
+
- [Documentation](#documentation)
|
|
59
|
+
- [License](#license)
|
|
38
60
|
|
|
39
61
|
## The programming model
|
|
40
62
|
|
|
63
|
+
A ticket sale for one event, with 100 seats and a hold that expires:
|
|
64
|
+
|
|
41
65
|
```typescript
|
|
42
|
-
import { Actor,
|
|
66
|
+
import { Actor, broadcastValue, configure } from "solid-objects"
|
|
43
67
|
import { sqlite } from "solid-objects/database/sqlite"
|
|
44
68
|
|
|
45
|
-
class
|
|
46
|
-
static override readonly actorType = "
|
|
69
|
+
class TicketSale extends Actor {
|
|
70
|
+
static override readonly actorType = "TicketSale"
|
|
71
|
+
|
|
72
|
+
remaining = 100
|
|
73
|
+
holds: Record<string, number> = {}
|
|
74
|
+
|
|
75
|
+
override observables(): Record<string, unknown> {
|
|
76
|
+
return { remaining: broadcastValue(this.remaining) }
|
|
77
|
+
}
|
|
47
78
|
|
|
48
|
-
|
|
79
|
+
reserve({ buyer }: { buyer: string }): boolean {
|
|
80
|
+
if (this.remaining === 0 || buyer in this.holds) return false
|
|
81
|
+
this.remaining -= 1
|
|
82
|
+
this.holds = { ...this.holds, [buyer]: Date.now() }
|
|
83
|
+
this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer })
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
49
86
|
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
87
|
+
expire({ buyer }: { buyer: string }): void {
|
|
88
|
+
if (!(buyer in this.holds)) return
|
|
89
|
+
const rest = { ...this.holds }
|
|
90
|
+
delete rest[buyer]
|
|
91
|
+
this.holds = rest
|
|
92
|
+
this.remaining += 1
|
|
53
93
|
}
|
|
54
94
|
}
|
|
55
95
|
|
|
56
|
-
const runtime =
|
|
57
|
-
database: sqlite({ path: "
|
|
96
|
+
const runtime = configure({
|
|
97
|
+
database: sqlite({ path: "tickets.sqlite3" }),
|
|
58
98
|
authorizeMessage: () => true,
|
|
59
99
|
authorizeQuery: () => true,
|
|
60
100
|
})
|
|
@@ -62,39 +102,111 @@ const runtime = createRuntime({
|
|
|
62
102
|
await runtime.install()
|
|
63
103
|
|
|
64
104
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
105
|
+
const sale = TicketSale.ref("event-42")
|
|
106
|
+
const buyers = ["ada", "grace", "alan"]
|
|
107
|
+
await Promise.all(buyers.map((buyer) => sale.reserve({ buyer })))
|
|
67
108
|
} finally {
|
|
68
109
|
await runtime.close()
|
|
69
110
|
}
|
|
70
111
|
```
|
|
71
112
|
|
|
72
|
-
|
|
73
|
-
commit one state transition at a time, even when different requests or
|
|
74
|
-
processes submit them concurrently
|
|
113
|
+
Every reserve enters the durable mailbox for `event-42`. They execute in order
|
|
114
|
+
and commit one state transition at a time, even when different requests or
|
|
115
|
+
Node.js processes submit them concurrently, so the guard on `remaining` cannot
|
|
116
|
+
oversell.
|
|
117
|
+
|
|
118
|
+
That example wants three things from the same number. It must never go below
|
|
119
|
+
zero. It must give the seat back if the buyer does not pay within ten minutes.
|
|
120
|
+
It must show the current count to everyone watching the page.
|
|
121
|
+
|
|
122
|
+
The first is one UPDATE statement. The second is an `expiresAt` column plus a
|
|
123
|
+
sweeper. The third is a push on every code path that changes the number. The
|
|
124
|
+
combination is what costs, not any one of them. Here the guard, the ten-minute
|
|
125
|
+
alarm, and the published count are one class, and they commit together.
|
|
126
|
+
|
|
127
|
+
`install()` prepares the database and starts nothing. The example above finishes
|
|
128
|
+
because the caller's own path executes each call. A process serves background
|
|
129
|
+
work only after `runtime.run(signal)` starts its roles, so a process that
|
|
130
|
+
installs and then waits never claims a ready message. Nothing is lost while no
|
|
131
|
+
process runs. The message stays ready until one does.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
process.on("SIGTERM", () => controller.abort())
|
|
136
|
+
await runtime.run(controller.signal)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Why not just use transactions?
|
|
140
|
+
|
|
141
|
+
Often you should. If the whole job is read a row, decide, write it back, and
|
|
142
|
+
answer the request, then a transaction with `SELECT ... FOR UPDATE` does that
|
|
143
|
+
and you need nothing else installed. In the browser, `navigator.locks` is the
|
|
144
|
+
same answer. Reach for those first.
|
|
145
|
+
|
|
146
|
+
The argument for an actor is scope, not discipline. A lock is scoped to one
|
|
147
|
+
transaction, on one connection, in one process. The ticket sale above leaves
|
|
148
|
+
that scope on one line: the hold expires in ten minutes, and no transaction
|
|
149
|
+
stays open for ten minutes. A `setTimeout` does not cover it either, because it
|
|
150
|
+
dies with the process.
|
|
151
|
+
|
|
152
|
+
Any column named `expiresAt`, `scheduledAt`, or `nextRunAt` is evidence that
|
|
153
|
+
the critical section already outlived the lock that was supposed to cover it.
|
|
154
|
+
What follows such a column is a sweeper that looks for due rows, and then a
|
|
155
|
+
race between that sweeper and the next writer of the same row. The column, the
|
|
156
|
+
sweeper, and the race are what a Solid Objects actor replaces.
|
|
157
|
+
|
|
158
|
+
Three cases a lock cannot reach:
|
|
159
|
+
|
|
160
|
+
- work that fires at a future moment, when no transaction of yours is open;
|
|
161
|
+
- work that must survive a process restart, which rules out an in-process
|
|
162
|
+
timer; and
|
|
163
|
+
- a fan-in whose critical section spans many jobs over minutes, such as an
|
|
164
|
+
import that counts its own chunks as each one finishes.
|
|
165
|
+
|
|
166
|
+
If it all happens inside one request, use a lock. If something has to happen
|
|
167
|
+
later, or has to survive a restart, that is when this is worth installing.
|
|
168
|
+
|
|
169
|
+
## Is it worth installing here?
|
|
170
|
+
|
|
171
|
+
Worth it when several requests, jobs, or processes act on the same cart, room,
|
|
172
|
+
device, event, or session, and each next action needs the last committed state.
|
|
173
|
+
Worth it when that same thing also owns work that fires later, or a number a
|
|
174
|
+
live page must show: per-document reminders and realtime projections of
|
|
175
|
+
committed state are the same argument.
|
|
176
|
+
|
|
177
|
+
Not worth it for a plain counter, a single-row update inside one transaction, a
|
|
178
|
+
stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a
|
|
179
|
+
large JSON document that belongs in normalized rows, globally placed edge
|
|
180
|
+
state, or a global rate-limit counter that every request touches. One hot
|
|
181
|
+
identity is serialized on purpose, so making everything one identity makes a
|
|
182
|
+
queue. Split an identity only when the domain can tolerate independent
|
|
183
|
+
ordering and transactions.
|
|
184
|
+
|
|
185
|
+
High-QPS reads and hot identities are where this runtime stops being the right
|
|
186
|
+
tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial
|
|
187
|
+
performance layer for the family that adds grouped commits, which coalesce
|
|
188
|
+
concurrent writes into fewer database commits; optional ephemeral operations,
|
|
189
|
+
which take loss-tolerant calls out of the durable journal; and materialized
|
|
190
|
+
projections, which build read models after commit so reads stop competing with
|
|
191
|
+
mailbox work. It ships for the Rails gem today, and the Node build is in
|
|
192
|
+
development.
|
|
193
|
+
|
|
194
|
+
[What Solid Objects is for](#what-solid-objects-is-for) has the pattern table,
|
|
195
|
+
and [Choosing Solid Objects](docs/fit.md) is the longer guide.
|
|
75
196
|
|
|
76
197
|
## Run it now with SQLite
|
|
77
198
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
package includes a quickstart:
|
|
199
|
+
The published package includes a quickstart that needs no checkout, database
|
|
200
|
+
server, container, or configuration:
|
|
81
201
|
|
|
82
202
|
```bash
|
|
83
203
|
npm exec --yes --package=solid-objects@latest -- solid-objects quickstart
|
|
84
204
|
```
|
|
85
205
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
It states its plan first, prints the `Counter` class it runs, and asks for
|
|
91
|
-
permission. It executes the work only after you answer, and then it explains
|
|
92
|
-
what each result proves. It asks nothing when stdin is not a terminal, so CI
|
|
93
|
-
never waits. Add `--yes` to skip the question in a terminal, or `--json` for a
|
|
94
|
-
machine-readable summary.
|
|
95
|
-
|
|
96
|
-
The executable asserts rather than merely printing a plausible result. It exits
|
|
97
|
-
with a non-zero code when one of those checks fails.
|
|
206
|
+
It states its plan, prints the `Counter` class it runs, and asks before doing
|
|
207
|
+
anything. It asserts rather than printing a plausible result, and exits
|
|
208
|
+
non-zero when a check fails. Add `--yes` to skip the question, or `--json` for
|
|
209
|
+
a machine-readable summary.
|
|
98
210
|
|
|
99
211
|
## What Solid Objects is for
|
|
100
212
|
|
|
@@ -121,38 +233,25 @@ prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
|
|
|
121
233
|
|
|
122
234
|
## Measured behavior
|
|
123
235
|
|
|
124
|
-
One developer machine, not a capacity promise
|
|
125
|
-
|
|
126
|
-
17.11 and MySQL 9.7.1 run natively, not in a container.
|
|
236
|
+
One developer machine, not a capacity promise: Apple M5, Node.js 24.18.0, 250
|
|
237
|
+
operations at client concurrency 16, on August 22, 2026.
|
|
127
238
|
|
|
128
239
|
| Measurement | Result |
|
|
129
240
|
| ------------------------------------------------------------- | ---------------: |
|
|
130
241
|
| Committed operations per second, one hot identity, SQLite | 286 to 323 ops/s |
|
|
131
242
|
| The same identity across four processes, SQLite | 507 to 519 ops/s |
|
|
132
|
-
| The same identity across four processes, PostgreSQL | 266 to 331 ops/s |
|
|
133
|
-
| The same identity across four processes, MySQL | 214 to 228 ops/s |
|
|
134
243
|
| Idle wake-up to committed result, one process | 2.66 ms p50 |
|
|
135
244
|
| Idle wake-up to committed result, two processes, polling only | 1,006 ms p50 |
|
|
136
|
-
| Idle CPU per process, 100 ms fast interval | 0.121% |
|
|
137
|
-
| Idle database passes per second, after backoff | 4.0 |
|
|
138
245
|
|
|
139
|
-
|
|
246
|
+
Calls to one identity are serialized on purpose, so per-call latency includes
|
|
247
|
+
the wait behind the other fifteen callers. That last row is the tradeoff to
|
|
248
|
+
know before deploying: use PostgreSQL notifications or the optional Redis
|
|
249
|
+
Pub/Sub when separate processes need low-latency delivery. The same databases
|
|
250
|
+
in Docker Desktop reached 1.8x to 4.9x less throughput.
|
|
140
251
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
the honest number for that case.
|
|
145
|
-
|
|
146
|
-
The same PostgreSQL and MySQL versions in Docker Desktop reached 1.8x to 4.9x
|
|
147
|
-
less throughput on those rows. Measure your own deployment shape before you
|
|
148
|
-
plan capacity.
|
|
149
|
-
|
|
150
|
-
The polling-only row is the tradeoff to know before you deploy: use PostgreSQL
|
|
151
|
-
notifications or the optional Redis Pub/Sub when separate processes need
|
|
152
|
-
low-latency delivery.
|
|
153
|
-
|
|
154
|
-
Conditions, sources of bias, and the complete matrix for all three databases
|
|
155
|
-
are in [Benchmarks](docs/benchmarks.md).
|
|
252
|
+
PostgreSQL and MySQL numbers, idle CPU, sources of bias, and the full matrix
|
|
253
|
+
are in [Benchmarks](docs/benchmarks.md). Measure your own deployment shape
|
|
254
|
+
before planning capacity.
|
|
156
255
|
|
|
157
256
|
## Running in a deployed application
|
|
158
257
|
|
|
@@ -191,74 +290,59 @@ with your own workload.
|
|
|
191
290
|
|
|
192
291
|
## How it works
|
|
193
292
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
1. commits calls to a durable per-ID mailbox;
|
|
201
|
-
2. claims one activation with a renewable lease;
|
|
202
|
-
3. executes one operation at a time outside the database transaction;
|
|
203
|
-
4. commits state, completion, and staged work in a short fenced transaction;
|
|
204
|
-
5. retries recoverable failures and exposes terminal failures as dead letters;
|
|
205
|
-
6. publishes committed realtime invalidations in revision order.
|
|
293
|
+
An actor is addressed by its class and an application-chosen ID. Fields are
|
|
294
|
+
durable state, methods are operations, and getters are queries. One operation
|
|
295
|
+
is one turn: the runtime claims the actor under a fenced lease, runs your
|
|
296
|
+
JavaScript outside the transaction, then commits state, results, staged
|
|
297
|
+
effects, reminders, and realtime invalidations together. A failure retries with
|
|
298
|
+
backoff and dead-letters at the limit.
|
|
206
299
|
|
|
207
300
|
The fence includes the activation owner, token, generation, expiration, and
|
|
208
|
-
claimed message
|
|
209
|
-
cannot commit.
|
|
210
|
-
and the
|
|
211
|
-
|
|
212
|
-
Redis is optional wake-up infrastructure. It can reduce notification latency in
|
|
213
|
-
a multi-process MySQL deployment. The relational database stays the durable
|
|
214
|
-
source of truth, and polling stays the recovery path.
|
|
215
|
-
|
|
216
|
-
Idle roles back off from the configured 100 ms fast polling interval to one
|
|
217
|
-
second. Processed work and wake-up notifications reset that interval
|
|
218
|
-
immediately. The default wake-up reaches only the current Node process; use the
|
|
219
|
-
PostgreSQL or optional Redis adapter when separate processes need low-latency
|
|
220
|
-
delivery. The runtime warns once when it sees that topology without an adapter.
|
|
221
|
-
|
|
222
|
-
## Good and poor fits
|
|
301
|
+
claimed message, so a worker that finishes its JavaScript after losing the
|
|
302
|
+
lease cannot commit. The database is the source of truth. Redis is optional
|
|
303
|
+
acceleration, and polling remains the recovery path.
|
|
223
304
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
| Per-document or per-device reminders | Large JSON documents that should remain normalized rows |
|
|
230
|
-
| Realtime projections of committed state | Globally placed edge state or managed elastic placement |
|
|
231
|
-
|
|
232
|
-
One hot identity is intentionally serialized. Split an identity only when the
|
|
233
|
-
domain can tolerate independent ordering and transactions. Solid Objects does
|
|
234
|
-
not provide a transaction across object identities.
|
|
235
|
-
|
|
236
|
-
The longer decision guide is in [Choosing Solid Objects](docs/fit.md).
|
|
305
|
+
[`examples/failure-recovery/demo.ts`](examples/failure-recovery/demo.ts) kills a
|
|
306
|
+
worker mid-turn and shows the survivor finish it.
|
|
307
|
+
[Architecture](docs/architecture.md) and
|
|
308
|
+
[Operations](docs/operations.md) cover the turn lifecycle, polling backoff, and
|
|
309
|
+
wake-up adapters.
|
|
237
310
|
|
|
238
311
|
## Delivery boundaries
|
|
239
312
|
|
|
240
313
|
- Operations are ordered per identity and execute **at least once**.
|
|
241
|
-
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
-
|
|
245
|
-
request, email, payment, file write, or other external side effect.
|
|
246
|
-
- Different identities can execute concurrently; one hot identity cannot.
|
|
247
|
-
- State, result, actor-to-actor delivery, reminders, effects, commit actions,
|
|
248
|
-
and realtime invalidations commit together for one operation.
|
|
314
|
+
- Fencing protects the database commit. It cannot undo an HTTP request, email,
|
|
315
|
+
payment, or file write, so external systems need the stable effect ID or
|
|
316
|
+
another durable idempotency key.
|
|
317
|
+
- Different identities run concurrently; one hot identity cannot.
|
|
249
318
|
- Cross-object transactions are not provided.
|
|
250
|
-
-
|
|
251
|
-
|
|
252
|
-
- Direct application-database writes are guarded only when the application
|
|
253
|
-
uses the supplied database facade. Unwrapped clients cannot be intercepted.
|
|
254
|
-
- Realtime sessions are process-local. A multi-process application must bridge
|
|
255
|
-
committed broadcast events to the processes holding live connections.
|
|
319
|
+
- Realtime sessions are process-local, so a multi-process application must
|
|
320
|
+
bridge committed broadcast events to the processes holding live connections.
|
|
256
321
|
|
|
257
|
-
|
|
258
|
-
|
|
322
|
+
[Correctness and delivery semantics](docs/correctness.md) has the complete
|
|
323
|
+
contract, including `stateVersion` compatibility and the database write guard,
|
|
324
|
+
and [Errors and recovery](docs/errors-and-recovery.md) covers failure handling.
|
|
259
325
|
|
|
260
326
|
## Realtime committed state
|
|
261
327
|
|
|
328
|
+
For a like count or a dashboard number, write the row and then send on your own
|
|
329
|
+
socket. That is less code than this library and it works.
|
|
330
|
+
|
|
331
|
+
It gets harder when several people write to the same record at once. Each
|
|
332
|
+
request builds its payload in its own process and sends it. The lock decided
|
|
333
|
+
who wrote first, but it has no say over which of the two sends arrives last, so
|
|
334
|
+
a viewer can be left looking at the older number. The second gap is that the
|
|
335
|
+
send is not part of the write: if the process dies after the database commits
|
|
336
|
+
and before the send goes out, the tab keeps a wrong number and nothing corrects
|
|
337
|
+
it.
|
|
338
|
+
|
|
339
|
+
An observable is the alternative. The change and its publication commit
|
|
340
|
+
together, so no crash can leave one without the other. A worker delivers the
|
|
341
|
+
publication afterwards, claiming rows in actor revision order, and subscribers
|
|
342
|
+
reject a duplicate or stale revision. Delivery is still at least once, so the
|
|
343
|
+
guarantee is that a subscriber cannot end up on an older value, not that a
|
|
344
|
+
value is sent exactly once.
|
|
345
|
+
|
|
262
346
|
Actors opt into browser-visible dependencies. In `0.13`, an unwrapped
|
|
263
347
|
observable triggers invalidation without storing or sending its value. Use
|
|
264
348
|
`broadcastValue()` only for a scalar that every authorized subscriber may see:
|
|
@@ -321,33 +405,27 @@ await runtime.install()
|
|
|
321
405
|
await Counter.ref("page-hits").increment()
|
|
322
406
|
```
|
|
323
407
|
|
|
324
|
-
That code runs identically in every tab. `sharedSqliteWasm` elects one
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
single dedicated worker.
|
|
329
|
-
|
|
330
|
-
Two companions complete the local-first story:
|
|
408
|
+
That code runs identically in every tab. `sharedSqliteWasm` elects one database
|
|
409
|
+
holder per origin through the Web Locks API, carries the other tabs' SQL to it
|
|
410
|
+
over a `BroadcastChannel`, and fails over onto the same durable state when the
|
|
411
|
+
holder's tab dies. Use `sqliteWasm` for a single dedicated worker.
|
|
331
412
|
|
|
332
|
-
- `solid-objects/browser/tab-host`
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
server with at-least-once delivery, per-actor order, and an idempotent
|
|
338
|
-
server ingest, so offline writes reconcile when the network returns.
|
|
413
|
+
Two companions complete the local-first story: `solid-objects/browser/tab-host`
|
|
414
|
+
runs one runtime for all tabs when the application prefers request-level
|
|
415
|
+
routing, and `solid-objects/transmit` drains the transactional effects outbox
|
|
416
|
+
to a server with at-least-once delivery, per-actor order, and an idempotent
|
|
417
|
+
server ingest, so offline writes reconcile when the network returns.
|
|
339
418
|
|
|
340
419
|
### The backend can be Rails, not only Node
|
|
341
420
|
|
|
342
|
-
The browser runtime does not require a Node server behind it. The transmit
|
|
343
|
-
|
|
421
|
+
The browser runtime does not require a Node server behind it. The transmit wire
|
|
422
|
+
contract is shared with the Ruby gem
|
|
344
423
|
([solid-objects-ruby](https://github.com/cardmagic/solid-objects-ruby)):
|
|
345
424
|
`SolidObjects::Transmission.receive` accepts the same envelopes as the Node
|
|
346
|
-
ingest `receiveTransmitEnvelope`, dedups on the same `transmit:<effectId>`
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
actors — no Node service in between:
|
|
425
|
+
ingest `receiveTransmitEnvelope`, dedups on the same `transmit:<effectId>` key,
|
|
426
|
+
and both repositories pin the contract with one shared fixture file. A browser
|
|
427
|
+
front end therefore replays its offline writes directly onto Ruby server
|
|
428
|
+
actors, with no Node service in between:
|
|
351
429
|
|
|
352
430
|
```ruby
|
|
353
431
|
class TransmitController < ApplicationController
|
|
@@ -371,65 +449,51 @@ The wire shapes are documented in the
|
|
|
371
449
|
|
|
372
450
|
## Comparison
|
|
373
451
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
daemon, and keeps object state in the SQL database the application already
|
|
395
|
-
operates. Choose celld to run Workers-format code across a fleet with
|
|
396
|
-
bucket-based placement. Choose Solid Objects to keep one database, no extra
|
|
397
|
-
process, and an ordinary Node deployment.
|
|
398
|
-
|
|
399
|
-
[docs/comparisons.md](docs/comparisons.md) holds the sourced comparison for each
|
|
400
|
-
dimension: realtime projections, edge placement, cross-identity transactions,
|
|
401
|
-
and operational data access.
|
|
452
|
+
A SQL transaction or row lock serializes selected rows for one transaction and
|
|
453
|
+
needs nothing installed. A job queue retries a job but leaves ordering to queue
|
|
454
|
+
configuration. Solid Objects serializes by object ID, keeps state in the
|
|
455
|
+
database you already run, and adds a library rather than a service. Cloudflare
|
|
456
|
+
Durable Objects, Rivet, DBOS, and Restate each add a managed runtime or server.
|
|
457
|
+
|
|
458
|
+
celld and Solid Objects both self-host the Durable Objects model, and the
|
|
459
|
+
difference is what you run. celld runs a daemon that embeds V8 and executes
|
|
460
|
+
Wrangler bundles, gives each object its own SQLite database, replicates that
|
|
461
|
+
database to an object-storage bucket you own, and moves ownership between nodes
|
|
462
|
+
through compare-and-swap on that bucket. Solid Objects runs plain TypeScript
|
|
463
|
+
classes inside your Node processes, adds no daemon, and keeps object state in
|
|
464
|
+
the SQL database the application already operates. Choose celld to run
|
|
465
|
+
Workers-format code across a fleet with bucket-based placement. Choose Solid
|
|
466
|
+
Objects to keep one database, no extra process, and an ordinary Node
|
|
467
|
+
deployment.
|
|
468
|
+
|
|
469
|
+
[docs/comparisons.md](docs/comparisons.md) has the full table across eight
|
|
470
|
+
systems, with primary sources for every dimension: realtime projections, edge
|
|
471
|
+
placement, cross-identity transactions, and operational data access.
|
|
402
472
|
|
|
403
473
|
## Requirements and supported systems
|
|
404
474
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
with InnoDB
|
|
410
|
-
- optional `pg`, `mysql2`, `redis`, or `@sqlite.org/sqlite-wasm` peer
|
|
411
|
-
dependency only for the selected adapter
|
|
412
|
-
- for the browser runtime: a browser with OPFS for persistent storage and the
|
|
413
|
-
Web Locks API for the multi-tab host
|
|
475
|
+
Node.js 24.4.0 or newer, TypeScript 5.9 or newer, and SQLite through
|
|
476
|
+
`node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer on InnoDB.
|
|
477
|
+
`pg`, `mysql2`, `ioredis`, and `@sqlite.org/sqlite-wasm` are optional peer
|
|
478
|
+
dependencies, installed only for the adapters you use.
|
|
414
479
|
|
|
415
|
-
[Supported versions](docs/support.md) records the exact CI matrix
|
|
416
|
-
boundaries.
|
|
480
|
+
[Supported versions](docs/support.md) records the exact CI matrix, the browser
|
|
481
|
+
requirements for OPFS and the Web Locks API, and the boundaries.
|
|
417
482
|
|
|
418
483
|
## Operations
|
|
419
484
|
|
|
420
|
-
`runtime.run(signal)` supervises actor, effect, reminder, broadcast,
|
|
421
|
-
and stale-process recovery roles.
|
|
422
|
-
|
|
423
|
-
session-backed CSRF protection
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
mode removes the mutations. Use the explicitly public read-only mode only for
|
|
427
|
-
synthetic demo data, because it exposes stored arguments, results, errors,
|
|
428
|
-
identifiers, and operational metadata.
|
|
485
|
+
`runtime.run(signal)` supervises the actor, effect, reminder, broadcast,
|
|
486
|
+
retention, and stale-process recovery roles. An optional `solid-objects/web`
|
|
487
|
+
export mounts a database-backed operator dashboard behind a deny-by-default
|
|
488
|
+
administration policy, with session-backed CSRF protection and Fetch or
|
|
489
|
+
Node/Connect mounting. Administration is also available through the JSON CLI
|
|
490
|
+
and typed runtime managers.
|
|
429
491
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
and [
|
|
492
|
+
The dashboard has an explicitly public read-only mode. Use it only for
|
|
493
|
+
synthetic demo data: it exposes stored arguments, results, errors, identifiers,
|
|
494
|
+
and operational metadata. See [Operations](docs/operations.md), the
|
|
495
|
+
[dashboard guide](docs/dashboard.md), and
|
|
496
|
+
[Configuration](docs/configuration.md).
|
|
433
497
|
|
|
434
498
|
## Design provenance
|
|
435
499
|
|
|
@@ -441,8 +505,8 @@ twelve earlier JavaScript release generations.
|
|
|
441
505
|
|
|
442
506
|
The TypeScript implementation is not a source translation. It redesigned the
|
|
443
507
|
API around inferred TypeScript references, Node runtime supervision,
|
|
444
|
-
`node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions,
|
|
445
|
-
|
|
508
|
+
`node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions, a
|
|
509
|
+
framework-neutral component registry, and browser-safe package exports. The
|
|
446
510
|
[parity ledger](docs/parity.md) records capability relationships and deliberate
|
|
447
511
|
runtime differences.
|
|
448
512
|
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.14.
|
|
1
|
+
export declare const VERSION = "0.14.3";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "0.14.
|
|
1
|
+
export const VERSION = "0.14.3";
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/docs/correctness.md
CHANGED
|
@@ -58,7 +58,12 @@
|
|
|
58
58
|
|
|
59
59
|
- At-least-once execution means actor code may begin more than once. State and
|
|
60
60
|
staged intents from a failed turn roll back, but arbitrary external work does
|
|
61
|
-
not. External systems need stable idempotency keys.
|
|
61
|
+
not. External systems need stable idempotency keys. This clause is
|
|
62
|
+
observable, not decorative: `pnpm run test:at-least-once` crashes an
|
|
63
|
+
effect worker between the sink write and the acknowledgement, restarts
|
|
64
|
+
it, and shows the sink reading 2 with deduplication off — then shows a
|
|
65
|
+
guard on the stable effect id absorbing the same duplicate, with the
|
|
66
|
+
sink reading 1. The state commit happens exactly once in both runs.
|
|
62
67
|
- The activation fence protects the Solid Objects commit. It cannot revoke or
|
|
63
68
|
undo network calls, files, emails, payments, or other external effects.
|
|
64
69
|
- One identity processes one write operation at a time. This is the ordering
|
package/docs/operations.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Operations
|
|
2
2
|
|
|
3
|
+
`install()` prepares the database and starts nothing. A process serves
|
|
4
|
+
background work only after `runtime.run(signal)` starts its roles. A process
|
|
5
|
+
that registers actors, installs, and then waits never claims a ready message,
|
|
6
|
+
and work enqueued with `send` stays ready until some process runs the roles.
|
|
7
|
+
A direct call or an explicit `sync` needs no running role, because the caller's
|
|
8
|
+
own path executes it.
|
|
9
|
+
|
|
3
10
|
Runtime roles use durable polling as the correctness fallback. Consecutive
|
|
4
11
|
empty passes double each role's wait from `pollingIntervalMilliseconds` to
|
|
5
12
|
`idlePollingIntervalMilliseconds`, which defaults to one second. Processed
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Actor } from "solid-objects"
|
|
2
|
+
|
|
3
|
+
export class DeliveryCounter extends Actor {
|
|
4
|
+
static override readonly actorType = "DeliveryCounter"
|
|
5
|
+
|
|
6
|
+
count = 0
|
|
7
|
+
|
|
8
|
+
deliver(): number {
|
|
9
|
+
this.count += 1
|
|
10
|
+
this.emit("record", { arguments: {} })
|
|
11
|
+
return this.count
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { fork, type ChildProcess } from "node:child_process"
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
import { createRuntime } from "solid-objects"
|
|
8
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
9
|
+
import { DeliveryCounter } from "./actor.ts"
|
|
10
|
+
import { readSink } from "./sink.ts"
|
|
11
|
+
|
|
12
|
+
const directory = await mkdtemp(join(tmpdir(), "solid-objects-at-least-once-"))
|
|
13
|
+
const databasePath = join(directory, "state.sqlite3")
|
|
14
|
+
const runtime = createRuntime({
|
|
15
|
+
database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
|
|
16
|
+
leaseDurationMilliseconds: 250,
|
|
17
|
+
leaseRenewalIntervalMilliseconds: 50,
|
|
18
|
+
processHeartbeatIntervalMilliseconds: 75,
|
|
19
|
+
processAliveThresholdMilliseconds: 300,
|
|
20
|
+
workerCount: 1,
|
|
21
|
+
effectWorkerCount: 0,
|
|
22
|
+
reminderSchedulerCount: 0,
|
|
23
|
+
retentionIntervalMilliseconds: 0,
|
|
24
|
+
deadProcessCleanupIntervalMilliseconds: 0,
|
|
25
|
+
authorizeMessage: () => true,
|
|
26
|
+
authorizeQuery: () => true,
|
|
27
|
+
authorizeAdministration: () => true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
runtime.register(DeliveryCounter)
|
|
32
|
+
await runtime.install()
|
|
33
|
+
const duplicate = await proveDuplicateAtSink()
|
|
34
|
+
const remedy = await proveDeduplicationAbsorbsIt()
|
|
35
|
+
process.stdout.write(`${JSON.stringify({ duplicate, remedy }, null, 2)}\n`)
|
|
36
|
+
} finally {
|
|
37
|
+
await runtime.close()
|
|
38
|
+
await rm(directory, { recursive: true })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function proveDuplicateAtSink(): Promise<{
|
|
42
|
+
stateCommits: number
|
|
43
|
+
sinkDeliveries: number
|
|
44
|
+
sameEffectId: boolean
|
|
45
|
+
attempts: number[]
|
|
46
|
+
}> {
|
|
47
|
+
const sinkPath = join(directory, "sink-dedup-off.json")
|
|
48
|
+
await stageOneDelivery("dedup-off")
|
|
49
|
+
await crashThenRecover({ sinkPath, deduplicate: "off" })
|
|
50
|
+
|
|
51
|
+
const sink = await readSink(sinkPath)
|
|
52
|
+
const snapshot = await runtime.ref(DeliveryCounter, "dedup-off").snapshot()
|
|
53
|
+
assert.equal(snapshot.count, 1, "the state commit happened exactly once")
|
|
54
|
+
assert.equal(sink.deliveries.length, 2, "the sink observed the duplicate")
|
|
55
|
+
assert.equal(
|
|
56
|
+
sink.deliveries[0]?.effectId,
|
|
57
|
+
sink.deliveries[1]?.effectId,
|
|
58
|
+
"both deliveries carried the same stable effect id",
|
|
59
|
+
)
|
|
60
|
+
return {
|
|
61
|
+
stateCommits: snapshot.count,
|
|
62
|
+
sinkDeliveries: sink.deliveries.length,
|
|
63
|
+
sameEffectId: sink.deliveries[0]?.effectId === sink.deliveries[1]?.effectId,
|
|
64
|
+
attempts: sink.deliveries.map((delivery) => delivery.attempt),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function proveDeduplicationAbsorbsIt(): Promise<{
|
|
69
|
+
stateCommits: number
|
|
70
|
+
sinkDeliveries: number
|
|
71
|
+
}> {
|
|
72
|
+
const sinkPath = join(directory, "sink-dedup-on.json")
|
|
73
|
+
await stageOneDelivery("dedup-on")
|
|
74
|
+
await crashThenRecover({ sinkPath, deduplicate: "on" })
|
|
75
|
+
|
|
76
|
+
const sink = await readSink(sinkPath)
|
|
77
|
+
const snapshot = await runtime.ref(DeliveryCounter, "dedup-on").snapshot()
|
|
78
|
+
assert.equal(snapshot.count, 1, "the state commit happened exactly once")
|
|
79
|
+
assert.equal(sink.deliveries.length, 1, "the stable effect id absorbed the duplicate")
|
|
80
|
+
return { stateCommits: snapshot.count, sinkDeliveries: sink.deliveries.length }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function stageOneDelivery(actorId: string): Promise<void> {
|
|
84
|
+
const message = await runtime.ref(DeliveryCounter, actorId).send.deliver()
|
|
85
|
+
const worker = runtime.worker()
|
|
86
|
+
try {
|
|
87
|
+
let processed = 0
|
|
88
|
+
for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
|
|
89
|
+
processed = await worker.runOnce({ activationRetention: "release" })
|
|
90
|
+
if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
91
|
+
}
|
|
92
|
+
assert.equal(processed, 1, "the actor turn committed and staged the effect")
|
|
93
|
+
} finally {
|
|
94
|
+
await worker.stop()
|
|
95
|
+
}
|
|
96
|
+
assert.equal(await message.status(), "completed")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function crashThenRecover(options: {
|
|
100
|
+
sinkPath: string
|
|
101
|
+
deduplicate: "on" | "off"
|
|
102
|
+
}): Promise<void> {
|
|
103
|
+
const crashing = spawnEffectWorker({ ...options, mode: "crash" })
|
|
104
|
+
const crashExit = await crashing.finished
|
|
105
|
+
assert.equal(crashExit, 1, "the first delivery crashed before acknowledgement")
|
|
106
|
+
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
108
|
+
|
|
109
|
+
const recovering = spawnEffectWorker({ ...options, mode: "complete" })
|
|
110
|
+
const recoveryExit = await recovering.finished
|
|
111
|
+
assert.equal(recoveryExit, 0, "the second delivery completed and acknowledged")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function spawnEffectWorker(options: {
|
|
115
|
+
sinkPath: string
|
|
116
|
+
deduplicate: "on" | "off"
|
|
117
|
+
mode: "crash" | "complete"
|
|
118
|
+
}): { child: ChildProcess; finished: Promise<number | null> } {
|
|
119
|
+
const child = fork(
|
|
120
|
+
fileURLToPath(new URL("./effect-worker.ts", import.meta.url)),
|
|
121
|
+
[databasePath, options.sinkPath, options.mode, options.deduplicate],
|
|
122
|
+
{ stdio: ["ignore", "inherit", "inherit", "ipc"] },
|
|
123
|
+
)
|
|
124
|
+
const finished = new Promise<number | null>((resolve, reject) => {
|
|
125
|
+
child.once("exit", (code) => resolve(code))
|
|
126
|
+
child.once("error", reject)
|
|
127
|
+
})
|
|
128
|
+
return { child, finished }
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createRuntime } from "solid-objects"
|
|
2
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
3
|
+
import { DeliveryCounter } from "./actor.ts"
|
|
4
|
+
import { recordDelivery } from "./sink.ts"
|
|
5
|
+
|
|
6
|
+
const databasePath = requiredArgument(2)
|
|
7
|
+
const sinkPath = requiredArgument(3)
|
|
8
|
+
const mode = requiredArgument(4)
|
|
9
|
+
const deduplicate = requiredArgument(5) === "on"
|
|
10
|
+
|
|
11
|
+
const runtime = createRuntime({
|
|
12
|
+
database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
|
|
13
|
+
pollingIntervalMilliseconds: 10,
|
|
14
|
+
leaseDurationMilliseconds: 250,
|
|
15
|
+
leaseRenewalIntervalMilliseconds: 50,
|
|
16
|
+
processHeartbeatIntervalMilliseconds: 75,
|
|
17
|
+
processAliveThresholdMilliseconds: 300,
|
|
18
|
+
workerCount: 0,
|
|
19
|
+
effectWorkerCount: 1,
|
|
20
|
+
reminderSchedulerCount: 0,
|
|
21
|
+
retentionIntervalMilliseconds: 0,
|
|
22
|
+
deadProcessCleanupIntervalMilliseconds: 0,
|
|
23
|
+
authorizeMessage: () => true,
|
|
24
|
+
authorizeQuery: () => true,
|
|
25
|
+
authorizeAdministration: () => true,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
runtime.register(DeliveryCounter)
|
|
29
|
+
runtime.registerEffect("record", async (_argumentsValue, context) => {
|
|
30
|
+
const { applied } = await recordDelivery({
|
|
31
|
+
path: sinkPath,
|
|
32
|
+
effectId: context.id,
|
|
33
|
+
attempt: context.attempt,
|
|
34
|
+
deduplicate,
|
|
35
|
+
})
|
|
36
|
+
process.send?.({ event: "sink.recorded", effectId: context.id, applied })
|
|
37
|
+
if (mode === "crash") {
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
})
|
|
42
|
+
await runtime.install()
|
|
43
|
+
const effectWorker = runtime.effectWorker()
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
let processed = 0
|
|
47
|
+
for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
|
|
48
|
+
processed = await effectWorker.runOnce()
|
|
49
|
+
if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
50
|
+
}
|
|
51
|
+
if (processed === 0) throw new Error("no effect became claimable")
|
|
52
|
+
process.send?.({ event: "effects.finished", processed })
|
|
53
|
+
} finally {
|
|
54
|
+
await effectWorker.stop()
|
|
55
|
+
await runtime.close()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requiredArgument(index: number): string {
|
|
59
|
+
const value = process.argv[index]
|
|
60
|
+
if (!value) throw new TypeError(`argument ${index - 1} is required`)
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
export interface SinkDelivery {
|
|
4
|
+
effectId: string
|
|
5
|
+
attempt: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SinkState {
|
|
9
|
+
deliveries: SinkDelivery[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readSink(path: string): Promise<SinkState> {
|
|
13
|
+
try {
|
|
14
|
+
const parsed: SinkState = JSON.parse(await readFile(path, "utf-8"))
|
|
15
|
+
return parsed
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { deliveries: [] }
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function recordDelivery(options: {
|
|
23
|
+
path: string
|
|
24
|
+
effectId: string
|
|
25
|
+
attempt: number
|
|
26
|
+
deduplicate: boolean
|
|
27
|
+
}): Promise<{ applied: boolean }> {
|
|
28
|
+
const sink = await readSink(options.path)
|
|
29
|
+
const seen = sink.deliveries.some((delivery) => delivery.effectId === options.effectId)
|
|
30
|
+
if (options.deduplicate && seen) return { applied: false }
|
|
31
|
+
sink.deliveries.push({ effectId: options.effectId, attempt: options.attempt })
|
|
32
|
+
await writeFile(options.path, JSON.stringify(sink, null, 2))
|
|
33
|
+
return { applied: true }
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid-objects",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "Race-free realtime state per application identity, backed by your SQL database",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
"test:mysql": "vitest run test/mysql.test.ts",
|
|
108
108
|
"test:package": "node scripts/release-artifact-smoke.mjs",
|
|
109
109
|
"test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
|
|
110
|
+
"test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",
|
|
110
111
|
"test:redis": "vitest run test/redis-wake-up.test.ts",
|
|
111
112
|
"test:watch": "vitest",
|
|
112
113
|
"benchmark": "pnpm run build && node benchmarks/run.ts",
|