Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/alien-signals-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@supergrain/kernel": major
"@supergrain/mill": patch
"@supergrain/husk": patch
---

Upgrade the reactive core from `alien-signals` 2.0.7 to 3.2.1.

**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**: it runs before each re-run and once on dispose, matching React's `useEffect` mental model. A callback that returns a non-function value will throw `"cleanup is not a function"` on its next run — so read signals for subscription with a statement body or `void`:

```ts
effect(() => void store.count); // subscribe-only, no cleanup
effect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // cleanup
});
```

`useSignalEffect(fn)` now accepts `fn: () => void | (() => void)` and wires that cleanup to the component lifecycle.

**Internal rename.** alien-signals renamed `getCurrentSub`/`setCurrentSub` to `getActiveSub`/`setActiveSub`; `@supergrain/kernel/internal` now re-exports the new names. `ReactiveNode` is imported from `alien-signals/system`. No change to the public package-root API (these primitives were never exported from it).

All reactive semantics (fine-grained tracking, batching, Map/Set notification coalescing) are unchanged — verified across the kernel, mill, husk, and silo test suites.
47 changes: 47 additions & 0 deletions .changeset/silo-effect-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@supergrain/silo": major
---

Rebuild the network/async layer on an internal [Effect](https://effect.website/) engine and remodel the reactive handle as a statechart. **Breaking.**

**Adapters stay Promise-first.** `DocumentAdapter.find` returns `Promise<unknown> | Effect.Effect<unknown, AdapterError>` — **return a plain `Promise`** for the common case (the store runs it on its Effect engine and turns a rejection into an `AdapterError` for you), or **return an `Effect`** to own the failure channel / compose retries / manage resources. Effect powers the engine internally but is not required at the adapter boundary. `effect` is a peer dependency (installed, but you don't have to write Effect).

**Typed errors.** New `AdapterError` / `NotFoundError` / `ProcessorError` (`Data.TaggedError`, union `SiloError`), exported from the root. They are the `E` channel of adapter Effects and the error carried by a failed handle.

**Per-model `retry` / `timeout`.** `ModelConfig` and `QueryConfig` accept an Effect `Schedule` (`retry`) and a `Duration` (`timeout`).

**Fiber-based cancellation (opt-in).** Each chunk's fetch runs on its own interruptible fiber, and the batch window now runs on `Effect.sleep` (the whole engine is on Effect's clock). Fetch cancellation is exposed as a capability: `subscribeDocument(type, id)` / `subscribeQuery(type, params)` ref-count interest and return an unsubscribe function; when the last subscriber for **every** key in an in-flight chunk goes away, the fetch is interrupted — aborting the request via an `AbortSignal` — and its handles reset to idle so renewed interest refetches. `gcTimeMs` (default `0` = next tick) defers the interrupt so a quick re-subscribe cancels it. The React `useDocument` / `useQuery` hooks are **pure reactive reads** and do not auto-wire this — a signals-native "cancel when a handle has no reactive observers" wants an observation-lifecycle primitive in the kernel core that doesn't exist yet. Adapters receive the signal regardless: `find(ids, { signal })` (optional) — thread it into `fetch(url, { signal })` for a real network abort, or ignore it and interruption just discards the result (no stale write).

**The handle fields changed.** `DocumentHandle` / `QueryHandle` are now a `status`-discriminated union over flat fields:

```ts
type DocumentHandle<T, E = SiloError> =
| {
status: "pending";
value: undefined;
error: undefined;
fetchedAt: undefined;
isFetching: boolean;
promise: Promise<T> | undefined;
}
| {
status: "success";
value: T;
error: E | undefined;
fetchedAt: Date; // refetch error coexists
isFetching: boolean;
promise: Promise<T> | undefined;
}
| {
status: "error";
value: undefined;
error: E;
fetchedAt: undefined;
isFetching: boolean;
promise: Promise<T> | undefined;
};
```

The previous `status: "IDLE" | "PENDING" | "SUCCESS" | "ERROR"`, `data: T | undefined`, `isPending`, and `hasData` are gone; `error` is now a typed `SiloError`. Narrowing on `status` refines `value` to `T`; `error` and `value` coexist in `success` (stale-while-revalidate); `isFetching` is orthogonal (stays out of `status`, so `status` doesn't flip on a background refetch); each field is tracked independently (fine-grained reactivity).

Migration: replace `handle.data` with `handle.value`; `handle.isPending` with `handle.value === undefined && handle.isFetching`; `handle.hasData` with `handle.value !== undefined`; `handle.status` string literals are now lowercase. Promise-returning adapters keep working as-is — no `Effect.tryPromise` wrapping required.
58 changes: 35 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ An entity cache with request batching. Think TanStack Query, except the fetched
Declare your models and adapters, build the store, then read documents anywhere in the tree:

```tsx
import { type DocumentAdapter, type DocumentStore, type QueryAdapter } from "@supergrain/silo";
import {
AdapterError,
type DocumentAdapter,
type DocumentStore,
type QueryAdapter,
} from "@supergrain/silo";
import { createDocumentStoreContext } from "@supergrain/silo/react";
import { Effect } from "effect";

// 1. Models are keyed by id. Queries are keyed by a params object — for
// endpoints whose response only makes sense with its params (dashboards,
Expand All @@ -68,22 +74,27 @@ type Queries = {
};
};

// 2. Adapters. Both take N keys and return raw responses — bulk endpoint,
// fan-out, websocket, whatever. Silo doesn't care how you hit the wire.
// 2. Adapters. Both take N keys and return an Effect producing the raw response
// (failing with AdapterError) — bulk endpoint, fan-out, websocket, whatever.
// Silo doesn't care how you hit the wire.
const userAdapter: DocumentAdapter = {
async find(ids) {
return Promise.all(ids.map((id) => fetch(`/api/users/${id}`).then((r) => r.json())));
},
find: (ids) =>
Effect.tryPromise({
try: () => Promise.all(ids.map((id) => fetch(`/api/users/${id}`).then((r) => r.json()))),
catch: (cause) => new AdapterError({ type: "user", keys: ids, cause }),
}),
};
const postsAdapter: QueryAdapter<Queries["posts"]["params"]> = {
async find(paramsList) {
const res = await fetch("/api/posts/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ queries: paramsList }),
});
return res.json(); // one array of results, aligned 1:1 with paramsList
},
find: (paramsList) =>
Effect.tryPromise({
try: () =>
fetch("/api/posts/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ queries: paramsList }),
}).then((r) => r.json()), // one array of results, aligned 1:1 with paramsList
catch: (cause) => new AdapterError({ type: "posts", keys: [], cause }),
}),
};

// 3. Context factory — one Provider, typed hooks.
Expand All @@ -106,19 +117,20 @@ function App() {
}

// 5. Read by (type, id) or (type, params). Both return reactive handles with
// the same lifecycle fields (isPending, error, data, promise, ...).
// flat, orthogonal fields: value, error, isFetching, fetchedAt, status,
// plus a stable `promise`.
function UserCard({ id }: { id: string }) {
const user = useDocument("user", id);
if (user.isPending) return <Skeleton />;
return <div>{user.data?.attributes.firstName}</div>;
if (user.value === undefined) return <Skeleton />;
return <div>{user.value.attributes.firstName}</div>;
}

function AuthorPosts({ authorId }: { authorId: string }) {
const posts = useQuery("posts", { authorId, status: "published", limit: 20 });
if (posts.isPending) return <Skeleton />;
if (posts.value === undefined) return <Skeleton />;
return (
<ul>
{posts.data?.posts.map((p) => (
{posts.value.posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
Expand Down Expand Up @@ -196,9 +208,9 @@ import { useDocument } from "@supergrain/silo/react";

function UserCard({ id }: { id: string }) {
const user = useDocument("user", id);
use(user.promise); // suspends on first load; never re-suspends on refetch
const value = use(user.promise!); // suspends on first load; never re-suspends on refetch

return <div>{user.data!.attributes.firstName}</div>;
return <div>{value.attributes.firstName}</div>;
}

function UserList() {
Expand All @@ -212,11 +224,11 @@ function UserList() {
}
```

The promise resolves exactly once on first success — later `insertDocument` calls update `data` in place but the promise reference stays stable, so `use()` doesn't re-suspend. After an error, a recovery `insertDocument` produces a **new** resolved promise so a Suspense boundary nested in an error boundary can recover.
The promise resolves exactly once on first success — later `insertDocument` calls update the `data` region in place but the promise reference stays stable, so `use()` doesn't re-suspend. After a first-load error, a recovery `insertDocument` produces a **new** resolved promise so a Suspense boundary nested in an error boundary can recover.

Because fetches are batched, naive `use(user.promise)` calls sprinkled through a list **don't waterfall** — the three `<UserCard>`s above collapse into one `userAdapter.find(["1", "2", "3"])` call before suspending. This is the piece that usually makes Suspense unusable at scale; here it's the default.

Want inline loading UI instead? Drop the `use(user.promise)` line and read `user.isPending` / `user.error` directly. Same hook, same handle, no config switch.
Want inline loading UI instead? Drop the `use(user.promise!)` line and branch on `user.data` / `user.fetch` directly. Same hook, same handle, no config switch.

## Install

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"alien-signals": "^2.0.7"
"alien-signals": "^3.2.1"
},
"devDependencies": {
"@changesets/cli": "^2.29.7",
Expand Down
8 changes: 4 additions & 4 deletions packages/husk/src/resource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createReactive } from "@supergrain/kernel";
import { getCurrentSub, setCurrentSub } from "@supergrain/kernel/internal";
import { getActiveSub, setActiveSub } from "@supergrain/kernel/internal";
import { effect } from "alien-signals";

/**
Expand Down Expand Up @@ -53,12 +53,12 @@ export function registerDisposer(target: object, fn: () => void): void {
type SetupResult = void | (() => void) | Promise<void>;

function withUntracked<R>(run: () => R): R {
const prev = getCurrentSub();
setCurrentSub(undefined);
const prev = getActiveSub();
setActiveSub(undefined);
try {
return run();
} finally {
setCurrentSub(prev);
setActiveSub(prev);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/js-krauset/analyze-gap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ console.log(" Hook 4: useMemo (from useComputed — creates computed
console.log(" Hook 5: useContext (from Store.useStore())");
console.log(" + alienEffect() creation (alien-signals effect node)");
console.log(" + computed() creation (alien-signals computed for isSelected)");
console.log(" + getCurrentSub/setCurrentSub per render");
console.log(" + getActiveSub/setActiveSub per render");
console.log(" + Proxy trap on every property read (item.id, item.label)");
console.log("");
console.log("Per 1000 rows: 5000 extra hook slots + 1000 effects + 1000 computeds + proxy reads");
Expand Down
8 changes: 4 additions & 4 deletions packages/kernel/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@
- `profiler.ts`
Opt-in counters for signal reads, skips, and writes. Zero cost when disabled.
- `internal.ts`
Subpath entrypoint for sibling Supergrain packages (mill, kernel/react). Exposes the raw write helpers and the un-wrapped `startBatch`/`endBatch`/`getCurrentSub`/`setCurrentSub` primitives that have footguns the public API hides.
Subpath entrypoint for sibling Supergrain packages (mill, kernel/react). Exposes the raw write helpers and the un-wrapped `startBatch`/`endBatch`/`getActiveSub`/`setActiveSub` primitives that have footguns the public API hides.
- `react/`
The React subpath — `tracked`, `useReactive`, `createStoreContext`, `useComputed`, `useSignalEffect`, `<For>`. Reaches the kernel runtime via the public `@supergrain/kernel` and `@supergrain/kernel/internal` subpaths so the React bundle stays decoupled from kernel's internal layout.

## Runtime model

`createReactive(initial)` returns a reactive Proxy over the root object. Every property read inside an active subscriber (`getCurrentSub()` non-null) lazily allocates a per-property signal node and subscribes the active sub to it. Writes through the Proxy go through `setProperty`, which writes the raw value, then notifies the per-property signal (and an array-length signal for arrays, plus a per-target version signal for structural subscribers).
`createReactive(initial)` returns a reactive Proxy over the root object. Every property read inside an active subscriber (`getActiveSub()` non-null) lazily allocates a per-property signal node and subscribes the active sub to it. Writes through the Proxy go through `setProperty`, which writes the raw value, then notifies the per-property signal (and an array-length signal for arrays, plus a per-target version signal for structural subscribers).

- Reads with no active subscriber short-circuit past signal allocation and return the raw value (the `getCurrentSub() == null` skip path).
- Reads with no active subscriber short-circuit past signal allocation and return the raw value (the `getActiveSub() == null` skip path).
- Nested objects and arrays are wrapped on demand via `createReactiveProxy`, with `proxyCache` ensuring one proxy per raw target — handle identity stays stable across reads.
- Frozen targets (e.g. `Object.freeze`d documents stored by `@supergrain/silo`) bypass the proxy and return as-is, preserving reference identity for inserted documents.
- Array mutators (`push`, `pop`, `splice`, `sort`, `reverse`, `fill`, `copyWithin`, `shift`, `unshift`) are wrapped in `startBatch`/`endBatch` so their internal multi-step writes coalesce into a single notification — synchronous effects don't observe partial states.

## Signal layer

Signal propagation is delegated to [`alien-signals`](https://github.com/stackblitz/alien-signals). The `signal` / `computed` / `effect` primitives are re-exported from `@supergrain/kernel` for direct use; the lower-level `startBatch` / `endBatch` / `getCurrentSub` / `setCurrentSub` primitives are deliberately not re-exported from the package root because they mutate global state and leak on exception. Use `batch(fn)` instead. Sibling Supergrain packages that need the raw primitives (e.g. for tracking-context manipulation in `tracked()`) import from `@supergrain/kernel/internal`.
Signal propagation is delegated to [`alien-signals`](https://github.com/stackblitz/alien-signals). The `signal` / `computed` / `effect` primitives are re-exported from `@supergrain/kernel` for direct use; the lower-level `startBatch` / `endBatch` / `getActiveSub` / `setActiveSub` primitives are deliberately not re-exported from the package root because they mutate global state and leak on exception. Use `batch(fn)` instead. Sibling Supergrain packages that need the raw primitives (e.g. for tracking-context manipulation in `tracked()`) import from `@supergrain/kernel/internal`.

## Public API

Expand Down
2 changes: 1 addition & 1 deletion packages/kernel/benchmarks/additional.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe("Additional: Signal Subscription/Unsubscription", () => {
const store = createReactive({ value: 0 });
const disposers = [];
for (let i = 0; i < 10000; i++) {
disposers.push(effect(() => store.value));
disposers.push(effect(() => void store.value));
}
for (const d of disposers) {
d();
Expand Down
2 changes: 1 addition & 1 deletion packages/kernel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"alien-signals": "^2.0.7"
"alien-signals": "^3.2.1"
},
"devDependencies": {
"@preact/signals-core": "^1.14.0",
Expand Down
14 changes: 7 additions & 7 deletions packages/kernel/src/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* `wrap()` dispatch here when the value is a Map or Set.
*/

import { getCurrentSub, startBatch, endBatch, signal } from "alien-signals";
import { getActiveSub, startBatch, endBatch, signal } from "alien-signals";

import {
$OWN_KEYS,
Expand Down Expand Up @@ -82,7 +82,7 @@ const collectionProxyCache = new WeakMap<object, object>();
// ---------------------------------------------------------------------------

function trackOwnKeys(target: object): void {
if (getCurrentSub()) {
if (getActiveSub()) {
const nodes = getNodes(target);
getNode(nodes, $OWN_KEYS, 0)();
}
Expand Down Expand Up @@ -141,7 +141,7 @@ export function createReactiveMap<K, V>(rawTarget: Map<K, V>): Map<K, V> {
if (prop === "get") {
return function reactiveGet(key: K): V | undefined {
const rawKey = unwrap(key);
if (getCurrentSub()) {
if (getActiveSub()) {
profileSignalRead();
const s = getOrCreateKeySignal(rawKey);
const v = s() as V | undefined;
Expand All @@ -156,7 +156,7 @@ export function createReactiveMap<K, V>(rawTarget: Map<K, V>): Map<K, V> {
if (prop === "has") {
return function reactiveHas(key: K): boolean {
const rawKey = unwrap(key);
if (getCurrentSub()) {
if (getActiveSub()) {
profileSignalRead();
getOrCreateKeySignal(rawKey)();
} else {
Expand Down Expand Up @@ -274,7 +274,7 @@ export function createReactiveMap<K, V>(rawTarget: Map<K, V>): Map<K, V> {
): void {
trackOwnKeys(target);
for (const [k, v] of rawTarget.entries()) {
if (getCurrentSub()) {
if (getActiveSub()) {
profileSignalRead();
getOrCreateKeySignal(k)();
}
Expand All @@ -288,7 +288,7 @@ export function createReactiveMap<K, V>(rawTarget: Map<K, V>): Map<K, V> {
return function* reactiveEntries(): IterableIterator<[K, V]> {
trackOwnKeys(target);
for (const [k, v] of rawTarget.entries()) {
if (getCurrentSub()) {
if (getActiveSub()) {
profileSignalRead();
getOrCreateKeySignal(k)();
}
Expand All @@ -315,7 +315,7 @@ export function createReactiveMap<K, V>(rawTarget: Map<K, V>): Map<K, V> {
return function* reactiveValues(): IterableIterator<V> {
trackOwnKeys(target);
for (const [k, v] of rawTarget.entries()) {
if (getCurrentSub()) {
if (getActiveSub()) {
profileSignalRead();
getOrCreateKeySignal(k)();
}
Expand Down
8 changes: 7 additions & 1 deletion packages/kernel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ export { createReactive, unwrap, $BRAND, type Signal, type Branded } from "./sto
export { getNodesIfExist, $TRACK } from "./core";

// Re-export signal primitives from alien-signals for convenience.
// `startBatch`/`endBatch`/`getCurrentSub`/`setCurrentSub` are intentionally
// `startBatch`/`endBatch`/`getActiveSub`/`setActiveSub` are intentionally
// not re-exported — they mutate global counters and leak unsafely on
// exception. Use `batch()` (below) instead. Internal consumers can still
// reach the raw primitives via `@supergrain/kernel/internal`.
//
// NOTE (alien-signals 3.x): `effect(fn)` now treats `fn`'s return value as a
// cleanup function — it runs before each re-run and on dispose. A callback that
// returns a non-function value (e.g. `effect(() => store.count)`) will throw
// "cleanup is not a function" on its next run. Read for subscription with a
// statement body or `void`: `effect(() => void store.count)`.
export { effect, signal, computed } from "alien-signals";
export { batch } from "./batch";
export {
Expand Down
Loading
Loading