solid-objects 0.13.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +32 -27
  3. package/dist/actor.d.ts +5 -0
  4. package/dist/actor.d.ts.map +1 -1
  5. package/dist/actor.js +32 -0
  6. package/dist/actor.js.map +1 -1
  7. package/dist/doctor.js +2 -2
  8. package/dist/doctor.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/process-administration.d.ts +5 -0
  14. package/dist/process-administration.d.ts.map +1 -1
  15. package/dist/process-administration.js +9 -0
  16. package/dist/process-administration.js.map +1 -1
  17. package/dist/records.d.ts +1 -0
  18. package/dist/records.d.ts.map +1 -1
  19. package/dist/reminder-administration.d.ts +2 -0
  20. package/dist/reminder-administration.d.ts.map +1 -1
  21. package/dist/reminder-administration.js.map +1 -1
  22. package/dist/reminder-scheduler.js +1 -1
  23. package/dist/reminder-scheduler.js.map +1 -1
  24. package/dist/repository.d.ts.map +1 -1
  25. package/dist/repository.js +7 -4
  26. package/dist/repository.js.map +1 -1
  27. package/dist/runtime.d.ts +2 -1
  28. package/dist/runtime.d.ts.map +1 -1
  29. package/dist/runtime.js +9 -5
  30. package/dist/runtime.js.map +1 -1
  31. package/dist/schema.d.ts.map +1 -1
  32. package/dist/schema.js +14 -1
  33. package/dist/schema.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/docs/api.md +45 -0
  37. package/docs/operations.md +14 -3
  38. package/docs/parity.md +2 -2
  39. package/docs/releasing.md +9 -3
  40. package/docs/support.md +12 -1
  41. package/examples/failure-recovery/actor.ts +6 -8
  42. package/examples/failure-recovery/demo.ts +8 -26
  43. package/examples/failure-recovery/serialization.ts +133 -0
  44. package/package.json +2 -2
@@ -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.1",
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": {