solid-objects 0.14.8 → 0.15.0

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 (61) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/actor.d.ts +25 -9
  3. package/dist/actor.d.ts.map +1 -1
  4. package/dist/actor.js +31 -2
  5. package/dist/actor.js.map +1 -1
  6. package/dist/cloudflare/engine.d.ts.map +1 -1
  7. package/dist/cloudflare/engine.js +12 -7
  8. package/dist/cloudflare/engine.js.map +1 -1
  9. package/dist/core.d.ts +1 -0
  10. package/dist/core.d.ts.map +1 -1
  11. package/dist/core.js +1 -0
  12. package/dist/core.js.map +1 -1
  13. package/dist/doctor.d.ts.map +1 -1
  14. package/dist/doctor.js +10 -2
  15. package/dist/doctor.js.map +1 -1
  16. package/dist/effect-recovery-coordinator.d.ts +29 -0
  17. package/dist/effect-recovery-coordinator.d.ts.map +1 -0
  18. package/dist/effect-recovery-coordinator.js +152 -0
  19. package/dist/effect-recovery-coordinator.js.map +1 -0
  20. package/dist/effect-recovery.d.ts +27 -0
  21. package/dist/effect-recovery.d.ts.map +1 -0
  22. package/dist/effect-recovery.js +10 -0
  23. package/dist/effect-recovery.js.map +1 -0
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/platform/uuid.d.ts.map +1 -1
  29. package/dist/platform/uuid.js +2 -1
  30. package/dist/platform/uuid.js.map +1 -1
  31. package/dist/reference.d.ts +4 -1
  32. package/dist/reference.d.ts.map +1 -1
  33. package/dist/reference.js.map +1 -1
  34. package/dist/repository.d.ts +1 -0
  35. package/dist/repository.d.ts.map +1 -1
  36. package/dist/repository.js +53 -7
  37. package/dist/repository.js.map +1 -1
  38. package/dist/runtime.d.ts +0 -1
  39. package/dist/runtime.d.ts.map +1 -1
  40. package/dist/runtime.js +3 -16
  41. package/dist/runtime.js.map +1 -1
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +26 -1
  44. package/dist/schema.js.map +1 -1
  45. package/dist/types.d.ts +17 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/dist/wake-up-notification.d.ts +8 -0
  50. package/dist/wake-up-notification.d.ts.map +1 -0
  51. package/dist/wake-up-notification.js +12 -0
  52. package/dist/wake-up-notification.js.map +1 -0
  53. package/dist/worker.js +11 -1
  54. package/dist/worker.js.map +1 -1
  55. package/docs/api.md +259 -4
  56. package/docs/effect-recovery.md +59 -0
  57. package/docs/parity.md +31 -1
  58. package/examples/failure-recovery/actor.ts +19 -1
  59. package/examples/failure-recovery/demo.ts +47 -7
  60. package/examples/failure-recovery/worker.ts +9 -1
  61. package/package.json +3 -3
package/docs/api.md CHANGED
@@ -54,8 +54,11 @@ authorization, capability boundaries, and release validation.
54
54
  - `reference.live`: read-only live signals for an actor, enabled by the
55
55
  `solid-objects/signals` entry point documented below.
56
56
  - `ActorClass`, `ActorReference`, `ActorMessageSender`, `ActorSnapshot`,
57
- `ActorOperationNames`, `ActorQueryNames`, `StagedOperations`, and
58
- `ScheduledOperations`: inferred actor-class and fluent-dispatch types.
57
+ `ActorOperationNames`, `ActorQueryNames`, `StagedOperations`,
58
+ `ScheduledOperationsFor`, and `ScheduledOperations`: inferred actor-class and
59
+ fluent-dispatch types, plus the legacy dynamic scheduling map.
60
+ - `EffectOptions`: effect arguments and independently checked success/failure
61
+ callback names. Effect names themselves belong to the runtime's global registry.
59
62
  - `SnapshotWithIncarnation`: the `{ snapshot, instanceId, revision,
60
63
  createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
61
64
  - `MessageReference`: immutable durable message identity with `id`,
@@ -69,6 +72,10 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
69
72
  `PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
70
73
  transactional work and typed personalized projections.
71
74
 
75
+ `EffectFailurePayload<Arguments>`, `EffectSuccessPayload<Arguments, Result>`,
76
+ and `SerializedError` describe effect callback messages. They are also exported
77
+ from the browser-safe `solid-objects/core` entry point.
78
+
72
79
  `observables()` returns a flat object. Unwrapped values are invalidation-only:
73
80
  their real values participate in change detection, but only their names enter
74
81
  the durable envelope. Use an explicit marker when wire behavior matters:
@@ -126,7 +133,7 @@ operation. If you arm one alarm per queued item, only the last one remains:
126
133
  // Wrong. Every entry overwrites the previous entry's alarm.
127
134
  add({ entry }: { entry: Entry }): void {
128
135
  this.entries = [...this.entries, entry]
129
- this.schedule({ at: new Date(entry.waitUntil) }).deliver!()
136
+ this.schedule({ at: new Date(entry.waitUntil) }).deliver()
130
137
  }
131
138
  ```
132
139
 
@@ -136,7 +143,7 @@ own identifier for the item and names that item's alarm, so each item gets one:
136
143
  ```typescript
137
144
  add({ entry }: { entry: Entry }): void {
138
145
  this.entries = [...this.entries, entry]
139
- this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver!()
146
+ this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver()
140
147
  }
141
148
  ```
142
149
 
@@ -155,6 +162,254 @@ row per item. It also cannot strand an entry when the runtime coalesces an
155
162
  occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
156
163
  when one item needs an alarm that you can move on its own.
157
164
 
165
+ ### Recovering abandoned effects
166
+
167
+ `emit` returns an `EffectHandle` (`{ id: string }`) on every successful call. Save
168
+ it in actor state to identify that exact persisted effect. The handle, state,
169
+ effect, and callback bindings commit together; a rejected turn persists none of
170
+ them. Existing callers can ignore the handle. Overrides and wrappers that
171
+ previously returned `void` must return `super.emit(...)`; explicit
172
+ `return this.emit(...)` and code expecting `undefined` need updating.
173
+
174
+ `onRecovery` opts a SQL effect into automatic retirement when its processing
175
+ owner stops heartbeating. `onStatus` is independent and optional: it only receives
176
+ responses to `requestEffectRecovery(handle)`, which requires both bindings.
177
+ Register both callbacks in `emit`; requests cannot rebind them. Requests stage an
178
+ intent in the current actor's fenced commit and perform no synchronous database
179
+ lookup inside the actor method. Only the originating instance can use its handle.
180
+
181
+ `recoveryTimeoutMilliseconds` is an optional positive safe integer requiring
182
+ `onRecovery`. The effective freshness window is the greater of that persisted
183
+ override and the runtime's current `processAliveThresholdMilliseconds` (default
184
+ 60,000). It can extend the window, never shorten it. It measures time since the
185
+ owner's database heartbeat, not effect duration or progress. An owner that keeps
186
+ heartbeating protects its effect indefinitely.
187
+
188
+ Heartbeat update errors emit `solid_objects.process.heartbeat_failed` and retry
189
+ at the configured interval without consuming effect attempts. An outage lasting
190
+ beyond the freshness window can still permit recovery; this does not cancel
191
+ external work or extend the timeout.
192
+
193
+ `EffectRetiredPayload<Arguments>` is the `onRecovery` envelope: `effectId`, original
194
+ `arguments`, and `outcome: EffectRecoveryOutcome.Retired`. No outcome guard is
195
+ needed in that callback. `EffectRecoveryPayload<Arguments, Result>` is the
196
+ discriminated union received by `onStatus`. `EffectRecoveryOutcome` is a frozen
197
+ constant object and a derived string-union type, exported from root and core.
198
+
199
+ | Constant | Outcome | Meaning |
200
+ | ---------------- | ------------------ | ------------------------------------------------------------------ |
201
+ | `Retired` | `"retired"` | This check retired the abandoned processing effect. |
202
+ | `Deferred` | `"deferred"` | Owner is fresh; preserve its claim and attempts. |
203
+ | `Pending` | `"pending"` | Initial execution or retry remains with the scheduler. |
204
+ | `Completed` | `"completed"` | Includes original arguments and recorded result, including `null`. |
205
+ | `Dead` | `"dead"` | Preserve the existing terminal failure and failure callback. |
206
+ | `AlreadyRetired` | `"alreadyRetired"` | An earlier decision retired it; no new recovery notification. |
207
+ | `Missing` | `"missing"` | Owned routing metadata remains but the effect was pruned. |
208
+
209
+ Every outcome includes `effectId`. Retired and completed require original
210
+ arguments; other outcomes may include retained arguments. Only completed has a
211
+ successful `result`. Database errors propagate as errors, never as missing or
212
+ abandoned outcomes. Unknown, foreign, and expired handles fail without exposing
213
+ another actor's effects or recreating a destroyed actor.
214
+
215
+ Automatic retirement sends only `onRecovery`. A winning explicit check enqueues
216
+ `onRecovery` first and its separate `onStatus` response second in one transaction.
217
+ Only `onRecovery` should emit replacement work. A completed status can repair an
218
+ outcome notification using the same guarded helper as `onSuccess`:
219
+
220
+ ```ts
221
+ import {
222
+ Actor,
223
+ EffectRecoveryOutcome,
224
+ type EffectHandle,
225
+ type EffectRetiredPayload,
226
+ type EffectRecoveryPayload,
227
+ type EffectSuccessPayload,
228
+ type JsonValue,
229
+ } from "solid-objects"
230
+
231
+ type ReportArguments = { revision: number }
232
+ type ReportResult = { artifactKey: string }
233
+
234
+ class ReportExport extends Actor {
235
+ static override readonly actorType = "ReportExport"
236
+ revision = 0
237
+ exportEffect: EffectHandle | null = null
238
+ artifactKey = ""
239
+ appliedEffectId: string | null = null
240
+
241
+ start(): void {
242
+ this.exportEffect = this.emit("build_report", {
243
+ arguments: { revision: ++this.revision },
244
+ onSuccess: "exportFinished",
245
+ onFailure: "exportFailed",
246
+ onRecovery: "recoverExport",
247
+ onStatus: "inspectExport",
248
+ recoveryTimeoutMilliseconds: 120_000,
249
+ })
250
+ this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
251
+ }
252
+
253
+ watchdog(): void {
254
+ if (this.exportEffect) this.requestEffectRecovery(this.exportEffect)
255
+ }
256
+
257
+ recoverExport(payload: EffectRetiredPayload<ReportArguments>): void {
258
+ if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
259
+ return
260
+ this.start()
261
+ }
262
+
263
+ exportFinished(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
264
+ this.applyExportResult(payload)
265
+ }
266
+
267
+ exportFailed(_payload: JsonValue): void {}
268
+
269
+ inspectExport(payload: EffectRecoveryPayload<ReportArguments, ReportResult>): void {
270
+ if (payload.effectId !== this.exportEffect?.id) return
271
+ if (payload.outcome === EffectRecoveryOutcome.Completed) this.applyExportResult(payload)
272
+ if (
273
+ payload.outcome === EffectRecoveryOutcome.Deferred ||
274
+ payload.outcome === EffectRecoveryOutcome.Pending
275
+ ) {
276
+ this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
277
+ }
278
+ }
279
+
280
+ private applyExportResult(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
281
+ if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
282
+ return
283
+ if (this.appliedEffectId === payload.effectId) return
284
+ this.artifactKey = payload.result.artifactKey
285
+ this.appliedEffectId = payload.effectId
286
+ }
287
+ }
288
+ ```
289
+
290
+ Routing metadata remains until the originating instance is destroyed or pruned;
291
+ it survives effect/message pruning but does not pin the instance. Checks after
292
+ that boundary fail. Callback delivery and idempotency follow durable mailbox
293
+ retention. Retirement survives a crash before callback delivery.
294
+
295
+ The Durable Objects backend returns ordinary emit handles using its outbox ID,
296
+ but rejects recovery callbacks, timeouts, and recovery intents before committing
297
+ the actor turn: it has no shared SQL process-heartbeat registry. See
298
+ [effect recovery coordination](effect-recovery.md) for transaction and lock order.
299
+
300
+ **External actions still require idempotency.** Retirement fences library state;
301
+ it does not cancel the previous JavaScript handler or prove its remote request
302
+ stopped. It does not provide exactly-once external execution.
303
+
304
+ ### Typing your onFailure handler
305
+
306
+ An effect callback is an ordinary actor operation. Its payload always includes
307
+ the stable `effectId` and the original serialized `arguments`, including `{}`
308
+ when the effect was emitted without arguments. Use the exported types when a
309
+ watchdog or failure handler needs to correlate work with the current generation:
310
+
311
+ ```typescript
312
+ import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects"
313
+
314
+ type RunArguments = { generation: number }
315
+
316
+ class ChatRun extends Actor {
317
+ static override readonly actorType = "ChatRun"
318
+ generation = 0
319
+ status = "idle"
320
+ reply = ""
321
+
322
+ start(): void {
323
+ this.emit("run_model", {
324
+ arguments: { generation: ++this.generation },
325
+ onSuccess: "finishTurn",
326
+ onFailure: "failTurn",
327
+ })
328
+ }
329
+
330
+ failTurn({ arguments: original, error }: EffectFailurePayload<RunArguments>): void {
331
+ if (original.generation !== this.generation) return
332
+ this.status = `${error.name}: ${error.message}`
333
+ }
334
+
335
+ finishTurn({
336
+ arguments: original,
337
+ result,
338
+ }: EffectSuccessPayload<RunArguments, { reply: string }>): void {
339
+ if (original.generation !== this.generation) return
340
+ this.status = "finished"
341
+ this.reply = result.reply
342
+ }
343
+ }
344
+ ```
345
+
346
+ Failure payloads contain `error: SerializedError`, with string `name` and
347
+ `message` fields. They do not include a stack or cause. Success payloads contain
348
+ `result`, which can be any JSON value; an undefined effect return becomes
349
+ `null`. The default argument type is `JsonObject` and the default success result
350
+ type is `JsonValue`. Declare argument shapes with a JSON-compatible type alias.
351
+
352
+ These types describe the SQL and Cloudflare callback envelopes. Error messages
353
+ for non-Error throws retain each backend's existing serialization behavior.
354
+ The generic parameters express your application's contract; they do not add
355
+ runtime validation or infer types from `registerEffect()`. Keep registered
356
+ effect results and the handler's declared argument/result types in agreement.
357
+
358
+ ### Typed operation references
359
+
360
+ `schedule` and `transmit` infer this actor's operation names and arguments, including
361
+ inside actor methods and for inherited application operations. The returned
362
+ `ScheduledOperationsFor<ActorType>` values return `void` and preserve required,
363
+ optional, and zero-argument operation signatures. No non-null assertion is needed:
364
+
365
+ ```typescript
366
+ class ChatRun extends Actor {
367
+ generation = 0
368
+ status = "idle"
369
+
370
+ start({ generation }: { generation: number }): void {
371
+ this.generation = generation
372
+ this.schedule({ at: new Date(Date.now() + 60_000), key: "watchdog" }).recoverIfStuck({
373
+ generation,
374
+ })
375
+ this.emit("run_model", { arguments: { generation }, onFailure: "failTurn" })
376
+ }
377
+
378
+ recoverIfStuck({ generation }: { generation: number }): void {
379
+ if (generation !== this.generation) return
380
+ this.status = "recovering"
381
+ }
382
+
383
+ failTurn({ error }: { error: { message: string } }): void {
384
+ this.status = error.message
385
+ }
386
+ }
387
+ ```
388
+
389
+ Misspelled operations/callbacks, state properties, queries, and Actor infrastructure
390
+ are rejected. `emit` checks each callback independently: widening one callback to
391
+ `string` does not disable literal checking of the other. A deliberately widened
392
+ `string` callback retains runtime validation. Object properties can also widen to
393
+ `string`; preserve literals with `as const` or specialize `EffectOptions` to keep
394
+ static checking when options are stored in a variable. Effect and commit-action names remain
395
+ strings because their registries are runtime-wide; inferring registered names needs
396
+ a separate registry typing design.
397
+
398
+ For deliberately dynamic scheduling, retain the exported legacy map explicitly:
399
+
400
+ ```typescript
401
+ const dynamicActor: Actor = this
402
+ const operations: ScheduledOperations = dynamicActor.schedule({ at: deadline })
403
+ operations[operationName]!({ generation })
404
+ ```
405
+
406
+ This opts out of operation-name and argument inference and retains the existing
407
+ runtime operation checks. Direct calls, queries, and `sendTo` keep their inference.
408
+ Subclasses that override `schedule` or `transmit` with an explicit legacy
409
+ `ScheduledOperations` return annotation must update their override signatures to
410
+ match the generic Actor methods. This is a compile-time compatibility change;
411
+ runtime scheduling and transmission behavior are unchanged.
412
+
158
413
  ### Runtime managers
159
414
 
160
415
  Every manager below is available as a property on `SolidObjectsRuntime`; the
@@ -0,0 +1,59 @@
1
+ # Effect recovery coordination
2
+
3
+ Install the additive schema migration and upgrade all effect workers and process
4
+ cleanup roles before emitting recovery-enabled effects. Older runtime versions
5
+ do not honor the persisted recovery bindings or the new lock protocol.
6
+
7
+ The single `emit` API allocates a stable effect ID at staging and returns its
8
+ JSON handle. The fenced actor commit persists the effect, actor state, and
9
+ optional recovery/status binding together. `requestEffectRecovery` stages a
10
+ check on that same transaction connection. It never opens another transaction
11
+ while an application commit action holds locks.
12
+
13
+ Automatic polling checks at most `claimScanLimit` stale candidates per pass,
14
+ prefiltering with database time, owner heartbeat, and the effective timeout. It
15
+ does not lock fresh owners or their actors, even when the global liveness floor
16
+ has elapsed but an effect's extended grace has not. Each candidate gets one
17
+ independent transaction; unlocked candidate reads remain hints only. Lock order is origin
18
+ instance, effects ordered by ID, recovery bindings ordered by effect ID, then
19
+ current owner processes ordered by ID. Explicit batches acquire all effect and
20
+ binding locks before any process locks. Completion/failure lock the instance
21
+ before the effect. Pending claims lock the effect and never subsequently lock
22
+ the instance. Mailbox insertion reuses the origin instance lock.
23
+
24
+ After waiting for these locks, the decision uses current ownership, a locked
25
+ heartbeat, database wall time, and the maximum of the current runtime threshold
26
+ and the persisted per-effect override. Missing owners are stale; query errors
27
+ are errors. Process shutdown preserves heartbeat evidence and opted-in claims.
28
+ Process pruning excludes effect owners; later polling revisits stopped owners
29
+ until each effect's individual grace expires. Ordinary effects and pending
30
+ retries retain their scheduler behavior.
31
+
32
+ Retirement records `retired_at_ms` in `effect_recoveries`, clears the claim, and
33
+ uses the existing terminal `completed` effect storage state. The durable binding
34
+ distinguishes retirement from successful completion and is checked first by all
35
+ recovery observations. No success callback is generated. This avoids rewriting
36
+ existing status constraints across PostgreSQL, MySQL, and SQLite. Internal
37
+ effect-table status alone is not the recovery outcome. Late completion/failure
38
+ must still match a processing claim, which retirement removes.
39
+
40
+ The terminal transition and `effect:<id>:recovery` mailbox insertion are atomic.
41
+ A winning explicit check additionally enqueues its separate status response,
42
+ after recovery, keyed by `effect:<id>:check:<internal-request-id>`. Failure of
43
+ either insertion rolls the transaction back. Multiple checks share one durable
44
+ retirement, with one response per request. A crash after commit cannot lose the
45
+ recovery callback. Successful retirement wakes actor workers after commit.
46
+ Wake-up failures are logged without changing the committed decision; mailbox
47
+ polling provides delivery.
48
+
49
+ Bindings belong to the exact originating instance, survive effect/message
50
+ pruning, and cascade when the instance is deleted. They neither pin instances nor
51
+ authorize cross-actor access. Within that retention lifetime a pruned effect can
52
+ report missing or already retired. Outside it, checks fail. Message idempotency
53
+ has normal mailbox retention; applications cannot supply internal request IDs.
54
+
55
+ See [the watchdog example](api.md#recovering-abandoned-effects). Success and
56
+ completed-status repair use one application guard; status never owns replacement.
57
+ External systems still require idempotency across retries and replacement
58
+ generations. Stale heartbeat evidence grants library recovery permission; it
59
+ does not prove the previous handler or remote operation stopped.
package/docs/parity.md CHANGED
@@ -29,6 +29,13 @@ such boundary between a gem and its dependents.
29
29
 
30
30
  ## Status vocabulary
31
31
 
32
+ Operation-reference typing is runtime-specific: TypeScript infers scheduled and
33
+ transmitted operations from the concrete receiver, and checks literal effect
34
+ callback names. Ruby offers opt-in RBS generation from declared application types
35
+ in [solid-objects-ruby#66](https://github.com/cardmagic/solid-objects-ruby/pull/66).
36
+ Both preserve runtime operation validation and global effect/commit-action names;
37
+ this does not imply automatic TypeScript-style inference in Ruby.
38
+
32
39
  - **Native**: the TypeScript runtime provides the capability in a Node-native
33
40
  shape.
34
41
  - **Partial**: the core exists, but an important Ruby guarantee or operational
@@ -54,7 +61,7 @@ is needed for the JavaScript stale-result race fix.
54
61
  | Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
55
62
  | Backpressure and payload caps | Partial | Serialization enforces a shared maximum JSON nesting depth, raising `InvalidPayload`, and an optional caller-supplied `maxBytes` limit, raising `PayloadTooLarge`; reminder names are bounded to 255 characters. Distributed per-actor rate limits and global admission control do not exist yet, matching the open Ruby roadmap item. |
56
63
  | Idle activation cache | Native | Long-running workers retain hydrated actors under renewable fenced leases, restore public state after failed turns, and release on timeout, fairness yield, lease loss, or shutdown. |
57
- | Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation. |
64
+ | Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation. Typed callback envelopes are exported. |
58
65
  | Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. |
59
66
  | One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
60
67
  | Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
@@ -65,6 +72,11 @@ is needed for the JavaScript stale-result race fix.
65
72
  | Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured `MessageFailed`; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |
66
73
  | Result lookup by request ID | Planned | This is also an open Ruby roadmap item and will be implemented in both runtimes when its authorization shape is settled. |
67
74
 
75
+ Effect callback envelopes are typed with `EffectFailurePayload`,
76
+ `EffectSuccessPayload`, and `SerializedError` in both SQL and Cloudflare.
77
+ Ruby RBS contracts are tracked in cardmagic/solid-objects-ruby#64 and preserve
78
+ Ruby field names; this does not change runtime delivery semantics.
79
+
68
80
  ## Operations
69
81
 
70
82
  | Capability | Status | TypeScript shape or remaining work |
@@ -205,3 +217,21 @@ Rails generators, Active Record models/controllers, Turbo rendering, and
205
217
  Action Cable are not copied into this package. The Rack dashboard is represented
206
218
  by the framework-neutral Fetch and Node adapter, renderer callbacks, and the
207
219
  same authorization and CSRF boundaries.
220
+
221
+ ## Effect recovery
222
+
223
+ Both runtimes maintain heartbeats during effect execution and retry failed
224
+ updates at the configured interval, reporting `process.heartbeat_failed`.
225
+
226
+ | Capability | Status | Contract |
227
+ | ----------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
228
+ | Abandoned SQL effect recovery | Native | Stable emit handles, automatic retirement, staged status checks, durable callbacks, and extending heartbeat grace in both languages. |
229
+ | Shared process-heartbeat recovery on Cloudflare | Not applicable | Durable Objects has no shared SQL process registry; recovery options and intents fail before commit. |
230
+
231
+ SQL effect recovery uses the same contract in Ruby and JavaScript: one `emit`
232
+ returns a stable handle; `onRecovery`/`on_recovery` opts into atomic retirement
233
+ and a durable callback; optional `onStatus`/`on_status` answers explicit staged
234
+ checks. Per-effect recovery timeouts extend the runtime heartbeat threshold
235
+ (milliseconds in JS, seconds in Ruby). Cloudflare returns emit handles but rejects
236
+ process-heartbeat recovery options and intents before commit. See
237
+ [the transaction protocol](effect-recovery.md).
@@ -1,6 +1,24 @@
1
1
  import { appendFile, access, writeFile } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
- import { Actor } from "solid-objects"
3
+ import { Actor, type EffectHandle, type EffectRetiredPayload } from "solid-objects"
4
+
5
+ export class RecoverableReport extends Actor {
6
+ static override readonly actorType = "RecoverableReport"
7
+ exportEffect: EffectHandle | null = null
8
+ recoveryCount = 0
9
+
10
+ start(): void {
11
+ this.exportEffect = this.emit("build_report", {
12
+ arguments: { revision: 1 },
13
+ onRecovery: "recoverExport",
14
+ })
15
+ }
16
+
17
+ recoverExport(payload: EffectRetiredPayload<{ revision: number }>): void {
18
+ if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== 1) return
19
+ this.recoveryCount += 1
20
+ }
21
+ }
4
22
 
5
23
  export class RecoveryCounter extends Actor {
6
24
  static override readonly actorType = "RecoveryCounter"
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"
7
7
  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
- import { RecoveryCounter } from "./actor.ts"
10
+ import { RecoverableReport, RecoveryCounter } from "./actor.ts"
11
11
  import {
12
12
  assertSerializedExecution,
13
13
  parseSerializationEvent,
@@ -46,11 +46,15 @@ const runtime = createRuntime({
46
46
 
47
47
  try {
48
48
  runtime.register(RecoveryCounter)
49
+ runtime.register(RecoverableReport)
49
50
  await runtime.install()
50
51
  const serialization = await proveSerialization()
51
52
  const crash = await proveCrashRecovery()
52
53
  const fencing = await proveFencing()
53
- process.stdout.write(`${JSON.stringify({ serialization, crash, fencing }, null, 2)}\n`)
54
+ const effectRecovery = await proveEffectRecovery()
55
+ process.stdout.write(
56
+ `${JSON.stringify({ serialization, crash, fencing, effectRecovery }, null, 2)}\n`,
57
+ )
54
58
  } finally {
55
59
  await runtime.close()
56
60
  await rm(directory, { recursive: true })
@@ -140,15 +144,51 @@ async function recoveryResult(options: {
140
144
  return { attempts, finalState: snapshot.count, repeatedEffects: effects.length }
141
145
  }
142
146
 
143
- function spawnWorker(): {
147
+ async function proveEffectRecovery(): Promise<{ recoveryCallbacks: number }> {
148
+ const reference = runtime.ref(RecoverableReport, "report")
149
+ await reference.start()
150
+ await runtime.repository.registerProcess("abandoned-effect-owner", "effect")
151
+ const effect = await runtime.repository.claimEffect("abandoned-effect-owner")
152
+ assert.ok(effect)
153
+ await runtime.settings.database.connection((connection) =>
154
+ connection.run(
155
+ `UPDATE ${runtime.repository.table("processes")} SET heartbeat_at_ms = 0 WHERE id = ?`,
156
+ ["abandoned-effect-owner"],
157
+ ),
158
+ )
159
+ const retiringWorker = spawnWorker({ mode: "retire-effects" })
160
+ const stopped = retiringWorker.finished.catch(() => undefined)
161
+ try {
162
+ await Promise.race([
163
+ retiringWorker.waitFor((message) => message.event === "effects.retired"),
164
+ retiringWorker.finished.then(() => {
165
+ throw new Error("worker exited before retirement")
166
+ }),
167
+ ])
168
+ } finally {
169
+ retiringWorker.child.kill("SIGKILL")
170
+ await stopped
171
+ }
172
+ assert.equal((await reference.snapshot()).recoveryCount, 0)
173
+ await spawnWorker().finished
174
+ assert.equal((await reference.snapshot()).recoveryCount, 1)
175
+ assert.equal(await runtime.repository.claimEffect("abandoned-effect-owner"), undefined)
176
+ return { recoveryCallbacks: 1 }
177
+ }
178
+
179
+ function spawnWorker(options: { mode?: "retire-effects" } = {}): {
144
180
  child: ChildProcess
145
181
  finished: Promise<void>
146
182
  waitFor(predicate: (message: WorkerMessage) => boolean): Promise<WorkerMessage>
147
183
  } {
148
- const child = fork(fileURLToPath(new URL("./worker.ts", import.meta.url)), [databasePath], {
149
- cwd: fileURLToPath(new URL("../..", import.meta.url)),
150
- stdio: ["ignore", "pipe", "pipe", "ipc"],
151
- })
184
+ const child = fork(
185
+ fileURLToPath(new URL("./worker.ts", import.meta.url)),
186
+ [databasePath, ...(options.mode ? [options.mode] : [])],
187
+ {
188
+ cwd: fileURLToPath(new URL("../..", import.meta.url)),
189
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
190
+ },
191
+ )
152
192
  const messages: WorkerMessage[] = []
153
193
  const listeners = new Set<(message: WorkerMessage) => void>()
154
194
  let stderr = ""
@@ -1,6 +1,6 @@
1
1
  import { createRuntime } from "solid-objects"
2
2
  import { sqlite } from "solid-objects/database/sqlite"
3
- import { RecoveryCounter } from "./actor.ts"
3
+ import { RecoverableReport, RecoveryCounter } from "./actor.ts"
4
4
 
5
5
  const databasePath = requiredArgument(2)
6
6
  const runtime = createRuntime({
@@ -24,7 +24,15 @@ const runtime = createRuntime({
24
24
  })
25
25
 
26
26
  runtime.register(RecoveryCounter)
27
+ runtime.register(RecoverableReport)
27
28
  await runtime.install()
29
+ if (process.argv[3] === "retire-effects") {
30
+ await runtime.repository.cleanupStaleProcesses()
31
+ process.send?.({ event: "effects.retired" })
32
+ await new Promise<void>(() => {
33
+ process.on("message", () => {})
34
+ })
35
+ }
28
36
  const worker = runtime.worker()
29
37
 
30
38
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.14.8",
3
+ "version": "0.15.0",
4
4
  "description": "Race-free realtime state per application identity, backed by your SQL database",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -115,8 +115,8 @@
115
115
  "test": "vitest run",
116
116
  "test:browser": "pnpm run build && playwright test",
117
117
  "test:coverage": "vitest run --coverage",
118
- "test:postgresql": "vitest run test/postgresql.test.ts",
119
- "test:mysql": "vitest run test/mysql.test.ts",
118
+ "test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts",
119
+ "test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts",
120
120
  "test:package": "node scripts/release-artifact-smoke.mjs",
121
121
  "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
122
122
  "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",