What is Themis?

Redux + Saga state management with Svelte-readable, React-signal, and Kefir-observable selectors.

@augmentcode/themis moves shared state, async workflows, and derived data into explicit, testable Redux + Saga building blocks. It is not the official Redux Toolkit and does not use RTK APIs such as createSlice, configureStore, or createAsyncThunk.

It ships three Store variants: Store (Svelte readables), ReactStore (Preact React signals), and StreamingStore (Kefir observables). All three share the same constructor signature, selector API, and saga lifecycle — only the reactive primitive returned by direct selector calls differs.

The package also ships 40+ ESLint rules and an AI-skills CLI so AI coding agents can learn the architecture and apply it correctly.

When to use it

Pick Themis when you need predictable, observable, AI-writable state.

  • Keep shared application state in one Redux store instead of ad hoc component or module state.
  • Put API calls, subscriptions, timers, persistence, and other side effects in sagas instead of components or hooks.
  • Read derived state through selectors matched to the caller: Svelte readables, React signals, or Kefir streams — plus .select(state, ...args) for synchronous reads and .effect(...args) for sagas.
  • Skip needless recomputation: proxy-based tracking records the exact state paths each selector reads, so unrelated state changes do not invalidate derived values.
  • Coalesce selector emissions near frame rate so a store changing every 1 ms does not force consumer work every 1 ms.
  • Model entity-heavy state with normalized collection helpers: O(1) lookups, insertion-order preservation, reference counting.
  • Enforce architecture at lint time: unique action type strings, one selectors file per slice, no inline selectors in sagas, typed-redux-saga yield* style, channel cleanup guards, and more.

Installation

npm install @augmentcode/themis

redux, redux-saga, typed-redux-saga, and fast-equals ship as direct dependencies and are installed automatically.

All framework packages are optional peer dependencies:

  • svelte@^5 — required only for Store / Svelte-readable selectors
  • @preact/signals-react@^3 — required only for ReactStore
  • kefir@^3 — required only for StreamingStore
  • react@^18 || ^19 — required only for ReactStore

For saga integration tests:

npm install -D redux-saga-test-plan

Quick Start

Full setup examples for each Store variant.

<script lang="ts">
  import { onDestroy, onMount } from 'svelte';
  import { Store } from '@augmentcode/themis/svelte-store';
  import { counterReducer } from './slices/counter/counter-slice';
  import { counterSaga } from './slices/counter/sagas/counter-saga';

  const store = new Store(
    { counter: counterReducer },
    undefined,
    { throttledSelectorFrequency: 64, sagaMonitor: true }
  );

  const dispose = store.init();
  onDestroy(dispose);
  onMount(() => store.runSaga(counterSaga));
</script>

Choosing a Store Variant

All three variants share the same constructor, selector API, and saga lifecycle. Only the reactive primitive returned by direct selector calls differs.

VariantImport pathSelector returnsComponent readsNon-component reads
Store@augmentcode/themis/svelte-storeSvelte Readable<R>$selector or subscribe().select(state, ...args)
ReactStore@augmentcode/themis/react-storePreact ReadonlySignal<R>signal.value or .useValue(...args).select(state, ...args)
StreamingStore@augmentcode/themis/streaming-storeKefir Observable<R>.observe(cb).select(state, ...args)

All three constructors share the signature new (reducersMap?, middleware?, options?). Pass undefined for middleware when you only need options.

Public Import Paths

Every subpath is a stable public surface. Do not import from dist/ internals.

SubpackageUse for
@augmentcode/themis/svelte-storeCanonical Svelte-readable Store class
@augmentcode/themis/react-storeReactStore — selectors return Preact ReadonlySignal + .useValue()
@augmentcode/themis/streaming-storeStreamingStore — selectors return Kefir observables
@augmentcode/themis/sagaSaga helpers: selector channels, retry, streaming, waitFor
@augmentcode/themis/typesPublic TypeScript types (StoreInstanceState, StoreOptions, etc.)
@augmentcode/themis/eslint-pluginsComposed ESLint domain configs: svelte, react, streaming, store, core
@augmentcode/themis/components-svelte/use-init-storeSvelte helper: init store + auto-dispose on destroy
@augmentcode/themis/components-svelte/use-run-sagaSvelte helper: start saga on mount, cancel on unmount
@augmentcode/themis/utils/collections/collection-utilsCollection CRUD, query, and ref-counting utilities
@augmentcode/themis/utils/store/create-actioncreateAction, createAsyncAction
@augmentcode/themis/utils/store/create-reducercreateReducer with fluent .with() builder
@augmentcode/themis/utils/store/boolean-preferencecreateBooleanPreference — set/toggle boolean state fields
@augmentcode/themis/utils/store/domain-scopedcreateDomainScopedHelpers — per-domain-ID state slices
@augmentcode/themis/utils/sagas/debounce-sagadebounceSaga (legacy compat only — prefer takeLatest + delay)
@augmentcode/themis/utils/sagas/retry-with-timeoutretryWithTimeout (re-exported from /saga)
@augmentcode/themis/utils/sagas/wrap-async-generatorwrapStreamingGenerator (re-exported from /saga)
@augmentcode/themis/utils/sagas/selector-channel-effectsLow-level channel effects (re-exported from /saga)

Creating Selectors

Create selectors from the configured Store instance with store.createSelector(fn). The callback receives the concrete app state — each reducer domain is fully typed, not a generic Record.

import { Store } from '@augmentcode/themis/svelte-store';
import type { StoreInstanceState } from '@augmentcode/themis/types';
import { todosReducer } from './slices/todos/todos-slice';

export const store = new Store({ todos: todosReducer });
export type AppState = StoreInstanceState<typeof store>;
// AppState['todos'] is inferred from todosReducer — no manual annotation needed

// Simple selector (no args)
export const selectItemCount = store.createSelector(
  (state) => state.todos.collection.ids.length
);

// Selector with arguments
export const selectTodoById = store.createSelector(
  (state, todoId: string) => state.todos.collection.map[todoId]
);

// Composing selectors via .select()
export const selectCompletedTodos = store.createSelector((state) => {
  const todos = selectAllTodos.select(state);     // reuse without re-reading state
  return todos.filter((t) => t.completed);
});

Declare selectors in the slice's *-selectors.ts file, never inline in components or saga modules. Each slice directory owns exactly one selectors file paired with one slice file.

Usage Modes

One selector, every context. Each usage mode is covered by a different method on the selector object.

Svelte components

<!-- In a Svelte component -->
<script lang="ts">
  import { selectTodoById, selectItemCount } from '$lib/todos/todos-selectors';

  const count = selectItemCount();        // Readable<number>
  const todo  = selectTodoById(todoId);   // Readable<Todo | undefined>
</script>

<p>{$count} items</p>
<p>{$todo?.title}</p>

React components

// In a React component
import { selectTodoById, selectItemCount } from './todos/todos-selectors';

// Preferred: direct signal call returns ReadonlySignal<R>
const countSignal = selectItemCount();      // ReadonlySignal<number>
const todoSignal  = selectTodoById(id);     // ReadonlySignal<Todo | undefined>

function TodoTitle({ id }: { id: string }) {
  // .useValue() returns plain R — use only when a plain value is required
  const todo = selectTodoById.useValue(id);
  return <span>{todo?.title}</span>;
}

Sagas

import { call, put, takeLatest } from 'typed-redux-saga';
import { selectItemCount, selectTodoById } from './todos/todos-selectors';

function* fetchDetailSaga(action: ReturnType<typeof loadTodo>) {
  // Use .effect() — the only correct way to read selectors in sagas
  const count = yield* selectItemCount.effect();
  const todo  = yield* selectTodoById.effect(action.payload[0]);
  // ...
}
ContextMethodReturnsNotes
Svelte component initselectFoo()Readable<R>getContext() — only valid at init time
React component / hook (signal)selectFoo()ReadonlySignal<R>Preferred React path
React component / hook (plain)selectFoo.useValue(...args)RUse only when plain value required
Event handlers / callbacksselectFoo.select(store.state)RDirect read, no context needed
Sagasyield* selectFoo.effect()SagaGenerator<R>Uses redux-saga select effect
Composing selectorsselectFoo.select(state)RInside another selector callback
Bound to a storeselectFoo.withStore(store)Readable / Signal / Observable<R>For shared/library selectors

Proxy Memoization

Themis wraps state in a Proxy during selector execution and records every property access. On subsequent Redux dispatches, only selectors whose tracked paths changed (by reference) are re-run.

// Themis wraps state in a Proxy during selector execution.
// Only paths actually accessed are recorded.

export const selectTodoById = store.createSelector(
  (state, todoId: string) => state.todos.collection.map[todoId]
  // Tracks: state.todos.collection (stops at Collection boundary)
  // Does NOT track: state.users, state.counter, etc.
);

// Re-runs ONLY when:
//   - state.todos.collection changes (reference equality)
//   - todoId argument changes (shallow equality)
// Unrelated updates (e.g. counter increment) skip this selector entirely.

Collection boundaries are not proxied — the Collection object itself is the tracked unit, because Collection helpers always return new objects on mutation. This keeps tracking cheap without sacrificing fine-grained change detection.

Selector emissions are additionally coalesced at throttledSelectorFrequency FPS (default 64). Rapid Redux writes batch into one emission per frame, preventing unnecessary UI or stream work. Do not add extra memoize, debounce, or scheduler wrappers around Store-created selectors.

Reducers & Actions

Use createAction and createReducer from their explicit leaf imports. RTK APIs (createSlice, createAsyncThunk) are blocked by the ESLint plugin.

Actions

import { createAction, createAsyncAction }
  from '@augmentcode/themis/utils/store/create-action';

// Plain action — no args, no payload
export const increment = createAction('counter/increment');

// Action with typed payload
export const setCount = createAction('counter/setCount', (value: number) => value);

// Action with multiple args mapped to a payload object
export const updateUser = createAction(
  'users/update',
  (id: string, changes: Partial<User>) => ({ id, changes })
);

// Async action — carries .success and .failure sub-actions
export const fetchTodos = createAsyncAction<[], void, Todo[]>(
  'todos/fetch',       // asyncActionType
  'todos/fetch/stages' // stagesActionType
);
// fetchTodos()          → StoreAsyncAction
// fetchTodos.success(todos) → dispatched by saga on success
// fetchTodos.failure(err)   → dispatched by saga on error

Reducers — fluent builder

import { createReducer } from '@augmentcode/themis/utils/store/create-reducer';
import { increment, setCount, fetchTodos } from './counter-slice';

type CounterState = { count: number; loading: boolean };

export const counterReducer = createReducer<CounterState>({ count: 0, loading: false })
  .with(increment, (state) => ({ ...state, count: state.count + 1 }))
  .with(setCount,  (state, { payload: [value] }) => ({ ...state, count: value }))
  .with(fetchTodos,         (state) => ({ ...state, loading: true }))
  .with(fetchTodos.success, (state) => ({ ...state, loading: false }))
  .with(fetchTodos.failure, (state) => ({ ...state, loading: false }));

Boolean preference helper

import { createBooleanPreference }
  from '@augmentcode/themis/utils/store/boolean-preference';

const darkMode = createBooleanPreference<SettingsState, 'darkMode'>({
  sliceName:       'settings',
  field:           'darkMode',
  setActionName:   'settings/setDarkMode',
  toggleActionName:'settings/toggleDarkMode',
});

// Provides: darkMode.setAction(true/false), darkMode.toggleAction()
// Register in your reducer builder:
const settingsReducer = createReducer<SettingsState>({ darkMode: false });
darkMode.register(settingsReducer);

Reducers must remain pure and synchronous. No API calls, no Dates, no Sets/Maps, no random values. State that is persisted to storage should be serializable (primitives, plain objects, arrays, Collections).

Collections

Collection<T, K> stores entities by ID with O(1) lookup and insertion-order preservation. All helpers are immutable — they return new collections, never mutate.

Create

import { createCollection, addItem, addItems, getItem,
  upsertItem, updateItem, removeItem, getItems, filterItems,
  decreaseRefsCount, increaseRefsCount }
  from '@augmentcode/themis/utils/collections/collection-utils';

type Todo = { id: string; title: string; completed: boolean };

// Empty collection keyed on 'id'
let todos = createCollection<Todo, 'id'>('id');

// Seed with initial items
todos = createCollection<Todo, 'id'>('id', [
  { id: '1', title: 'Buy milk',    completed: false },
  { id: '2', title: 'Write tests', completed: true  },
]);

CRUD & Query

// Add (no-op if ID already exists)
todos = addItem(todos, { id: '3', title: 'Deploy', completed: false });
todos = addItems(todos, [item4, item5]);

// Upsert (add if new, replace if exists)
todos = upsertItem(todos, { id: '1', title: 'Buy oat milk', completed: false });

// Partial update — unspecified fields preserved
todos = updateItem(todos, { id: '1', completed: true });

// Remove by ID
todos = removeItem(todos, '3');

// O(1) lookup
const todo = getItem(todos, '1');         // Todo | undefined
const all  = getItems(todos);             // Todo[] in insertion order
const done = filterItems(todos, (t): t is Todo => t.completed);

Reference counting

// Reference counting for shared entities
import { addItemAndCountRef, decreaseRefsCount, getRefsCount }
  from '@augmentcode/themis/utils/collections/collection-utils';

// Add item and set its ref count to 1
users = addItemAndCountRef(users, newUser);

// Another consumer claims a reference
users = increaseRefsCount(users, userId);

// Consumer releases — item removed automatically when count hits 0
users = decreaseRefsCount(users, userId);

// In a reducer:
.with(removeContext, (state, { payload: [id] }) => ({
  ...state,
  users: decreaseRefsCount(state.users, id),
}))

When decreaseRefsCount brings a count to 0 or below, the item is automatically removed from both ids and map. Useful for shared entities referenced by multiple features.

Access via selectors

// Access collections through selectors, not directly in components
import { getItem, getItems } from '@augmentcode/themis/utils/collections/collection-utils';
import { store } from '$lib/store';

export const selectTodosCollection = store.createSelector(
  (state) => state.todos.collection
);

export const selectTodo = store.createSelector(
  (state, id: string) => getItem(selectTodosCollection.select(state), id)
);

export const selectAllTodos = store.createSelector(
  (state) => getItems(selectTodosCollection.select(state))
);

Core Effects & Patterns

Sagas are generator functions that handle side effects. Import typed effects from typed-redux-saga and use yield* for type safety. Themis uses redux-saga as its saga middleware.

import { call, put, take, fork, delay,
  takeEvery, takeLatest, takeLeading, race, all }
  from 'typed-redux-saga';

// takeLatest — cancel previous run when a new action arrives (+ built-in debounce)
export function* searchSaga() {
  yield* takeLatest(searchInputChanged, function* (action) {
    yield* delay(300);                         // settle before hitting API
    const results = yield* call(api.search, action.payload[0]);
    yield* put(searchResultsLoaded(results));
  });
}

// takeEvery — spawn a new task for each matching action
export function* syncSaga() {
  yield* takeEvery(itemSaved, function* (action) {
    try {
      yield* call(api.sync, action.payload[0]);
      yield* put(itemSaved.success({ item: action.payload[0], synced: true }));
    } catch (e) {
      yield* put(itemSaved.failure(e instanceof Error ? e : new Error(String(e))));
    }
  });
}

// Multi-step wizard workflow
export function* wizardSaga() {
  yield* takeEvery(startWizard, function* () {
    yield* put(showStep(1));
    yield* take(step1Complete);
    yield* put(showStep(2));
    yield* take(step2Complete);
    yield* put(wizardDone());
  });
}

// Race — first to finish wins
export function* fetchWithTimeout() {
  const { data, timeout } = yield* race({
    data:    call(api.fetchData),
    timeout: delay(5_000),
  });
  if (timeout) yield* put(fetchTimedOut());
  else yield* put(dataLoaded(data!));
}

Key rules:

  • Never use yield* select((state) => ...) inline lambdas — always use named selectors with .effect().
  • Pass action creators directly to takeEvery / takeLatest, not .type.
  • Never use take('*') — wildcard takes wake the saga on every dispatch, including high-frequency streaming chunks.
  • Use attached fork for child work. Avoid spawn for package-owned saga work.
  • Wrap API calls in yield* call(fn, args) so they can be mocked in tests.

Selector Channels

React to selector value changes inside sagas. The helpers in @augmentcode/themis/saga manage channel lifecycle automatically.

import { takeLatestFromSelector, takeEveryFromSelector,
  takeLeadingFromSelector, createChannelFromSelector }
  from '@augmentcode/themis/saga';
import { take } from 'typed-redux-saga';

// React to selector value changes — most common pattern
function* watchCurrentItem() {
  yield* takeLatestFromSelector(
    selectCurrentItemId,
    function* ({ payload, prevPayload }) {
      if (payload) yield* put(loadItemData(payload));
    }
  );
}

// With selector arguments
function* watchSpecificItem(itemId: string) {
  yield* takeLatestFromSelector(
    selectItemById,
    [itemId],
    function* ({ payload }) {
      yield* call(syncItem, payload);
    }
  );
}

// Low-level channel for races or complex patterns
function* complexWatcher() {
  const channel = yield* createChannelFromSelector(selectCurrentMode);
  try {
    while (true) {
      const { payload, prevPayload } = yield* take(channel);
      // handle mode change
    }
  } finally {
    channel.close(); // always close in finally
  }
}

Always close low-level channels in finally blocks. The takeLatestFromSelector, takeEveryFromSelector, and takeLeadingFromSelector helpers handle cleanup automatically.

Retry, Streaming & waitFor

Three more helpers exported from @augmentcode/themis/saga.

retryWithTimeout

import { retryWithTimeout } from '@augmentcode/themis/saga';
import { call, put } from 'typed-redux-saga';

function* syncRemoteState() {
  const outcome = yield* retryWithTimeout(
    function* () {
      yield* call(api.syncRemoteState);
    },
    {
      maxRetries: 2,
      timeoutMs:  30_000,
      getDelayMs: (attempt) => 500 * (attempt + 1), // 500 ms, 1 s, ...
      onAttemptError: (err, attempt) =>
        console.warn(`Attempt ${attempt} failed:`, err),
    }
  );
  // outcome: "success" | "retries-exhausted" | "timeout"
  if (outcome !== 'success') yield* put(syncFailed(outcome));
}

wrapStreamingGenerator

import { wrapStreamingGenerator } from '@augmentcode/themis/saga';
import { put } from 'typed-redux-saga';

function* consumeStream(stream: AsyncGenerator<MessageChunk, void, unknown>) {
  yield* wrapStreamingGenerator(
    stream,
    function* (chunk) {
      yield* put(messageChunkReceived(chunk));
    },
    { timeoutMs: 30_000, onError: (err) => reportStreamError(err) }
  );
}

waitFor

import { waitFor } from '@augmentcode/themis/saga';

function* awaitReady() {
  const ready = yield* waitFor(
    selectIsReady,
    [],
    (value) => value === true,
    10_000 // optional timeout ms
  );
  if (!ready) yield* put(initTimedOut());
}

waitFor(selector, args, predicate, timeoutMs?) polls the selector value via channel subscription and resolves as true when the predicate passes, or false on timeout.

Store Options

Passed as the third constructor argument to any Store variant.

  • throttledSelectorFrequencynumberdefault: 64

    Selector emission coalescing in frames per second. Accepts finite values in the inclusive 1–256 range (fractional values like 48.5 are accepted). Out-of-range values throw.

  • sagaMonitorbooleandefault: false

    Enables the built-in redux-saga monitor for Store-owned saga middleware. Diagnostics only — leave off in production.

  • traceSelectorsbooleandefault: false

    Enables diagnostic selector flush tracing to the console. Diagnostics only — leave off in production.

Lifecycle

Every Store variant follows the same initialization and teardown pattern.

store.init(initialState?)

Prepares the Redux store, starts the internal saga manager, and returns a disposer function. Does NOT start app sagas. Pass preloaded state when hydrating from persistence.

store.runSaga(sagaFn)

Starts one saga and returns a cancel function. Multiple calls with the same saga function share a reference-counted instance — the saga only stops when every cancel function is invoked.

store.dispose()

Tears down the initialized Store context and stops all saga tasks owned by it. Equivalent to calling the disposer returned by store.init().

store.state

Synchronous read of the current Redux state. Use selectors with .select(store.state) in event handlers.

store.dispatch

Direct access to the Redux dispatch function. Usually dispatched from components via the action creator or from sagas via put().

store.addMiddleware(mw)

Register additional Redux middleware before store.init() is called.

store.getReducers()

Returns the combined reducer map, including package-internal domains.

store.initDevTool()

Exposes the runtime to browser/devtools. Returns its own cleanup; also cleaned up by store.dispose().

StoreInstanceState<typeof store>

TypeScript helper: infer the full app state type from a configured Store instance. StoreState<typeof store> is a supported alias.

Svelte root layout example

<script lang="ts">
  import { onDestroy, onMount } from 'svelte';
  import { store } from '$lib/store';
  import { appSaga } from '$lib/sagas/app-saga';

  const dispose = store.init();
  onDestroy(dispose);

  // Sagas start AFTER init, from onMount for proper lifecycle order
  onMount(() => store.runSaga(appSaga));
</script>

CLI & Skills

The CLI installs AI agent skills and lists available commands.

npx themis help
# or: ./node_modules/.bin/themis help

Skill bundles

Skills are pre-written guidance documents for AI coding agents. They teach the agent how to use Themis correctly: slice structure, selector ownership, saga patterns, collection utilities, and more.

# Install all skill bundles into .agents/skills/themis/
npx themis install-skills

# Install a specific bundle
npx themis install-skills:svelte
npx themis install-skills:react
npx themis install-skills:streaming

# Remove installed skills before uninstalling the package
npx themis cleanup-skills

ESLint

40+ custom lint rules that encode the Themis architecture. Import exactly one domain root config per app path from @augmentcode/themis/eslint-plugins.

// eslint.config.js (flat config)
import { svelte } from '@augmentcode/themis/eslint-plugins';
export default svelte;
ConfigUse for
coreAny JS/TS package — package hygiene, forbidden RTK APIs, import shapes
storecore + store rules — state/saga packages without a UI framework
sveltecore + store + Svelte-specific component boundary rules
reactcore + store + React/signal boundary rules
streamingcore + store — Node, server, worker, and CLI consumers

The former full / recommended root configs and the @augmentcode/themis/eslint-architecture specifiers are removed. Switch to the appropriate domain root. Per-rule configs are still available via the plugins named export.

Architecture

Core principles and data flow.

  • Single source of truth — All shared state lives in one Redux store.
  • State is read-only — Changes happen only through dispatched actions, never direct mutation.
  • Reducers are pure — Given the same state and action, a reducer always produces the same result. No side effects, no async, no Dates/Maps/Sets.
  • Side effects live in sagas — API calls, persistence, timers, event listeners, and async workflows belong in redux-saga generators, not in components or reducers.
  • Selectors derive data — Components read state through selectors, which provide proxy-based memoization and frame-rate coalescing.

Slice file structure

src/slices/<domain>/
├── <domain>-slice.ts          # Only action/reducer owner for this slice
├── <domain>-selectors.ts      # Only selector owner for this slice
├── <domain>-slice.test.ts     # Reducer unit tests
└── sagas/
    ├── <domain>-saga.ts       # Saga logic
    └── <domain>-saga.test.ts  # Saga integration tests

Each slice directory owns exactly one *-slice.ts and one *-selectors.ts. Split multiple logical slices into separate directories. Reducer map keys and action type namespaces use camelCase: userPreferences / "userPreferences/updateTheme".

Further Reading

Extended documentation shipped inside the package under docs/.

  • docs/SELECTORS.mdSelector creation, all usage modes, proxy memoization, collection selectors, lifecycle rules, anti-patterns.
  • docs/SAGAS.mdCore effects, selector channel effects, debounce patterns, retry helpers, streaming generator, saga manager, error handling.
  • docs/ARCHITECTURE.mdData flow, store shape, middleware pipeline, saga lifecycle, slice file structure, utility reuse protocol.
  • docs/COLLECTIONS.mdCollection type, CRUD operations, reference counting, query helpers, when to use vs plain arrays.
  • docs/REDUCERS.mdcreateAction, createAsyncAction, createReducer, boolean preference, domain-scoped state helpers.
  • docs/TESTING.mdReducer unit tests, selector tests with .select(), saga tests with redux-saga-test-plan.
  • docs/WAITFOR.mdwaitFor helper: waiting for selector predicates in sagas with optional timeout.
  • skills/SKILL.mdRoot router for AI agent skill families: Core, Svelte, React, Streaming.
  • skills/core/Framework-independent Redux and redux-saga agent guidance.
  • skills/svelte/Svelte Store, readable selectors, Svelte component patterns.
  • skills/react/ReactStore, Preact signal selectors, React component patterns.
  • skills/streaming/StreamingStore, Kefir observable selectors, Node/server patterns.