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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.14.1 - 2026-08-23

- Add `solid-objects/signals`, live signals on actor references
([#24](https://github.com/cardmagic/solid-objects-js/issues/24)). One
side-effect import enables `reference.live`: read-only signals on the
proposed standard JavaScript signals API (`signal-polyfill`, a new
optional peer dependency) that subscribe through an in-process
`runtime.realtime` session when first watched and unsubscribe after a
linger when the last watcher leaves. Value-broadcast observables feed
named signals; `live.snapshot` re-fetches the authorized snapshot on
each accepted envelope; `live.payloads.<name>` carries personalized
payload projections under their independent revision fences; stale
revisions are fenced. Works in Node and
in the browser runtime.

## 0.14.0 - 2026-08-23

- Add `solid-objects/database/shared-sqlite-wasm`, the transparent
Expand Down
63 changes: 63 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ generic signatures; this index explains the supported role of every export.
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`, and
`ScheduledOperations`: inferred actor-class and fluent-dispatch types.
Expand Down Expand Up @@ -492,6 +494,67 @@ The transmit wire contract is shared with the Ruby gem
([solid-objects-ruby#49](https://github.com/cardmagic/solid-objects-ruby/pull/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`:

```typescript
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
Expand Down
11 changes: 11 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ complete:
replay deduplication, and recovery after an offline period, on SQLite,
PostgreSQL, and MySQL.

## JavaScript-only: live signals

`reference.live` (the `solid-objects/signals` entry) adapts committed
actor state to the proposed standard JavaScript signals API, so
signal-consuming renderers track actors with no manual registration. No
Ruby row exists because the slot it fills is already native in Rails:
the gem's Turbo and Action Cable component surface re-renders partials
from the same committed observables. Each runtime renders with its
ecosystem's primitive; the guarantee — views track committed state under
revision fencing and the same privacy model — is what parity preserves.

## Shared capability: the transmit family

The transmit family is the one part of the browser work that both runtimes
Expand Down
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solid-objects",
"version": "0.14.0",
"version": "0.14.1",
"description": "Race-free realtime state per application identity, backed by your SQL database",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -78,6 +78,10 @@
"types": "./dist/browser/tab-host.d.ts",
"import": "./dist/browser/tab-host.js"
},
"./signals": {
"types": "./dist/signals.d.ts",
"import": "./dist/signals.js"
},
"./transmit": {
"types": "./dist/transmit.d.ts",
"import": "./dist/transmit.js"
Expand Down Expand Up @@ -121,12 +125,14 @@
"pg": "^8.23.0",
"prettier": "^3.9.6",
"redis": "^6.2.1",
"signal-polyfill": "^0.2.2",
"typescript": "^5.9.0",
"vitest": "^4.1.10",
"ws": "^8.21.3"
},
"peerDependencies": {
"@sqlite.org/sqlite-wasm": ">=3.50.0-build1",
"signal-polyfill": ">=0.2.2",
"mysql2": "^3.23.3",
"pg": "^8.23.0",
"redis": "^6.2.1"
Expand All @@ -135,6 +141,9 @@
"@sqlite.org/sqlite-wasm": {
"optional": true
},
"signal-polyfill": {
"optional": true
},
"mysql2": {
"optional": true
},
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scripts/check-browser-imports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const browserSafeRoots = [
"src/browser/host.ts",
"src/browser/index.ts",
"src/browser/tab-host.ts",
"src/signals.ts",
"src/transmit.ts",
"src/context.ts",
"src/database/deadline.ts",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-documentation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const entryPoints = [
"src/browser/host.ts",
"src/browser/tab-host.ts",
"src/transmit.ts",
"src/signals.ts",
"src/web/index.ts",
]

Expand Down
34 changes: 34 additions & 0 deletions src/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DestroyOptions,
InvocationOptions,
JsonObject,
JsonValue,
MessageStatus,
SnapshotOptions,
} from "./types.js"
Expand Down Expand Up @@ -164,6 +165,27 @@ export class ActorMessageSenderCore<ActorType extends Actor> {
}
}

export interface LiveSignal<Value> {
get(): Value
}

export type ActorLiveSignals<ActorType extends Actor> = {
readonly snapshot: LiveSignal<ActorSnapshot<ActorType> | undefined>
readonly payloads: {
readonly [name: string]: LiveSignal<DeepReadonly<JsonValue> | undefined>
}
} & {
readonly [name: string]: LiveSignal<DeepReadonly<JsonValue> | undefined>
}

export type LiveSignalsFactory = (reference: ActorReferenceCore<Actor>) => object

let liveSignalsFactory: LiveSignalsFactory | undefined

export function installLiveSignals(factory: LiveSignalsFactory): void {
liveSignalsFactory = factory
}

export class ActorReferenceCore<ActorType extends Actor> {
readonly send: ActorMessageSender<ActorType>
readonly runtime: SolidObjectsRuntime
Expand Down Expand Up @@ -194,6 +216,18 @@ export class ActorReferenceCore<ActorType extends Actor> {
return createInvoker(this, options)
}

#live: object | undefined

get live(): ActorLiveSignals<ActorType> {
if (!liveSignalsFactory) {
throw new Error(
'ref.live is inactive; import "solid-objects/signals" once to enable live signals',
)
}
this.#live ??= liveSignalsFactory(this as unknown as ActorReferenceCore<Actor>)
return this.#live as ActorLiveSignals<ActorType>
}

snapshot(options: SnapshotOptions = {}): Promise<ActorSnapshot<ActorType>> {
return this.runtime.snapshot(this, options) as Promise<ActorSnapshot<ActorType>>
}
Expand Down
Loading