Skip to content
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ jobs:
- run: pnpm run test:recovery
- run: pnpm audit --audit-level=high

floor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: "24.4.0"
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run test:package
- run: pnpm run test:recovery
Comment on lines +38 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Floor skips packaged artifact smoke

The floor job does not run test:package, so it does not continuously validate clean installation and execution of the generated package at Node 24.4.0; a floor-specific packaging or executable incompatibility can therefore pass the publish gate.

Suggested change
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run test:recovery
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run test:package
- run: pnpm run test:recovery
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/ci.yml
Line: 38-41

Comment:
**Floor skips packaged artifact smoke**

The floor job does not run `test:package`, so it does not continuously validate clean installation and execution of the generated package at Node 24.4.0; a floor-specific packaging or executable incompatibility can therefore pass the publish gate.

```suggestion
      - run: pnpm install --frozen-lockfile
      - run: pnpm run test
      - run: pnpm run build
      - run: pnpm run test:package
      - run: pnpm run test:recovery
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


postgresql:
runs-on: ubuntu-latest
strategy:
Expand Down Expand Up @@ -127,7 +141,7 @@ jobs:

publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: [quality, postgresql, mysql, browser, redis]
needs: [quality, floor, postgresql, mysql, browser, redis]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## 0.13.3 - 2026-08-18

- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is
the first release that accepts `readBigInts` on the `DatabaseSync`
constructor, which the SQLite adapter needs to read 64-bit integers without
losing precision. Node.js 24.0.0 through 24.3.x ignore the option, and the
effect recovery and transaction retry tests fail there. A new CI job runs the
default suite, the build, the packaged artifact smoke test, and the recovery
demo on the floor.
- Record that `node:sqlite` stays experimental until Node.js 24.15.0 and prints
a warning on stderr before it.
- Make the failure-recovery demo's serialization proof count messages rather
than executions. A worker that loses its lease mid-operation leaves the
replacement to execute the same message again, which is the at-least-once
contract, so the proof failed on slower machines for behaviour it documents
elsewhere. Each serialization event now carries its attempt and process, so a
start pairs with its own finish instead of with whichever finish came next.
The proof asserts that every start has its own finish, that exactly the two
sent messages ran, and that the surviving attempt of each message never
overlaps another message's surviving attempt; a superseded attempt may
overlap anything, because it keeps running until it notices the lost lease and
its write is fenced out. The committed state check is unchanged, and the demo
reports the executions it saw. `assertSerializedExecution` moved into its own
module with unit coverage for the clean, retried, superseded-overlap,
still-running-replacement, unexplained-overlap, boundary, unfinished,
unmatched-finish, double-start, restart-after-finish, and lost-message cases.

## 0.13.2 - 2026-08-17

- Accept a `key` on `schedule`, naming a reminder for the item it is waiting
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ lowest layer that can prove it.
## Setup

Install Node.js 24.15 or newer, enable Corepack, and install the locked
dependencies:
dependencies. The package supports Node.js 24.4.0 or newer, and CI runs the
default suite, the build, the packaged artifact smoke test, and the recovery
demo on that floor. Node.js 24.15 is where `node:sqlite` stops printing an
experimental warning:

```bash
corepack enable
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ processes submit them concurrently.

## Run it now with SQLite

Node.js 24.15 or newer is required. The `0.13.2` release includes a
packaged quickstart:
Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred,
because `node:sqlite` prints an experimental warning before it. The `0.13.3`
release includes a packaged quickstart:
Comment on lines +63 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pinned quickstart requires newer Node

When a user on Node 24.4.0–24.14.x runs the documented quickstart with npm engine-strict enabled, the command installs solid-objects@0.13.1, whose engine constraint remains >=24.15.0, causing npm to reject the installation despite the new stated minimum.

Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 60-62

Comment:
**Pinned quickstart requires newer Node**

When a user on Node 24.4.0–24.14.x runs the documented quickstart with npm engine-strict enabled, the command installs `solid-objects@0.13.1`, whose engine constraint remains `>=24.15.0`, causing npm to reject the installation despite the new stated minimum.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


```bash
npm exec --yes --package=solid-objects@0.13.2 -- solid-objects quickstart
npm exec --yes --package=solid-objects@0.13.3 -- solid-objects quickstart
```

The command needs no repository checkout, database server, Redis, container, or
Expand Down Expand Up @@ -258,7 +259,8 @@ edge placement, cross-identity transactions, and operational data access—is in

## Requirements and supported systems

- Node.js 24.15 or newer
- Node.js 24.4.0 or newer; 24.15 or newer to avoid the `node:sqlite`
experimental warning
- TypeScript 5.9 or newer for TypeScript applications
- SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer
with InnoDB
Expand Down
4 changes: 2 additions & 2 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
Parity means preserving a capability and its correctness or security boundary,
not copying a Rails API into Node.

Reference: Ruby `solid_objects` 0.13.2. The JavaScript package began at the
Reference: Ruby `solid_objects` 0.13.3. The JavaScript package began at the
Ruby design's `0.12` capability generation; that version number did not imply
earlier JavaScript releases.

The Node `0.13.2` implementation has capability parity with that reference. Its
The Node `0.13.3` implementation has capability parity with that reference. Its
relational runtime, correctness boundaries, administration, diagnostics,
operator dashboard, realtime projections, browser behavior, and supported
adapters have native equivalents. Rails-specific rendering surfaces are
Expand Down
4 changes: 2 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ npm trust github solid-objects \
4. Create and push an annotated tag matching the package version:

```shell
git tag -a v0.13.2 -m "Version 0.13.2"
git push origin v0.13.2
git tag -a v0.13.3 -m "Version 0.13.3"
git push origin v0.13.3
```

The tag runs the complete CI matrix. The publish job starts only after every
Expand Down
13 changes: 12 additions & 1 deletion docs/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| Component | Supported or tested range |
| -------------- | ----------------------------------------------------------- |
| Node.js | 24.15 or newer; CI uses 24.15 |
| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
| TypeScript | 5.9 or newer for TypeScript applications |
| SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
| PostgreSQL | 14 or newer; CI runs 14 and 18 |
Expand All @@ -15,6 +15,17 @@
The package is ESM-only. PostgreSQL, MySQL, and Redis require their optional
peer dependency. SQLite has no driver dependency beyond Node.js.

The Node.js floor is 24.4.0 because the SQLite adapter reads integer columns as
`BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the
`DatabaseSync` constructor. Node.js 24.0.0 through 24.3.x ignore the option and
return `Number`, which loses precision on 64-bit values and fails the effect
recovery and transaction retry tests.

Node.js 24.15.0 is the first release where `node:sqlite` is no longer
experimental. Between 24.4.0 and 24.14.x the module works but prints
`ExperimentalWarning: SQLite is an experimental feature` on stderr, and its API
can change. Prefer 24.15.0 or newer where the choice is free.

## What the matrix covers

The default suite exercises actor definitions, mailbox ordering, state
Expand Down
14 changes: 6 additions & 8 deletions examples/failure-recovery/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,14 @@ export class RecoveryCounter extends Actor {
async serialize({ controlDirectory }: { controlDirectory: string }): Promise<number> {
const message = this.currentMessage
if (!message) throw new Error("serialize requires a durable message")
await appendFile(
join(controlDirectory, "serialization.jsonl"),
`${JSON.stringify({ event: "start", messageId: message.id, at: Date.now() })}\n`,
)
// The attempt and the process identify the execution, so a start pairs with
// its own finish even when a superseded attempt outlives its replacement.
const execution = { messageId: message.id, attempt: message.attempt, processId: process.pid }
const path = join(controlDirectory, "serialization.jsonl")
await appendFile(path, `${JSON.stringify({ event: "start", ...execution, at: Date.now() })}\n`)
await new Promise((resolve) => setTimeout(resolve, 100))
this.count += 1
await appendFile(
join(controlDirectory, "serialization.jsonl"),
`${JSON.stringify({ event: "finish", messageId: message.id, at: Date.now() })}\n`,
)
await appendFile(path, `${JSON.stringify({ event: "finish", ...execution, at: Date.now() })}\n`)
return this.count
}
}
Expand Down
34 changes: 8 additions & 26 deletions examples/failure-recovery/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@ import { fork, type ChildProcess } from "node:child_process"
import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import { RecoveryCounter } from "./actor.ts"
import {
assertSerializedExecution,
parseSerializationEvent,
type SerializationProof,
} from "./serialization.ts"

interface WorkerMessage {
event: string
attempt?: number
processed?: number
}

interface SerializationEvent {
event: "start" | "finish"
messageId: string
at: number
}

interface ExternalEffectEvent {
messageId: string
attempt: number
Expand Down Expand Up @@ -59,7 +58,7 @@ try {

assert.equal(existsSync(directory), false)

async function proveSerialization(): Promise<{ finalState: number; overlap: false }> {
async function proveSerialization(): Promise<SerializationProof & { finalState: number }> {
const controlDirectory = join(directory, "serialization")
await mkdir(controlDirectory)
const reference = runtime.ref(RecoveryCounter, "serialized")
Expand All @@ -74,15 +73,10 @@ async function proveSerialization(): Promise<{ finalState: number; overlap: fals
join(controlDirectory, "serialization.jsonl"),
parseSerializationEvent,
)
assert.equal(events.length, 4)
const starts = events.filter((event) => event.event === "start")
const finishes = events.filter((event) => event.event === "finish")
assert.equal(starts.length, 2)
assert.equal(finishes.length, 2)
assert(Number(starts[1]?.at) >= Number(finishes[0]?.at))
const proof = assertSerializedExecution(events, { messageCount: 2 })
const snapshot = await reference.snapshot()
assert.equal(snapshot.count, 2)
return { finalState: snapshot.count, overlap: false }
return { ...proof, finalState: snapshot.count }
}

async function proveCrashRecovery(): Promise<{
Expand Down Expand Up @@ -200,18 +194,6 @@ async function readJsonLines<Value>(
return (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).map(parse)
}

function parseSerializationEvent(line: string): SerializationEvent {
const event = JSON.parse(line) as Partial<SerializationEvent>
if (
(event.event !== "start" && event.event !== "finish") ||
typeof event.messageId !== "string" ||
typeof event.at !== "number"
) {
throw new TypeError("invalid serialization event")
}
return { event: event.event, messageId: event.messageId, at: event.at }
}

function parseExternalEffectEvent(line: string): ExternalEffectEvent {
const event = JSON.parse(line) as Partial<ExternalEffectEvent>
if (
Expand Down
133 changes: 133 additions & 0 deletions examples/failure-recovery/serialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import assert from "node:assert/strict"

export interface SerializationEvent {
event: "start" | "finish"
messageId: string
attempt: number
processId: number
at: number
}

export interface SerializationProof {
executions: number
retried: boolean
supersededOverlap: boolean
}

interface Execution {
messageId: string
attempt: number
processId: number
startedAt: number
finishedAt: number
}

export function parseSerializationEvent(line: string): SerializationEvent {
const event = JSON.parse(line) as Partial<SerializationEvent>
if (
(event.event !== "start" && event.event !== "finish") ||
typeof event.messageId !== "string" ||
typeof event.attempt !== "number" ||
typeof event.processId !== "number" ||
typeof event.at !== "number"
) {
throw new TypeError("invalid serialization event")
}
return {
event: event.event,
messageId: event.messageId,
attempt: event.attempt,
processId: event.processId,
at: event.at,
}
}

// One identity commits one state transition at a time. The control file is
// written outside the transaction, so it records execution attempts rather than
// commits: a worker that loses its lease keeps running until it notices, and its
// replacement executes the same message under a higher attempt. The superseded
// attempt may therefore overlap anything, because its write is fenced out and
// the committed state is what proves it.
//
// Each event carries its attempt and process, so a start pairs with its own
// finish rather than with whichever finish arrived next. Without that, a
// superseded attempt finishing late reads as its replacement finishing, and a
// second message could then overlap a replacement that is still running.
export function assertSerializedExecution(
events: readonly SerializationEvent[],
options: { messageCount: number },
): SerializationProof {
const executions = pairExecutions(events)

const messageIds = new Set(executions.map((execution) => execution.messageId))
assert.equal(
messageIds.size,
options.messageCount,
`expected ${options.messageCount} messages to run, saw ${messageIds.size}`,
)

const survivingAttempt = new Map<string, number>()
for (const execution of executions) {
const highest = survivingAttempt.get(execution.messageId) ?? 0
if (execution.attempt > highest) survivingAttempt.set(execution.messageId, execution.attempt)
}
const surviving = executions.filter(
(execution) => survivingAttempt.get(execution.messageId) === execution.attempt,
)

for (const [index, execution] of surviving.entries()) {
for (const other of surviving.slice(index + 1)) {
assert(
!overlaps(execution, other),
`${describe(execution)} and ${describe(other)} overlap, and neither was superseded`,
)
}
}

const supersededOverlap = executions.some((execution) =>
executions.some((other) => other !== execution && overlaps(execution, other)),
)

return {
executions: executions.length,
retried: executions.length > options.messageCount,
supersededOverlap,
}
}

function pairExecutions(events: readonly SerializationEvent[]): Execution[] {
const started = new Map<string, SerializationEvent>()
const executions: Execution[] = []

for (const event of [...events].sort((left, right) => left.at - right.at)) {
const key = `${event.messageId}#${event.attempt}#${event.processId}`
if (event.event === "start") {
assert(!started.has(key), `${describe(event)} started twice`)
started.set(key, event)
continue
}
const start = started.get(key)
assert(start !== undefined, `${describe(event)} finished with no matching start`)
started.delete(key)
executions.push({
messageId: event.messageId,
attempt: event.attempt,
processId: event.processId,
startedAt: start.at,
finishedAt: event.at,
})
}

const unfinished = [...started.values()].map(describe)
assert.equal(unfinished.length, 0, `${unfinished.join(", ")} never wrote a finish`)

return executions
}

function overlaps(left: Execution, right: Execution): boolean {
return left.startedAt < right.finishedAt && right.startedAt < left.finishedAt
}

function describe(execution: { messageId: string; attempt: number }): string {
return `${execution.messageId} attempt ${execution.attempt}`
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solid-objects",
"version": "0.13.2",
"version": "0.13.3",
"description": "Race-free realtime state per application identity, backed by your SQL database",
"type": "module",
"license": "MIT",
Expand All @@ -23,7 +23,7 @@
"url": "git+https://github.com/cardmagic/solid-objects-js.git"
},
"engines": {
"node": ">=24.15.0"
"node": ">=24.4.0"
},
"packageManager": "pnpm@11.11.0",
"bin": {
Expand Down
Loading