Skip to content

Commit c9b7d51

Browse files
committed
Merge main and prepare 0.13.3
main released 0.13.2 while this branch was open, and this branch had claimed that number for the Node floor change. Both the changelog and the README quickstart conflicted on it. The released 0.13.2 section stays as main wrote it, and the floor entries move to a new 0.13.3 section. 0.13.3 also matches the Ruby gem, whose release carries the same date. The rest of the version references follow the release procedure: package.json, src/version.ts, the process metadata test literal, the README quickstart, docs/parity.md, and the example tag in docs/releasing.md.
2 parents 9be5c93 + bfdbd22 commit c9b7d51

28 files changed

Lines changed: 580 additions & 56 deletions

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ jobs:
144144
needs: [quality, floor, postgresql, mysql, browser, redis]
145145
runs-on: ubuntu-latest
146146
permissions:
147-
contents: read
147+
contents: write
148148
id-token: write
149149
steps:
150150
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -178,3 +178,10 @@ jobs:
178178
fi
179179
- if: steps.registry.outputs.exists != 'true'
180180
run: npm publish --access public
181+
- name: Write release notes
182+
run: node scripts/release-notes.mjs "${GITHUB_REF_NAME#v}" > "${RUNNER_TEMP}/release-notes.md"
183+
- name: Create GitHub release
184+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
185+
with:
186+
name: ${{ github.ref_name }}
187+
body_path: ${{ runner.temp }}/release-notes.md

‎CHANGELOG.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
# Changelog
22

3-
## 0.13.2 - 2026-08-18
3+
## 0.13.3 - 2026-08-18
44

55
- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is
66
the first release that accepts `readBigInts` on the `DatabaseSync`
77
constructor, which the SQLite adapter needs to read 64-bit integers without
88
losing precision. Node.js 24.0.0 through 24.3.x ignore the option, and the
99
effect recovery and transaction retry tests fail there. A new CI job runs the
10-
default suite, the build, the packaged artifact smoke test, and the
11-
recovery demo on the floor.
10+
default suite, the build, the packaged artifact smoke test, and the recovery
11+
demo on the floor.
1212
- Record that `node:sqlite` stays experimental until Node.js 24.15.0 and prints
1313
a warning on stderr before it.
1414

15+
## 0.13.2 - 2026-08-17
16+
17+
- Accept a `key` on `schedule`, naming a reminder for the item it is waiting
18+
on rather than for its operation, so one actor can hold an alarm per queued
19+
item. Scheduling the same key again moves that item's alarm and leaves the
20+
others alone. Without a key the name is still the operation, so existing
21+
reminders keep their names and their coalescing behaviour. Adds a nullable
22+
`message_operation` column to the reminders table, left null on existing rows.
23+
The composed name is bounded by the 255 characters MySQL holds it in, checked
24+
on the name rather than the key alone.
25+
- Add an authorized `runtime.administration.processes()` query for inspecting
26+
live and stale process rows through the runtime's database adapter.
27+
- Document rolling-deployment overlap as a reason for the polling-only warning.
28+
1529
## 0.13.1 - 2026-08-16
1630

1731
- Back idle actor, effect, reminder, and broadcast polling off exponentially

‎README.md‎

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
# Durable Objects for Node, backed by your existing SQL database
1+
# Solid Objects JS
22

33
[![CI](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
44
[![npm](https://img.shields.io/npm/v/solid-objects)](https://www.npmjs.com/package/solid-objects)
55

6+
Self-hosted, distributed Durable Objects in Node without a daemon using your
7+
existing SQL database.
8+
69
Build addressable TypeScript objects with serialized calls and durable state
710
using SQLite, PostgreSQL, or MySQL, without deploying to Cloudflare.
811

@@ -58,11 +61,11 @@ processes submit them concurrently.
5861
## Run it now with SQLite
5962

6063
Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred,
61-
because `node:sqlite` prints an experimental warning before it. The `0.13.2`
64+
because `node:sqlite` prints an experimental warning before it. The `0.13.3`
6265
release includes a packaged quickstart:
6366

6467
```bash
65-
npm exec --yes --package=solid-objects@0.13.2 -- solid-objects quickstart
68+
npm exec --yes --package=solid-objects@0.13.3 -- solid-objects quickstart
6669
```
6770

6871
The command needs no repository checkout, database server, Redis, container, or
@@ -77,6 +80,28 @@ local run, it verifies that:
7780
- operations for two different identities overlap in time; and
7881
- the runtime closes and temporary state is removed.
7982

83+
## What Solid Objects is for
84+
85+
Use Solid Objects when more than one request, job, or process can act on the
86+
same logical thing and the next action must use its latest committed state.
87+
These are the stateful coordination patterns for which people often reach for
88+
Durable Objects:
89+
90+
| Pattern | One identity per | What the object coordinates |
91+
| --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
92+
| Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
93+
| Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
94+
| Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
95+
| Per-key rate limits | API key, account, or device | Token checks and decrements are serialized; a reminder can refill the bucket |
96+
| Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
97+
98+
The common shape is one durable coordination boundary with an application
99+
defined identity. Work for that identity is serialized, while unrelated rooms,
100+
carts, accounts, or sessions can progress concurrently. A single global rate
101+
limiter or another very hot identity is a poor fit because it becomes an
102+
intentional bottleneck. If one ordinary row transaction solves the problem,
103+
prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
104+
80105
## Running in a deployed application
81106

82107
[Shuffle Up and Play](https://shuffleupandplay.com/) is a deployed reference
@@ -112,28 +137,6 @@ and multi-process lease fencing are verified separately by the library's
112137
[correctness contract](docs/correctness.md). Evaluate those guarantees and
113138
limits against your own workload.
114139

115-
## What Solid Objects is for
116-
117-
Use Solid Objects when more than one request, job, or process can act on the
118-
same logical thing and the next action must use its latest committed state.
119-
These are the stateful coordination patterns for which people often reach for
120-
Durable Objects:
121-
122-
| Pattern | One identity per | What the object coordinates |
123-
| --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
124-
| Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
125-
| Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
126-
| Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
127-
| Per-key rate limits | API key, account, or device | Token checks and decrements are serialized; a reminder can refill the bucket |
128-
| Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
129-
130-
The common shape is one durable coordination boundary with an application
131-
defined identity. Work for that identity is serialized, while unrelated rooms,
132-
carts, accounts, or sessions can progress concurrently. A single global rate
133-
limiter or another very hot identity is a poor fit because it becomes an
134-
intentional bottleneck. If one ordinary row transaction solves the problem,
135-
prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
136-
137140
## How it works
138141

139142
An object is addressed by its TypeScript class and application-defined ID.

‎docs/api.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,48 @@ function playerForSession<PlayerType extends { sessionId: string }>(options: {
7979
}
8080
```
8181

82+
### Reminders
83+
84+
A reminder is one alarm per actor and name. Scheduling a name that is already
85+
armed **moves the existing alarm** rather than adding a second one, which is
86+
what makes a reminder safe to re-arm from a handler that may run more than once.
87+
88+
Without a key that name is the operation, so one actor holds one alarm per
89+
operation, and arming one per queued item keeps only the last:
90+
91+
```typescript
92+
// Wrong. Every entry overwrites the previous entry's alarm.
93+
add({ entry }: { entry: Entry }): void {
94+
this.entries = [...this.entries, entry]
95+
this.schedule({ at: new Date(entry.waitUntil) }).deliver!()
96+
}
97+
```
98+
99+
Pass `key` when an actor is waiting on several things at once. The key is your
100+
own identifier for the item and names that item's alarm, so each item gets one:
101+
102+
```typescript
103+
add({ entry }: { entry: Entry }): void {
104+
this.entries = [...this.entries, entry]
105+
this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver!()
106+
}
107+
```
108+
109+
Scheduling the same key again moves that item's alarm and leaves the others
110+
alone. The operation still decides which handler runs; the key only decides
111+
which alarm is which.
112+
113+
A key must be non-empty, and the name it composes must fit the 255 characters
114+
MySQL holds it in. That is checked on the composed name rather than the key
115+
alone, so a long operation with a short key is caught too. A key may hold colons
116+
of its own, because an actor member name cannot.
117+
118+
An actor that only needs to know "what is next" can still keep one alarm and
119+
drain everything due when it fires. That costs one row instead of one per item
120+
and cannot strand an entry when an occurrence is coalesced, so prefer it for a
121+
large queue of interchangeable items and prefer `key` when an item needs an
122+
alarm that can be moved on its own.
123+
82124
### Runtime managers
83125

84126
Every manager below is available as a property on `SolidObjectsRuntime`; the
@@ -89,6 +131,9 @@ class and result types are also exported for integration typing.
89131
idempotent paused-alarm `resume()`.
90132
- `runtime.processes` / `ProcessManager`: immutable role `all()` and stale-owner
91133
`cleanup()`.
134+
- `runtime.administration` / `AdministrationManager`: an authorized
135+
`processes()` query for inspecting live and stale process rows through the
136+
runtime's own database adapter.
92137
- `runtime.reconciliation` / `ReconciliationManager`: `active()`,
93138
`withoutPendingWork()`, `statesFor()`, and `orphaned()` bounded reads.
94139
- `runtime.retention` / `RetentionManager`: `preview()` and authorized `prune()`

‎docs/operations.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ delivery; without one, newly committed work can wait up to the current idle
1717
polling interval. Notification errors are isolated and logged by role and error
1818
class without failing the committed work.
1919

20+
The warning excludes process rows with the current hostname and host process ID.
21+
It can therefore appear during a rolling deployment or restart overlap when an
22+
older and newer process briefly share the same database. A process that stopped
23+
without graceful cleanup remains live until its heartbeat exceeds
24+
`processAliveThresholdMilliseconds`; inspect
25+
`runtime.administration.processes()` to distinguish a live overlap from a stale
26+
row.
27+
2028
Each role exposes `currentPollingIntervalMilliseconds`.
2129
`solid_objects.polling.interval_changed` reports the role, reason, previous
2230
interval, and current interval. The polling-only warning is also emitted as
@@ -62,9 +70,12 @@ runs under the application-write guard, and `onDeactivate()` is best effort:
6270
it may not run after a crash, cannot establish a correctness guarantee, and a
6371
failure is logged without preventing lease release.
6472

65-
`runtime.processes.all()` returns administration-authorized immutable process
66-
metadata with hostname, host process ID, Node and Solid Objects versions, and a
67-
current `stale` flag. Graceful shutdown first persists `draining` with a
73+
`runtime.administration.processes()` returns the same administration-authorized
74+
immutable process metadata as `runtime.processes.all()`, with hostname, host
75+
process ID, Node and Solid Objects versions, and a current `stale` flag. It is
76+
safe to call through the runtime's database adapter while workers are running;
77+
the query is serialized with other database access and does not require a
78+
second SQLite connection. Graceful shutdown first persists `draining` with a
6879
`shutdownRequestedAt` timestamp, then deactivates owned actors and atomically
6980
releases every role claim before persisting `stopped`. `cleanup()` reauthorizes
7081
separately and performs the same release for stale running or draining

‎docs/parity.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
44
Parity means preserving a capability and its correctness or security boundary,
55
not copying a Rails API into Node.
66

7-
Reference: Ruby `solid_objects` 0.13.1. The JavaScript package began at the
7+
Reference: Ruby `solid_objects` 0.13.3. The JavaScript package began at the
88
Ruby design's `0.12` capability generation; that version number did not imply
99
earlier JavaScript releases.
1010

11-
The Node `0.13.1` implementation has capability parity with that reference. Its
11+
The Node `0.13.3` implementation has capability parity with that reference. Its
1212
relational runtime, correctness boundaries, administration, diagnostics,
1313
operator dashboard, realtime projections, browser behavior, and supported
1414
adapters have native equivalents. Rails-specific rendering surfaces are

‎docs/releasing.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ npm trust github solid-objects \
2828

2929
1. Update the version in `package.json` and `src/version.ts`, refresh the
3030
lockfile when needed, and move the release notes out of the Unreleased
31-
section in `CHANGELOG.md`.
31+
section in `CHANGELOG.md` into a dated section for the new version. The
32+
publish job reads that section, so a version without one fails the release.
3233
2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`,
3334
`pnpm run build`, `pnpm run pack:check`, `pnpm run test:package`,
3435
`pnpm run test:recovery`, `pnpm run test:browser`, and
@@ -38,11 +39,16 @@ npm trust github solid-objects \
3839
4. Create and push an annotated tag matching the package version:
3940

4041
```shell
41-
git tag -a v0.13.1 -m "Version 0.13.1"
42-
git push origin v0.13.1
42+
git tag -a v0.13.3 -m "Version 0.13.3"
43+
git push origin v0.13.3
4344
```
4445

4546
The tag runs the complete CI matrix. The publish job starts only after every
4647
quality, database, Redis, and browser job succeeds. It rejects tags that do not
4748
match `package.json`, safely skips versions already present in npm, and
4849
publishes new versions with npm provenance.
50+
51+
The job then builds the release notes with `scripts/release-notes.mjs`, which
52+
prints the `CHANGELOG.md` section for the tagged version, and creates the GitHub
53+
release for the tag. Re-running the job on a tag that npm already holds still
54+
creates a missing release.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "solid-objects",
3-
"version": "0.13.2",
3+
"version": "0.13.3",
44
"description": "Race-free realtime state per application identity, backed by your SQL database",
55
"type": "module",
66
"license": "MIT",

‎scripts/release-notes.mjs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import fs from "node:fs"
2+
import process from "node:process"
3+
4+
const version = process.argv[2]
5+
6+
if (!version) {
7+
process.stderr.write("usage: node scripts/release-notes.mjs <version>\n")
8+
process.exit(1)
9+
}
10+
11+
const entry = findEntry(fs.readFileSync("CHANGELOG.md", "utf8"), version)
12+
13+
if (!entry) {
14+
process.stderr.write(`CHANGELOG.md has no entry for ${version}\n`)
15+
process.exit(1)
16+
}
17+
18+
const manifest = JSON.parse(fs.readFileSync("package.json", "utf8"))
19+
const repository = manifest.repository.url.replace(/^git\+/, "").replace(/\.git$/, "")
20+
const tag = `v${version}`
21+
const changelogLink = `${repository}/blob/${tag}/CHANGELOG.md#${anchor(entry.heading)}`
22+
const correctnessLink = `${repository}/blob/${tag}/docs/correctness.md`
23+
24+
process.stdout.write(
25+
[
26+
entry.body,
27+
"",
28+
`Install with \`npm install ${manifest.name}@${version}\`.`,
29+
"",
30+
`See the [full changelog](${changelogLink}) and [correctness boundaries](${correctnessLink}).`,
31+
"",
32+
].join("\n"),
33+
)
34+
35+
function findEntry(changelog, releasedVersion) {
36+
const lines = changelog.split("\n")
37+
const start = lines.findIndex((line) => isHeadingFor(line, releasedVersion))
38+
if (start === -1) return undefined
39+
40+
const remainder = lines.slice(start + 1)
41+
const end = remainder.findIndex((line) => line.startsWith("## "))
42+
const body = end === -1 ? remainder : remainder.slice(0, end)
43+
44+
return { heading: heading(lines[start]), body: body.join("\n").trim() }
45+
}
46+
47+
function isHeadingFor(line, releasedVersion) {
48+
if (!line.startsWith("## ")) return false
49+
50+
const text = heading(line)
51+
52+
return text === releasedVersion || text.startsWith(`${releasedVersion} `)
53+
}
54+
55+
function heading(line) {
56+
return line.replace(/^##\s+/, "").trim()
57+
}
58+
59+
function anchor(text) {
60+
return text
61+
.toLowerCase()
62+
.replace(/[^\w\- ]+/g, "")
63+
.replace(/ /g, "-")
64+
}

0 commit comments

Comments
 (0)