@signal-tree/kernel
v15.3.0
Published
SignalTree: framework-neutral reactive application state with causal semantics.
Readme
@signal-tree/kernel
Framework-neutral SignalTree state: fields and entity collections,
transactions and rollback, undo, external updates through link(), batching,
and DevTools.
This is the package framework adapters are built on, so it also documents the advanced vocabulary (causal turns, restoration designation, the observation seam). Application developers usually need only the terms in the glossary's first table.
Angular applications should construct trees through @signal-tree/angular
(requires Angular 20, 21, or 22 — see peerDependencies in
packages/angular/package.json).
React applications should construct and observe trees through
@signal-tree/react. Use the kernel directly for framework-neutral runtimes,
libraries, and tests. Framework packages forward this neutral surface by
identity, so an application should use its framework package as its one
SignalTree import root.
Semantic Guidance
The canonical v15 model and composition guidance ships with this package as
llms.txt. It explains the facade rule, link() relationships,
persistence composition, and why human-readable causal explanations are
projections rather than retained kernel facts.
Install
npm install @signal-tree/kernelCreate A Tree
import { signalTree } from '@signal-tree/kernel';
const tree = signalTree({
count: 0,
user: {
name: 'Ada',
active: true,
},
});$ is the state facade. Root, branch, and terminal locations support whole-value
reads, replacements, and updater functions. A value call is never a patch;
derive a patched value with an updater.
tree.$();
tree.$.user();
tree.$.user({ name: 'Grace', active: true });
tree.$.user((user) => ({ ...user, active: false }));
tree.$.count();
tree.$.count(1);
tree.$.count((count) => count + 1);The state literal defines the public paths. There are no actions, reducers, or selectors required for ordinary reads and writes.
Terminal Values
A plain object normally becomes a branch whose properties are locations. Use
leaf(value) when an object should remain one atomic value, or when a callable
is state rather than topology:
import { leaf, signalTree } from '@signal-tree/kernel';
const tree = signalTree({
user: { name: 'Ada' },
bounds: leaf({ min: 0, max: 100 }),
handler: leaf((value: number) => console.log(value)),
});
tree.$.user.name('Grace');
tree.$.bounds({ min: 10, max: 90 });
tree.$.handler(leaf((value) => persist(value)));At construction, leaf(object) stops dot-path expansion. At invocation,
location(leaf(callable)) distinguishes callable data from the updater grammar.
The wrapper never enters canonical state, snapshots, persistence, restoration,
or links; reads return the original raw value by identity.
Construction
State, capabilities, and derived values are declared in one construction call:
const tree = signalTree(initialState, {
enhancers: [batching(), restoration(), devTools()],
derived: ($) => ({
// Return realization-native computed values here.
}),
});There is no late .with() phase or fluent .derived() chain. SignalTree
validates the complete enhancer set before construction and resolves declared
capability requirements from that set.
The neutral kernel does not own framework lifecycle, rendering, dependency injection, or effect scheduling. Framework packages provide those realizations.
EntityMap
entityMap() creates a normalized collection at any state path.
import { entityMap, signalTree } from '@signal-tree/kernel';
type Product = {
id: number;
name: string;
inStock: boolean;
};
const tree = signalTree({
catalog: {
products: entityMap<Product, number>({
selectId: (product) => product.id,
}),
},
});
const products = tree.$.catalog.products;
products.addMany([
{ id: 1, name: 'Laptop', inStock: true },
{ id: 2, name: 'Chair', inStock: false },
]);
products.all();
products.ids();
products.count();
products.empty();
products.asMap();
products.has(1)();
products.where((product) => product.inStock)();
products.find((product) => product.name === 'Laptop')();
products.byId(1)?.();
products.byIdOrFail(1)();
products.updateOne(1, { inStock: false });
products.replaceOne(1, { id: 1, name: 'Laptop', inStock: true });
products.upsertOne({ id: 3, name: 'Desk', inStock: true });
products.removeOne(2);EntityMap preserves collection order and stable entity handles across ordinary
updates. changeId(from, to) adopts a new key without remove-and-add identity
loss. Keep each entity fact under one EntityMap authority and derive selections
rather than duplicating entity objects elsewhere.
Read-Only Views
asReadonly(tree) narrows the same runtime object to a read-only type. It does
not allocate a second store or create a runtime security boundary.
import { asReadonly } from '@signal-tree/kernel';
const reader = asReadonly(tree);
reader.$.count();
reader.$.catalog.products.byId(1)?.();Location write overloads and EntityMap mutation methods are absent from the read-only type. Use this for consumers that should receive reads while an application-owned Ops service retains the writable tree.
Built-In Enhancers
batching()
Coalesces notifications for grouped writes and adds the batching capability.
const tree = signalTree(state, {
enhancers: [batching()],
});restoration()
Retains designated causal turns for undo and redo.
import { restoration, signalTree, undoable } from '@signal-tree/kernel';
const tree = signalTree(
{ count: 0 },
{
enhancers: [restoration({ maxHistorySize: 50 })],
}
);
// Connect these handlers to separate user actions.
const actions = {
increment: () => undoable(() => tree.$.count((count) => count + 1)),
undo: () => tree.undo(),
redo: () => tree.redo(),
};undoable() designates the current synchronous authored turn. It is not an async
scope and does not create a separate state authority. History is recorded when
that turn settles: call undo from a later user action, not immediately after
undoable() in the same synchronous function. The tree owner calls destroy()
when this store is no longer needed.
transactions()
Adds an explicit pending operation that can be confirmed or rolled back. Use it for pending authority, not as a synonym for retained undo history.
Confirmed records are retained only while a live obligation needs them — a confirmed turn is released once no older pending turn could still consult it. Diagnostic history is a separate, explicitly bounded facility:
transactions({ history: { retain: 100 } });Without it, confirmedTurnReader reports retention.truncated === true with no
turns. That is deliberately distinguishable from "nothing happened", which
reports truncated === false.
devTools()
Connects the tree to Redux DevTools and adds the typed debug-session surface.
const tree = signalTree(state, {
enhancers: [devTools({ name: 'Application' })],
});
const session = tree.exportDebugSession();External Truth
external() classifies synchronous writes whose authoritative decision came
from outside the current authored operation. Restoration observes those writes
but does not claim them as undoable authored work.
import { external } from '@signal-tree/kernel';
const rows = await api.list();
external(() => tree.$.rows.setAll(rows));Acquire data first. Passing an async callback to external() is invalid because
the classification scope ends when the callback returns.
Links
link() expresses a live relationship between state locations while preserving
the kernel's authority and causal-turn semantics. Use it for genuine ongoing
synchronization, not as a request wrapper or migration bridge.
Errors
Observe library-reported diagnostics through onTreeError():
import { onTreeError } from '@signal-tree/kernel';
const stop = onTreeError((event) => {
console.error(event.operation, event.treeId, event.path, event.error);
});
stop();Applications observe errors; reporting remains owned by the library.
Persistence, Async Work, And Forms
SignalTree 15 does not publish persistence, serialization, async-request, or forms capabilities. Applications own storage formats, migrations, fetching, cancellation, retries, validation, and form control behavior.
Write resolved external data through ordinary paths or EntityMap, using
external() when restoration must not claim the write. Use framework effects or
application services for storage synchronization.
Lifetime
A tree owns runtime resources until destroy() releases them.
const tree = signalTree({ value: 1 });
try {
tree.$.value(2);
} finally {
tree.destroy();
}Application-root stores may live for the process lifetime. Component, route, SSR-request, test, and temporary-workflow trees have bounded owners and must be destroyed at that boundary. Dropping the last local reference is not prompt resource reclamation.
Angular's defineStore binds tree destruction to DestroyRef. Direct kernel
construction remains the caller's responsibility.
Failed pending-transaction rollback throws SignalTreeRollbackError, whose
stable code and structured cause distinguish refusal from application
errors.
A refusal is atomic: it changes no state, retires nothing, and leaves the
transaction pending, so confirm() and a retried rollback() both remain
available. Reversing an older transaction while a newer overlapping one is
still open refuses (cause.kind === 'later-pending-dependency'); settle the
newer one first.
Exports
The package publishes three code entry points:
@signal-tree/kernel@signal-tree/kernel/adapter@signal-tree/kernel/internals— supported tooling observation seam
The adapter entry point is the framework-neutral observation SDK. It is not a compatibility layer or an application convenience surface.
createSignalTreeFactory(observation)binds framework observation to tree construction.isConstructionBranch(value)identifies recursively traversed construction definitions, excluding explicit leaves, markers and terminal containers. Framework facades use this boundary for their own input validation; native reactive identity checks remain framework-owned.isNodeAccessor(value)distinguishes a root or branch accessor from a terminal location when a realization must route framework integration.replaceLocation(location, value)applies raw replacement ingress when the caller already knows the operation semantics, including callable state; it does not re-enter the authored updater grammar.observeOwnerInvalidation(owner, callback)wakes a framework observer so it can reread canonical truth.readCanonicalSnapshot(owner)reads the owner-qualified whole-tree snapshot.withRestorationDesignation(callback)identifies framework-originated user writes that are eligible for restoration.
An ObservationAdapter supplies dependency tokens and
runInvalidationGroup(run) so transactions and restoration can apply all
changes before framework observers are notified. It never owns or mirrors
location state.
An adapter may also supply an epoch, which lets a framework invalidate a whole subject through one native primitive instead of one carrier per field:
createEpoch()returns anEpochHandle— an opaque callable the adapter owns. The kernel stores it, passes it back, and never writes through it or inspects what is inside.advanceEpoch(epoch)marks that handle stale. The kernel calls this; the adapter decides what the framework does about it.
The two are a pair. An adapter that supplies one without the other does not
receive an epoch at all, because a handle the kernel cannot advance would
silently stop invalidating. EpochHandle is exported from
@signal-tree/kernel/adapter alongside ObservationAdapter and
ObservationToken.
Redux DevTools collection display
The optional entityKeyedView setting adds an id-keyed byId view alongside
an EntityMap's all array in Redux DevTools snapshots. It is off by default
because it increases payload size. Keys come from id, key, or uuid; the
keyed view is omitted if any included entity lacks a key. This is display data:
the existing all representation and time-travel hydration remain unchanged.
Tooling observation
@signal-tree/kernel/internals is a supported observation seam for tools such as
Studio. Application code continues to use its framework facade; these exports
are not additions to the kernel root API.
treeRuntimeIdexposes runtime identity for equality and map keys, never a persisted identity.treeCapabilitiesreports construction capabilities; an empty list is a bare tree.confirmedTurnReaderreads retained committed consequences without installing history. ItsConfirmedTurnReader,ConfirmedTurnSnapshot,ConfirmedTurnView,ConfirmedTurnEffectView,ConfirmedTurnEffectKindandConfirmedTurnRetentiontypes describe that window, including retention limits. A tree that has not asked for diagnostic history retains nothing for the reader, andConfirmedTurnRetention.truncatedsays so rather than presenting an empty window as a complete one — it is asserted by the transaction authority, never inferred from gaps in turn ids. Reads after destruction throwStudioTreeDestroyedError.observeWritessubscribes toObservedWriteFrameobservation. A notification does not establish a causal relationship, intermediate attempted write, or complete history.activeTransactionContextreturns the synchronous transaction callback's owner and local ID, orundefinedoutside that scope. It does not report confirmation or propagate acrossawait.withWriteObservationScopeassociates writes with a bounded, owner-qualified tooling token during a synchronous callback.ObservedWriteFrame.declaredScopesusesDeclaredWriteScopesto preserve retainedtokens,includesUnscopedcontributions andomittedoverflow when notifications coalesce. Declarations describe scope membership, not proven input dependencies or exclusive causes. Observer delivery does not inherit the scope.
Internally, getConfirmedTurnRecords supplies retained records to the tooling
projection. Tools consume confirmedTurnReader; the raw internal transaction
runtime accessor is not the supported inspection contract.
Studio is a separate private product with an explicit development-only attachment. Its adapter and query engine are not public npm packages. Tooling must retain unknown/unsupported distinctions and must not infer causality from event timing.
