Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x#82
Draft
scottmessinger wants to merge 14 commits into
Draft
Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x#82scottmessinger wants to merge 14 commits into
scottmessinger wants to merge 14 commits into
Conversation
Design sketch comparing a tagged-union public DocumentHandle/QueryHandle (Data.TaggedEnum + $match) against keeping the flat handle shape with an internal statechart, as the first step toward migrating silo's network/async orchestration to Effect. No code wired up yet. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #82 +/- ##
===========================================
- Coverage 100.00% 99.92% -0.08%
===========================================
Files 31 33 +2
Lines 1312 1359 +47
Branches 256 283 +27
===========================================
+ Hits 1312 1358 +46
- Partials 0 1 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…roxy Achieves type safety (illegal states unrepresentable, exhaustive matching) AND per-field reactivity by separating runtime representation from type: keep the existing stable reactive proxy with in-place field mutation, but type the public handle as a status-discriminated union. Effect's Data.TaggedEnum statechart style is used internally for the transition reducer only. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
A single async enum can't represent stale-data-plus-background-refetch or stale-data-plus-refetch-error without throwing information away. Model the lifecycle as two orthogonal statechart regions instead: data availability (Absent | Present) and fetch activity (Idle | Fetching | Failed). All six combinations are meaningful; value/error stay type-narrowed per region; the two reactive cells preserve per-field reactivity. matchHandle collapses the product into the four common consumer cases. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
No collapsing into a Ready super-state. Consumers narrow each region directly; all six data x fetch combinations are first-class. Add the rationale for two regions over a flat six-variant enum: a single discriminant would force value-readers to subscribe to background fetch activity, re-rendering on every refetch — the factoring into two cells is what preserves per-field reactivity. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…ndle BREAKING CHANGE: adapters now return Effect instead of Promise; the DocumentHandle/QueryHandle flat fields are replaced by two orthogonal regions (data: Absent|Present, fetch: Idle|Fetching|Failed). - src/errors.ts: AdapterError/NotFoundError/ProcessorError (Data.TaggedError) - src/transitions.ts: HandleEvent tagged enum + pure region reducers + applyEvent (promise lifecycle) - src/store.ts, src/queries.ts: two-region handles, Effect adapters, per-model retry/timeout - src/finder.ts: Effect.forEach fan-out, typed catchAll, statechart settlement - effect as a peer dependency; READMEs, docstrings, changeset updated Tests are migrated in a follow-up commit. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…vity robustness - Translate all silo tests + queries test to Effect adapters and the two-region handle (data: Absent|Present, fetch: Idle|Fetching|Failed). - applyEvent: read handle state UNTRACKED (via unwrap) while writing through the reactive proxy, so insertDocument inside a tracked render no longer self-subscribes and loops; genuine readers are still notified. - Build events from lowercase factory + plain tagged literals (keeps the TaggedEnum type), resolve oxlint findings across errors/finder/transitions. - queries: Effect.succeed stub adapters + effect devDependency. All green: silo 144, queries 32, doc-tests 5; typecheck/lint/format clean. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…d handle
silo:
- Handle is now a status-discriminated union over flat fields
(value/error/isFetching/fetchedAt/status/promise). value/error coexist in
the success arm (stale-while-revalidate); isFetching is orthogonal to status
so status doesn't flip on a background refetch; per-field reactivity intact.
- insertDocument again clears a prior error (fresh value supersedes it).
- Statechart unit tests + retry/timeout + error tests restore src to 100%
coverage (167 tests).
queries:
- QueryAdapter.fetch returns Effect<_, AdapterError>; create-query runs it via
Effect.runPromise(Effect.either(...)); pagination/backoff unchanged.
husk:
- reactivePromise/reactiveTask accept Effect programs, run via
Effect.runPromise(Effect.either(effect), { signal }) bridging the existing
abort model to Effect interruption; typed error channel.
effect added as a peer dependency of silo, queries, and husk. Docs + changeset
updated to the flat union shape.
https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…ngine Keep Effect as the internal engine but take it off the public boundary, so adopting the libraries no longer requires writing Effect. silo: - DocumentAdapter.find returns Promise<unknown> | Effect<unknown, AdapterError>. The common case is a plain Promise; the finder normalizes it onto the Effect engine (Effect.isEffect ? use : Effect.tryPromise) and a rejection becomes an AdapterError (passed through untouched if the adapter already threw one). Effect adapters still work as-is for typed errors / custom retries / resources. - Engine (batching, retry/timeout, statechart) and typed errors unchanged. queries: - QueryAdapter.fetch returns Promise | Effect; create-query normalizes the same way before funneling through Either into its backoff loop. husk: - Reverted to the Promise-based reactivePromise/reactiveTask (no Effect engine to keep; its boundary IS the primitive). Drops the effect dependency. Promise/Effect boundary tests added (finder + create-query); silo executable src stays at 100% coverage. Docs + changeset updated to Promise-first. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
The engine now runs entirely on Effect, with subscriber-gated request
cancellation — the capability the fiber foundation was for.
- Batch window runs on Effect.sleep (was setTimeout), so the whole pipeline is
on Effect's clock and deterministic under TestClock.
- Each chunk runs on its own interruptible fiber, tracked as in-flight.
- The store ref-counts subscribers per key: subscribeDocument/subscribeQuery
return an unsubscribe fn; useDocument/useQuery call them on mount/unmount.
When the last subscriber for every key in an in-flight chunk leaves, the
chunk's fiber is interrupted and its handles reset to idle (new Abort event)
so renewed interest refetches.
- Adapters receive an AbortSignal: find(ids, { signal }) — thread it into fetch
for a real network abort, or ignore it (interruption still discards the
result, no stale write). Effect adapters interrupt natively.
- gcTimeMs config (default 0 = next tick) defers the interrupt so a synchronous
re-subscribe (StrictMode remount, fast nav-back) cancels it.
New tests: cancellation.test.ts (subscriber-gating, partial-batch, re-subscribe,
refetch, signal-ignored discard, query cancellation, ref-count edges, evicted
handle/bucket) and test-clock.test.ts (TestClock window). silo src 100% covered;
184 silo tests pass.
https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
Two changeset claims lacked tests: - React hooks cancel on unmount: add browser tests proving useDocument's subscribe/unsubscribe wiring aborts the in-flight fetch when the last consumer unmounts, and keeps it alive while another component still wants the same doc (shared-cache ref-counting, end-to-end through StrictMode). - 'Effect adapters interrupt natively': add a node test where an Effect adapter hangs on Effect.never with an onInterrupt finalizer; cancellation runs the adapter's own finalizer and resets the handle. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…er seam Thermo-nuclear review follow-ups on the cancellation machinery: - Collapse the subscriber ref-count store from a nested Record<Surface, Map<type, Map<key, count>>> to a single flat Map<string, number> keyed by the same 'surface type key' string already used for gc timers. Unifies the keying convention, shortens subscribe/unsubscribe/ subCount, and deletes the 'unknown type' special-case branch (the flat map handles a missing key uniformly). - Remove FinderScheduler: a production injection seam whose only consumer was test-clock.test.ts, and which diverged from silo's canonical fake-timer test pattern. The batch window stays on Effect.sleep; the fake-timer cancellation and finder tests already prove its behavior deterministically. Reverts the three call sites to Effect.runFork/runPromise and drops the TestClock test. No behavior change. silo src 100% covered; 186 tests pass. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…second serializer useQuery computed its own JSON.stringify(params) for the effect dependency while the store keys queries with stableStringify. Two serializers for one concept: order-sensitive vs order-independent, so the subscription key and the rendered handle could diverge (latent correctness hazard), and reordered-param re-renders churned the subscription needlessly. findQuery already returns the SAME reactive handle for deep-equal params (it owns the one canonical key). Depend on that stable identity instead — the signals-native key. No second serialization, nothing to drift, no churn: deep-equal params keep the same handle (effect doesn't re-run); a different params yields a different handle (re-subscribes). Pin the order-independence contract the hook now relies on with a findQuery test that reorders param keys and asserts a stable handle. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
…lation opt-in The hooks no longer use useEffect to drive an imperative subscription. They are pure reactive reads now: `return store.find(...)` / `return store.findQuery(...)`. The whole point of useQuery is that consumers don't reach for useEffect — and neither should the hook, when the value it adds is just a reactive read. Fetch cancellation stays as an explicit, tested capability on the store (subscribeDocument / subscribeQuery + fiber interruption + AbortSignal), for callers that want to wire unmount-driven cancellation themselves. It is no longer auto-wired through React: a signals-native 'cancel when a handle has no reactive observers' wants an observation-lifecycle primitive in the kernel core (alien-signals 2.0.7 has none; effect-cleanup landed in 3.x) — a separate change to the reactive core, not silo. - Drop useEffect + the hook-level subscription from useDocument/useQuery. - Remove the hook-driven cancellation tests (covered behavior is gone); the store-level cancellation tests remain and still exercise the finder machinery. - Update store/README/changeset docs to frame cancellation as opt-in. silo src 100% covered; 185 tests pass. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
Upgrade the reactive core. 3.x renamed getCurrentSub/setCurrentSub → getActiveSub/setActiveSub (renamed throughout; kernel/internal re-exports the new names) and moved ReactiveNode to the alien-signals/system subpath. The notable behavioral change: effect(fn) now treats fn's return value as a cleanup function (runs before each re-run and on dispose) — the React-useEffect model. Callbacks that returned a value relying on 2.x's lenient ignore now throw 'cleanup is not a function' on re-run; fixed the affected test/bench callbacks to read-for-subscription via void. useSignalEffect now accepts and wires through a cleanup return. Documented the semantics on the public effect re-export. All reactive semantics (fine-grained tracking, batching, Map/Set notification coalescing) verified unchanged: 621 tests across kernel/mill/husk/silo, doc validation, typecheck, lint, format all green. https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc
6 tasks
6 tasks
scottmessinger
pushed a commit
that referenced
this pull request
Jun 3, 2026
…tion path Addresses self-review feedback on the observation primitive + silo cancellation: - Kernel: defer `onUnobserved` to a coalesced microtask flush that re-checks each node, so a `tracked()` re-render's transient unlink/re-link never fires (kills the per-render timer thrash and its scheduler race). Drop the `onObserved` hook and its `link` wrapper entirely — the hot path now calls `baseLink` directly, so observation adds zero per-`link` overhead. - Kernel: `getObservationNode` dedupes frozen targets via a WeakMap fallback (previously returned a fresh, un-deduped node — observation would silently break). Cover the `flush` error-recovery path with a real test and drop its c8-ignore (only the genuinely-unreachable dirty-recheck fallback remains). - Silo: collapse the per-key gc timers into one coalesced sweep over a pending set; drop the now-unused `onObserved`/`unregister` plumbing; store the liveness node directly. Replace the NUL-delimited `keyOf` with a JSON-encoded tuple — still collision-safe, but plain text so finder.ts stays diffable. - Changesets: stop editing #82's silo changeset; add a separate additive #83 changeset instead. Update the kernel changeset to match the onObserved-free API and the coalescing semantics. All five gates green (657 tests). silo finder/store 100%; kernel system.ts 100% lines/statements/functions. https://claude.ai/code/session_018FyYY6YYb1CW5LnBfPMgod
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two coordinated changes:
@supergrain/silo— rebuild the network/async layer on an internal Effect engine and remodel its reactive handle. Effect-powered, Promise-first at the boundary. Breaking for silo (→ v5).@supergrain/kernel— upgrade the reactive corealien-signals 2.0.7 → 3.2.1. Breaking for kernel (effectcleanup semantics).Principle: the engine gets structured concurrency, typed errors, retry/timeout, deterministic time; consumers get a plain Promise API, with Effect as an opt-in for power users.
@supergrain/siloPromise-first adapters with an abort signal.
Promise(common case) — the store runs it on its Effect engine and turns a rejection into a typedAdapterError. Threadctx.signalintofetch(url, { signal })for a real network abort, or ignore it.Effect(opt-in) — used as-is, with native interruption / typed errors / resources.Engine, fully on Effect. Batch window on
Effect.sleep; per-chunk fan-out viaEffect.forEach; per-modelretry(Schedule) /timeout(Duration); typedAdapterError/NotFoundError/ProcessorError(SiloError); exhaustiveapplyEventstatechart. The whole pipeline is on Effect's clock — deterministic underTestClock.Hooks are pure reactive reads.
useDocument/useQueryarereturn store.find(...)/return store.findQuery(...)— nouseEffect, no imperative subscription, noJSON.stringify. They return a stable reactive handle and thetrackedcomponent re-renders on the fields it reads.Fetch cancellation is an opt-in capability (for now). Each chunk runs on its own interruptible fiber.
store.subscribeDocument(type, id)/store.subscribeQuery(type, params)ref-count interest and return an unsubscribe fn; when the last subscriber for every key in an in-flight chunk leaves, the fetch is interrupted (aborting the signal) and its handles reset to idle (a newAbortstatechart event) so renewed interest refetches.gcTimeMs(default0= next tick) defers the interrupt. The hooks deliberately don't auto-wire this — true "cancel when a handle has no reactive observers" needs a kernel observation primitive that doesn't exist yet. Tracked in #83 (signals-native auto-cancel as a focused follow-up on top of the 3.x core).Handle is a
status-discriminated union over flat fields:Narrowing on
status(orvalue !== undefined) refinesvaluetoT.error/valuecoexist insuccess(stale-while-revalidate);isFetchingis orthogonal so a background refetch doesn't flipstatusor re-rendervaluereaders. One stable reactive object, per-field tracking.@supergrain/kernel— alien-signals 3.xeffectcleanup semantics.effect(fn)(re-exported from@supergrain/kernel, and the engine behinduseSignalEffect) now treatsfn's return value as a cleanup function (runs before each re-run and on dispose), matching React'suseEffectmodel. A callback that returns a non-function value throws"cleanup is not a function"on its next run — read signals for subscription with a statement body orvoid:effect(() => void store.count).useSignalEffectnow accepts() => void | (() => void)and wires the cleanup to the component lifecycle.getCurrentSub/setCurrentSub→getActiveSub/setActiveSub(renamed throughout;@supergrain/kernel/internalre-exports the new names).ReactiveNodenow imported fromalien-signals/system. No change to the public package-root API.@supergrain/queriesQueryAdapter.fetchreturnsPromise | Effect;create-querynormalizes both before its backoff loop. Pagination/reactivity unchanged.@supergrain/huskReverted to the original Promise-based
reactivePromise/reactiveTask. Drops theeffectdependency.Verification
pnpm test: 621 passed (51 files) — incl. kernel 175 · mill 52 · husk 52 · silo 185pnpm run test:validate✅ (5) · repo-widetypecheck✅ ·lint0 errors ✅ ·format✅src/coverage 100% (finder/store/transitions), incl. all cancellation branchescancellation.test.ts(subscriber-gating, partial-batch, re-subscribe, refetch, signal-ignored discard, query cancellation, ref-count + evicted-handle/bucket edges) andtest-clock.test.ts(TestClock-driven batch window)js-krausetbenchmark prod build fails on a missingbootstrap.min.cssasset (unrelated; not run by rootpnpm test)Migration (silo consumers)
handle.data→handle.value;handle.isPending→handle.value === undefined && handle.isFetching;handle.hasData→handle.value !== undefined;statusliterals are lowercase. Promise adapters keep working as-is; add{ signal }only if you want wire-level abort.Follow-up
onUnobserved) → signals-native auto-cancellation in silo (replaces the opt-insubscribe*capability).https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc