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
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ lowest layer that can prove it.
## Setup

Install Node.js 24.15 or newer, enable Corepack, and install the locked
dependencies. The package supports Node.js 24.4.0 or newer, and CI runs the
default suite, the build, the packaged artifact smoke test, and the recovery
demo on that floor. Node.js 24.15 is where `node:sqlite` stops printing an
dependencies. The package supports Node.js 24.4.0 or newer. On that floor, CI
runs the default suite, the build, the packaged artifact smoke test, and the
recovery demo. Node.js 24.15 is where `node:sqlite` stops printing an
experimental warning:

```bash
Expand Down
188 changes: 123 additions & 65 deletions README.md

Large diffs are not rendered by default.

46 changes: 23 additions & 23 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ generic signatures; this index explains the supported role of every export.
- `SolidObjectsRuntime`: installation, registration, supervision, and manager
owner. The normal lifecycle is `install()`, `run(signal)`, then `close()`.
`snapshotWithIncarnation(reference)` returns the same authorized fields as
`snapshot()` alongside the read instance's `instanceId`, `revision`, and
`createdAtMs`, computed from the identical read so a caller can fence a
derived write (for example a downstream projection) against a stale or
superseded actor incarnation. `createdAtMs` orders incarnations at
millisecond granularity; see
`snapshot()`. It adds the read instance's `instanceId`, `revision`, and
`createdAtMs` from that identical read. A caller can therefore fence a derived
write, such as a downstream projection, against a stale or superseded actor
incarnation. `createdAtMs` orders incarnations to the millisecond. See
[Limitations and non-goals](correctness.md#limitations-and-non-goals) for
the same-millisecond boundary.
- `Actor`: base class providing `ref()`, `actorId`, `currentMessage`,
Expand All @@ -38,8 +37,8 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
- `MessageReference`: immutable durable message identity with `id`,
`requestId`, actor identity, `sequence`, `status()`, `result()`, and `wait()`.
- `InvocationOptions`, `AsyncInvocationOptions`, `SnapshotOptions`, and
`DestroyOptions`: authorization, idempotency, timing, and scheduling options
used by reference methods.
`DestroyOptions`: the options for authorization, idempotency, time, and
schedule that the reference methods use.

`ActorIntents`, `EffectIntent`, `CommitActionIntent`, `ReminderIntent`,
`OutboundMessageIntent`, `ReminderOptions`, `OutboundMessageOptions`,
Expand All @@ -59,11 +58,11 @@ override observables(): Record<string, unknown> {
}
```

Both values must be JSON-compatible and are evaluated after each successful
turn. An invalidation-only value participates in change detection but is never
written to the broadcast outbox or invalidation envelope. The envelope carries
its name in `invalidations`, allowing component registries to refresh a
reauthorized endpoint without exposing the value.
Both values must be JSON-compatible. The runtime evaluates them after each
successful turn. An invalidation-only value takes part in change detection, but
the runtime never writes it to the broadcast outbox or the invalidation
envelope. The envelope carries its name in `invalidations`. A component registry
can then refresh a reauthorized endpoint, and the value stays private.

`MessageReference` does not retain an invocation's authorization context.
Supply `authorizationContext` to each `status()`, `result()`, and `wait()` call;
Expand Down Expand Up @@ -91,12 +90,13 @@ function playerForSession<PlayerType extends { sessionId: string }>(options: {

### Reminders

A reminder is one alarm per actor and name. Scheduling a name that is already
armed **moves the existing alarm** rather than adding a second one, which is
what makes a reminder safe to re-arm from a handler that may run more than once.
A reminder is one alarm per actor and name. If you schedule a name that is
already armed, the runtime **moves the existing alarm**. It does not add a
second one. A reminder is therefore safe to re-arm from a handler that can run
more than once.

Without a key that name is the operation, so one actor holds one alarm per
operation, and arming one per queued item keeps only the last:
Without a key, that name is the operation. One actor then holds one alarm per
operation. If you arm one alarm per queued item, only the last one remains:

```typescript
// Wrong. Every entry overwrites the previous entry's alarm.
Expand Down Expand Up @@ -126,10 +126,10 @@ alone, so a long operation with a short key is caught too. A key may hold colons
of its own, because an actor member name cannot.

An actor that only needs to know "what is next" can still keep one alarm and
drain everything due when it fires. That costs one row instead of one per item
and cannot strand an entry when an occurrence is coalesced, so prefer it for a
large queue of interchangeable items and prefer `key` when an item needs an
alarm that can be moved on its own.
drain everything that is due when it fires. That costs one row instead of one
row per item. It also cannot strand an entry when the runtime coalesces an
occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
when one item needs an alarm that you can move on its own.

### Runtime managers

Expand Down Expand Up @@ -333,8 +333,8 @@ The wire format, trust boundary, revision rules, and component semantics are in
`DashboardExtension` objects, and `DashboardMiddleware` functions.
- `DashboardRequestContext` supplies the existing administration authorization
context and an optional `DashboardSession`. Read/write access requires the
session so its `read()` and `write()` methods can hold the masked CSRF token
across requests; read-only modes do not create CSRF state.
session, because its `read()` and `write()` methods hold the masked CSRF token
across requests. Read-only modes create no CSRF state.
- `DashboardRoute`, `DashboardRouteContext`, `DashboardPolicy`, `DashboardPage`,
and `DashboardTab` define extension pages. Every route requires a policy.
- `DashboardRenderer`, `DashboardRenderInput`, and `DashboardMiddlewareInput`
Expand Down
37 changes: 20 additions & 17 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ same operation, delivery mode, and arguments.

The correctness contract is:

> Messages for one actor are durably enqueued and processed sequentially, at
> least once, by at most one valid activation lease holder at a time.
> Solid Objects durably enqueues the messages for one actor. At most one valid
> activation lease holder processes them at a time, in sequence, and at least
> once.

Actor code runs outside the database transaction. A short transaction guarded
by the activation owner, token, generation, expiration, and claimed-message
Expand All @@ -36,10 +37,10 @@ at the shutdown boundary; database leases and fencing remain the correctness
mechanism if a failed role was still executing actor code.

SQLite serializes access through one process-local connection and begins write
transactions immediately. PostgreSQL and MySQL use bounded pools, keep each
transaction on one checked-out client, store timestamps and sequences as
64-bit integers, and lock an actor's instance row while allocating mailbox
sequences. MySQL creates InnoDB tables and retries only the side-effect-free
transactions immediately. PostgreSQL and MySQL use bounded pools. They keep each
transaction on one checked-out client. They store timestamps and sequences as
64-bit integers. They lock an actor's instance row during mailbox sequence
allocation. MySQL creates InnoDB tables and retries only the side-effect-free
enqueue transaction when InnoDB chooses it as a deadlock victim. Every adapter
uses database time and the same fencing predicates.

Expand All @@ -51,19 +52,21 @@ statement and lock timeouts. MySQL bounds pool checkout and client queries and
installs transaction execution and lock-wait limits. A deadline before enqueue
commit produces no durable message. After commit, timeout diagnostics retain
the message reference for later recovery.
Already-running JavaScript actor code is cooperative rather than forcefully
preempted; leases and fenced commits remain authoritative if it outlives the
caller's wait.
JavaScript actor code that already runs is cooperative. The runtime does not
preempt it by force. If it outlives the caller's wait, leases and fenced commits
stay authoritative.

Each database adapter also tracks its active transaction through Node's async
context. A committed call or message wait fails before enqueue or polling when
the same logical call stack already owns a Solid Objects transaction, rather
than waiting for a connection or serialized SQLite slot it cannot release.
context. A committed call or message wait fails early when the same logical call
stack already owns a Solid Objects transaction. It fails before enqueue or
polling. It does not wait for a connection or a serialized SQLite slot that it
cannot release.

PostgreSQL notifications are an opt-in latency layer. One event-driven client
per runtime listens on role-specific channels before the worker checks durable
state, which closes the listener-startup race without holding a polling
connection per worker. A notification advances a process-local role generation
per runtime listens on role-specific channels. It listens before the worker
checks durable state. This closes the listener-startup race, and no worker holds
a polling connection of its own. A notification advances a process-local role
generation
and wakes every matching waiter. Reconnection and notification loss fall back
to adaptive polling, whose current wait can be as long as the configured idle
ceiling.
Expand All @@ -81,8 +84,8 @@ keeps its original future availability.

The global claim reads at most `claimScanLimit` ordered candidates. If another
worker acquires the first candidate's lease, the transaction continues through
that bounded set instead of returning idle and sacrificing parallelism across
independent actor identities.
that bounded set. It does not return idle, because an idle return loses
parallelism across independent actor identities.

When a pass becomes idle, a long-running worker keeps the hydrated actor and
continues renewing the same fenced lease until its idle timeout. A later turn
Expand Down
11 changes: 6 additions & 5 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ WebSocket or stream connection, passes that fresh server-side subject as the
session's `authorizationContext`, and forwards incoming protocol messages to
`session.receive()`. Every subscribe request calls `authorizeSubscription`
before actor type lookup, so denied callers cannot probe the registry. A new
connection must use a newly resolved authorization context; do not copy a user
object from an earlier request or trust an actor ID supplied by the browser.
connection must use a newly resolved authorization context. Do not copy a user
object from an earlier request. Do not trust an actor ID that the browser
supplies.

Successful subscription authorization allows the `broadcastValue()` portion
of the explicit `observables()` projection for that actor, including the
immediate committed replay. Unwrapped observables and values marked with
Successful subscription authorization allows the `broadcastValue()` portion of
the explicit `observables()` projection for that actor. The immediate committed
replay is part of that portion. Unwrapped observables and values marked with
`broadcastInvalidation()` disclose only their names, not their values.
Subscription authorization does not
authorize actor state, operations, queries, destruction, or administration.
Expand Down
Loading