Kumbatio
Concepts

Persistence

The EnergyPersistence contract, the built-in localStorage and memory adapters, and how flush and dispose behave.

Persistence is an interface, not an assumption. The engine works entirely in memory by default; give it an adapter and it hydrates on creation, saves in the background with retry, and can observe external writes for cross-context sync.

The contract

interface EnergyPersistence {
  load(): Promise<EnergyState | null>
  save(state: EnergyState): Promise<void>
  // Optional: subscribe to externally persisted updates (cross-tab, worker, ...)
  observe?(onState: (state: EnergyState) => void): () => void
}

Adapters persist the full EnergyState - level, timestamp, source, revision, origin - because reconciliation needs all of it. A save that fails should reject (not swallow) so the engine's retry queue can observe the failure.

Platform-specific adapters (SQLite for desktop, IndexedDB, server sync) belong in consuming apps - the contract is deliberately small enough to implement in a few lines.

Built-in adapters

Import from @kumbatio/energy-system/persistence:

import { localStoragePersistence, memoryPersistence } from '@kumbatio/energy-system/persistence'

localStoragePersistence(key?)

Stores the state as JSON under key (default 'energy-state'). Its observe hooks the storage event, so two tabs sharing the key converge automatically - the engine reconciles whichever state wins deterministically. In environments without localStorage, load returns null and observe is a no-op; save rejects so the engine retries rather than silently losing state.

memoryPersistence(initial?)

In-memory storage for tests, SSR, or ephemeral sessions. Its observe notifies on every save, which makes it handy for wiring two engines together in tests.

Engine behavior with persistence

const engine = createEnergyEngine({
  initialLevel: 75,
  persistence: localStoragePersistence(),
  onPersistenceError(error, state) {
    telemetry.report(error) // observe failures; the engine still retries
  },
})
  • Hydration is automatic. On creation the engine calls load() and reconciles the stored state against the in-memory one. Invalid or too-far-future records are ignored, never repaired.
  • Saves are background. setLevel() notifies subscribers synchronously; persistence runs behind it with bounded exponential backoff (250ms doubling up to 30s), so a failing store - quota exceeded, say - is not hammered forever.
  • External observation is wired for you. If the adapter implements observe, the engine subscribes and reconciles incoming states through the same deterministic ordering as hydration.

flush() - waiting for durability

setLevel doesn't wait for storage. When a workflow must not report completion until the state is durable, flush:

engine.setLevel(50)
await engine.flush()

flush() resolves once the current state version is durably persisted. Two failure modes reject instead of lying:

  • Disposed engine - flushing after dispose() rejects.
  • Unreadable storage at startup - an initial flush() (before any level change) waits for hydration first, and rejects if that hydration read failed. The engine will not overwrite storage it couldn't read with a default state.

Without a persistence adapter, flush() resolves immediately.

dispose() - releasing resources

engine.dispose()

Dispose releases the persistence observation, cancels retry timers, clears subscribers, and rejects any pending flush() waiters. A disposed engine is inert: setLevel/cycleLevel become no-ops and no further persistence is scheduled. In React, the EnergyProvider disposes the engine it created on unmount.

Writing your own adapter

import type { EnergyPersistence, EnergyState } from '@kumbatio/energy-system'

export function myStorePersistence(store: MyStore): EnergyPersistence {
  return {
    async load() {
      const record = await store.get('energy-state')
      return record ?? null // return null, not a fabricated state
    },
    async save(state: EnergyState) {
      await store.set('energy-state', state) // reject on failure - the engine retries
    },
    observe(onState) {
      const stop = store.onChange('energy-state', onState)
      return stop
    },
  }
}

Validate untrusted records before returning them from load() - or return them as-is and rely on the engine, which strictly validates and ignores anything invalid. Never "repair" a corrupt record into a newer-looking state; that could win reconciliation it shouldn't.