solid-objects 0.13.2 → 0.13.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 +27 -0
- package/README.md +6 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/parity.md +2 -2
- package/docs/releasing.md +9 -3
- package/docs/support.md +12 -1
- package/examples/failure-recovery/actor.ts +6 -8
- package/examples/failure-recovery/demo.ts +8 -26
- package/examples/failure-recovery/serialization.ts +133 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.3 - 2026-08-18
|
|
4
|
+
|
|
5
|
+
- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is
|
|
6
|
+
the first release that accepts `readBigInts` on the `DatabaseSync`
|
|
7
|
+
constructor, which the SQLite adapter needs to read 64-bit integers without
|
|
8
|
+
losing precision. Node.js 24.0.0 through 24.3.x ignore the option, and the
|
|
9
|
+
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 recovery
|
|
11
|
+
demo on the floor.
|
|
12
|
+
- Record that `node:sqlite` stays experimental until Node.js 24.15.0 and prints
|
|
13
|
+
a warning on stderr before it.
|
|
14
|
+
- Make the failure-recovery demo's serialization proof count messages rather
|
|
15
|
+
than executions. A worker that loses its lease mid-operation leaves the
|
|
16
|
+
replacement to execute the same message again, which is the at-least-once
|
|
17
|
+
contract, so the proof failed on slower machines for behaviour it documents
|
|
18
|
+
elsewhere. Each serialization event now carries its attempt and process, so a
|
|
19
|
+
start pairs with its own finish instead of with whichever finish came next.
|
|
20
|
+
The proof asserts that every start has its own finish, that exactly the two
|
|
21
|
+
sent messages ran, and that the surviving attempt of each message never
|
|
22
|
+
overlaps another message's surviving attempt; a superseded attempt may
|
|
23
|
+
overlap anything, because it keeps running until it notices the lost lease and
|
|
24
|
+
its write is fenced out. The committed state check is unchanged, and the demo
|
|
25
|
+
reports the executions it saw. `assertSerializedExecution` moved into its own
|
|
26
|
+
module with unit coverage for the clean, retried, superseded-overlap,
|
|
27
|
+
still-running-replacement, unexplained-overlap, boundary, unfinished,
|
|
28
|
+
unmatched-finish, double-start, restart-after-finish, and lost-message cases.
|
|
29
|
+
|
|
3
30
|
## 0.13.2 - 2026-08-17
|
|
4
31
|
|
|
5
32
|
- Accept a `key` on `schedule`, naming a reminder for the item it is waiting
|
package/README.md
CHANGED
|
@@ -60,11 +60,12 @@ processes submit them concurrently.
|
|
|
60
60
|
|
|
61
61
|
## Run it now with SQLite
|
|
62
62
|
|
|
63
|
-
Node.js 24.
|
|
64
|
-
|
|
63
|
+
Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred,
|
|
64
|
+
because `node:sqlite` prints an experimental warning before it. The `0.13.3`
|
|
65
|
+
release includes a packaged quickstart:
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
|
-
npm exec --yes --package=solid-objects@0.13.
|
|
68
|
+
npm exec --yes --package=solid-objects@0.13.3 -- solid-objects quickstart
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
The command needs no repository checkout, database server, Redis, container, or
|
|
@@ -258,7 +259,8 @@ edge placement, cross-identity transactions, and operational data access—is in
|
|
|
258
259
|
|
|
259
260
|
## Requirements and supported systems
|
|
260
261
|
|
|
261
|
-
- Node.js 24.15 or newer
|
|
262
|
+
- Node.js 24.4.0 or newer; 24.15 or newer to avoid the `node:sqlite`
|
|
263
|
+
experimental warning
|
|
262
264
|
- TypeScript 5.9 or newer for TypeScript applications
|
|
263
265
|
- SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer
|
|
264
266
|
with InnoDB
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.13.
|
|
1
|
+
export declare const VERSION = "0.13.3";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "0.13.
|
|
1
|
+
export const VERSION = "0.13.3";
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/docs/parity.md
CHANGED
|
@@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
|
|
|
4
4
|
Parity means preserving a capability and its correctness or security boundary,
|
|
5
5
|
not copying a Rails API into Node.
|
|
6
6
|
|
|
7
|
-
Reference: Ruby `solid_objects` 0.13.
|
|
7
|
+
Reference: Ruby `solid_objects` 0.13.3. The JavaScript package began at the
|
|
8
8
|
Ruby design's `0.12` capability generation; that version number did not imply
|
|
9
9
|
earlier JavaScript releases.
|
|
10
10
|
|
|
11
|
-
The Node `0.13.
|
|
11
|
+
The Node `0.13.3` implementation has capability parity with that reference. Its
|
|
12
12
|
relational runtime, correctness boundaries, administration, diagnostics,
|
|
13
13
|
operator dashboard, realtime projections, browser behavior, and supported
|
|
14
14
|
adapters have native equivalents. Rails-specific rendering surfaces are
|
package/docs/releasing.md
CHANGED
|
@@ -28,7 +28,8 @@ npm trust github solid-objects \
|
|
|
28
28
|
|
|
29
29
|
1. Update the version in `package.json` and `src/version.ts`, refresh the
|
|
30
30
|
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.
|
|
32
33
|
2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`,
|
|
33
34
|
`pnpm run build`, `pnpm run pack:check`, `pnpm run test:package`,
|
|
34
35
|
`pnpm run test:recovery`, `pnpm run test:browser`, and
|
|
@@ -38,11 +39,16 @@ npm trust github solid-objects \
|
|
|
38
39
|
4. Create and push an annotated tag matching the package version:
|
|
39
40
|
|
|
40
41
|
```shell
|
|
41
|
-
git tag -a v0.13.
|
|
42
|
-
git push origin v0.13.
|
|
42
|
+
git tag -a v0.13.3 -m "Version 0.13.3"
|
|
43
|
+
git push origin v0.13.3
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
The tag runs the complete CI matrix. The publish job starts only after every
|
|
46
47
|
quality, database, Redis, and browser job succeeds. It rejects tags that do not
|
|
47
48
|
match `package.json`, safely skips versions already present in npm, and
|
|
48
49
|
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/docs/support.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
| Component | Supported or tested range |
|
|
6
6
|
| -------------- | ----------------------------------------------------------- |
|
|
7
|
-
| Node.js | 24.
|
|
7
|
+
| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
|
|
8
8
|
| TypeScript | 5.9 or newer for TypeScript applications |
|
|
9
9
|
| SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
|
|
10
10
|
| PostgreSQL | 14 or newer; CI runs 14 and 18 |
|
|
@@ -15,6 +15,17 @@
|
|
|
15
15
|
The package is ESM-only. PostgreSQL, MySQL, and Redis require their optional
|
|
16
16
|
peer dependency. SQLite has no driver dependency beyond Node.js.
|
|
17
17
|
|
|
18
|
+
The Node.js floor is 24.4.0 because the SQLite adapter reads integer columns as
|
|
19
|
+
`BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the
|
|
20
|
+
`DatabaseSync` constructor. Node.js 24.0.0 through 24.3.x ignore the option and
|
|
21
|
+
return `Number`, which loses precision on 64-bit values and fails the effect
|
|
22
|
+
recovery and transaction retry tests.
|
|
23
|
+
|
|
24
|
+
Node.js 24.15.0 is the first release where `node:sqlite` is no longer
|
|
25
|
+
experimental. Between 24.4.0 and 24.14.x the module works but prints
|
|
26
|
+
`ExperimentalWarning: SQLite is an experimental feature` on stderr, and its API
|
|
27
|
+
can change. Prefer 24.15.0 or newer where the choice is free.
|
|
28
|
+
|
|
18
29
|
## What the matrix covers
|
|
19
30
|
|
|
20
31
|
The default suite exercises actor definitions, mailbox ordering, state
|
|
@@ -25,16 +25,14 @@ export class RecoveryCounter extends Actor {
|
|
|
25
25
|
async serialize({ controlDirectory }: { controlDirectory: string }): Promise<number> {
|
|
26
26
|
const message = this.currentMessage
|
|
27
27
|
if (!message) throw new Error("serialize requires a durable message")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
28
|
+
// The attempt and the process identify the execution, so a start pairs with
|
|
29
|
+
// its own finish even when a superseded attempt outlives its replacement.
|
|
30
|
+
const execution = { messageId: message.id, attempt: message.attempt, processId: process.pid }
|
|
31
|
+
const path = join(controlDirectory, "serialization.jsonl")
|
|
32
|
+
await appendFile(path, `${JSON.stringify({ event: "start", ...execution, at: Date.now() })}\n`)
|
|
32
33
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
33
34
|
this.count += 1
|
|
34
|
-
await appendFile(
|
|
35
|
-
join(controlDirectory, "serialization.jsonl"),
|
|
36
|
-
`${JSON.stringify({ event: "finish", messageId: message.id, at: Date.now() })}\n`,
|
|
37
|
-
)
|
|
35
|
+
await appendFile(path, `${JSON.stringify({ event: "finish", ...execution, at: Date.now() })}\n`)
|
|
38
36
|
return this.count
|
|
39
37
|
}
|
|
40
38
|
}
|
|
@@ -8,6 +8,11 @@ import { fork, type ChildProcess } from "node:child_process"
|
|
|
8
8
|
import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
|
|
9
9
|
import { sqlite } from "solid-objects/database/sqlite"
|
|
10
10
|
import { RecoveryCounter } from "./actor.ts"
|
|
11
|
+
import {
|
|
12
|
+
assertSerializedExecution,
|
|
13
|
+
parseSerializationEvent,
|
|
14
|
+
type SerializationProof,
|
|
15
|
+
} from "./serialization.ts"
|
|
11
16
|
|
|
12
17
|
interface WorkerMessage {
|
|
13
18
|
event: string
|
|
@@ -15,12 +20,6 @@ interface WorkerMessage {
|
|
|
15
20
|
processed?: number
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
interface SerializationEvent {
|
|
19
|
-
event: "start" | "finish"
|
|
20
|
-
messageId: string
|
|
21
|
-
at: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
23
|
interface ExternalEffectEvent {
|
|
25
24
|
messageId: string
|
|
26
25
|
attempt: number
|
|
@@ -59,7 +58,7 @@ try {
|
|
|
59
58
|
|
|
60
59
|
assert.equal(existsSync(directory), false)
|
|
61
60
|
|
|
62
|
-
async function proveSerialization(): Promise<{ finalState: number
|
|
61
|
+
async function proveSerialization(): Promise<SerializationProof & { finalState: number }> {
|
|
63
62
|
const controlDirectory = join(directory, "serialization")
|
|
64
63
|
await mkdir(controlDirectory)
|
|
65
64
|
const reference = runtime.ref(RecoveryCounter, "serialized")
|
|
@@ -74,15 +73,10 @@ async function proveSerialization(): Promise<{ finalState: number; overlap: fals
|
|
|
74
73
|
join(controlDirectory, "serialization.jsonl"),
|
|
75
74
|
parseSerializationEvent,
|
|
76
75
|
)
|
|
77
|
-
|
|
78
|
-
const starts = events.filter((event) => event.event === "start")
|
|
79
|
-
const finishes = events.filter((event) => event.event === "finish")
|
|
80
|
-
assert.equal(starts.length, 2)
|
|
81
|
-
assert.equal(finishes.length, 2)
|
|
82
|
-
assert(Number(starts[1]?.at) >= Number(finishes[0]?.at))
|
|
76
|
+
const proof = assertSerializedExecution(events, { messageCount: 2 })
|
|
83
77
|
const snapshot = await reference.snapshot()
|
|
84
78
|
assert.equal(snapshot.count, 2)
|
|
85
|
-
return { finalState: snapshot.count
|
|
79
|
+
return { ...proof, finalState: snapshot.count }
|
|
86
80
|
}
|
|
87
81
|
|
|
88
82
|
async function proveCrashRecovery(): Promise<{
|
|
@@ -200,18 +194,6 @@ async function readJsonLines<Value>(
|
|
|
200
194
|
return (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).map(parse)
|
|
201
195
|
}
|
|
202
196
|
|
|
203
|
-
function parseSerializationEvent(line: string): SerializationEvent {
|
|
204
|
-
const event = JSON.parse(line) as Partial<SerializationEvent>
|
|
205
|
-
if (
|
|
206
|
-
(event.event !== "start" && event.event !== "finish") ||
|
|
207
|
-
typeof event.messageId !== "string" ||
|
|
208
|
-
typeof event.at !== "number"
|
|
209
|
-
) {
|
|
210
|
-
throw new TypeError("invalid serialization event")
|
|
211
|
-
}
|
|
212
|
-
return { event: event.event, messageId: event.messageId, at: event.at }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
197
|
function parseExternalEffectEvent(line: string): ExternalEffectEvent {
|
|
216
198
|
const event = JSON.parse(line) as Partial<ExternalEffectEvent>
|
|
217
199
|
if (
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
|
|
3
|
+
export interface SerializationEvent {
|
|
4
|
+
event: "start" | "finish"
|
|
5
|
+
messageId: string
|
|
6
|
+
attempt: number
|
|
7
|
+
processId: number
|
|
8
|
+
at: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SerializationProof {
|
|
12
|
+
executions: number
|
|
13
|
+
retried: boolean
|
|
14
|
+
supersededOverlap: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Execution {
|
|
18
|
+
messageId: string
|
|
19
|
+
attempt: number
|
|
20
|
+
processId: number
|
|
21
|
+
startedAt: number
|
|
22
|
+
finishedAt: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseSerializationEvent(line: string): SerializationEvent {
|
|
26
|
+
const event = JSON.parse(line) as Partial<SerializationEvent>
|
|
27
|
+
if (
|
|
28
|
+
(event.event !== "start" && event.event !== "finish") ||
|
|
29
|
+
typeof event.messageId !== "string" ||
|
|
30
|
+
typeof event.attempt !== "number" ||
|
|
31
|
+
typeof event.processId !== "number" ||
|
|
32
|
+
typeof event.at !== "number"
|
|
33
|
+
) {
|
|
34
|
+
throw new TypeError("invalid serialization event")
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
event: event.event,
|
|
38
|
+
messageId: event.messageId,
|
|
39
|
+
attempt: event.attempt,
|
|
40
|
+
processId: event.processId,
|
|
41
|
+
at: event.at,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One identity commits one state transition at a time. The control file is
|
|
46
|
+
// written outside the transaction, so it records execution attempts rather than
|
|
47
|
+
// commits: a worker that loses its lease keeps running until it notices, and its
|
|
48
|
+
// replacement executes the same message under a higher attempt. The superseded
|
|
49
|
+
// attempt may therefore overlap anything, because its write is fenced out and
|
|
50
|
+
// the committed state is what proves it.
|
|
51
|
+
//
|
|
52
|
+
// Each event carries its attempt and process, so a start pairs with its own
|
|
53
|
+
// finish rather than with whichever finish arrived next. Without that, a
|
|
54
|
+
// superseded attempt finishing late reads as its replacement finishing, and a
|
|
55
|
+
// second message could then overlap a replacement that is still running.
|
|
56
|
+
export function assertSerializedExecution(
|
|
57
|
+
events: readonly SerializationEvent[],
|
|
58
|
+
options: { messageCount: number },
|
|
59
|
+
): SerializationProof {
|
|
60
|
+
const executions = pairExecutions(events)
|
|
61
|
+
|
|
62
|
+
const messageIds = new Set(executions.map((execution) => execution.messageId))
|
|
63
|
+
assert.equal(
|
|
64
|
+
messageIds.size,
|
|
65
|
+
options.messageCount,
|
|
66
|
+
`expected ${options.messageCount} messages to run, saw ${messageIds.size}`,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const survivingAttempt = new Map<string, number>()
|
|
70
|
+
for (const execution of executions) {
|
|
71
|
+
const highest = survivingAttempt.get(execution.messageId) ?? 0
|
|
72
|
+
if (execution.attempt > highest) survivingAttempt.set(execution.messageId, execution.attempt)
|
|
73
|
+
}
|
|
74
|
+
const surviving = executions.filter(
|
|
75
|
+
(execution) => survivingAttempt.get(execution.messageId) === execution.attempt,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for (const [index, execution] of surviving.entries()) {
|
|
79
|
+
for (const other of surviving.slice(index + 1)) {
|
|
80
|
+
assert(
|
|
81
|
+
!overlaps(execution, other),
|
|
82
|
+
`${describe(execution)} and ${describe(other)} overlap, and neither was superseded`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const supersededOverlap = executions.some((execution) =>
|
|
88
|
+
executions.some((other) => other !== execution && overlaps(execution, other)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
executions: executions.length,
|
|
93
|
+
retried: executions.length > options.messageCount,
|
|
94
|
+
supersededOverlap,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pairExecutions(events: readonly SerializationEvent[]): Execution[] {
|
|
99
|
+
const started = new Map<string, SerializationEvent>()
|
|
100
|
+
const executions: Execution[] = []
|
|
101
|
+
|
|
102
|
+
for (const event of [...events].sort((left, right) => left.at - right.at)) {
|
|
103
|
+
const key = `${event.messageId}#${event.attempt}#${event.processId}`
|
|
104
|
+
if (event.event === "start") {
|
|
105
|
+
assert(!started.has(key), `${describe(event)} started twice`)
|
|
106
|
+
started.set(key, event)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const start = started.get(key)
|
|
110
|
+
assert(start !== undefined, `${describe(event)} finished with no matching start`)
|
|
111
|
+
started.delete(key)
|
|
112
|
+
executions.push({
|
|
113
|
+
messageId: event.messageId,
|
|
114
|
+
attempt: event.attempt,
|
|
115
|
+
processId: event.processId,
|
|
116
|
+
startedAt: start.at,
|
|
117
|
+
finishedAt: event.at,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const unfinished = [...started.values()].map(describe)
|
|
122
|
+
assert.equal(unfinished.length, 0, `${unfinished.join(", ")} never wrote a finish`)
|
|
123
|
+
|
|
124
|
+
return executions
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function overlaps(left: Execution, right: Execution): boolean {
|
|
128
|
+
return left.startedAt < right.finishedAt && right.startedAt < left.finishedAt
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function describe(execution: { messageId: string; attempt: number }): string {
|
|
132
|
+
return `${execution.messageId} attempt ${execution.attempt}`
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid-objects",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Race-free realtime state per application identity, backed by your SQL database",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"url": "git+https://github.com/cardmagic/solid-objects-js.git"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=24.
|
|
26
|
+
"node": ">=24.4.0"
|
|
27
27
|
},
|
|
28
28
|
"packageManager": "pnpm@11.11.0",
|
|
29
29
|
"bin": {
|