Engine
createEnergyEngine - the stateful core that owns energy state, notifies subscribers, and drives persistence
createEnergyEngine
function createEnergyEngine(options?: EnergyEngineOptions): EnergyEngineCreates a stateful engine that owns an EnergyState, notifies subscribers on transitions, resolves adaptation strategies, and (optionally) persists state through an EnergyPersistence adapter.
When a persistence adapter is supplied, the engine auto-hydrates on creation (an internal call to hydrate()) and, if the adapter implements observe, subscribes to externally persisted state updates (cross-tab, worker) for the engine's lifetime.
import { createEnergyEngine } from '@kumbatio/energy-system'
import { localStoragePersistence } from '@kumbatio/energy-system/persistence'
const engine = createEnergyEngine({
initialLevel: 100,
persistence: localStoragePersistence(),
onChange: (state, prev) => console.log(prev.level, '->', state.level),
})EnergyEngineOptions
Prop
Type
EnergyEngine
Prop
Type
start
start(): voidBegins hydration and cross-context observation. Idempotent, and a no-op on a disposed engine or one without persistence. Only needed when the engine was created with autoStart: false.
Deferring these is what keeps an engine safe to construct during a React render. React discards in-progress renders, and only a committed tree runs effects - an engine that hydrated and subscribed at construction would strand a live storage listener with nothing left to release it. EnergyProvider does exactly this internally.
getState
getState(): EnergyStateReturns the current (frozen) energy state snapshot.
setLevel
setLevel(level: EnergyLevel, source?: EnergySource): voidSets the energy level. source defaults to 'manual'. The new state's ordering key is guaranteed strictly newer than the previous one: the timestamp is clamped to never go backwards, and when the clock has not advanced the revision counter is incremented instead (or the timestamp bumped by 1 ms if revision would overflow Number.MAX_SAFE_INTEGER). No-op on a disposed engine.
cycleLevel
cycleLevel(): voidAdvances to the next level in cycle order 100 -> 75 -> 50 -> 25 -> 0 -> 100, with source 'manual'. No-op on a disposed engine.
subscribe
subscribe(listener: EnergyChangeListener): () => voidSubscribes to state changes; returns an unsubscribe function. Listener exceptions are caught and logged, never propagated. On a disposed engine, returns a no-op unsubscriber without registering the listener.
resolve
resolve<T>(strategy: AdaptationStrategy<T>): TResolves an AdaptationStrategy against the current level - equivalent to strategy.resolve(engine.getState().level).
hydrate
hydrate(): Promise<void>Loads persisted state. Called automatically by start() when persistence is configured - at construction unless autoStart: false - but can be called manually. Loaded state is validated (level, source, future-skew via maxFutureSkewMs); invalid state is logged and ignored. The loaded state is applied when no local transition happened during the load, or when it wins reconciliation against the current state (see below). No-op without persistence or when disposed.
flush
flush(): Promise<void>Waits until the current state version is durably persisted. Resolves immediately without persistence. Rejects if the engine is disposed, or if an unchanged initial state cannot be reconciled because the persistence hydration read failed (the engine refuses to overwrite an unread stored value with the default state).
dispose
dispose(): voidReleases engine-owned resources: unsubscribes persistence observation, cancels retry timers, clears listeners, and rejects pending flush() waiters with Error('Energy engine disposed before persistence completed'). A disposed engine is inert - it never mutates state, notifies, or persists again.
isPreferredEnergyState
function isPreferredEnergyState(candidate: EnergyState, current: EnergyState): booleanShould candidate replace current? The engine's own reconciliation rule, exported because it is the hardest part of the model to reimplement correctly and because anything sharing energy state across contexts needs exactly this answer.
The comparison walks four keys in order, stopping at the first that differs:
timestamp- greater wins.revision- greater wins. Two writes inside one clock tick are not simultaneous.source-manual>scheduled>inferred. Nothing the system worked out on its own overwrites what the person said.origin- lexicographically greater wins.
Key 4 is arbitrary, deliberately. When two producers write the same instant, the same revision, and the same class of source there is no principled winner - and an arbitrary rule every context computes identically beats a coin flip each context tosses separately. Convergence is the property that matters.
Equal on every key returns false: an identical state is not a change and must not fire a notification.
import { isPreferredEnergyState } from '@kumbatio/energy-system'
// Merging what another device reported:
if (isPreferredEnergyState(remote, engine.getState())) {
engine.setLevel(remote.level, remote.source)
}The relation is antisymmetric, and an implementation of it must stay that way. If both directions ever reported "preferred", two contexts observing each other would swap states forever. The conformance vectors assert both directions of every pair for this reason.
