From 33cf7116204aa244ba4e784cf933058c00e34445 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 13:47:29 -0700 Subject: [PATCH 1/6] feat: live signals on actor references Implements #24. One side-effect import of solid-objects/signals enables reference.live: read-only signals on the proposed standard JavaScript signals API, completing the one-property-three-tenses shape (snapshot.count, await counter.count, counter.live.count). - The signals entry installs a factory into the reference core, so signal-polyfill stays an optional peer that never loads unless the entry is imported; reference.live throws a pointer otherwise. - Signals are Signal.Computed mirrors over internal state, read-only by construction. watched/unwatched callbacks drive the lifecycle: the first watcher opens an in-process runtime.realtime session (subscribe replays committed observables immediately), and the last watcher's departure closes it after a configurable linger. - Value-broadcast observables feed named signals from envelopes; invalidation-only names stay undefined by design, and live.snapshot re-fetches the authorized snapshot, coalesced, on each accepted envelope. Envelopes apply only on a monotonic revision advance for the same instance, the browser client's fence. - Entries dedupe per runtime and actor identity, so two refs to one actor share one session. Vitest covers replay, follow-on commits through the broadcast outbox, invalidation-only snapshot refresh, the revision fence, the watch/linger lifecycle with a subscription-count leak check, proxy stability, read-only enforcement, and the helpful error without the entry. Playwright proves the stack in Chromium: a WASM-runtime actor increments and a standard signal watcher observes the committed values inside the browser worker. Deviation from the RFP recorded honestly: v1 rides runtime-backed references (Node and the browser host), not the thin-page realtime client, because refs carry their runtime and the in-process realtime session already delivers envelopes on both platforms. The thin-page variant and the shuffleupandplay Lit demo are follow-ups. --- CHANGELOG.md | 13 ++ docs/api.md | 51 ++++++++ docs/parity.md | 11 ++ package.json | 9 ++ pnpm-lock.yaml | 8 ++ scripts/check-browser-imports.mjs | 1 + scripts/check-documentation.mjs | 1 + src/reference.ts | 31 +++++ src/signals.ts | Bin 0 -> 6806 bytes test/browser-server.mjs | 18 ++- test/browser/live-signals-worker.mjs | 74 +++++++++++ test/browser/live-signals.browser.ts | 26 ++++ test/live-signals-uninstalled.test.ts | 29 +++++ test/live-signals.test.ts | 181 ++++++++++++++++++++++++++ 14 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/signals.ts create mode 100644 test/browser/live-signals-worker.mjs create mode 100644 test/browser/live-signals.browser.ts create mode 100644 test/live-signals-uninstalled.test.ts create mode 100644 test/live-signals.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd036d..3d72720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- 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; 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 diff --git a/docs/api.md b/docs/api.md index a41cc23..73b0f9b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. @@ -492,6 +494,55 @@ 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 = runtime.ref(Counter, "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)`: set the unsubscribe linger. + `LiveSignalsConfiguration` carries `lingerMilliseconds` (default one + second): how long a signal with no watchers keeps its subscription + before the session closes. +- `activeLiveSubscriptionCount()`: the number of open live sessions, for + diagnostics and leak tests. +- `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. +- Envelopes apply only on a monotonic revision advance for the same + instance, the same fence the browser client uses. +- `snapshot` is a reserved name on `live`; an observable named + `snapshot` is 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 diff --git a/docs/parity.md b/docs/parity.md index 3f6ffaa..0b3b92d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 diff --git a/package.json b/package.json index 3c31408..dd7c17f 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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" @@ -135,6 +141,9 @@ "@sqlite.org/sqlite-wasm": { "optional": true }, + "signal-polyfill": { + "optional": true + }, "mysql2": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86ccf64..74c1829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: redis: specifier: ^6.2.1 version: 6.2.1 + signal-polyfill: + specifier: ^0.2.2 + version: 0.2.2 typescript: specifier: ^5.9.0 version: 5.9.3 @@ -577,6 +580,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-polyfill@0.2.2: + resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1160,6 +1166,8 @@ snapshots: siginfo@2.0.0: {} + signal-polyfill@0.2.2: {} + source-map-js@1.2.1: {} split2@4.2.0: {} diff --git a/scripts/check-browser-imports.mjs b/scripts/check-browser-imports.mjs index b5ac6ca..3c2a7f0 100644 --- a/scripts/check-browser-imports.mjs +++ b/scripts/check-browser-imports.mjs @@ -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", diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index eea8ff1..6345ba1 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -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", ] diff --git a/src/reference.ts b/src/reference.ts index 3830049..3573338 100644 --- a/src/reference.ts +++ b/src/reference.ts @@ -7,6 +7,7 @@ import type { DestroyOptions, InvocationOptions, JsonObject, + JsonValue, MessageStatus, SnapshotOptions, } from "./types.js" @@ -164,6 +165,24 @@ export class ActorMessageSenderCore { } } +export interface LiveSignal { + get(): Value +} + +export type ActorLiveSignals = { + readonly snapshot: LiveSignal | undefined> +} & { + readonly [name: string]: LiveSignal | undefined> +} + +export type LiveSignalsFactory = (reference: ActorReferenceCore) => object + +let liveSignalsFactory: LiveSignalsFactory | undefined + +export function installLiveSignals(factory: LiveSignalsFactory): void { + liveSignalsFactory = factory +} + export class ActorReferenceCore { readonly send: ActorMessageSender readonly runtime: SolidObjectsRuntime @@ -194,6 +213,18 @@ export class ActorReferenceCore { return createInvoker(this, options) } + #live: object | undefined + + get live(): ActorLiveSignals { + 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) + return this.#live as ActorLiveSignals + } + snapshot(options: SnapshotOptions = {}): Promise> { return this.runtime.snapshot(this, options) as Promise> } diff --git a/src/signals.ts b/src/signals.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f32937f67bdb4a9518a4b3e0c383602a7b7d29e GIT binary patch literal 6806 zcmb_gTW=dh6z18#V&WoX3wKFhEhkk8MHDE7x&k4D!rDGghHhrf%&rqR^?&EwW_QPS znx@c)CYhac|ITgf)vENBxiwRJ;qt<4&C-`wrXTpv$E&i~ENxNrdsZ%1n-$>g*N_*L zy@Ka%4MmAZkuLjc=F95<{wwPiaFhKVc1fSjtI~Jw=k#~?-fhnq>q3Ne%C;7FeeQ^e*QsOQAi) zx&{3wF^*}QtHTCCc#nX$&H(TS>qHXTsqN0H%6*0`H_Li{0CUB1NYI?i~{%9;t4(sF46RBy1wly#NFf{3_1VD*2o7#oS{ zuBsl^mTDKO`S^(=8bptWaEbB9vt?cc=$!siLk)}O1Af62L(VldCxo+hn-2gl)`05L zhOC7uvTUf4D`t-|(p$ep#xB2tXaVNJ1F@skjx{xKV?!mzi3$qQ>;fv5BSnQuW4t;< z)i}%J0qUlPqgOFCPSN{2OxjM_hOD>3M#xixSf78UtIN{ccf4SnmAFaW;QZOlLa}88a5yszK=)RfJJL;-n-<&U zL5A6QSF7Q6XmS};bv>79*H;{9gv9K6N@0>4lTfm~@B=+G0+@$wE!cnB1TFgJ>IRocaoN9R|G*9Jw zMgyQh3;497TjOf}B}txSb4o_Q7JUgC*4z#H6i9%1JeDv&DqnZ(yy7RB zRUXh=^RRJqv}LCV>}>st)iw%oEJ?-??JP1Wv0GXKm4G#2$JDsoHESG=olHziJMB39 zhZrXDYI)I?6R+c&ma5q-LON04t^7K-)n1W|R_L@6$DVl1VacFMbF(-7||k{?qd)x!M>+4PD#S_5^M*c={G9oe+8l)#79My-takZbY1x@g07 zAgpoAr+U1HBEd@Q;21asMgyfT6KuuwI?1B;;I4QRkkSAA1Sk2ddHeO|wahWPGXDno zTY8t0)-^KSH!4~o87A`5%1^EP&*FpjaiZy}v=^yBl(Q?Ew{}OLf91#)QAj!-vgMp8A>>`EbMSt7R%*dGe284>SB { pathname === "/runtime-worker.mjs" || pathname === "/tab-host-worker.mjs" || pathname === "/transmit-worker.mjs" || - pathname === "/shared-db-worker.mjs" + pathname === "/shared-db-worker.mjs" || + pathname === "/live-signals-worker.mjs" ) { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return } + if (pathname.startsWith("/vendor/signal-polyfill/")) { + const vendorPath = resolve( + signalPolyfillRoot, + `.${pathname.slice("/vendor/signal-polyfill".length)}`, + ) + if (!vendorPath.startsWith(`${signalPolyfillRoot}/`)) { + response.writeHead(404) + response.end() + return + } + await serveFile({ response, path: vendorPath }) + return + } if (pathname.startsWith("/vendor/sqlite-wasm/")) { const vendorPath = resolve(sqliteWasmRoot, `.${pathname.slice("/vendor/sqlite-wasm".length)}`) if (!vendorPath.startsWith(`${sqliteWasmRoot}/`)) { @@ -154,6 +169,7 @@ async function serveFile({ response, path }) { contents = contents .toString("utf-8") .replaceAll('"@sqlite.org/sqlite-wasm"', '"/vendor/sqlite-wasm/index.mjs"') + .replaceAll('"signal-polyfill"', '"/vendor/signal-polyfill/index.js"') } response.writeHead(200, { "content-type": contentType }) response.end(contents) diff --git a/test/browser/live-signals-worker.mjs b/test/browser/live-signals-worker.mjs new file mode 100644 index 0000000..8238b32 --- /dev/null +++ b/test/browser/live-signals-worker.mjs @@ -0,0 +1,74 @@ +import { Actor, broadcastValue, configure, sqliteWasm } from "/browser/host.js" +import "/signals.js" +import { Signal } from "signal-polyfill" + +class LiveCounter extends Actor { + static actorType = "LiveCounter" + + count = 0 + + increment() { + this.count += 1 + return this.count + } + + observables() { + return { count: broadcastValue(this.count) } + } +} + +self.onmessage = async (event) => { + const { requestId } = event.data + try { + const database = await sqliteWasm({ path: "live-signals.db" }) + const runtime = configure({ + database, + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + authorizeSubscription: () => true, + pollingIntervalMilliseconds: 5, + syncPollingIntervalMilliseconds: 5, + }) + runtime.register(LiveCounter) + await runtime.install() + const runAbort = new AbortController() + const running = runtime.run(runAbort.signal) + + const counter = runtime.ref(LiveCounter, "browser-live") + const observed = [] + const watcher = new Signal.subtle.Watcher(async () => { + await 0 + observed.push(counter.live.count.get()) + watcher.watch() + }) + watcher.watch(counter.live.count) + counter.live.count.get() + + await counter.increment() + await counter.increment() + await waitFor(() => counter.live.count.get() === 2) + + watcher.unwatch(counter.live.count) + runAbort.abort() + await running.catch(() => undefined) + await runtime.close() + await database.close() + postMessage({ requestId, ok: true, value: { final: 2, observed } }) + } catch (error) { + postMessage({ + requestId, + ok: false, + message: String((error && error.message) || error), + stack: String((error && error.stack) || ""), + }) + } +} + +async function waitFor(condition) { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (condition()) return + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error("condition never became true") +} diff --git a/test/browser/live-signals.browser.ts b/test/browser/live-signals.browser.ts new file mode 100644 index 0000000..7f94771 --- /dev/null +++ b/test/browser/live-signals.browser.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test" + +test("live signals track a browser runtime actor", async ({ page }) => { + await page.goto("/") + + const report = await page.evaluate( + () => + new Promise((resolve, reject) => { + const worker = new Worker("/live-signals-worker.mjs", { type: "module" }) + worker.onmessage = (event) => { + worker.terminate() + if (event.data.ok) resolve(event.data.value) + else reject(new Error(`${event.data.message}\n${event.data.stack}`)) + } + worker.onerror = (event) => { + worker.terminate() + reject(new Error(event.message)) + } + worker.postMessage({ requestId: crypto.randomUUID() }) + }), + ) + + expect(report).toMatchObject({ final: 2 }) + const observed = (report as { observed: number[] }).observed + expect(observed[observed.length - 1]).toBe(2) +}) diff --git a/test/live-signals-uninstalled.test.ts b/test/live-signals-uninstalled.test.ts new file mode 100644 index 0000000..664262b --- /dev/null +++ b/test/live-signals-uninstalled.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import "../src/platform/node.js" +import { Actor } from "../src/actor.js" +import { createRuntime } from "../src/runtime.js" +import { sqlite } from "../src/database/sqlite.js" + +class PlainCounter extends Actor { + static override readonly actorType = "PlainCounter" + + count = 0 + + increment(): number { + this.count += 1 + return this.count + } +} + +describe("live signals without the signals entry", () => { + it("explains that solid-objects/signals must be imported", () => { + const runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + }) + const counter = runtime.ref(PlainCounter, "plain") + expect(() => counter.live).toThrow("solid-objects/signals") + }) +}) diff --git a/test/live-signals.test.ts b/test/live-signals.test.ts new file mode 100644 index 0000000..cf97ee9 --- /dev/null +++ b/test/live-signals.test.ts @@ -0,0 +1,181 @@ +import { Signal } from "signal-polyfill" +import { afterEach, describe, expect, it } from "vitest" +import "../src/platform/node.js" +import { + activeLiveSubscriptionCount, + configureLiveSignals, + type LiveSignal, +} from "../src/signals.js" +import { Actor, broadcastInvalidation, broadcastValue } from "../src/actor.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" +import { sqlite } from "../src/database/sqlite.js" + +class LiveCounter extends Actor { + static override readonly actorType = "LiveCounter" + + count = 0 + note = "quiet" + + increment(): number { + this.count += 1 + return this.count + } + + annotate({ note }: { note: string }): void { + this.note = note + } + + override observables(): Record { + return { + count: broadcastValue(this.count), + note: broadcastInvalidation(this.note), + } + } +} + +const runtimes: SolidObjectsRuntime[] = [] +const watchers: Array<{ + watcher: InstanceType + signals: LiveSignal[] +}> = [] + +afterEach(async () => { + for (const { watcher, signals } of watchers) { + for (const signal of signals) watcher.unwatch(signal as never) + } + watchers.length = 0 + await Promise.all(runtimes.map((runtime) => runtime.close())) + runtimes.length = 0 + configureLiveSignals({ lingerMilliseconds: 50 }) + await eventually(() => activeLiveSubscriptionCount() === 0) +}) + +async function liveRuntime(): Promise { + const runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + authorizeSubscription: () => true, + pollingIntervalMilliseconds: 1, + syncPollingIntervalMilliseconds: 1, + }) + runtime.register(LiveCounter) + runtimes.push(runtime) + await runtime.install() + return runtime +} + +function watch(...signals: LiveSignal[]): void { + const watcher = new Signal.subtle.Watcher(() => {}) + for (const signal of signals) watcher.watch(signal as never) + for (const signal of signals) (signal as { get(): unknown }).get() + watchers.push({ watcher, signals }) +} + +async function eventually(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (condition()) return + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error("condition never became true") +} + +describe("live signals", () => { + it("replays committed observables into a watched signal", async () => { + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "replayed") + await counter.increment() + + watch(counter.live.count!) + await eventually(() => counter.live.count!.get() === 1) + }) + + it("follows later commits through the broadcast outbox", async () => { + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "following") + watch(counter.live.count!) + await eventually(() => activeLiveSubscriptionCount() === 1) + + await counter.increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => counter.live.count!.get() === 1) + + await counter.increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => counter.live.count!.get() === 2) + }) + + it("keeps live.snapshot current for invalidation-only observables", async () => { + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "noted") + watch(counter.live.snapshot) + await eventually(() => activeLiveSubscriptionCount() === 1) + + await counter.annotate({ note: "loud" }) + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + + await eventually(() => { + const snapshot = counter.live.snapshot.get() as { note?: string } | undefined + return snapshot?.note === "loud" + }) + expect(counter.live.note!.get()).toBeUndefined() + }) + + it("ignores envelopes with stale revisions", async () => { + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "fenced") + watch(counter.live.count!) + await eventually(() => activeLiveSubscriptionCount() === 1) + await counter.increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => counter.live.count!.get() === 1) + + const incarnation = await runtime.snapshotWithIncarnation(counter) + await runtime.realtime.publish({ + actorType: "LiveCounter", + actorId: "fenced", + instanceId: incarnation.instanceId, + revision: "0", + observables: { count: 999 }, + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(counter.live.count!.get()).toBe(1) + }) + + it("subscribes on first watch and releases after the linger", async () => { + configureLiveSignals({ lingerMilliseconds: 50 }) + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "lifecycled") + expect(activeLiveSubscriptionCount()).toBe(0) + + const watcher = new Signal.subtle.Watcher(() => {}) + watcher.watch(counter.live.count! as never) + counter.live.count!.get() + await eventually(() => activeLiveSubscriptionCount() === 1) + + watcher.unwatch(counter.live.count! as never) + await eventually(() => activeLiveSubscriptionCount() === 0) + + watcher.watch(counter.live.count! as never) + counter.live.count!.get() + await eventually(() => activeLiveSubscriptionCount() === 1) + watcher.unwatch(counter.live.count! as never) + }) + + it("exposes read-only signals and a stable proxy", () => { + const runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + }) + runtimes.push(runtime) + runtime.register(LiveCounter) + const counter = runtime.ref(LiveCounter, "readonly") + + expect(counter.live).toBe(counter.live) + expect(counter.live.count).toBe(counter.live.count) + expect("set" in counter.live.count!).toBe(false) + }) +}) From f61b7ee75fd9eeca38b936471ee12682e5f5f4e0 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 13:56:10 -0700 Subject: [PATCH 2/6] docs: use the static ref form in the signals example Counter.ref("page-hits") is the canonical form with configure(); runtime.ref is the isolated-runtime variant. --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 73b0f9b..dbf57c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -502,7 +502,7 @@ signals API. One side-effect import enables `reference.live`: ```typescript import "solid-objects/signals" -const counter = runtime.ref(Counter, "page-hits") +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 ``` From f8d7e33d12982f838550f31204cdcf770fe8d96c Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 14:02:00 -0700 Subject: [PATCH 3/6] fix: retry failed live subscriptions and evict idle entries Greptile round one found two lifecycle gaps: - A denied or transiently failed subscribe left a truthy but unsubscribed session installed, so watched signals could never recover. The failure path now tears the session down and, while watchers remain, retries after a configurable retryMilliseconds (default one second). A test denies the first two subscriptions and proves the third succeeds and values flow. - The per-runtime entry cache retained signal state for every actor identity ever watched. An entry now registers itself on open and evicts itself on close, so an abandoned actor holds no cached state; a rewatched signal reopens and re-registers through the entry it still holds. liveEntryCount(runtime) exposes the cache size, and a test proves 1 while watched, 0 after the linger, and 1 again on rewatch. --- docs/api.md | 12 ++++++--- src/signals.ts | Bin 6806 -> 8225 bytes test/live-signals.test.ts | 54 +++++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index dbf57c4..cff44a1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -513,12 +513,16 @@ 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)`: set the unsubscribe linger. +- `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. -- `activeLiveSubscriptionCount()`: the number of open live sessions, for - diagnostics and leak tests. + 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 cached per-actor entries, for diagnostics and leak tests. + A closed entry leaves the runtime cache, so an abandoned actor holds + no signal state. - `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 diff --git a/src/signals.ts b/src/signals.ts index 8f32937f67bdb4a9518a4b3e0c383602a7b7d29e..1bfb136c6ef5ac8076d96b315e9940e2734446a6 100644 GIT binary patch delta 1129 zcmb7E&ui2`6lOQ8TZ^r%piA9qU$HC+ETI%s8XH%U_M{i_peNa8->y^BNy#KayA3^w zCr<<7N$})FP{_@T^y;Z+LC}9he3O~&uDS|dhDqkl_r4!5d3oi{%@0SVDnN`$yemJJ z9-ay@(NFo0($~@)0PBGrM7+oPY1~Oz#9dvSmz(lid11A9y34|l38cENaA1EQCfpdW zfi+l`>(;^vjkRT6GrPCeMYH>CEvPY^}|ZB0=|GmS?+-B_Mnt3x5~MCd-v zPn0IM?SOy7c&z;Uw@d))acK>jO<4SesG|dlc90t~3My;qPiTDN<*S-v ze)GIP(Q^_@_9*7Vxf LWRT_ab9<$q1wMHQ delta 167 zcmZ4JFwJzs1;)v58J#!#F`ZzX?7%8K`4H>u$(z|iHtVxnGD&hNC?x0S6_*s1CYR(F zX%wZVr52^;C8t_V+$gMN;|LP8(^RMiDpOD>$;d3$Q-&*1uvLJwH+yl(uy5uNl3<=} rE2g&DSKN?Y6)vd>)}5MHQk0omtXG^`qLH0isiOcAsNB3# diff --git a/test/live-signals.test.ts b/test/live-signals.test.ts index cf97ee9..0a2f41e 100644 --- a/test/live-signals.test.ts +++ b/test/live-signals.test.ts @@ -4,6 +4,7 @@ import "../src/platform/node.js" import { activeLiveSubscriptionCount, configureLiveSignals, + liveEntryCount, type LiveSignal, } from "../src/signals.js" import { Actor, broadcastInvalidation, broadcastValue } from "../src/actor.js" @@ -46,7 +47,7 @@ afterEach(async () => { watchers.length = 0 await Promise.all(runtimes.map((runtime) => runtime.close())) runtimes.length = 0 - configureLiveSignals({ lingerMilliseconds: 50 }) + configureLiveSignals({ lingerMilliseconds: 50, retryMilliseconds: 50 }) await eventually(() => activeLiveSubscriptionCount() === 0) }) @@ -163,6 +164,57 @@ describe("live signals", () => { watcher.unwatch(counter.live.count! as never) }) + it("retries after a failed subscription and recovers", async () => { + configureLiveSignals({ lingerMilliseconds: 50, retryMilliseconds: 50 }) + let denials = 2 + const runtime = createRuntime({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + authorizeSubscription: () => { + if (denials > 0) { + denials -= 1 + return false + } + return true + }, + pollingIntervalMilliseconds: 1, + syncPollingIntervalMilliseconds: 1, + }) + runtime.register(LiveCounter) + runtimes.push(runtime) + await runtime.install() + const counter = runtime.ref(LiveCounter, "recovering") + await counter.increment() + + watch(counter.live.count!) + await eventually(() => counter.live.count!.get() === 1) + expect(denials).toBe(0) + }) + + it("evicts an idle entry from the runtime cache after the linger", async () => { + configureLiveSignals({ lingerMilliseconds: 50 }) + const runtime = await liveRuntime() + const counter = runtime.ref(LiveCounter, "evicted") + + const watcher = new Signal.subtle.Watcher(() => {}) + watcher.watch(counter.live.count! as never) + counter.live.count!.get() + await eventually(() => activeLiveSubscriptionCount() === 1) + expect(liveEntryCount(runtime)).toBe(1) + + watcher.unwatch(counter.live.count! as never) + await eventually(() => activeLiveSubscriptionCount() === 0) + expect(liveEntryCount(runtime)).toBe(0) + + watcher.watch(counter.live.count! as never) + counter.live.count!.get() + await eventually(() => activeLiveSubscriptionCount() === 1) + expect(liveEntryCount(runtime)).toBe(1) + watcher.unwatch(counter.live.count! as never) + }) + it("exposes read-only signals and a stable proxy", () => { const runtime = createRuntime({ database: sqlite({ path: ":memory:" }), From 69a1f15e8fe9015964283db4525f82b833b6a88e Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 14:13:57 -0700 Subject: [PATCH 4/6] fix: hold live entries by weak reference for one canonical entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile round two found the race the previous eviction created: a retained proxy could resurrect an evicted entry while a new reference had already created a replacement, leaving two independently active entries and subscriptions for one actor. The registry now holds entries through WeakRef with a FinalizationRegistry sweep. One entry stays canonical per actor for as long as any proxy or signal for it is reachable, so competing entries cannot exist by construction, and an entry whose consumers are all garbage-collected leaves the cache with them. Eviction is no longer tied to the linger; the linger only closes the session. The regression test encodes the reported scenario: watch through one reference, linger past close, watch through a second reference, then rewatch the first — one subscription, one entry, identical signal objects from both references, and one increment observed by both. This commit also removes a stray NUL byte that had been hiding in the entry key template and defeating text searches of the file. --- docs/api.md | 9 ++++++--- src/signals.ts | Bin 8225 -> 8289 bytes test/live-signals.test.ts | 31 ++++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/api.md b/docs/api.md index cff44a1..2d5de10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -520,9 +520,12 @@ signals API composes the same way. second): how long a still-watched signal waits before it retries a denied or failed subscription. - `activeLiveSubscriptionCount()` and `liveEntryCount(runtime)`: open - sessions and cached per-actor entries, for diagnostics and leak tests. - A closed entry leaves the runtime cache, so an abandoned actor holds - no signal state. + 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 diff --git a/src/signals.ts b/src/signals.ts index 1bfb136c6ef5ac8076d96b315e9940e2734446a6..00322299412da9964b5d96b64e1259afc7026357 100644 GIT binary patch delta 680 zcmZ9KyGq1B6oye;m6Tctg3DP2$!LfQR_i9Ng%;vAi(q9Dl9P2XPGmB>vR+8#14y30 z7w|%AJ0HOpu<$)Rxq2xE66XB>fBr9d?0$A%;>MI4iYPfQPdS;+#%14R9MF@Q;wdTkPh8XFL!40Vk<9g8aNZ{)X zT9ySvvFf4E1$wMZShrw{M0a$`4f5pVsqvho)5qz?% znJe`O?OZ>nhuZy2Iy?71mG%}s8u1A_=Y8}$N7OAh*{Akn+qQM4X+=Rd3<7i&df63$ zAv=LraKHuab?qn~iari_L^_>z*3U5T+Hg~`XhYAr?9eS20|Wl$#i1+%civ+M-;gHqEoi-EL4HJ5@yDo`Xdwb)9*H?hD5D4Lmp8S<9Lb?cQh83o!OqO9+k+)XBqeY>1@>@Kml2ml#fiBPl`>hs8 zZVnf_#wrMk9i$+))0nI&`<#(svY?#FWIs6r^=e@FK|@6cDJZL95vT)-B8A$`PvvSD E0nuN;eE { expect(denials).toBe(0) }) - it("evicts an idle entry from the runtime cache after the linger", async () => { + it("keeps one canonical entry across references and eviction cycles", async () => { configureLiveSignals({ lingerMilliseconds: 50 }) const runtime = await liveRuntime() - const counter = runtime.ref(LiveCounter, "evicted") + const first = runtime.ref(LiveCounter, "canonical") const watcher = new Signal.subtle.Watcher(() => {}) - watcher.watch(counter.live.count! as never) - counter.live.count!.get() + watcher.watch(first.live.count! as never) + first.live.count!.get() await eventually(() => activeLiveSubscriptionCount() === 1) expect(liveEntryCount(runtime)).toBe(1) - watcher.unwatch(counter.live.count! as never) + watcher.unwatch(first.live.count! as never) await eventually(() => activeLiveSubscriptionCount() === 0) - expect(liveEntryCount(runtime)).toBe(0) - watcher.watch(counter.live.count! as never) - counter.live.count!.get() + const second = runtime.ref(LiveCounter, "canonical") + watcher.watch(second.live.count! as never) + second.live.count!.get() await eventually(() => activeLiveSubscriptionCount() === 1) + + watcher.watch(first.live.count! as never) + first.live.count!.get() + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(activeLiveSubscriptionCount()).toBe(1) expect(liveEntryCount(runtime)).toBe(1) - watcher.unwatch(counter.live.count! as never) + expect(first.live.count).toBe(second.live.count) + + await runtime.ref(LiveCounter, "canonical").increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => first.live.count!.get() === 1) + expect(second.live.count!.get()).toBe(1) + + watcher.unwatch(first.live.count! as never) + watcher.unwatch(second.live.count! as never) }) it("exposes read-only signals and a stable proxy", () => { From e9608df1feae945a2fd12ba3c007135dee014afd Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 16:02:06 -0700 Subject: [PATCH 5/6] feat: payload signals on live references The session machinery already delivered payload envelopes; the live adapter dropped them. live.payloads. 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. --- CHANGELOG.md | 4 +- docs/api.md | 9 ++++- src/reference.ts | 3 ++ src/signals.ts | 84 +++++++++++++++++++++++++++++++++++---- test/live-signals.test.ts | 44 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d72720..7f547ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ `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; stale revisions are fenced. Works in Node and + each accepted envelope; `live.payloads.` 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 diff --git a/docs/api.md b/docs/api.md index 2d5de10..443c0e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -542,10 +542,15 @@ Behavior: 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.` 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` is a reserved name on `live`; an observable named - `snapshot` is shadowed. +- `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. diff --git a/src/reference.ts b/src/reference.ts index 3573338..cae7d08 100644 --- a/src/reference.ts +++ b/src/reference.ts @@ -171,6 +171,9 @@ export interface LiveSignal { export type ActorLiveSignals = { readonly snapshot: LiveSignal | undefined> + readonly payloads: { + readonly [name: string]: LiveSignal | undefined> + } } & { readonly [name: string]: LiveSignal | undefined> } diff --git a/src/signals.ts b/src/signals.ts index 0032229..18fb318 100644 --- a/src/signals.ts +++ b/src/signals.ts @@ -17,6 +17,7 @@ export interface LiveSignalsConfiguration { } const SNAPSHOT_KEY = "snapshot" +const PAYLOADS_KEY = "payloads" let lingerMilliseconds = 1_000 let retryMilliseconds = 1_000 @@ -65,6 +66,11 @@ class LiveActorEntry { readonly #mirrors = new Map | undefined>>() readonly #snapshotState = new Signal.State | undefined>(undefined) #snapshotMirror: LiveSignal | undefined> | undefined + readonly #payloadStates = new Map | undefined>>() + readonly #payloadMirrors = new Map | undefined>>() + readonly #payloadWatcherCounts = new Map() + readonly #payloadFences = new Map() + #payloadsProxy: object | undefined #watcherCount = 0 #snapshotWatcherCount = 0 #session: LiveSession | undefined @@ -91,6 +97,49 @@ class LiveActorEntry { return mirror } + payloadsProxy(): object { + this.#payloadsProxy ??= new Proxy( + {}, + { + get: (_target, property) => { + if (typeof property !== "string") return undefined + return this.payloadSignalFor(property) + }, + has: (_target, property) => typeof property === "string", + }, + ) + return this.#payloadsProxy + } + + payloadSignalFor(name: string): LiveSignal | undefined> { + const existing = this.#payloadMirrors.get(name) + if (existing) return existing + const state = this.payloadStateFor(name) + const mirror = new Signal.Computed(() => state.get(), { + [Signal.subtle.watched]: () => { + this.#payloadWatcherCounts.set(name, (this.#payloadWatcherCounts.get(name) ?? 0) + 1) + this.retain({ snapshot: false, payloadName: name }) + }, + [Signal.subtle.unwatched]: () => { + const count = (this.#payloadWatcherCounts.get(name) ?? 1) - 1 + if (count <= 0) this.#payloadWatcherCounts.delete(name) + else this.#payloadWatcherCounts.set(name, count) + this.release({ snapshot: false }) + }, + }) + this.#payloadMirrors.set(name, mirror) + return mirror + } + + private payloadStateFor(name: string) { + let state = this.#payloadStates.get(name) + if (!state) { + state = new Signal.State | undefined>(undefined) + this.#payloadStates.set(name, state) + } + return state + } + snapshotSignal(): LiveSignal | undefined> { this.#snapshotMirror ??= new Signal.Computed(() => this.#snapshotState.get(), { [Signal.subtle.watched]: () => this.retain({ snapshot: true }), @@ -108,7 +157,7 @@ class LiveActorEntry { return state } - private retain(options: { snapshot: boolean }): void { + private retain(options: { snapshot: boolean; payloadName?: string }): void { this.#watcherCount += 1 if (options.snapshot) this.#snapshotWatcherCount += 1 if (this.#linger !== undefined) { @@ -116,6 +165,7 @@ class LiveActorEntry { this.#linger = undefined } if (!this.#session) this.open() + else if (options.payloadName !== undefined) this.sendSubscribe(this.#session) else if (options.snapshot) void this.refreshSnapshot() } @@ -141,13 +191,7 @@ class LiveActorEntry { }) as LiveSession this.#session = session openSessions.add(this) - void session - .receive({ - version: 1, - action: "subscribe", - actorType: this.#reference.actorType, - actorId: this.#reference.actorId, - }) + void this.sendSubscribe(session) .then(() => this.refreshSnapshot()) .catch((error: unknown) => { this.#reference.runtime.settings.logger.warn({ @@ -166,6 +210,17 @@ class LiveActorEntry { }) } + private sendSubscribe(session: LiveSession): Promise { + const payloadNames = [...this.#payloadWatcherCounts.keys()] + return session.receive({ + version: 1, + action: "subscribe", + actorType: this.#reference.actorType, + actorId: this.#reference.actorId, + ...(payloadNames.length > 0 ? { payloads: payloadNames } : {}), + }) + } + private close(): void { if (this.#retry !== undefined) { clearTimeout(this.#retry) @@ -179,6 +234,10 @@ class LiveActorEntry { } private receive(envelope: RealtimeEnvelope): void { + if (envelope.kind === "payload") { + this.receivePayload(envelope) + return + } if (envelope.kind !== "invalidation") return const revision = BigInt(envelope.revision) if (this.#instanceId === envelope.instanceId && revision <= this.#revision) return @@ -190,6 +249,14 @@ class LiveActorEntry { void this.refreshSnapshot() } + private receivePayload(envelope: RealtimeEnvelope & { kind: "payload" }): void { + const revision = BigInt(envelope.revision) + const fence = this.#payloadFences.get(envelope.name) + if (fence && fence.instanceId === envelope.instanceId && revision <= fence.revision) return + this.#payloadFences.set(envelope.name, { instanceId: envelope.instanceId, revision }) + this.payloadStateFor(envelope.name).set(envelope.payload as DeepReadonly) + } + private async refreshSnapshot(): Promise { if (this.#snapshotWatcherCount === 0) return if (this.#refreshing) { @@ -245,6 +312,7 @@ installLiveSignals((reference) => { get(_target, property) { if (typeof property !== "string") return undefined if (property === SNAPSHOT_KEY) return resolved.snapshotSignal() + if (property === PAYLOADS_KEY) return resolved.payloadsProxy() return resolved.signalFor(property) }, has(_target, property) { diff --git a/test/live-signals.test.ts b/test/live-signals.test.ts index c2bbd39..0a17444 100644 --- a/test/live-signals.test.ts +++ b/test/live-signals.test.ts @@ -34,6 +34,25 @@ class LiveCounter extends Actor { } } +class LivePayloadCounter extends Actor { + static override readonly actorType = "LivePayloadCounter" + static override readonly payloads = { + personalized: (actor: LivePayloadCounter) => ({ count: actor.count }), + doubled: (actor: LivePayloadCounter) => ({ value: actor.count * 2 }), + } + + count = 0 + + increment(): number { + this.count += 1 + return this.count + } + + override observables(): Record { + return { count: broadcastValue(this.count) } + } +} + const runtimes: SolidObjectsRuntime[] = [] const watchers: Array<{ watcher: InstanceType @@ -62,6 +81,7 @@ async function liveRuntime(): Promise { syncPollingIntervalMilliseconds: 1, }) runtime.register(LiveCounter) + runtime.register(LivePayloadCounter) runtimes.push(runtime) await runtime.install() return runtime @@ -228,6 +248,30 @@ describe("live signals", () => { watcher.unwatch(second.live.count! as never) }) + it("delivers personalized payload signals with independent fences", async () => { + const runtime = await liveRuntime() + const counter = runtime.ref(LivePayloadCounter, "payloaded") + watch(counter.live.payloads.personalized!) + await eventually(() => activeLiveSubscriptionCount() === 1) + + await counter.increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => { + const payload = counter.live.payloads.personalized!.get() as { count?: number } | undefined + return payload?.count === 1 + }) + + watch(counter.live.payloads.doubled!) + await counter.increment() + await runtime.testing.drain({ roles: ["actors", "broadcasts"] }) + await eventually(() => { + const doubled = counter.live.payloads.doubled!.get() as { value?: number } | undefined + return doubled?.value === 4 + }) + expect((counter.live.payloads.personalized!.get() as { count?: number }).count).toBe(2) + expect(counter.live.payloads.personalized).toBe(counter.live.payloads.personalized) + }) + it("exposes read-only signals and a stable proxy", () => { const runtime = createRuntime({ database: sqlite({ path: ":memory:" }), From a4f0d3db1ba4b0159632030ad033fd1cd8a82e2f Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 23 Aug 2026 18:19:42 -0700 Subject: [PATCH 6/6] release: bump to 0.14.1 package.json and src/version.ts advance together, and the Unreleased notes become the dated 0.14.1 section the publish job reads. --- CHANGELOG.md | 2 +- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f547ac..72bcfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 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 diff --git a/package.json b/package.json index dd7c17f..a48df50 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/version.ts b/src/version.ts index d2a1bcf..cb02bb9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.0" +export const VERSION = "0.14.1"