Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,58 @@
# Changelog

## Unreleased
## 0.16.0 - 2026-09-23

- Find a message whose reference a caller lost. `runtime.findBy({ requestId })`
answers a request id, which is unique across the table, and
`reference.findBy({ idempotencyKey })` answers a key, which is unique per
actor, so the receiver supplies the scope the key needs. Naming neither key,
naming both, or naming an idempotency key without a reference throws.
- Authorize every lookup with the hook the original call ran, against the stored
operation and arguments, because a request id is not a capability. An absent
row, an actor this process no longer registers, and a caller the policy
refuses all return `undefined`, so a lookup cannot be used to ask whether a
request id exists.
- Add `messageReference.outcome()`, which reports the status, the result, the
persisted error, the rejection, and the attempt count, so a terminal failure
answers as well as a success. Every value is frozen, so a caller cannot mutate
a durable result it read. The error carries `name` and `message`; this runtime
has never persisted a stack, which [parity](docs/parity.md) now records
against Ruby's `backtrace`.
- Add schema version 12: a unique index on `messages.request_id`. The table had
only `UNIQUE (actor_type, actor_id, request_id)`, which cannot serve a lookup
that names the request id alone.
- Tell a pruned message from one that never existed. An actor remembers the
idempotency keys of its own finished turns, the way an Orleans grain keeps its
deduplication history in grain state, so the memory needs no second store and
no second write. `reference.findBy({ idempotencyKey })` throws
`MessagePruned` for a key the actor remembers and whose message retention
removed, and still returns `undefined` for a key no caller ever sent. The
An actor remembers the operation beside each key, so the pruned answer runs
the same hook against the same operation a lookup of the surviving row would,
and a caller the policy refuses reads `undefined` for both. Gating it on
`snapshot` would have told a caller who may read state, but not the
operation, that the operation had run. The Durable Objects lookup no longer
demands a synthetic `__lookupMessage__` query in addition, which a policy
that allows only declared queries refused. `retainedIdempotencyKeys` bounds the memory and defaults
to 64 keys for each actor. A lookup by request id cannot make the
distinction, because the runtime generates a request id and no actor
remembers one. `retainedIdempotencyKeysBytes` bounds the serialized memory as
well, because an idempotency key has no length limit and the memory outlives
the message row. An actor drops its oldest keys until the list fits, so a key
long enough to fill the limit by itself is never remembered.
- Add schema version 13: `instances.completed_idempotency_keys`. The doctor
now reports the column as missing when it is not installed.
- Implement `findBy` and `messageOutcome` on the Durable Objects runtime, which
`ActorRuntime` required and the backend did not supply, so `pnpm run check`
and `pnpm run build` both failed. A Durable Object indexes only its own
messages, so `runtime.findBy({ requestId })` without a reference raises
`UnsupportedCapability`; every other form works. A lookup that the policy
refuses answers `undefined` there too. The Durable Objects `lookup` gates on
`authorizeQuery` before it reads, which would otherwise have thrown
`Unauthorized` where the SQL runtime answers absent, and a lookup that threw
where it was refused is a way to ask whether a key exists.
- Read the expected schema migration list from `SCHEMA_VERSIONS` in the doctor
and in the tests that assert it, rather than from four hand-copied lists.

- Retry a dead effect or broadcast. `runtime.deadLetters` keeps its message
meaning and answers `effects` and `broadcasts`, so the kind rides on the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Solid Object JavaScript Actors elegantly fit anything where one identifiable thi
- Ticket holds and reservations
- Multiplayer games and shared rooms
- Shopping carts and checkout recovery
- Rate limits and account quotas
- Low-rate quotas and account limits
- Session expiration
- Job leases and workflows
- Connected devices
Expand Down
34 changes: 32 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,36 @@ runtime scheduling and transmission behavior are unchanged.
Every manager below is available as a property on `SolidObjectsRuntime`; the
class and result types are also exported for integration typing.

- `runtime.findBy({ requestId })` and `reference.findBy({ idempotencyKey })`
rebuild a `MessageReference` for work whose reference a caller lost. A request
id is unique across the table, so the runtime answers it; an idempotency key
is unique per actor, so a reference supplies that scope.
`runtime.findBy({ reference, idempotencyKey })` is the explicit form. Naming
neither key, naming both, or naming an idempotency key without a reference
throws a `TypeError`. An absent row, an unregistered actor, and a caller the
policy refuses all return `undefined`.
- An actor remembers the idempotency keys of its own last
`retainedIdempotencyKeys` finished turns, so `reference.findBy({
idempotencyKey })` throws `MessagePruned` for a key the actor remembers and
whose message retention removed, and returns `undefined` for a key no caller
ever sent. An actor remembers the operation and original arguments beside each key, so the pruned
answer runs the same hook against the same operation and arguments a lookup of the
surviving row would, and a caller the policy refuses reads `undefined` for
both. `runtime.findBy({ requestId })` returns
`undefined` in both cases, because the runtime generates a request id and no
actor remembers one.
- `retainedIdempotencyKeysBytes` bounds the serialized memory as well, because
an idempotency key has no length limit and the memory outlives the message
row. An actor drops its oldest keys until the list fits, so a key long enough
to fill the limit by itself is never remembered and its lookup answers
`undefined` rather than throwing.
- Remembered arguments count toward the serialized memory limit and remain until
the entry is evicted or the instance is removed. Entries from older versions
that lack arguments return absence after pruning because their original
authorization cannot be reproduced.
- `messageReference.outcome()` returns an `Outcome`: the status, the result, an
`ErrorRecord` for a dead message, a `RejectionRecord` for a rejected one, and
the attempt count.
- `runtime.deadLetters` / `DeadLetterManager`: `all()` and idempotent `retry()`.
- `runtime.deadLetters.effects` and `runtime.deadLetters.broadcasts`: a
`DeadLetterScope` for one `DeadLetterKind`. `all()` lists its dead rows as
Expand Down Expand Up @@ -700,8 +730,8 @@ provides through `database.wakeUp(options)`.
The root exports `SolidObjectsError` and its supported subclasses:

- policy and caller outcomes: `Unauthorized`, `Rejected`, `ActorDestroyed`,
`SyncEnqueueTimeout`, `SyncTimeout`, `SyncInsideTransaction`, and
`MessageFailed`;
`SyncEnqueueTimeout`, `SyncTimeout`, `SyncInsideTransaction`,
`MessagePruned`, and `MessageFailed`;
- admission and payload failures: `MailboxFull`, `InvalidPayload`,
`PayloadTooLarge`, `IdempotencyConflict`, `InvalidPayloadBroadcast`, and
`UnknownPayloadBroadcast`;
Expand Down
1 change: 1 addition & 0 deletions docs/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ replay current committed projections, not every event missed while offline.
| Actor-scoped dead letters and reminder administration | Supported |
| `commitAction`, shared application SQL transactions | Unsupported |
| Global repository, reconciliation, process controls, SQL dashboard | Unsupported |
| `findBy({ requestId })` without a reference | Unsupported; name the actor |
| Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions |
| SQL-to-Durable-Objects data migration | Not provided |

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ polling remains the correctness path.
| `instanceRetentionByActorType` | `{}` | Positive per-type instance-expiration opt-ins. |
| `processRetentionMilliseconds` | 7 days | Positive stopped-process retention. |
| `pruneBatchSize` | `1_000` | Positive integer maximum rows rechecked per pruning transaction. |
| `retainedIdempotencyKeys` | `64` | Positive integer idempotency keys an actor remembers. |
| `retainedIdempotencyKeysBytes` | `16_384` | Positive integer serialized size those keys must fit. |
| `redriveBatchSize` | `100` | Positive integer rows a redrive moves per transaction. |
| `redriveBatchPauseMilliseconds` | `50` | Non-negative pause between redrive batches. |
| `administrationIdentity` | `String(context)` | Names the operator recorded in an administration event. |
Expand Down
4 changes: 3 additions & 1 deletion docs/fit.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ history, and no actor-state migration contract.
limiter, see [Solid Objects Pro](https://solidobjects.pro/): grouped commits
coalesce concurrent writes into one insert, and ephemeral operations keep
loss-tolerant calls out of the durable journal. It ships for the Rails gem
today, and the Node build is in development.
today, and the Node build is in development. Distributed per-actor rate
limits, global admission control, and cache-capacity eviction are answered
there rather than in this runtime, and are not open roadmap items here.
- Durable workflow replay across named steps is more important than a mutable
object with ordered operations. The runtime redelivers an ordered message and
retries it. It does not replay a function from a step log.
Expand Down
Loading
Loading