Skip to content

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
mainfrom
claude/relaxed-johnson-fEGH6
Draft

Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x#82
scottmessinger wants to merge 14 commits into
mainfrom
claude/relaxed-johnson-fEGH6

Conversation

@scottmessinger
Copy link
Copy Markdown
Member

@scottmessinger scottmessinger commented Jun 1, 2026

Summary

Two coordinated changes:

  1. @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).
  2. @supergrain/kernel — upgrade the reactive core alien-signals 2.0.7 → 3.2.1. Breaking for kernel (effect cleanup 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/silo

Promise-first adapters with an abort signal.

interface DocumentAdapter {
  find(ids: string[], ctx?: { signal: AbortSignal }):
    Promise | Effect.Effect;
}
  • Return a Promise (common case) — the store runs it on its Effect engine and turns a rejection into a typed AdapterError. Thread ctx.signal into fetch(url, { signal }) for a real network abort, or ignore it.
  • Return an 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 via Effect.forEach; per-model retry (Schedule) / timeout (Duration); typed AdapterError/NotFoundError/ProcessorError (SiloError); exhaustive applyEvent statechart. The whole pipeline is on Effect's clock — deterministic under TestClock.

Hooks are pure reactive reads. useDocument / useQuery are return store.find(...) / return store.findQuery(...)no useEffect, no imperative subscription, no JSON.stringify. They return a stable reactive handle and the tracked component 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 new Abort statechart event) so renewed interest refetches. gcTimeMs (default 0 = 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:

type DocumentHandle =
  | { status: "pending"; value: undefined; error: undefined; fetchedAt: undefined; isFetching: boolean; promise }
  | { status: "success"; value: T; error: E | undefined; fetchedAt: Date; isFetching: boolean; promise }  // refetch error coexists
  | { status: "error"; value: undefined; error: E; fetchedAt: undefined; isFetching: boolean; promise };

Narrowing on status (or value !== undefined) refines value to T. error/value coexist in success (stale-while-revalidate); isFetching is orthogonal so a background refetch doesn't flip status or re-render value readers. One stable reactive object, per-field tracking.

@supergrain/kernel — alien-signals 3.x

  • Breaking — effect cleanup semantics. effect(fn) (re-exported from @supergrain/kernel, and the engine behind useSignalEffect) now treats fn's return value as a cleanup function (runs before each re-run and on dispose), matching React's useEffect model. 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 or void: effect(() => void store.count). useSignalEffect now accepts () => void | (() => void) and wires the cleanup to the component lifecycle.
  • Internal rename. getCurrentSub/setCurrentSubgetActiveSub/setActiveSub (renamed throughout; @supergrain/kernel/internal re-exports the new names). ReactiveNode now imported from alien-signals/system. No change to the public package-root API.
  • All reactive semantics (fine-grained tracking, batching, Map/Set notification coalescing) unchanged.

@supergrain/queries

QueryAdapter.fetch returns Promise | Effect; create-query normalizes both before its backoff loop. Pagination/reactivity unchanged.

@supergrain/husk

Reverted to the original Promise-based reactivePromise/reactiveTask. Drops the effect dependency.

Verification

  • pnpm test: 621 passed (51 files) — incl. kernel 175 · mill 52 · husk 52 · silo 185
  • pnpm run test:validate ✅ (5) · repo-wide typecheck ✅ · lint 0 errors ✅ · format
  • silo executable src/ coverage 100% (finder/store/transitions), incl. all cancellation branches
  • Tests: cancellation.test.ts (subscriber-gating, partial-batch, re-subscribe, refetch, signal-ignored discard, query cancellation, ref-count + evicted-handle/bucket edges) and test-clock.test.ts (TestClock-driven batch window)
  • Pre-existing: the js-krauset benchmark prod build fails on a missing bootstrap.min.css asset (unrelated; not run by root pnpm test)

Migration (silo consumers)

handle.datahandle.value; handle.isPendinghandle.value === undefined && handle.isFetching; handle.hasDatahandle.value !== undefined; status literals are lowercase. Promise adapters keep working as-is; add { signal } only if you want wire-level abort.

Follow-up

https://claude.ai/code/session_016FNH6xY9sFPmBaThUcHPAc

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
Copy link
Copy Markdown

codecov Bot commented Jun 1, 2026

Codecov Report

❌ Patch coverage is 99.27007% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 99.92%. Comparing base (a8a0c30) to head (1f9dba2).

Files with missing lines Patch % Lines
packages/queries/src/create-query.ts 85.71% 0 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 13 commits June 1, 2026 20:50
…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
@scottmessinger scottmessinger changed the title Migrate @supergrain/silo network/async layer to Effect Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x Jun 2, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants