Skip to content

Latest commit

 

History

History
1059 lines (891 loc) · 50.8 KB

File metadata and controls

1059 lines (891 loc) · 50.8 KB

Public API reference

The package exports one server entry point plus database, wake-up, and browser subpaths. The TypeScript declaration files are authoritative for exact generic signatures. This index explains the supported role of every export.

solid-objects/core and solid-objects/cloudflare

solid-objects/core exports portable actor definitions, errors, reference types, the ActorRuntime interface, and request-scoped withRuntime(runtime, callback). It does not initialize a Node platform or SQL driver.

solid-objects/cloudflare exports CloudflareRuntime and createRuntime({ backend }), durableObjects({ namespace, sessions }), createDurableObjectsHost({ actors, configure }), and createDurableObjectsSessionHost({ backend, resolveAuthorizationContext, maxSubscriptions }). CloudflareConfiguration supplies host policies, limits, instrumentation, and effects. DurableObjectsBackend, ActorNamespace, and SessionNamespace describe the bindings.

Actor references retain the operation, snapshot, send, and message-result APIs. lookupMessage() recovers acceptance by request ID; actorAdministration() provides bounded per-actor dead-letter and reminder operations. openWebSocket() bridges an authenticated HTTP upgrade to a session Durable Object.

EnqueueOutcomeUnknown reports ambiguous RPC acceptance and carries recovery identifiers. UnsupportedCapability rejects facilities requiring the shared SQL runtime. See Cloudflare Durable Objects for configuration, authorization, capability boundaries, and release validation.

solid-objects

Runtime and actors

  • configure(options): create the process default SolidObjectsRuntime.
  • createRuntime(options): create an isolated runtime without changing the default.
  • 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(). 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 for the same-millisecond boundary.
  • Actor: base class providing ref(), actorId, currentMessage, observables(), reject(), emit(), transmit(), commitAction(), schedule(), sendTo(), and protected lifecycle hooks. ScheduledOperationsFor types the map schedule() returns, whose calls return a ReminderHandle. TransmittedOperationsFor types the map transmit() returns, whose calls return nothing.
  • broadcastValue(value): mark an observable so its changed value enters the durable invalidation envelope.
  • broadcastInvalidation(value): compare the real observable value but put only its name in the durable envelope when it changes.
  • ObservableBroadcast: the immutable marker type returned by either helper.
  • VERSION: running package version.
  • reference.live: read-only live signals for an actor, enabled by the solid-objects/signals entry point documented below.
  • ActorClass, ActorReference, ActorMessageSender, ActorSnapshot, ActorOperationNames, ActorQueryNames, StagedOperations, ScheduledOperationsFor, and ScheduledOperations: inferred actor-class and fluent-dispatch types, plus the legacy dynamic scheduling map.
  • EffectOptions: effect arguments and independently checked success/failure callback names. Effect names themselves belong to the runtime's global registry.
  • SnapshotWithIncarnation: the { snapshot, instanceId, revision, 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: the options for authorization, idempotency, time, and schedule that the reference methods use.

ScheduledReminder is one armed reminder as an actor reads it, and ReminderReader is how a runtime supplies them.

ActorIntents, EffectIntent, CommitActionIntent, ReminderIntent, UnscheduleIntent, UnscheduleAllIntent, ReminderMutation, OutboundMessageIntent, ReminderOptions, OutboundMessageOptions, PayloadBroadcasts, and PayloadBroadcastValue describe actor-declared transactional work and typed personalized projections. ReminderMutation is the union of one scheduled reminder and the two cancellations, held in one list so they apply in the order the turn called them.

EffectFailurePayload<Arguments>, EffectSuccessPayload<Arguments, Result>, and SerializedError describe effect callback messages. They are also exported from the browser-safe solid-objects/core entry point.

observables() returns a flat object. Unwrapped values are invalidation-only: their real values participate in change detection, but only their names enter the durable envelope. Use an explicit marker when wire behavior matters:

override observables(): Record<string, unknown> {
  return {
    version: broadcastValue(this.document.version),
    sidebar: broadcastInvalidation(this.sidebarForCurrentState()),
  }
}

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; the runtime reauthorizes the persisted operation every time. Durable results are JSON, so an operation that returns undefined or is declared void resolves as null.

Declare named payload return shapes with a type alias rather than an interface. PayloadBroadcastValue requires the implicit string index signature of a JSON object, which TypeScript gives object type aliases but not interfaces.

Snapshots return DeepReadonly, so application helpers should accept readonly structure rather than cast it away. A helper that only needs a session ID can preserve its useful result type with a generic boundary:

function playerForSession<PlayerType extends { sessionId: string }>(options: {
  room: { readonly players: readonly PlayerType[] }
  sessionId: string | null
}): PlayerType | undefined {
  return options.room.players.find((player) => player.sessionId === options.sessionId)
}

Reminders

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. One actor then holds one alarm per operation. If you arm one alarm per queued item, only the last one remains:

// Wrong. Every entry overwrites the previous entry's alarm.
add({ entry }: { entry: Entry }): void {
  this.entries = [...this.entries, entry]
  this.schedule({ at: new Date(entry.waitUntil) }).deliver()
}

Pass key when an actor is waiting on several things at once. The key is your own identifier for the item and names that item's alarm, so each item gets one:

add({ entry }: { entry: Entry }): void {
  this.entries = [...this.entries, entry]
  this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver()
}

Scheduling the same key again moves that item's alarm and leaves the others alone. The operation still decides which handler runs; the key only decides which alarm is which.

A key must be non-empty, and the name it composes must fit the 255 characters MySQL holds it in. That is checked on the composed name rather than the key 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 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.

Cancelling a reminder

schedule() returns a ReminderHandle ({ name: string }) naming the alarm it armed. unschedule() cancels one alarm, by operation, by operation and key, or by that handle. unscheduleAll() cancels every key of one operation.

const MONTH = 30 * 24 * 60 * 60 * 1000

class Subscription extends Actor {
  static override readonly actorType = "subscriptions"

  status = "trialing"
  renewal: ReminderHandle | null = null

  startTrial(): void {
    this.schedule({ at: new Date(Date.now() + (14 * MONTH) / 30) }).trialExpired()
  }

  convertToPaid(): void {
    this.status = "active"
    this.unschedule("trialExpired")
    this.renewal = this.schedule({
      at: new Date(Date.now() + MONTH),
      everyMilliseconds: MONTH,
    }).chargeRenewal()
  }

  cancelled(): void {
    this.status = "cancelled"
    if (this.renewal) this.unschedule(this.renewal)
  }

  trialExpired(): void {
    this.status = "expired"
  }

  chargeRenewal(): void {}
}

this.unschedule(this.renewal) and this.unschedule("chargeRenewal") cancel the same alarm. Prefer the handle when the actor already stored one, because it cannot drift from the name that armed the reminder.

A keyed alarm cancels by the key that armed it, and unscheduleAll() cancels every key of one operation:

class Shipment extends Actor {
  static override readonly actorType = "shipments"

  dispatch({ carrierIds }: { carrierIds: string[] }): void {
    for (const carrierId of carrierIds) {
      this.schedule({ at: new Date(Date.now() + MONTH / 30), key: carrierId }).chaseCarrier({
        carrierId,
      })
    }
  }

  shipped({ carrierId }: { carrierId: string }): void {
    this.unschedule("chaseCarrier", { key: carrierId })
  }

  stopChasing(): void {
    this.unscheduleAll("chaseCarrier")
  }

  chaseCarrier(_options: { carrierId: string }): void {}
}

unschedule() and unscheduleAll() refuse an operation the actor does not declare, with the UnknownOperation that schedule() already throws, so a typo fails the turn rather than cancelling nothing. A handle skips that check, because the schedule() call that produced it was already checked.

Reading the schedule

reminder() returns the armed alarm as a ScheduledReminder, or undefined. reminders() returns every key of one operation. Both are async, because an actor reads its own rows rather than holding them in memory:

async nextChargeAt(): Promise<number | null> {
  return (await this.reminder("chargeRenewal"))?.runAtMilliseconds ?? null
}

async pendingCarriers(): Promise<(string | null)[]> {
  return (await this.reminders("chaseCarrier")).map((reminder) => reminder.key)
}

A read applies the intents staged so far in the turn, so an actor that schedules and then reads sees what the commit will write, and one that cancels and then reads sees the alarm gone.

key and intervalMilliseconds are null rather than undefined when absent, so a ScheduledReminder returns from an operation without a serialization error.

Reading is available during a turn and from a snapshot projection, so an observable can report what is armed. reminder() and reminders() refuse an operation the actor does not declare, as schedule() and unschedule() do.

A one-shot that already fired is not reported. Its row stays as completed, and an alarm that cannot fire again is not armed.

ScheduledReminder carries no occurrence count. The SQL backends track one and Durable Objects does not, so it is left out rather than reported for one backend only.

A cancellation is staged like a schedule, so it commits with the state change that decided it and a turn that throws cancels nothing. Both apply in the order the turn called them, so cancelling and then scheduling the same name leaves it armed at the new time.

Cancelling an alarm that does not exist is not an error. unschedule() returns nothing, because it stages an intent rather than applying one, and an answer given at call time could be stale by the time the turn commits.

A handle is a plain object, so it survives in actor state and still cancels after a deactivation. Passing a handle together with key is a TypeError, because the handle already names the key.

Recovering abandoned effects

emit returns an EffectHandle ({ id: string }) on every successful call. Save it in actor state to identify that exact persisted effect. The handle, state, effect, and callback bindings commit together; a rejected turn persists none of them. Existing callers can ignore the handle. Overrides and wrappers that previously returned void must return super.emit(...); explicit return this.emit(...) and code expecting undefined need updating.

onRecovery opts a SQL effect into automatic retirement when its processing owner stops heartbeating. onStatus is independent and optional: it only receives responses to requestEffectRecovery(handle), which requires both bindings. Register both callbacks in emit; requests cannot rebind them. Requests stage an intent in the current actor's fenced commit and perform no synchronous database lookup inside the actor method. Only the originating instance can use its handle.

recoveryTimeoutMilliseconds is an optional positive safe integer requiring onRecovery. The effective freshness window is the greater of that persisted override and the runtime's current processAliveThresholdMilliseconds (default 60,000). It can extend the window, never shorten it. It measures time since the owner's database heartbeat, not effect duration or progress. An owner that keeps heartbeating protects its effect indefinitely.

Heartbeat update errors emit solid_objects.process.heartbeat_failed and retry at the configured interval without consuming effect attempts. An outage lasting beyond the freshness window can still permit recovery; this does not cancel external work or extend the timeout.

EffectRetiredPayload<Arguments> is the onRecovery envelope: effectId, original arguments, and outcome: EffectRecoveryOutcome.Retired. No outcome guard is needed in that callback. EffectRecoveryPayload<Arguments, Result> is the discriminated union received by onStatus. EffectRecoveryOutcome is a frozen constant object and a derived string-union type, exported from root and core.

Constant Outcome Meaning
Retired "retired" This check retired the abandoned processing effect.
Deferred "deferred" Owner is fresh; preserve its claim and attempts.
Pending "pending" Initial execution or retry remains with the scheduler.
Completed "completed" Includes original arguments and recorded result, including null.
Dead "dead" Preserve the existing terminal failure and failure callback.
AlreadyRetired "alreadyRetired" An earlier decision retired it; no new recovery notification.
Missing "missing" Owned routing metadata remains but the effect was pruned.

Every outcome includes effectId. Retired and completed require original arguments; other outcomes may include retained arguments. Only completed has a successful result. Database errors propagate as errors, never as missing or abandoned outcomes. Unknown, foreign, and expired handles fail without exposing another actor's effects or recreating a destroyed actor.

Automatic retirement sends only onRecovery. A winning explicit check enqueues onRecovery first and its separate onStatus response second in one transaction. Only onRecovery should emit replacement work. A completed status can repair an outcome notification using the same guarded helper as onSuccess:

import {
  Actor,
  EffectRecoveryOutcome,
  type EffectHandle,
  type EffectRetiredPayload,
  type EffectRecoveryPayload,
  type EffectSuccessPayload,
  type JsonValue,
} from "solid-objects"

type ReportArguments = { revision: number }
type ReportResult = { artifactKey: string }

class ReportExport extends Actor {
  static override readonly actorType = "ReportExport"
  revision = 0
  exportEffect: EffectHandle | null = null
  artifactKey = ""
  appliedEffectId: string | null = null

  start(): void {
    this.exportEffect = this.emit("build_report", {
      arguments: { revision: ++this.revision },
      onSuccess: "exportFinished",
      onFailure: "exportFailed",
      onRecovery: "recoverExport",
      onStatus: "inspectExport",
      recoveryTimeoutMilliseconds: 120_000,
    })
    this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
  }

  watchdog(): void {
    if (this.exportEffect) this.requestEffectRecovery(this.exportEffect)
  }

  recoverExport(payload: EffectRetiredPayload<ReportArguments>): void {
    if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
      return
    this.start()
  }

  exportFinished(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
    this.applyExportResult(payload)
  }

  exportFailed(_payload: JsonValue): void {}

  inspectExport(payload: EffectRecoveryPayload<ReportArguments, ReportResult>): void {
    if (payload.effectId !== this.exportEffect?.id) return
    if (payload.outcome === EffectRecoveryOutcome.Completed) this.applyExportResult(payload)
    if (
      payload.outcome === EffectRecoveryOutcome.Deferred ||
      payload.outcome === EffectRecoveryOutcome.Pending
    ) {
      this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
    }
  }

  private applyExportResult(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
    if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
      return
    if (this.appliedEffectId === payload.effectId) return
    this.artifactKey = payload.result.artifactKey
    this.appliedEffectId = payload.effectId
  }
}

Routing metadata remains until the originating instance is destroyed or pruned; it survives effect/message pruning but does not pin the instance. Checks after that boundary fail. Callback delivery and idempotency follow durable mailbox retention. Retirement survives a crash before callback delivery.

The Durable Objects backend returns ordinary emit handles using its outbox ID, but rejects recovery callbacks, timeouts, and recovery intents before committing the actor turn: it has no shared SQL process-heartbeat registry. See effect recovery coordination for transaction and lock order.

External actions still require idempotency. Retirement fences library state; it does not cancel the previous JavaScript handler or prove its remote request stopped. It does not provide exactly-once external execution.

Typing your onFailure handler

An effect callback is an ordinary actor operation. Its payload always includes the stable effectId and the original serialized arguments, including {} when the effect was emitted without arguments. Use the exported types when a watchdog or failure handler needs to correlate work with the current generation:

import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects"

type RunArguments = { generation: number }

class ChatRun extends Actor {
  static override readonly actorType = "ChatRun"
  generation = 0
  status = "idle"
  reply = ""

  start(): void {
    this.emit("run_model", {
      arguments: { generation: ++this.generation },
      onSuccess: "finishTurn",
      onFailure: "failTurn",
    })
  }

  failTurn({ arguments: original, error }: EffectFailurePayload<RunArguments>): void {
    if (original.generation !== this.generation) return
    this.status = `${error.name}: ${error.message}`
  }

  finishTurn({
    arguments: original,
    result,
  }: EffectSuccessPayload<RunArguments, { reply: string }>): void {
    if (original.generation !== this.generation) return
    this.status = "finished"
    this.reply = result.reply
  }
}

Failure payloads contain error: SerializedError, with string name and message fields. They do not include a stack or cause. Success payloads contain result, which can be any JSON value; an undefined effect return becomes null. The default argument type is JsonObject and the default success result type is JsonValue. Declare argument shapes with a JSON-compatible type alias.

These types describe the SQL and Cloudflare callback envelopes. Error messages for non-Error throws retain each backend's existing serialization behavior. The generic parameters express your application's contract; they do not add runtime validation or infer types from registerEffect(). Keep registered effect results and the handler's declared argument/result types in agreement.

Typed operation references

schedule and transmit infer this actor's operation names and arguments, including inside actor methods and for inherited application operations. A scheduled operation call returns a ReminderHandle, through ScheduledOperationsFor<ActorType>, and a transmitted one returns void, through TransmittedOperationsFor<ActorType>. Both preserve required, optional, and zero-argument operation signatures. No non-null assertion is needed:

class ChatRun extends Actor {
  generation = 0
  status = "idle"

  start({ generation }: { generation: number }): void {
    this.generation = generation
    this.schedule({ at: new Date(Date.now() + 60_000), key: "watchdog" }).recoverIfStuck({
      generation,
    })
    this.emit("run_model", { arguments: { generation }, onFailure: "failTurn" })
  }

  recoverIfStuck({ generation }: { generation: number }): void {
    if (generation !== this.generation) return
    this.status = "recovering"
  }

  failTurn({ error }: { error: { message: string } }): void {
    this.status = error.message
  }
}

Misspelled operations/callbacks, state properties, queries, and Actor infrastructure are rejected. emit checks each callback independently: widening one callback to string does not disable literal checking of the other. A deliberately widened string callback retains runtime validation. Object properties can also widen to string; preserve literals with as const or specialize EffectOptions to keep static checking when options are stored in a variable. Effect and commit-action names remain strings because their registries are runtime-wide; inferring registered names needs a separate registry typing design.

For deliberately dynamic scheduling, retain the exported legacy map explicitly:

const dynamicActor: Actor = this
const operations: ScheduledOperations = dynamicActor.schedule({ at: deadline })
operations[operationName]!({ generation })

This opts out of operation-name and argument inference and retains the existing runtime operation checks. Direct calls, queries, and sendTo keep their inference. Subclasses that override schedule or transmit with an explicit legacy ScheduledOperations return annotation must update their override signatures to match the generic Actor methods. This is a compile-time compatibility change; runtime scheduling and transmission behavior are unchanged.

Runtime managers

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 DeadRow values, retry(id) returns one to pending, and redrive(options) moves a whole scope. RedriveOptions names actorType, failedAfter, and limit, which become the RedriveFilters the task records. UnknownDeadRow reports an id that does not exist.
  • runtime.redrives / RedriveManager: find(id), all({ status }), cancel(id), and advance(), which moves one bounded batch. A RedriveTask carries its id, kind, filters, RedriveStatus, moved, remaining, startedAt, finishedAt, and its own cancel(). RedriveScheduler is the component that advances tasks inside runtime.run(). UnknownRedrive and RedriveNotStarted report a missing task and a task that could not open.
  • runtime.reminders / ReminderManager: cursor-paginated all() and idempotent paused-alarm resume().
  • runtime.processes / ProcessManager: immutable role all() and stale-owner cleanup().
  • runtime.administration / AdministrationManager: an authorized processes() query for inspecting live and stale process rows through the runtime's own database adapter.
  • runtime.reconciliation / ReconciliationManager: active(), withoutPendingWork(), statesFor(), and orphaned() bounded reads.
  • runtime.retention / RetentionManager: preview() and authorized prune() for messages, instances, or processes.
  • runtime.doctor / Doctor: run({ roundTrip }) structured installation report.
  • runtime.testing / SolidObjectsTestHelper: deterministic drain() and explicit-time runDueReminders(), plus dependency-ordered reset().
  • runtime.realtime / RealtimeManager: connect(), process-local publish(), and close().

The manager types are DeadLetter; ReminderPage, ReminderPageOptions, ReminderRecord, ReminderStatus, and ResumeReminderOptions; ProcessCleanupResult, ProcessMetadata, ProcessRecord, and ProcessShutdownState; DoctorCheck, DoctorOptions, DoctorReport, and DoctorStatus; OrphanedReconciliationOptions, QuietReconciliationOptions, ReconciliationInstance, ReconciliationPage, ReconciliationPageOptions, and ReconciliationStatesOptions; RetentionOptions, RetentionResult, and RetentionTarget; and RunDueRemindersOptions, TestDrainOptions, and TestHelperRole. RealtimeConnectionOptions, RealtimeSession, and SubscriptionRequest define the server session API. AdministrationOptions carries the application-owned authorization context for administration calls.

The packaged solid-objects quickstart command is config-free and runs the SQLite example shipped in the npm artifact. Every other CLI command loads the application runtime configured through --config.

ProcessRecord.shutdownState is "running", "draining", or "stopped"; there is no separate running field. RetentionResult.count means eligible rows for preview() and rows actually deleted for prune().

Registration and integration

  • runtime.register(ActorClass): validate and register an actor definition.
  • runtime.ref(ActorClass, actorId): register and address an actor in an isolated runtime.
  • runtime.registerEffect(name, handler): register an at-least-once external effect handler. EffectContext carries the stable effect ID and source identity. Success operations receive { effectId, arguments, result } and failure operations receive { effectId, arguments, error }; arguments is the JSON object originally staged by emit().
  • runtime.registerCommitAction(name, handler): register a same-database fenced transaction handler. CommitActionContext includes the active DatabaseConnection.
  • guardApplicationDatabase(database): return a facade that rejects writes from actor-owned execution contexts.
  • parseSubscriptionRequest(value): validate the server-side JSON subscribe or unsubscribe request before session routing.
  • runCli(arguments, options) and CliRunOptions: embed and configure the packaged command implementation; applications normally invoke the solid-objects executable instead.

SolidObjectsConfiguration, AuthorizationInput, DestroyAuthorizationInput, AdministrationAuthorizationInput, SubscriptionAuthorizationInput, BroadcastEvent, and InstrumentationEvent type the host integration contract. JsonPrimitive, JsonValue, JsonObject, DeepReadonly, ActorIdentifier, MessageContext, MessageStatus, and Logger are shared types. Database, DatabaseConnection, DatabaseFamily, DatabaseTransactionOptions, and RunResult support custom database and commit-action integration. A custom PostgreSQL or MySQL adapter must honor transaction(callback, { isolationLevel: "read_committed" }); outbox claiming uses that isolation with row locks to avoid InnoDB gap-lock contention. SQLite adapters may treat the option as their ordinary serialized transaction.

BroadcastEvent.observables contains changed value-broadcast projections. BroadcastEvent.invalidations contains changed invalidation-only names. The runtime always supplies the array; consumers should treat its absence from an older or application-produced event as an empty array.

Runtime extensions and manual workers

runtime.registerComponent(factory, { count = 1 }) adds application-owned supervised roles. Each factory must return a LongRunningComponent:

interface LongRunningComponent {
  run(signal: AbortSignal): Promise<void>
  requestShutdown(): void
  stopped(): boolean
  stop(): void | Promise<void>
}

The runtime creates count independent instances, replaces an instance whose run() settles unexpectedly, and stops replacement before graceful shutdown. Factories should create fresh mutable state and stop() should be idempotent.

Worker, EffectWorker, ReminderScheduler, and BroadcastWorker are exported for test runners and hosts that intentionally operate roles outside runtime.run(). Runtime factory methods create the same classes. Each provides runOnce(), bounded runUntilIdle(), run(signal), requestShutdown(), stopped(), stop(), and the inspectable currentPollingIntervalMilliseconds. Manual roles still register process ownership and must be stopped. Prefer runtime.run() in production and runtime.testing in tests.

InProcessWakeUpAdapter, WakeUpAdapter, WakeUpRole, WakeUpWatch, and WakeUpWaitOptions define the notification extension. A watch must be obtained before checking durable state so a notification cannot fall between claim and wait. WakeUpWatch.wait() returns true for a notification and false for a timeout or cancellation. A legacy void result remains accepted and preserves the fast polling cadence.

configuration.wakeUp takes a name or an adapter. WakeUpSetting is that union, WakeUpName is one of WAKE_UP_NAMES, and the default is "automatic". Selection prefers SOLID_OBJECTS_REDIS_URL, then PostgreSQL notifications, then polling. An unknown name throws rather than polls.

selectWakeUp(options) runs that choice and returns a SelectedWakeUp, which pairs the adapter with a WakeUpCapability. WakeUpSelectionOptions names the inputs: the setting, the database, the idle polling interval, a logger, and an optional Redis URL and probe timeout. WakeUpCapability reports WakeUpAdapterName, whether the adapter crosses processes, the measured floor in milliseconds, and the reason. runtime.wakeUpAdapter() and runtime.wakeUpCapability() return the choice, which is made once per runtime.

An adapter may declare its own defaultCapability, which is what it reports about itself. Selection keeps what a configured adapter declares rather than assume, and runtime.wakeUpCapability() reports what selection installed. NotificationWakeUpAdapter adds channelFor(role), which a database that offers a notification channel provides through database.wakeUp(options).

Errors

The root exports SolidObjectsError and its supported subclasses:

  • policy and caller outcomes: Unauthorized, Rejected, ActorDestroyed, SyncEnqueueTimeout, SyncTimeout, SyncInsideTransaction, MessagePruned, and MessageFailed;
  • admission and payload failures: MailboxFull, InvalidPayload, PayloadTooLarge, IdempotencyConflict, InvalidPayloadBroadcast, and UnknownPayloadBroadcast;
  • definition and execution failures: InvalidActor, InvalidRejectionCode, UnknownActorType, UnknownOperation, ActorCallCycle, QueryMutatedState, StateMigrationError, ApplicationWriteForbidden, UnknownEffect, and UnknownCommitAction;
  • operational failures: LostActivation, DatabaseDeadlineExceeded, UnknownDeadLetter, UnknownReminder, ReminderNotPaused, and UnsupportedDatabase.

NonRetryableError is the application subclassing point. SyncTimeoutDetails and SyncTimeoutWaitingOn type timeout diagnostics. See Errors and recovery before deciding what to catch.

solid-objects/database/sqlite

  • sqlite(options): construct SQLiteDatabase.
  • SQLiteDatabase: Database implementation and close() owner.
  • SQLiteDatabaseOptions: path, busy timeout, and lock retry options.

solid-objects/database/sqlite-wasm

  • sqliteWasm(options): construct SQLiteWasmDatabase asynchronously. The first call loads the @sqlite.org/sqlite-wasm module.
  • SQLiteWasmDatabase: Database implementation on SQLite WASM and close() owner. It runs in a browser and in Node. One host owns the database file; the adapter serializes access on one connection.
  • SQLiteWasmDatabaseOptions: path plus a storage mode. "temporary" (the default) keeps data for the life of the process or page. "persistent" stores data in the browser origin's OPFS through the SQLite SAH pool VFS, and fails fast where OPFS is unavailable.

solid-objects/database/shared-sqlite-wasm

Many browser tabs, one database, no visible infrastructure. Every tab constructs the same shared database and runs an ordinary configure → install → ref flow; the adapter hides the coordination. The Web Locks API elects one holder per origin. The holder opens the real SQLite WASM database; every other instance sends its SQL over a BroadcastChannel session to the holder, through the same serialized access queue. When the holder dies, the lock releases, the next instance opens the pool, and the runtime's leases and fencing arbitrate the tabs' workers exactly as they arbitrate Node processes.

  • sharedSqliteWasm(options): construct SharedSQLiteWasmDatabase.
  • SharedSQLiteWasmDatabase: Database implementation with a role() probe (connecting, holder, or remote) and close().
  • SharedSQLiteWasmDatabaseOptions: path, an optional election name (defaults to the path), the storage mode (persistent by default except for :memory:), and the request, retry, session-idle, and open-attempt tuning knobs.
  • SharedDatabaseFailover: the retryable rejection an in-flight statement receives when the holder changes mid-operation. A session that has not executed a statement yet retries automatically; anything later surfaces, because replaying partially executed work is not safe.
  • SharedDatabaseUnavailable: the rejection for a closed instance, a timed-out request, or an idle session the holder reclaimed.

A transaction that dies with its holder rolls back with the pool, which is the same at-least-once story as a crashed Node process. Sessions that stay idle longer than sessionIdleTimeoutMilliseconds (default 10 seconds) are reclaimed so a dead tab cannot hold the database hostage.

solid-objects/database/postgresql

  • postgresql(options): construct PostgreSQLDatabase.
  • PostgreSQLDatabase: pooled Database implementation with wakeUp().
  • PostgreSQLDatabaseOptions and PostgreSQLDatabaseWakeUpOptions: pool and notification configuration.
  • postgresqlWakeUp(options) and PostgreSQLWakeUpAdapter: standalone LISTEN/NOTIFY wake-up integration.
  • PostgreSQLWakeUpOptions and PostgreSQLWakeUpFailure: listener options and failure callback data.

solid-objects/database/mysql

  • mysql(options): construct MySQLDatabase.
  • MySQLDatabase: pooled mysql2 Database implementation.
  • MySQLDatabaseOptions: pool configuration.
  • mysqlSql(sql): translate the portable conflict syntax used by custom database integrations.

solid-objects/wake-up/redis

  • redisWakeUp(options): construct RedisWakeUpAdapter.
  • RedisWakeUpAdapter: optional Pub/Sub latency layer.
  • RedisWakeUpOptions and RedisWakeUpFailure: connection, channel, timeout, and failure callback types.

solid-objects/browser

  • SolidObjectsBrowserClient: connect, subscribe, unsubscribe, receive, and close a versioned WebSocket client without Node imports.
  • BrowserClientOptions, ActorSubscription, InvalidationEnvelope, PayloadEnvelope, and RealtimeEnvelope: browser transport types.
  • parseInvalidation(value) and parseRealtimeEnvelope(value): validate and deeply freeze received JSON for custom transports.
  • SolidObjectsComponentRegistry: register keyed observable dependencies, coalesce refreshes, abort superseded work, fence application, and close.
  • ComponentRegistration, RegisteredComponent, ComponentRefreshStrategy, ComponentRefreshRequest, ComponentRefreshResult, ComponentApplication, ComponentRefreshFailure, and ComponentRegistryOptions: framework-neutral refresh contract types.

InvalidationEnvelope.observables contains values and InvalidationEnvelope.invalidations contains names without values. The component registry reacts to names in either location.

The wire format, trust boundary, revision rules, and component semantics are in Browser protocol.

solid-objects/browser/host

The entry point for a runtime host inside a browser worker. An import of this module registers the browser platform: a turn-scoped context store and a browser host identity. Do not import it in the same process as the Node entry points; the last registration wins.

  • Re-exports Actor, broadcastInvalidation, broadcastValue, configure, createRuntime, SolidObjectsRuntime, and VERSION from the core, and sqliteWasm, SQLiteWasmDatabase, and SQLiteWasmDatabaseOptions from the WASM adapter, so a worker needs one import.
  • The turn-scoped context store expects serialized actor turns. One worker hosts one runtime. A page talks to that worker through messages, not through direct actor references.
  • The store scopes only the synchronous part of a callback and restores the previous scope in strict stack order, so an interleaved task never observes another turn's scope. The cost of that isolation: after the first await inside an actor operation, currentActor(), applicationWritesForbidden(), and the database deadline read as unset. Keep guarded application-database writes in synchronous actor code or in commit actions; Node keeps full AsyncLocalStorage propagation.
  • Alarms and reminders fire only while the hosting worker is alive.

solid-objects/browser/tab-host

Many tabs, one runtime. Each tab starts a candidate host; the Web Locks API elects one leader per origin. The leader starts the runtime, runs its workers, and serves invocations from every tab over a BroadcastChannel. When the leader's tab dies, the lock releases and the next host promotes.

  • startTabHost(options): join the election. TabHostOptions carries the election name and a startRuntime callback; the callback runs only on promotion, so a follower never opens the database. It returns a TabHostRuntimeHandle with the runtime and an optional close.
  • TabHost: role(), leadership() (a promise that resolves on promotion), and close().
  • connectTabClient(options): connect from any tab. TabClientOptions carries the election name plus retry and timeout intervals.
  • TabClient.invoke(invocation): send a TabInvocation (actorType, actorId, operation, arguments). The client retries until a leader answers; the leader enqueues with the request id as the idempotency key, so a resend applies once.
  • TabInvocationTimeout and TabInvocationFailed: the client-side errors.

The election needs the Web Locks API. Every current browser provides it; Node provides navigator.locks from 24.5, so Node-side use of this module needs a newer Node than the package floor. startTabHost fails fast with a clear error where the API is missing.

A tab dies without a clean shutdown, so failover speed follows the fence settings. Give the browser runtime a short leaseDurationMilliseconds and processAliveThresholdMilliseconds (for example 750), with a leaseRenewalIntervalMilliseconds below the lease (for example 250), so a new leader reclaims a dead tab's activations before sync invocations time out. When startRuntime fails, close the database in a catch block; an open SAH pool otherwise blocks the next candidate until the worker dies.

solid-objects/transmit

The transactional outbox bridge between a local runtime and a server runtime. An actor stages a transmit intent with this.transmit() in the same transaction as its state change. The effect worker drains the outbox with at-least-once delivery, per-actor order, and retry backoff.

  • actor.transmit(): the fluent staging surface. this.transmit().increment( { amount }) stages a transmit intent that replays the operation on the server twin of the same actor, in the same transaction as the local state change.

  • TRANSMIT_EFFECT: the effect name (solid-objects.transmit) underneath transmit(). Stage it directly with emit() when the target differs from the source: the staged arguments hold operation, arguments, and an optional target actorType and actorId.

  • registerTransmit(options): register the drain handler on the local runtime. RegisterTransmitOptions carries the runtime and a deliver callback that carries a TransmitEnvelope to the server; throw from deliver while offline and the effect retries with backoff. Give a browser runtime a generous maxAttempts; an effect that exhausts its attempts during a long offline period lands in dead letters, and runtime.deadLetters.retry re-queues it.

  • receiveTransmitEnvelope(options): idempotent server ingest. arguments is optional in the envelope and defaults to an empty object, matching the staging side and the Ruby ingest. It enqueues an internal message with transmit:<effectId> as the idempotency key, so a replayed envelope applies once. The host must authenticate the sender before this call; internal delivery skips authorizeMessage. The call belongs inside whatever route the host application gives the transmit callback to post to:

    import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope } from "solid-objects"
    
    async function handleSyncRoute(request: Request): Promise<Response> {
      const sender = await authenticate(request)
      if (!sender) return new Response("Forbidden", { status: 403 })
      try {
        await receiveTransmitEnvelope({ runtime, envelope: await request.json() })
        return Response.json({})
      } catch (error) {
        if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) {
          return new Response(null, { status: 422 })
        }
        throw error
      }
    }

    The example uses a Fetch-style handler; any HTTP framework works. The 422 matters: it tells the sending outbox to dead-letter the effect instead of retrying it. InvalidPayload marks a malformed envelope, and IdempotencyConflict marks a replay whose arguments changed; both are permanently unappliable, and a 500 would make the outbox retry them forever.

  • Per-actor order comes from an ordered drain: a claimed transmit effect transmits every undelivered envelope for its actor up to its own mailbox sequence, oldest first. A duplicate transmission is safe; the server deduplicates by effect id. Run one effect worker per local runtime for the order guarantee.

  • InvalidTransmitEnvelope: the non-retryable rejection for malformed staged arguments; the effect dead-letters instead of retrying forever.

The tab host and transmit modules are browser-safe and also run in Node. The transmit wire contract is shared with the Ruby gem (solid-objects-ruby#49); compatibility/transmit-envelopes.json pins it in both repositories.

solid-objects/signals

Live signals: the read-side adapter on the proposed standard JavaScript signals API. One side-effect import enables reference.live:

import "solid-objects/signals"

const counter = Counter.ref("page-hits")
counter.live.count // a read-only signal of the broadcast observable
counter.live.snapshot // a read-only signal of the authorized snapshot

One property, three tenses: snapshot.count is one committed read, await counter.count asks the actor now, and counter.live.count stays current. From Lit, watch(counter.live.count) with the SignalWatcher mixin renders it with no further glue; any consumer of the standard signals API composes the same way.

  • configureLiveSignals(options): tune the lifecycle. LiveSignalsConfiguration carries lingerMilliseconds (default one second): how long a signal with no watchers keeps its subscription before the session closes; and retryMilliseconds (default one second): how long a still-watched signal waits before it retries a denied or failed subscription.
  • activeLiveSubscriptionCount() and liveEntryCount(runtime): open sessions and live per-actor entries, for diagnostics and leak tests. The cache holds entries through weak references: one canonical entry per actor for as long as any proxy or signal for it is reachable, so two references to one actor can never open competing subscriptions, and an entry whose signals are all garbage-collected leaves the cache with them.
  • ActorLiveSignals and LiveSignal: the structural types on reference.live. LiveSignal exposes only get(), so the package types never require the optional peer; at runtime every signal is a standard Signal.Computed, read-only by construction.

Behavior:

  • A signal subscribes its actor through an in-process runtime.realtime session when the first watcher arrives and closes the session after the linger when the last watcher leaves. The subscription authorizes through authorizeSubscription with an undefined authorization context.
  • Value-broadcast observables set their named signals from each envelope. Invalidation-only observables stay undefined by design; live.snapshot re-fetches the authorized snapshot (coalesced) on every accepted envelope, so private-value flows read from there.
  • Personalized payload projections arrive as live.payloads.<name> signals. A newly watched payload name re-sends the subscription with the grown name list, and each payload keeps the independent per-name revision fence the wire protocol gives it. Payloads evaluate under the live session's authorization context.
  • Envelopes apply only on a monotonic revision advance for the same instance, the same fence the browser client uses.
  • snapshot and payloads are reserved names on live; observables with those names are shadowed.
  • signal-polyfill is an optional peer dependency. Nothing loads it until the solid-objects/signals entry is imported; reference.live throws a pointer to that import otherwise.

solid-objects/web

  • createDashboard(options) creates an immutable SolidObjectsDashboard with a standard fetch(request, context) entry point.
  • createNodeDashboardHandler(options) adapts the Fetch entry point to node:http and Connect-compatible middleware.
  • DashboardOptions selects the runtime, mount path, DashboardAccess, chart library, DashboardExtension objects, and DashboardMiddleware functions.
  • DashboardRequestContext supplies the existing administration authorization context and an optional DashboardSession. Read/write access requires the 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 define immutable view overrides and middleware inputs.
  • DashboardChartLibrary selects the CDN, a self-hosted script, or disabled charts.
  • NodeDashboardHandler, NodeDashboardHandlerOptions, and NodeDashboardRequestContextResolver describe the Node adapter.
  • SolidObjectsDashboardContract is the minimal Fetch contract accepted by the Node adapter.

Mounting, authorization actions, CSRF behavior, pages, and extensions are in Operator dashboard.