What is Themis?
Version 0.2.4: 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.
Choose one concrete Store family per app path. Keep Svelte, React, and Streaming selector usage patterns separate instead of mixing their reactive primitives in the same runtime.
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/themisredux, redux-saga, typed-redux-saga, fast-equals, and kefir 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 ReactStorereact@^18 || ^19— required only for ReactStore
For saga integration tests:
npm install -D redux-saga-test-planQuick 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>import { ReactStore } from '@augmentcode/themis/react-store';
import { counterReducer } from './slices/counter/counter-slice';
import { counterSaga } from './slices/counter/sagas/counter-saga';
const reactStore = new ReactStore(
{ counter: counterReducer },
undefined,
{ throttledSelectorFrequency: 64 }
);
const dispose = reactStore.init();
const cancelCounterSaga = reactStore.runSaga(counterSaga);
// Selector — returns ReadonlySignal<number>
const selectCount = reactStore.createSelector((state) => state.counter.count);
function CounterLabel() {
const count = selectCount.useValue(); // plain number for hooks/components
return <span>{count}</span>;
}
// Cleanup
cancelCounterSaga();
dispose();import { StreamingStore } from '@augmentcode/themis/streaming-store';
import { counterReducer } from './slices/counter/counter-slice';
import { counterSaga } from './slices/counter/sagas/counter-saga';
const streamStore = new StreamingStore(
{ counter: counterReducer },
undefined,
{ throttledSelectorFrequency: 64 }
);
const dispose = streamStore.init();
const cancelCounterSaga = streamStore.runSaga(counterSaga);
// Selector — returns Kefir Observable<number>
const selectCount = streamStore.createSelector((state) => state.counter.count);
const count$ = selectCount();
const sub = count$.observe({ value: (count) => console.log(count) });
sub.unsubscribe();
cancelCounterSaga();
dispose();Choosing a Store Variant
All three variants share the same constructor, selector API, and saga lifecycle. Pick one family for each app path and keep its reactive usage patterns consistent.
| Variant | Import path | Selector returns | Component reads | Non-component reads |
|---|---|---|---|---|
| Store | @augmentcode/themis/svelte-store | Svelte Readable<R> | $selector or subscribe() | .select(state, ...args) |
| ReactStore | @augmentcode/themis/react-store | Preact ReadonlySignal<R> | .useValue(...args) | signal.value or .select(state, ...args) |
| StreamingStore | @augmentcode/themis/streaming-store | Kefir 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
Import from the explicit public subpath that owns an API. There is no package-root barrel, @augmentcode/themis/utils barrel, or components-svelte barrel.
| Subpackage | Use for |
|---|---|
| @augmentcode/themis/svelte-store | Canonical Svelte-readable Store class |
| @augmentcode/themis/react-store | ReactStore — selectors return Preact ReadonlySignal + .useValue() |
| @augmentcode/themis/streaming-store | StreamingStore — selectors return Kefir observables |
| @augmentcode/themis/saga | Saga helpers: selector channels, retry, streaming, waitFor |
| @augmentcode/themis/types | Public TypeScript types (StoreInstanceState, StoreOptions, etc.) |
| @augmentcode/themis/eslint-plugins | Composed ESLint domain configs: svelte, react, streaming, store, core |
| @augmentcode/themis/components-svelte/use-init-store | Svelte helper: init store + auto-dispose on destroy |
| @augmentcode/themis/components-svelte/use-run-saga | Svelte helper: start saga on mount, cancel on unmount |
| @augmentcode/themis/utils/collections/collection-utils | Collection CRUD, query, and ref-counting utilities |
| @augmentcode/themis/utils/store/create-action | createAction, createAsyncAction |
| @augmentcode/themis/utils/store/create-reducer | createReducer with fluent .with() builder |
| @augmentcode/themis/utils/store/boolean-preference | createBooleanPreference — set/toggle boolean state fields |
| @augmentcode/themis/utils/store/domain-scoped | createDomainScopedHelpers — per-domain-ID state slices |
| @augmentcode/themis/utils/sagas/debounce-saga | debounceSaga (legacy compat only — prefer takeLatest + delay) |
| @augmentcode/themis/utils/sagas/retry-with-timeout | retryWithTimeout (re-exported from /saga) |
| @augmentcode/themis/utils/sagas/wrap-async-generator | wrapStreamingGenerator (re-exported from /saga) |
| @augmentcode/themis/utils/sagas/selector-channel-effects | Low-level channel effects (re-exported from /saga) |
Do not import from @augmentcode/themis, a broad utility barrel, or dist/ internals. The leaf paths above are the supported application surface.
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]);
// ...
}| Context | Method | Returns | Notes |
|---|---|---|---|
| Svelte component init | selectFoo() | 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) | R | Use only when plain value required |
| Event handlers / callbacks | selectFoo.select(store.state) | R | Direct read, no context needed |
| Sagas | yield* selectFoo.effect() | SagaGenerator<R> | Uses redux-saga select effect |
| Composing selectors | selectFoo.select(state) | R | Inside another selector callback |
| Bound to a store | selectFoo.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 errorReducers — 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
forkfor child work. Avoidspawnfor 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';
import { put } from 'typed-redux-saga';
function* awaitReady(taskId: string) {
const ready = yield* waitFor(
selectTaskPhase,
[taskId],
(phase, previousPhase) =>
phase === 'ready' && previousPhase !== 'ready',
10_000 // optional timeout ms
);
if (!ready) {
yield* put(taskTimedOut(taskId));
return;
}
yield* put(startTask(taskId));
}waitFor(selector, args, predicate, timeoutMs?) checks the named selector immediately, then subscribes through a selector channel if the current value does not match. The predicate receives both the current and previous value.
It resolves as true when the predicate passes and false when a supplied timeout wins. Without a timeout it waits indefinitely. Use stable scalar selector arguments, keep the predicate pure, and always handle the timeout result. This helper waits for one condition; it is not a polling or continuous-monitoring API.
Store Options
Passed as the third constructor argument to any Store variant.
throttledSelectorFrequencynumberdefault: 64Selector 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: falseEnables the built-in redux-saga monitor for Store-owned saga middleware. Diagnostics only — leave off in production.
logReduxActionsbooleandefault: falsePublishes one Redux action event after each successful reducer pass. The default logger renders grouped action and state output.
traceSelectorsboolean | SelectorTracingOptionsdefault: falseEnables selector diagnostics. Use true for the compatibility preset or a flat options object to select categories, thresholds, summaries, and cadence.
loggerFactoryStoreLoggerFactorydefault: undefinedAttaches a Store-owned custom logger to the six read-only trace streams. Returning a disposer ties logger cleanup to Store disposal.
Diagnostics & Tracing
Selector tracing and Redux action logging are separate, opt-in diagnostics shared by Store, ReactStore, and StreamingStore.
import { Store } from '@augmentcode/themis/svelte-store';
import type { StoreLoggerFactory } from '@augmentcode/themis/types';
const loggerFactory: StoreLoggerFactory = (streams) => {
const errors = streams.runtimeError.observe((event) => reportError(event));
const summaries = streams.selectorSummary.observe((rows) => recordRows(rows));
return () => {
errors.unsubscribe();
summaries.unsubscribe();
};
};
const store = new Store(reducers, undefined, {
traceSelectors: {
traceExecution: true,
traceCache: true,
summaryEnabled: true,
summaryIntervalMs: 1_000,
},
logReduxActions: true,
loggerFactory,
});
const dispose = store.init();
const lifetimeSummary = store.getSelectorTraceSummary();
dispose();| Trace stream | What it publishes |
|---|---|
| selectorDetail | Execution, cache, invalidation, arguments, and result evidence enabled by traceSelectors categories |
| selectorSummary | Deep-frozen lifetime snapshots with counts, durations, invalidations, result outcomes, and cache metrics |
| selectorCadence | Selector scheduler subscription and tick events |
| sagaMonitor | Store-owned redux-saga monitor events when sagaMonitor is enabled |
| runtimeError | Store runtime errors with optional source, message, and payload context |
| reduxAction | Action plus previous/next state references and whether the state reference changed |
store.traceStreams is frozen and read-only. Consumers may observe its Kefir streams but cannot publish events or access Themis's internal emitters. Supplying loggerFactory replaces the built-in console logger for that Store instance; it does not duplicate default output.
Structured traceSelectors options independently enable traceExecution, traceCache, traceInvalidation, traceArguments, traceResults, and traceCadence. Inclusive thresholds include minDurationMs, minRecomputationCount, and minCacheMissCount.
Set summaryEnabled: true to allocate summary collection. On every configured interval, selectorSummary emits the same deep-frozen lifetime snapshot returned by store.getSelectorTraceSummary(), even when that interval has no eligible period rows. Separately, Themis consumes the interval's period counters, applies the configured thresholds, and prints a console aggregate only when eligible rows remain. Period counters reset each interval; the lifetime snapshot continues accumulating until disposal.
Initialize the Store before reproducing an issue and always retain its disposer. Disposal stops summary intervals, clears pending period data, and disposes custom logger subscriptions. Diagnostic options are construction-time configuration, not runtime toggles.
Selector summaries do not include state, arguments, results, or internal paths. Redux action events do retain action and state references, so redact secrets and personal data before exporting or sharing custom logger output.
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.stateSynchronous read of the current Redux state. Use selectors with .select(store.state) in event handlers.
store.dispatchDirect 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. It includes package-owned internal domains; do not register, read, or reuse those internal reducers in app code.
store.traceStreamsFrozen, read-only diagnostic streams shared by all three Store families.
store.getSelectorTraceSummary()Returns the deep-frozen lifetime selector summary when summary collection is enabled.
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>Reducer names prefixed with @internal_ and the internal saga manager are package-owned. Do not register @internal_ reducers, start internal sagas, or read those state domains from application code.
Testing
Match the test style to the layer: reducers and selectors are pure functions; sagas are generators that yield effects.
import { describe, expect, it } from 'vitest';
import { counterReducer, increment } from './counter-slice';
describe('counterReducer', () => {
it('initializes and handles actions without mutation', () => {
const initial = counterReducer(undefined, { type: '@@INIT' });
const next = counterReducer(Object.freeze(initial), increment());
expect(initial).toEqual({ value: 0 });
expect(next).toEqual({ value: 1 });
expect(next).not.toBe(initial);
});
});import { expect, it } from 'vitest';
import { selectCompletedTodos, selectTodoById } from './todos-selectors';
it('reads selectors synchronously from mock state', () => {
expect(selectCompletedTodos.select(mockState)).toHaveLength(1);
expect(selectTodoById.select(mockState, 'todo-1')?.title).toBe('Ship docs');
});
// Do not call selectCompletedTodos() in a pure unit test.
// The direct reactive form needs its Store-family runtime context.import { call, put } from 'typed-redux-saga';
import { expect, it } from 'vitest';
it('calls the API and dispatches success', () => {
const generator = handleFetch(fetchItems('query'));
expect(generator.next().value).toEqual(call(api.fetchItems, 'query'));
const items = [{ id: '1' }];
expect(generator.next(items).value).toEqual(put(setItems(items)));
expect(generator.next().done).toBe(true);
});- Call reducers directly and cover initialization, every action, unknown-action identity, immutability, and serializable output.
- Use
selector.select(mockState, ...args)for pure selector tests. Do not call the reactive selector form without its Store-family runtime context. - Test sagas with
redux-saga-test-planor step generators manually. Cover API calls, success effects, error effects, cancellation, and timeout paths. - Use Store for Svelte-readable integration tests and StreamingStore for Kefir emission tests. Control timers or requestAnimationFrame when asserting selector coalescing.
- Keep test files next to their source modules and use
silentRun()when saga-test-plan timeout warnings are not useful.
CLI & Skills
Installing the npm package does not copy AI skills. Choose and explicitly install the smallest matching skill bundle from your app root.
npx themis help
# or: ./node_modules/.bin/themis helpSkill 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 one Store-family bundle
npx themis install-skills:svelte
npx themis install-skills:react
npx themis install-skills:streaming
npx themis install-skills:core
# Install every bundle (these commands are aliases)
npx themis install-skills
npx themis install-skills:all
# Remove installed skills before uninstalling the package
npx themis cleanup-skills
npm uninstall @augmentcode/themisThe canonical copy lives at .agents/skills/themis/. Each install refreshes package-owned files and writes installed-skills.yml with the package version, selected target, and installed file list. User-authored and unrelated skills are preserved.
The CLI also creates or reuses a relative .claude/skills/themis compatibility link. Existing files, directories, or foreign links at that path are never overwritten; the canonical install continues with a warning.
npm 7+ does not run dependency-uninstall lifecycle cleanup. Run npx themis cleanup-skills before uninstalling so only manifest-owned files and the owned compatibility link are removed.
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;| Config | Use for |
|---|---|
| core | Any JS/TS package — package hygiene, forbidden RTK APIs, import shapes |
| store | core + store rules — state/saga packages without a UI framework |
| svelte | core + store + Svelte-specific component boundary rules |
| react | core + store + React/signal boundary rules |
| streaming | core + 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. The consumer validate-architecture CLI command is also removed; consuming apps get architecture checks through these composed ESLint roots.
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 testsEach 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.