solid-objects 0.15.2 → 0.16.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 (127) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/dist/actor-runtime.d.ts +9 -1
  4. package/dist/actor-runtime.d.ts.map +1 -1
  5. package/dist/actor.d.ts +22 -4
  6. package/dist/actor.d.ts.map +1 -1
  7. package/dist/actor.js +81 -2
  8. package/dist/actor.js.map +1 -1
  9. package/dist/broadcast-worker.js +1 -1
  10. package/dist/broadcast-worker.js.map +1 -1
  11. package/dist/cloudflare/configuration.d.ts +3 -1
  12. package/dist/cloudflare/configuration.d.ts.map +1 -1
  13. package/dist/cloudflare/configuration.js +4 -0
  14. package/dist/cloudflare/configuration.js.map +1 -1
  15. package/dist/cloudflare/engine.d.ts +4 -0
  16. package/dist/cloudflare/engine.d.ts.map +1 -1
  17. package/dist/cloudflare/engine.js +91 -24
  18. package/dist/cloudflare/engine.js.map +1 -1
  19. package/dist/cloudflare/records.d.ts +2 -0
  20. package/dist/cloudflare/records.d.ts.map +1 -1
  21. package/dist/cloudflare/runtime.d.ts +8 -0
  22. package/dist/cloudflare/runtime.d.ts.map +1 -1
  23. package/dist/cloudflare/runtime.js +52 -1
  24. package/dist/cloudflare/runtime.js.map +1 -1
  25. package/dist/cloudflare/storage.d.ts +2 -0
  26. package/dist/cloudflare/storage.d.ts.map +1 -1
  27. package/dist/cloudflare/storage.js +10 -0
  28. package/dist/cloudflare/storage.js.map +1 -1
  29. package/dist/configuration.d.ts +9 -5
  30. package/dist/configuration.d.ts.map +1 -1
  31. package/dist/configuration.js +43 -7
  32. package/dist/configuration.js.map +1 -1
  33. package/dist/database/types.d.ts +4 -0
  34. package/dist/database/types.d.ts.map +1 -1
  35. package/dist/dead-letter-scopes.d.ts +75 -0
  36. package/dist/dead-letter-scopes.d.ts.map +1 -0
  37. package/dist/dead-letter-scopes.js +157 -0
  38. package/dist/dead-letter-scopes.js.map +1 -0
  39. package/dist/dead-letters.d.ts +5 -0
  40. package/dist/dead-letters.d.ts.map +1 -1
  41. package/dist/dead-letters.js +16 -0
  42. package/dist/dead-letters.js.map +1 -1
  43. package/dist/definition.d.ts +2 -0
  44. package/dist/definition.d.ts.map +1 -1
  45. package/dist/definition.js +1 -1
  46. package/dist/definition.js.map +1 -1
  47. package/dist/doctor.d.ts +1 -0
  48. package/dist/doctor.d.ts.map +1 -1
  49. package/dist/doctor.js +35 -2
  50. package/dist/doctor.js.map +1 -1
  51. package/dist/effect-recovery-coordinator.d.ts +2 -0
  52. package/dist/effect-recovery-coordinator.d.ts.map +1 -1
  53. package/dist/effect-recovery-coordinator.js +1 -1
  54. package/dist/effect-recovery-coordinator.js.map +1 -1
  55. package/dist/effect-worker.js +1 -1
  56. package/dist/effect-worker.js.map +1 -1
  57. package/dist/errors.d.ts +4 -0
  58. package/dist/errors.d.ts.map +1 -1
  59. package/dist/errors.js +7 -0
  60. package/dist/errors.js.map +1 -1
  61. package/dist/index.d.ts +9 -5
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +5 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/outcome.d.ts +18 -0
  66. package/dist/outcome.d.ts.map +1 -0
  67. package/dist/outcome.js +2 -0
  68. package/dist/outcome.js.map +1 -0
  69. package/dist/platform/uuid.d.ts +1 -0
  70. package/dist/platform/uuid.d.ts.map +1 -1
  71. package/dist/platform/uuid.js +5 -0
  72. package/dist/platform/uuid.js.map +1 -1
  73. package/dist/records.d.ts +6 -0
  74. package/dist/records.d.ts.map +1 -1
  75. package/dist/redrive.d.ts +60 -0
  76. package/dist/redrive.d.ts.map +1 -0
  77. package/dist/redrive.js +239 -0
  78. package/dist/redrive.js.map +1 -0
  79. package/dist/reference.d.ts +17 -3
  80. package/dist/reference.d.ts.map +1 -1
  81. package/dist/reference.js +10 -0
  82. package/dist/reference.js.map +1 -1
  83. package/dist/reminder-scheduler.js +1 -1
  84. package/dist/reminder-scheduler.js.map +1 -1
  85. package/dist/repository.d.ts +34 -4
  86. package/dist/repository.d.ts.map +1 -1
  87. package/dist/repository.js +124 -21
  88. package/dist/repository.js.map +1 -1
  89. package/dist/runtime.d.ts +42 -1
  90. package/dist/runtime.d.ts.map +1 -1
  91. package/dist/runtime.js +263 -6
  92. package/dist/runtime.js.map +1 -1
  93. package/dist/schema.d.ts +1 -0
  94. package/dist/schema.d.ts.map +1 -1
  95. package/dist/schema.js +97 -1
  96. package/dist/schema.js.map +1 -1
  97. package/dist/types.d.ts +15 -0
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. package/dist/wake-up/postgresql.d.ts +4 -2
  102. package/dist/wake-up/postgresql.d.ts.map +1 -1
  103. package/dist/wake-up/postgresql.js +9 -0
  104. package/dist/wake-up/postgresql.js.map +1 -1
  105. package/dist/wake-up/redis.d.ts +2 -1
  106. package/dist/wake-up/redis.d.ts.map +1 -1
  107. package/dist/wake-up/redis.js +6 -0
  108. package/dist/wake-up/redis.js.map +1 -1
  109. package/dist/wake-up-selection.d.ts +22 -0
  110. package/dist/wake-up-selection.d.ts.map +1 -0
  111. package/dist/wake-up-selection.js +198 -0
  112. package/dist/wake-up-selection.js.map +1 -0
  113. package/dist/wake-up.d.ts +15 -0
  114. package/dist/wake-up.d.ts.map +1 -1
  115. package/dist/wake-up.js +6 -0
  116. package/dist/wake-up.js.map +1 -1
  117. package/dist/worker.js +1 -1
  118. package/dist/worker.js.map +1 -1
  119. package/docs/api.md +202 -6
  120. package/docs/cloudflare.md +1 -0
  121. package/docs/configuration.md +52 -22
  122. package/docs/correctness.md +8 -0
  123. package/docs/dashboard.md +9 -0
  124. package/docs/fit.md +3 -1
  125. package/docs/operations.md +104 -7
  126. package/docs/parity.md +100 -25
  127. package/package.json +1 -1
package/docs/api.md CHANGED
@@ -45,6 +45,9 @@ authorization, capability boundaries, and release validation.
45
45
  - `Actor`: base class providing `ref()`, `actorId`, `currentMessage`,
46
46
  `observables()`, `reject()`, `emit()`, `transmit()`, `commitAction()`,
47
47
  `schedule()`, `sendTo()`, and protected lifecycle hooks.
48
+ `ScheduledOperationsFor` types the map `schedule()` returns, whose calls return a
49
+ `ReminderHandle`. `TransmittedOperationsFor` types the map `transmit()`
50
+ returns, whose calls return nothing.
48
51
  - `broadcastValue(value)`: mark an observable so its changed value enters the
49
52
  durable invalidation envelope.
50
53
  - `broadcastInvalidation(value)`: compare the real observable value but put
@@ -67,10 +70,16 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
67
70
  `DestroyOptions`: the options for authorization, idempotency, time, and
68
71
  schedule that the reference methods use.
69
72
 
73
+ `ScheduledReminder` is one armed reminder as an actor reads it, and
74
+ `ReminderReader` is how a runtime supplies them.
75
+
70
76
  `ActorIntents`, `EffectIntent`, `CommitActionIntent`, `ReminderIntent`,
77
+ `UnscheduleIntent`, `UnscheduleAllIntent`, `ReminderMutation`,
71
78
  `OutboundMessageIntent`, `ReminderOptions`, `OutboundMessageOptions`,
72
79
  `PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
73
- transactional work and typed personalized projections.
80
+ transactional work and typed personalized projections. `ReminderMutation` is the
81
+ union of one scheduled reminder and the two cancellations, held in one list so
82
+ they apply in the order the turn called them.
74
83
 
75
84
  `EffectFailurePayload<Arguments>`, `EffectSuccessPayload<Arguments, Result>`,
76
85
  and `SerializedError` describe effect callback messages. They are also exported
@@ -162,6 +171,130 @@ row per item. It also cannot strand an entry when the runtime coalesces an
162
171
  occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
163
172
  when one item needs an alarm that you can move on its own.
164
173
 
174
+ #### Cancelling a reminder
175
+
176
+ `schedule()` returns a `ReminderHandle` (`{ name: string }`) naming the alarm it
177
+ armed. `unschedule()` cancels one alarm, by operation, by operation and key, or
178
+ by that handle. `unscheduleAll()` cancels every key of one operation.
179
+
180
+ ```typescript
181
+ const MONTH = 30 * 24 * 60 * 60 * 1000
182
+
183
+ class Subscription extends Actor {
184
+ static override readonly actorType = "subscriptions"
185
+
186
+ status = "trialing"
187
+ renewal: ReminderHandle | null = null
188
+
189
+ startTrial(): void {
190
+ this.schedule({ at: new Date(Date.now() + (14 * MONTH) / 30) }).trialExpired()
191
+ }
192
+
193
+ convertToPaid(): void {
194
+ this.status = "active"
195
+ this.unschedule("trialExpired")
196
+ this.renewal = this.schedule({
197
+ at: new Date(Date.now() + MONTH),
198
+ everyMilliseconds: MONTH,
199
+ }).chargeRenewal()
200
+ }
201
+
202
+ cancelled(): void {
203
+ this.status = "cancelled"
204
+ if (this.renewal) this.unschedule(this.renewal)
205
+ }
206
+
207
+ trialExpired(): void {
208
+ this.status = "expired"
209
+ }
210
+
211
+ chargeRenewal(): void {}
212
+ }
213
+ ```
214
+
215
+ `this.unschedule(this.renewal)` and `this.unschedule("chargeRenewal")` cancel the
216
+ same alarm. Prefer the handle when the actor already stored one, because it
217
+ cannot drift from the name that armed the reminder.
218
+
219
+ A keyed alarm cancels by the key that armed it, and `unscheduleAll()` cancels
220
+ every key of one operation:
221
+
222
+ ```typescript
223
+ class Shipment extends Actor {
224
+ static override readonly actorType = "shipments"
225
+
226
+ dispatch({ carrierIds }: { carrierIds: string[] }): void {
227
+ for (const carrierId of carrierIds) {
228
+ this.schedule({ at: new Date(Date.now() + MONTH / 30), key: carrierId }).chaseCarrier({
229
+ carrierId,
230
+ })
231
+ }
232
+ }
233
+
234
+ shipped({ carrierId }: { carrierId: string }): void {
235
+ this.unschedule("chaseCarrier", { key: carrierId })
236
+ }
237
+
238
+ stopChasing(): void {
239
+ this.unscheduleAll("chaseCarrier")
240
+ }
241
+
242
+ chaseCarrier(_options: { carrierId: string }): void {}
243
+ }
244
+ ```
245
+
246
+ `unschedule()` and `unscheduleAll()` refuse an operation the actor does not
247
+ declare, with the `UnknownOperation` that `schedule()` already throws, so a typo
248
+ fails the turn rather than cancelling nothing. A handle skips that check, because
249
+ the `schedule()` call that produced it was already checked.
250
+
251
+ #### Reading the schedule
252
+
253
+ `reminder()` returns the armed alarm as a `ScheduledReminder`, or `undefined`.
254
+ `reminders()` returns every key of one operation. Both are async, because an
255
+ actor reads its own rows rather than holding them in memory:
256
+
257
+ ```typescript
258
+ async nextChargeAt(): Promise<number | null> {
259
+ return (await this.reminder("chargeRenewal"))?.runAtMilliseconds ?? null
260
+ }
261
+
262
+ async pendingCarriers(): Promise<(string | null)[]> {
263
+ return (await this.reminders("chaseCarrier")).map((reminder) => reminder.key)
264
+ }
265
+ ```
266
+
267
+ A read applies the intents staged so far in the turn, so an actor that schedules
268
+ and then reads sees what the commit will write, and one that cancels and then
269
+ reads sees the alarm gone.
270
+
271
+ `key` and `intervalMilliseconds` are `null` rather than `undefined` when absent,
272
+ so a `ScheduledReminder` returns from an operation without a serialization error.
273
+
274
+ Reading is available during a turn and from a snapshot projection, so an
275
+ observable can report what is armed. `reminder()` and `reminders()` refuse an
276
+ operation the actor does not declare, as `schedule()` and `unschedule()` do.
277
+
278
+ A one-shot that already fired is not reported. Its row stays as `completed`, and
279
+ an alarm that cannot fire again is not armed.
280
+
281
+ `ScheduledReminder` carries no occurrence count. The SQL backends track one and
282
+ Durable Objects does not, so it is left out rather than reported for one backend
283
+ only.
284
+
285
+ A cancellation is staged like a schedule, so it commits with the state change
286
+ that decided it and a turn that throws cancels nothing. Both apply in the order
287
+ the turn called them, so cancelling and then scheduling the same name leaves it
288
+ armed at the new time.
289
+
290
+ Cancelling an alarm that does not exist is not an error. `unschedule()` returns
291
+ nothing, because it stages an intent rather than applying one, and an answer
292
+ given at call time could be stale by the time the turn commits.
293
+
294
+ A handle is a plain object, so it survives in actor state and still cancels
295
+ after a deactivation. Passing a handle together with `key` is a `TypeError`,
296
+ because the handle already names the key.
297
+
165
298
  ### Recovering abandoned effects
166
299
 
167
300
  `emit` returns an `EffectHandle` (`{ id: string }`) on every successful call. Save
@@ -358,9 +491,11 @@ effect results and the handler's declared argument/result types in agreement.
358
491
  ### Typed operation references
359
492
 
360
493
  `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:
494
+ inside actor methods and for inherited application operations. A scheduled operation
495
+ call returns a `ReminderHandle`, through `ScheduledOperationsFor<ActorType>`, and a
496
+ transmitted one returns `void`, through `TransmittedOperationsFor<ActorType>`. Both
497
+ preserve required, optional, and zero-argument operation signatures. No non-null
498
+ assertion is needed:
364
499
 
365
500
  ```typescript
366
501
  class ChatRun extends Actor {
@@ -415,7 +550,49 @@ runtime scheduling and transmission behavior are unchanged.
415
550
  Every manager below is available as a property on `SolidObjectsRuntime`; the
416
551
  class and result types are also exported for integration typing.
417
552
 
553
+ - `runtime.findBy({ requestId })` and `reference.findBy({ idempotencyKey })`
554
+ rebuild a `MessageReference` for work whose reference a caller lost. A request
555
+ id is unique across the table, so the runtime answers it; an idempotency key
556
+ is unique per actor, so a reference supplies that scope.
557
+ `runtime.findBy({ reference, idempotencyKey })` is the explicit form. Naming
558
+ neither key, naming both, or naming an idempotency key without a reference
559
+ throws a `TypeError`. An absent row, an unregistered actor, and a caller the
560
+ policy refuses all return `undefined`.
561
+ - An actor remembers the idempotency keys of its own last
562
+ `retainedIdempotencyKeys` finished turns, so `reference.findBy({
563
+ idempotencyKey })` throws `MessagePruned` for a key the actor remembers and
564
+ whose message retention removed, and returns `undefined` for a key no caller
565
+ ever sent. An actor remembers the operation and original arguments beside each key, so the pruned
566
+ answer runs the same hook against the same operation and arguments a lookup of the
567
+ surviving row would, and a caller the policy refuses reads `undefined` for
568
+ both. `runtime.findBy({ requestId })` returns
569
+ `undefined` in both cases, because the runtime generates a request id and no
570
+ actor remembers one.
571
+ - `retainedIdempotencyKeysBytes` bounds the serialized memory as well, because
572
+ an idempotency key has no length limit and the memory outlives the message
573
+ row. An actor drops its oldest keys until the list fits, so a key long enough
574
+ to fill the limit by itself is never remembered and its lookup answers
575
+ `undefined` rather than throwing.
576
+ - Remembered arguments count toward the serialized memory limit and remain until
577
+ the entry is evicted or the instance is removed. Entries from older versions
578
+ that lack arguments return absence after pruning because their original
579
+ authorization cannot be reproduced.
580
+ - `messageReference.outcome()` returns an `Outcome`: the status, the result, an
581
+ `ErrorRecord` for a dead message, a `RejectionRecord` for a rejected one, and
582
+ the attempt count.
418
583
  - `runtime.deadLetters` / `DeadLetterManager`: `all()` and idempotent `retry()`.
584
+ - `runtime.deadLetters.effects` and `runtime.deadLetters.broadcasts`: a
585
+ `DeadLetterScope` for one `DeadLetterKind`. `all()` lists its dead rows as
586
+ `DeadRow` values, `retry(id)` returns one to pending, and `redrive(options)`
587
+ moves a whole scope. `RedriveOptions` names `actorType`, `failedAfter`, and
588
+ `limit`, which become the `RedriveFilters` the task records. `UnknownDeadRow`
589
+ reports an id that does not exist.
590
+ - `runtime.redrives` / `RedriveManager`: `find(id)`, `all({ status })`,
591
+ `cancel(id)`, and `advance()`, which moves one bounded batch. A `RedriveTask`
592
+ carries its id, kind, filters, `RedriveStatus`, `moved`, `remaining`,
593
+ `startedAt`, `finishedAt`, and its own `cancel()`. `RedriveScheduler` is the
594
+ component that advances tasks inside `runtime.run()`. `UnknownRedrive` and
595
+ `RedriveNotStarted` report a missing task and a task that could not open.
419
596
  - `runtime.reminders` / `ReminderManager`: cursor-paginated `all()` and
420
597
  idempotent paused-alarm `resume()`.
421
598
  - `runtime.processes` / `ProcessManager`: immutable role `all()` and stale-owner
@@ -529,13 +706,32 @@ wait. `WakeUpWatch.wait()` returns `true` for a notification and `false` for a
529
706
  timeout or cancellation. A legacy `void` result remains accepted and preserves
530
707
  the fast polling cadence.
531
708
 
709
+ `configuration.wakeUp` takes a name or an adapter. `WakeUpSetting` is that
710
+ union, `WakeUpName` is one of `WAKE_UP_NAMES`, and the default is
711
+ `"automatic"`. Selection prefers `SOLID_OBJECTS_REDIS_URL`, then PostgreSQL
712
+ notifications, then polling. An unknown name throws rather than polls.
713
+
714
+ `selectWakeUp(options)` runs that choice and returns a `SelectedWakeUp`, which
715
+ pairs the adapter with a `WakeUpCapability`. `WakeUpSelectionOptions` names the
716
+ inputs: the setting, the database, the idle polling interval, a logger, and an
717
+ optional Redis URL and probe timeout. `WakeUpCapability` reports
718
+ `WakeUpAdapterName`, whether the adapter crosses processes, the measured floor
719
+ in milliseconds, and the reason. `runtime.wakeUpAdapter()` and
720
+ `runtime.wakeUpCapability()` return the choice, which is made once per runtime.
721
+
722
+ An adapter may declare its own `defaultCapability`, which is what it reports
723
+ about itself. Selection keeps what a configured adapter declares rather than
724
+ assume, and `runtime.wakeUpCapability()` reports what selection installed. `NotificationWakeUpAdapter` adds
725
+ `channelFor(role)`, which a database that offers a notification channel
726
+ provides through `database.wakeUp(options)`.
727
+
532
728
  ### Errors
533
729
 
534
730
  The root exports `SolidObjectsError` and its supported subclasses:
535
731
 
536
732
  - policy and caller outcomes: `Unauthorized`, `Rejected`, `ActorDestroyed`,
537
- `SyncEnqueueTimeout`, `SyncTimeout`, `SyncInsideTransaction`, and
538
- `MessageFailed`;
733
+ `SyncEnqueueTimeout`, `SyncTimeout`, `SyncInsideTransaction`,
734
+ `MessagePruned`, and `MessageFailed`;
539
735
  - admission and payload failures: `MailboxFull`, `InvalidPayload`,
540
736
  `PayloadTooLarge`, `IdempotencyConflict`, `InvalidPayloadBroadcast`, and
541
737
  `UnknownPayloadBroadcast`;
@@ -124,6 +124,7 @@ replay current committed projections, not every event missed while offline.
124
124
  | Actor-scoped dead letters and reminder administration | Supported |
125
125
  | `commitAction`, shared application SQL transactions | Unsupported |
126
126
  | Global repository, reconciliation, process controls, SQL dashboard | Unsupported |
127
+ | `findBy({ requestId })` without a reference | Unsupported; name the actor |
127
128
  | Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions |
128
129
  | SQL-to-Durable-Objects data migration | Not provided |
129
130
 
@@ -49,19 +49,19 @@ as an instruction to divide the actor. See
49
49
 
50
50
  ## Runtime roles and supervision
51
51
 
52
- | Option | Default | Contract |
53
- | ------------------------------------------- | -----------------------: | -------------------------------------------------------- |
54
- | `workerCount` | `1` | Non-negative actor workers. |
55
- | `effectWorkerCount` | `1` | Non-negative effect workers. |
56
- | `broadcastWorkerCount` | `1` | Non-negative broadcast workers when realtime is enabled. |
57
- | `reminderSchedulerCount` | `1` | Non-negative reminder schedulers. |
58
- | `processHeartbeatIntervalMilliseconds` | `15_000` | Positive persisted heartbeat cadence. |
59
- | `processAliveThresholdMilliseconds` | `60_000` | Positive age after which an owner is stale. |
60
- | `shutdownTimeoutMilliseconds` | `15_000` | Positive shared graceful-shutdown budget. |
61
- | `supervisorRestartDelayMilliseconds` | `100` | Positive initial failed-role replacement delay. |
62
- | `supervisorMaximumRestartDelayMilliseconds` | `10_000` | Positive cap no smaller than the initial delay. |
63
- | `wakeUp` | `InProcessWakeUpAdapter` | Adapter implementing `watch`, `notify`, and `close`. |
64
- | `logger` | console methods | Structured `debug`, `info`, `warn`, and `error` sink. |
52
+ | Option | Default | Contract |
53
+ | ------------------------------------------- | --------------: | ----------------------------------------------------------------- |
54
+ | `workerCount` | `1` | Non-negative actor workers. |
55
+ | `effectWorkerCount` | `1` | Non-negative effect workers. |
56
+ | `broadcastWorkerCount` | `1` | Non-negative broadcast workers when realtime is enabled. |
57
+ | `reminderSchedulerCount` | `1` | Non-negative reminder schedulers. |
58
+ | `processHeartbeatIntervalMilliseconds` | `15_000` | Positive persisted heartbeat cadence. |
59
+ | `processAliveThresholdMilliseconds` | `60_000` | Positive age after which an owner is stale. |
60
+ | `shutdownTimeoutMilliseconds` | `15_000` | Positive shared graceful-shutdown budget. |
61
+ | `supervisorRestartDelayMilliseconds` | `100` | Positive initial failed-role replacement delay. |
62
+ | `supervisorMaximumRestartDelayMilliseconds` | `10_000` | Positive cap no smaller than the initial delay. |
63
+ | `wakeUp` | `"automatic"` | A name or an adapter implementing `watch`, `notify`, and `close`. |
64
+ | `logger` | console methods | Structured `debug`, `info`, `warn`, and `error` sink. |
65
65
 
66
66
  Counts may be zero, but the complete configuration must leave at least one
67
67
  runtime role enabled. Broadcast workers are started only when `broadcast` or
@@ -78,15 +78,20 @@ polling remains the correctness path.
78
78
 
79
79
  ## Retention and cleanup
80
80
 
81
- | Option | Default | Contract |
82
- | ---------------------------------------- | ----------: | ---------------------------------------------------------------- |
83
- | `retentionIntervalMilliseconds` | `3_600_000` | Non-negative automatic history-pruning cadence; `0` disables it. |
84
- | `deadProcessCleanupIntervalMilliseconds` | `60_000` | Non-negative stale-owner recovery cadence; `0` disables it. |
85
- | `messageRetentionMilliseconds` | 30 days | Positive default completed-message retention. |
86
- | `messageRetentionByActorType` | `{}` | Positive per-type message-retention overrides. |
87
- | `instanceRetentionByActorType` | `{}` | Positive per-type instance-expiration opt-ins. |
88
- | `processRetentionMilliseconds` | 7 days | Positive stopped-process retention. |
89
- | `pruneBatchSize` | `1_000` | Positive integer maximum rows rechecked per pruning transaction. |
81
+ | Option | Default | Contract |
82
+ | ---------------------------------------- | ----------------: | ---------------------------------------------------------------- |
83
+ | `retentionIntervalMilliseconds` | `3_600_000` | Non-negative automatic history-pruning cadence; `0` disables it. |
84
+ | `deadProcessCleanupIntervalMilliseconds` | `60_000` | Non-negative stale-owner recovery cadence; `0` disables it. |
85
+ | `messageRetentionMilliseconds` | 30 days | Positive default completed-message retention. |
86
+ | `messageRetentionByActorType` | `{}` | Positive per-type message-retention overrides. |
87
+ | `instanceRetentionByActorType` | `{}` | Positive per-type instance-expiration opt-ins. |
88
+ | `processRetentionMilliseconds` | 7 days | Positive stopped-process retention. |
89
+ | `pruneBatchSize` | `1_000` | Positive integer maximum rows rechecked per pruning transaction. |
90
+ | `retainedIdempotencyKeys` | `64` | Positive integer idempotency keys an actor remembers. |
91
+ | `retainedIdempotencyKeysBytes` | `16_384` | Positive integer serialized size those keys must fit. |
92
+ | `redriveBatchSize` | `100` | Positive integer rows a redrive moves per transaction. |
93
+ | `redriveBatchPauseMilliseconds` | `50` | Non-negative pause between redrive batches. |
94
+ | `administrationIdentity` | `String(context)` | Names the operator recorded in an administration event. |
90
95
 
91
96
  Automatic retention prunes messages and stopped process records. Actor
92
97
  instance expiration remains an explicit administration action even when a type
@@ -158,6 +163,31 @@ short capped backoff only when no synchronous deadline is active.
158
163
  | `applicationName` | `"solid-objects"` |
159
164
  | `onPoolError` | structured `console.error` |
160
165
 
166
+ `wakeUp` takes a name or an adapter, as `config.cache_store` does in Rails.
167
+ `"automatic"` is the default. It prefers `SOLID_OBJECTS_REDIS_URL`, then
168
+ PostgreSQL notifications, then polling. `"in_process"` opts out, `"postgresql"`
169
+ and `"redis"` force one, and an unknown name throws rather than polls quietly.
170
+
171
+ A requested adapter that the environment cannot provide polls instead and says
172
+ so. `"postgresql"` on a database with no notification channel, `"redis"` without
173
+ `SOLID_OBJECTS_REDIS_URL`, and a Redis URL without the `redis` package each log
174
+ `solid_objects.wake_up.unavailable` once and report the reason in the
175
+ capability. Only a name that does not exist is refused, because a typo cannot be
176
+ honoured at all.
177
+
178
+ Selection runs once per runtime, on first use. `runtime.wakeUpCapability()`
179
+ reports what it chose, whether that choice crosses processes, its measured
180
+ floor in milliseconds, and why. The doctor reports the same record, and the
181
+ polling-only warning fires on what was installed rather than on whether a
182
+ setting was set. An adapter that declares its own `defaultCapability` keeps it, so a
183
+ configured `InProcessWakeUpAdapter` still warns.
184
+
185
+ Automatic selection proves the PostgreSQL path before it chooses it. It listens
186
+ on a probe channel, sends one `NOTIFY` from a second connection, and waits up to
187
+ two seconds for it to arrive. A probe that does not deliver falls back to
188
+ polling and warns once, because `LISTEN` does not survive a transaction pooler
189
+ such as PgBouncer.
190
+
161
191
  `database.wakeUp(options)` creates a dedicated notification adapter using the
162
192
  same connection string. Its options are `channelPrefix = "solid_objects"`,
163
193
  `applicationName = "solid-objects-wake-up"`, and `onListenerError`. Use a
@@ -5,6 +5,14 @@
5
5
  - Delivery is ordered per actor identity and at least once.
6
6
  - Different identities may execute concurrently.
7
7
  - Sequence allocation and durable enqueue are one transaction.
8
+ - An actor reads its own schedule. A read applies the intents staged so far in
9
+ the turn, so it agrees with what the commit will write rather than with what
10
+ the turn began with.
11
+ - A reminder can be cancelled. A cancellation commits with the state change that
12
+ decided it, and applies in the order the turn called it. A cancellation cannot
13
+ recall an occurrence the scheduler already turned into a message. It does
14
+ pre-empt one the scheduler claimed but has not yet enqueued, and the scheduler
15
+ treats that as ordinary work rather than a failure.
8
16
  - Concurrent callers that create the same actor produce one instance row and
9
17
  distinct sequences. The mailbox locks that row by its primary key, so MySQL
10
18
  does not upgrade a shared lock and the enqueue does not deadlock.
package/docs/dashboard.md CHANGED
@@ -141,6 +141,15 @@ Dead-letter retry calls `runtime.deadLetters.retry()`. It keeps the durable
141
141
  idempotency and the actor-operation validation of that method. If the runtime
142
142
  refuses a retry, the detail page shows it with status 422.
143
143
 
144
+ Dead effects and broadcasts have the same API, which the dashboard does not yet
145
+ surface. `runtime.deadLetters.effects` and `runtime.deadLetters.broadcasts` read
146
+ and retry their own kind, and `redrive` moves a whole scope as a durable task.
147
+ See [Operations](operations.md) for both.
148
+
149
+ Every retry and every redrive transition writes one row to
150
+ `solid_objects_administration_events`, holding the action, the kind, the
151
+ subject, and the identity that asked for it.
152
+
144
153
  `HEAD /` performs only a schema reachability query and creates no CSRF session
145
154
  state. Use it for liveness checks instead of polling the full dashboard.
146
155
 
package/docs/fit.md CHANGED
@@ -43,7 +43,9 @@ history, and no actor-state migration contract.
43
43
  limiter, see [Solid Objects Pro](https://solidobjects.pro/): grouped commits
44
44
  coalesce concurrent writes into one insert, and ephemeral operations keep
45
45
  loss-tolerant calls out of the durable journal. It ships for the Rails gem
46
- today, and the Node build is in development.
46
+ today, and the Node build is in development. Distributed per-actor rate
47
+ limits, global admission control, and cache-capacity eviction are answered
48
+ there rather than in this runtime, and are not open roadmap items here.
47
49
  - Durable workflow replay across named steps is more important than a mutable
48
50
  object with ordered operations. The runtime redelivers an ordered message and
49
51
  retries it. It does not replay a function from a step log.
@@ -14,16 +14,25 @@ work and wake-up notifications reset the role to the fast interval. Actor
14
14
  workers clamp the ceiling to `leaseRenewalIntervalMilliseconds` while they may
15
15
  hold cached activations.
16
16
 
17
- The default generation-based wake-up adapter interrupts waits for new actor
17
+ The runtime selects a wake-up adapter on first use. It prefers
18
+ `SOLID_OBJECTS_REDIS_URL`, then PostgreSQL notifications, then polling.
19
+ The generation-based in-process adapter interrupts waits for new actor
18
20
  messages, effects, reminders, and broadcasts in the same Node process. It does
19
- not cross a process boundary. When live processes share the database without a
20
- configured adapter, the runtime logs
21
- `solid_objects.polling_only_cross_process_wake_up` once. Use PostgreSQL
22
- notifications or optional Redis Pub/Sub when separate processes need prompt
23
- delivery. Without one of them, newly committed work can wait for the current
24
- idle polling interval. The runtime isolates notification errors and logs them by
21
+ not cross a process boundary. When live processes share the database and the
22
+ installed adapter does not cross processes, the runtime logs
23
+ `solid_objects.polling_only_cross_process_wake_up` once. Without a
24
+ cross-process adapter, newly committed work can wait for the current idle
25
+ polling interval. The runtime isolates notification errors and logs them by
25
26
  role and error class. The committed work does not fail.
26
27
 
28
+ `runtime.wakeUpCapability()` and the `wakeUp` doctor check report the adapter
29
+ that is installed, whether it crosses processes, its measured floor, and why it
30
+ was chosen. On PostgreSQL, selection first proves the path: it listens on a
31
+ probe channel, notifies it from a second connection, and waits for the
32
+ notification. A probe that does not arrive logs
33
+ `solid_objects.wake_up.pooled_session` once and falls back to polling, because
34
+ `LISTEN` does not survive a transaction pooler such as PgBouncer.
35
+
27
36
  The warning excludes process rows with the current hostname and host process ID.
28
37
  It can therefore appear during a rolling deployment or restart overlap when an
29
38
  older and newer process briefly share the same database. A process that stopped
@@ -218,3 +227,91 @@ be resumed.
218
227
  Scheduling an existing actor operation for a different time emits
219
228
  `solid_objects.reminder.replaced` after the fenced actor commit. Scheduling it
220
229
  for the same time emits nothing.
230
+
231
+ ## Dead letters, retry, and redrive
232
+
233
+ A message that exhausts its attempts becomes a dead letter. An effect or a
234
+ broadcast that exhausts its attempts stays in its own table with
235
+ `status = 'dead'`. All three are read and retried through one receiver, which
236
+ carries the kind:
237
+
238
+ ```ts
239
+ await runtime.deadLetters.all({ authorizationContext })
240
+ await runtime.deadLetters.retry(deadLetterId, { authorizationContext })
241
+
242
+ await runtime.deadLetters.effects.all({ authorizationContext })
243
+ await runtime.deadLetters.effects.retry(effectId, { authorizationContext })
244
+ await runtime.deadLetters.broadcasts.retry(broadcastId, { authorizationContext })
245
+ ```
246
+
247
+ An effect or broadcast retry returns the row to pending with a zero attempt
248
+ count, no claim, and immediate availability, and keeps its id, so a handler that
249
+ deduplicates on the effect id still sees the same key. An effect is
250
+ at-least-once by contract, so a retried effect can run twice.
251
+
252
+ Retry acts only on a dead row. A row that is pending, processing, or completed
253
+ comes back unchanged, so pressing a button twice cannot double-enqueue and
254
+ cannot take a row away from a worker that holds it.
255
+
256
+ An incident produces dead rows in the hundreds, so a scope also answers
257
+ `redrive`:
258
+
259
+ ```ts
260
+ const task = await runtime.deadLetters.effects.redrive({
261
+ actorType: "payments",
262
+ failedAfter: new Date(Date.now() - 6 * 60 * 60 * 1000),
263
+ limit: 5_000,
264
+ authorizationContext,
265
+ })
266
+
267
+ await task.cancel({ authorizationContext })
268
+ ```
269
+
270
+ `redrive` returns at once. The task is durable, and `runtime.run()` advances one
271
+ bounded batch per pass, so a redrive of thousands of rows never holds a
272
+ transaction longer than one batch. `redriveBatchSize` defaults to 100 and
273
+ `redriveBatchPauseMilliseconds` to 50.
274
+
275
+ A redrive moves the rows that were already dead when it started. A row that
276
+ fails again lands back in the same scope, and without that bound a task whose
277
+ handler is still broken would move it forever.
278
+
279
+ A redrive is idempotent over its scope and its filters. Starting the same one
280
+ while it runs returns the running task rather than a second one, which a
281
+ dashboard button an operator can press twice needs. A different scope or a
282
+ different filter starts its own task, and the same scope can be redriven again
283
+ once the first task finishes.
284
+
285
+ Read tasks back with `runtime.redrives`:
286
+
287
+ ```ts
288
+ await runtime.redrives.find(task.id, { authorizationContext })
289
+ await runtime.redrives.all({ status: "running", authorizationContext })
290
+ ```
291
+
292
+ A running task reports what is left to move rather than a stored estimate,
293
+ because rows die and are retried while it runs.
294
+
295
+ Retry, redrive, and cancel each go through `authorizeAdministration` under their
296
+ own resource name: `dead_letters`, `effect_dead_letters`,
297
+ `broadcast_dead_letters`, and `redrives`. Every retry and every task transition
298
+ writes one row to `solid_objects_administration_events`, holding the action, the
299
+ kind, the subject, the identity, and when it happened. The identity comes from
300
+ `administrationIdentity`, which receives the authorization context the caller
301
+ passed and defaults to its `String` form.
302
+
303
+ An event records an authorized press, not a state transition. Pressing retry
304
+ twice writes two rows, because an operator did two things and a log that shows
305
+ one cannot answer who pressed what. The row the event names carries the outcome.
306
+ A refused caller writes nothing, and a retry that raises after the lookup writes
307
+ nothing, because the event shares the transaction with the work. The redrive
308
+ transitions are different: `redrive.start`, `redrive.finish`, and
309
+ `redrive.cancel` are written only when the task actually changes.
310
+
311
+ The Durable Objects engine keeps its own message and outbox tables inside each
312
+ object, so these scopes and redrive cover the SQL backends. `deadLetters` there
313
+ reports its own dead rows as it did before.
314
+
315
+ Automatic redrive on a schedule is deliberately absent. A dead row means a
316
+ person decided something, and these APIs give that person an alternative to an
317
+ `UPDATE` against a runtime table.