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 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.15 or newer is required. The `0.13.2` release includes a
64
- packaged quickstart:
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.2 -- solid-objects quickstart
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.2";
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.2";
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.2. The JavaScript package began at the
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.2` implementation has capability parity with that reference. Its
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.2 -m "Version 0.13.2"
42
- git push origin v0.13.2
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.15 or newer; CI uses 24.15 |
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
- await appendFile(
29
- join(controlDirectory, "serialization.jsonl"),
30
- `${JSON.stringify({ event: "start", messageId: message.id, at: Date.now() })}\n`,
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; overlap: false }> {
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
- assert.equal(events.length, 4)
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, overlap: false }
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.2",
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.15.0"
26
+ "node": ">=24.4.0"
27
27
  },
28
28
  "packageManager": "pnpm@11.11.0",
29
29
  "bin": {