Skip to content

Commit e9608df

Browse files
committed
feat: payload signals on live references
The session machinery already delivered payload envelopes; the live adapter dropped them. live.payloads.<name> now carries personalized payload projections: a newly watched payload name re-sends the subscription with the grown name list, each payload keeps the independent per-name revision fence the wire protocol defines, and payloads evaluate under the live session's authorization context. payloads joins snapshot as a reserved name on live. The test watches one payload, sees it flow, then watches a second payload mid-session and proves the re-subscription delivers both with per-name fencing and stable signal identity. This removes the payload-signals item from the deferred list in #24; the thin-page client, the optimistic layer, and the Lit demo remain follow-ups on their own merits.
1 parent 69a1f15 commit e9608df

5 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
`runtime.realtime` session when first watched and unsubscribe after a
1111
linger when the last watcher leaves. Value-broadcast observables feed
1212
named signals; `live.snapshot` re-fetches the authorized snapshot on
13-
each accepted envelope; stale revisions are fenced. Works in Node and
13+
each accepted envelope; `live.payloads.<name>` carries personalized
14+
payload projections under their independent revision fences; stale
15+
revisions are fenced. Works in Node and
1416
in the browser runtime.
1517

1618
## 0.14.0 - 2026-08-23

‎docs/api.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,10 +542,15 @@ Behavior:
542542
envelope. Invalidation-only observables stay `undefined` by design;
543543
`live.snapshot` re-fetches the authorized snapshot (coalesced) on
544544
every accepted envelope, so private-value flows read from there.
545+
- Personalized payload projections arrive as
546+
`live.payloads.<name>` signals. A newly watched payload name re-sends
547+
the subscription with the grown name list, and each payload keeps the
548+
independent per-name revision fence the wire protocol gives it.
549+
Payloads evaluate under the live session's authorization context.
545550
- Envelopes apply only on a monotonic revision advance for the same
546551
instance, the same fence the browser client uses.
547-
- `snapshot` is a reserved name on `live`; an observable named
548-
`snapshot` is shadowed.
552+
- `snapshot` and `payloads` are reserved names on `live`; observables
553+
with those names are shadowed.
549554
- `signal-polyfill` is an optional peer dependency. Nothing loads it
550555
until the `solid-objects/signals` entry is imported; `reference.live`
551556
throws a pointer to that import otherwise.

‎src/reference.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ export interface LiveSignal<Value> {
171171

172172
export type ActorLiveSignals<ActorType extends Actor> = {
173173
readonly snapshot: LiveSignal<ActorSnapshot<ActorType> | undefined>
174+
readonly payloads: {
175+
readonly [name: string]: LiveSignal<DeepReadonly<JsonValue> | undefined>
176+
}
174177
} & {
175178
readonly [name: string]: LiveSignal<DeepReadonly<JsonValue> | undefined>
176179
}

‎src/signals.ts‎

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface LiveSignalsConfiguration {
1717
}
1818

1919
const SNAPSHOT_KEY = "snapshot"
20+
const PAYLOADS_KEY = "payloads"
2021

2122
let lingerMilliseconds = 1_000
2223
let retryMilliseconds = 1_000
@@ -65,6 +66,11 @@ class LiveActorEntry {
6566
readonly #mirrors = new Map<string, LiveSignal<DeepReadonly<JsonValue> | undefined>>()
6667
readonly #snapshotState = new Signal.State<ActorSnapshot<Actor> | undefined>(undefined)
6768
#snapshotMirror: LiveSignal<ActorSnapshot<Actor> | undefined> | undefined
69+
readonly #payloadStates = new Map<string, Signal.State<DeepReadonly<JsonValue> | undefined>>()
70+
readonly #payloadMirrors = new Map<string, LiveSignal<DeepReadonly<JsonValue> | undefined>>()
71+
readonly #payloadWatcherCounts = new Map<string, number>()
72+
readonly #payloadFences = new Map<string, { instanceId: string; revision: bigint }>()
73+
#payloadsProxy: object | undefined
6874
#watcherCount = 0
6975
#snapshotWatcherCount = 0
7076
#session: LiveSession | undefined
@@ -91,6 +97,49 @@ class LiveActorEntry {
9197
return mirror
9298
}
9399

100+
payloadsProxy(): object {
101+
this.#payloadsProxy ??= new Proxy(
102+
{},
103+
{
104+
get: (_target, property) => {
105+
if (typeof property !== "string") return undefined
106+
return this.payloadSignalFor(property)
107+
},
108+
has: (_target, property) => typeof property === "string",
109+
},
110+
)
111+
return this.#payloadsProxy
112+
}
113+
114+
payloadSignalFor(name: string): LiveSignal<DeepReadonly<JsonValue> | undefined> {
115+
const existing = this.#payloadMirrors.get(name)
116+
if (existing) return existing
117+
const state = this.payloadStateFor(name)
118+
const mirror = new Signal.Computed(() => state.get(), {
119+
[Signal.subtle.watched]: () => {
120+
this.#payloadWatcherCounts.set(name, (this.#payloadWatcherCounts.get(name) ?? 0) + 1)
121+
this.retain({ snapshot: false, payloadName: name })
122+
},
123+
[Signal.subtle.unwatched]: () => {
124+
const count = (this.#payloadWatcherCounts.get(name) ?? 1) - 1
125+
if (count <= 0) this.#payloadWatcherCounts.delete(name)
126+
else this.#payloadWatcherCounts.set(name, count)
127+
this.release({ snapshot: false })
128+
},
129+
})
130+
this.#payloadMirrors.set(name, mirror)
131+
return mirror
132+
}
133+
134+
private payloadStateFor(name: string) {
135+
let state = this.#payloadStates.get(name)
136+
if (!state) {
137+
state = new Signal.State<DeepReadonly<JsonValue> | undefined>(undefined)
138+
this.#payloadStates.set(name, state)
139+
}
140+
return state
141+
}
142+
94143
snapshotSignal(): LiveSignal<ActorSnapshot<Actor> | undefined> {
95144
this.#snapshotMirror ??= new Signal.Computed(() => this.#snapshotState.get(), {
96145
[Signal.subtle.watched]: () => this.retain({ snapshot: true }),
@@ -108,14 +157,15 @@ class LiveActorEntry {
108157
return state
109158
}
110159

111-
private retain(options: { snapshot: boolean }): void {
160+
private retain(options: { snapshot: boolean; payloadName?: string }): void {
112161
this.#watcherCount += 1
113162
if (options.snapshot) this.#snapshotWatcherCount += 1
114163
if (this.#linger !== undefined) {
115164
clearTimeout(this.#linger)
116165
this.#linger = undefined
117166
}
118167
if (!this.#session) this.open()
168+
else if (options.payloadName !== undefined) this.sendSubscribe(this.#session)
119169
else if (options.snapshot) void this.refreshSnapshot()
120170
}
121171

@@ -141,13 +191,7 @@ class LiveActorEntry {
141191
}) as LiveSession
142192
this.#session = session
143193
openSessions.add(this)
144-
void session
145-
.receive({
146-
version: 1,
147-
action: "subscribe",
148-
actorType: this.#reference.actorType,
149-
actorId: this.#reference.actorId,
150-
})
194+
void this.sendSubscribe(session)
151195
.then(() => this.refreshSnapshot())
152196
.catch((error: unknown) => {
153197
this.#reference.runtime.settings.logger.warn({
@@ -166,6 +210,17 @@ class LiveActorEntry {
166210
})
167211
}
168212

213+
private sendSubscribe(session: LiveSession): Promise<void> {
214+
const payloadNames = [...this.#payloadWatcherCounts.keys()]
215+
return session.receive({
216+
version: 1,
217+
action: "subscribe",
218+
actorType: this.#reference.actorType,
219+
actorId: this.#reference.actorId,
220+
...(payloadNames.length > 0 ? { payloads: payloadNames } : {}),
221+
})
222+
}
223+
169224
private close(): void {
170225
if (this.#retry !== undefined) {
171226
clearTimeout(this.#retry)
@@ -179,6 +234,10 @@ class LiveActorEntry {
179234
}
180235

181236
private receive(envelope: RealtimeEnvelope): void {
237+
if (envelope.kind === "payload") {
238+
this.receivePayload(envelope)
239+
return
240+
}
182241
if (envelope.kind !== "invalidation") return
183242
const revision = BigInt(envelope.revision)
184243
if (this.#instanceId === envelope.instanceId && revision <= this.#revision) return
@@ -190,6 +249,14 @@ class LiveActorEntry {
190249
void this.refreshSnapshot()
191250
}
192251

252+
private receivePayload(envelope: RealtimeEnvelope & { kind: "payload" }): void {
253+
const revision = BigInt(envelope.revision)
254+
const fence = this.#payloadFences.get(envelope.name)
255+
if (fence && fence.instanceId === envelope.instanceId && revision <= fence.revision) return
256+
this.#payloadFences.set(envelope.name, { instanceId: envelope.instanceId, revision })
257+
this.payloadStateFor(envelope.name).set(envelope.payload as DeepReadonly<JsonValue>)
258+
}
259+
193260
private async refreshSnapshot(): Promise<void> {
194261
if (this.#snapshotWatcherCount === 0) return
195262
if (this.#refreshing) {
@@ -245,6 +312,7 @@ installLiveSignals((reference) => {
245312
get(_target, property) {
246313
if (typeof property !== "string") return undefined
247314
if (property === SNAPSHOT_KEY) return resolved.snapshotSignal()
315+
if (property === PAYLOADS_KEY) return resolved.payloadsProxy()
248316
return resolved.signalFor(property)
249317
},
250318
has(_target, property) {

‎test/live-signals.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ class LiveCounter extends Actor {
3434
}
3535
}
3636

37+
class LivePayloadCounter extends Actor {
38+
static override readonly actorType = "LivePayloadCounter"
39+
static override readonly payloads = {
40+
personalized: (actor: LivePayloadCounter) => ({ count: actor.count }),
41+
doubled: (actor: LivePayloadCounter) => ({ value: actor.count * 2 }),
42+
}
43+
44+
count = 0
45+
46+
increment(): number {
47+
this.count += 1
48+
return this.count
49+
}
50+
51+
override observables(): Record<string, unknown> {
52+
return { count: broadcastValue(this.count) }
53+
}
54+
}
55+
3756
const runtimes: SolidObjectsRuntime[] = []
3857
const watchers: Array<{
3958
watcher: InstanceType<typeof Signal.subtle.Watcher>
@@ -62,6 +81,7 @@ async function liveRuntime(): Promise<SolidObjectsRuntime> {
6281
syncPollingIntervalMilliseconds: 1,
6382
})
6483
runtime.register(LiveCounter)
84+
runtime.register(LivePayloadCounter)
6585
runtimes.push(runtime)
6686
await runtime.install()
6787
return runtime
@@ -228,6 +248,30 @@ describe("live signals", () => {
228248
watcher.unwatch(second.live.count! as never)
229249
})
230250

251+
it("delivers personalized payload signals with independent fences", async () => {
252+
const runtime = await liveRuntime()
253+
const counter = runtime.ref(LivePayloadCounter, "payloaded")
254+
watch(counter.live.payloads.personalized!)
255+
await eventually(() => activeLiveSubscriptionCount() === 1)
256+
257+
await counter.increment()
258+
await runtime.testing.drain({ roles: ["actors", "broadcasts"] })
259+
await eventually(() => {
260+
const payload = counter.live.payloads.personalized!.get() as { count?: number } | undefined
261+
return payload?.count === 1
262+
})
263+
264+
watch(counter.live.payloads.doubled!)
265+
await counter.increment()
266+
await runtime.testing.drain({ roles: ["actors", "broadcasts"] })
267+
await eventually(() => {
268+
const doubled = counter.live.payloads.doubled!.get() as { value?: number } | undefined
269+
return doubled?.value === 4
270+
})
271+
expect((counter.live.payloads.personalized!.get() as { count?: number }).count).toBe(2)
272+
expect(counter.live.payloads.personalized).toBe(counter.live.payloads.personalized)
273+
})
274+
231275
it("exposes read-only signals and a stable proxy", () => {
232276
const runtime = createRuntime({
233277
database: sqlite({ path: ":memory:" }),

0 commit comments

Comments
 (0)