flurryx
SkillDev toolsSignal-first reactive state management for Angular. Bridge RxJS streams into cache-aware stores, keyed resources, mirrored state, and replayable history. Use when generating or modifying Angular code that uses flurryx for state management, or when scaffolding new feature modules that follow the flurryx facade pattern.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the flurryx skill
What this skill tells your AI
The instructions your AI receives, as published by fmflurry/settings-opencode in skills/flurryx/SKILL.md and read by ahel’s review.
Signal-first reactive state management for Angular. RxJS in, signals out.
When to Activate
- Build/modify state with
Store,syncToStore,syncToKeyedStore - Add facades/services exposing flurryx-backed signals
- Scaffold feature modules using flurryx for async state, caching, keyed resources
- Per-entity caches via
KeyedResourceData<TKey, TValue> - Mirroring/derivation:
mirror,mirrorSelf,derive,deriveSelf,mirrorKeyed,mirrorKey,deriveKey,collectKeyed - Channel persistence, history, replay, dead-letter recovery
- Reviewing AI-generated Angular code for flurryx correctness
Imports
// main API
import {
Store, BaseStore, LazyStore,
syncToStore, syncToKeyedStore,
SkipIfCached, Loading,
clearAllStores,
mirrorKey, deriveKey, collectKeyed,
cloneValue, createSnapshotRestorePatch,
createInMemoryStoreMessageChannel,
createStorageStoreMessageChannel,
createLocalStorageStoreMessageChannel,
createSessionStorageStoreMessageChannel,
createCompositeStoreMessageChannel,
isKeyedResourceData, createKeyedResourceData, isAnyKeyLoading,
CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS,
defaultErrorNormalizer,
} from 'flurryx';
import type {
ResourceState, StoreEnum, ResourceStatus, ResourceErrors,
KeyedResourceData, KeyedResourceKey,
StoreSignal, KeyedStoreSignal, KeyedResourceState, ValueOrSignal,
StoreOptions, StoreCacheInvalidateEvent,
MirrorOptions, DeriveOptions, CollectKeyedOptions,
SyncToStoreOptions, SyncToKeyedStoreOptions, ErrorNormalizer,
// history
StoreHistory, StoreHistoryEntry,
StoreDeadLetterEntry, StoreDeadLetterCommand, StoreDeadLetterMeta,
DeadLetterCommandResolverResult,
// messages
StoreMessage, StoreSnapshot, StoreMessageStatus,
UpdateStoreMessage, ClearStoreMessage, ClearAllStoreMessage,
StartLoadingStoreMessage, StopLoadingStoreMessage,
UpdateKeyedOneStoreMessage, ClearKeyedOneStoreMessage,
StartKeyedLoadingStoreMessage, EnsureKeyedSlotStoreMessage,
// channels
StoreMessageRecord, StoreMessageChannel,
StoreMessageChannelStorage, StoreMessageChannelOptions,
CompositeStoreMessageChannelOptions,
StorageStoreMessageChannelOptions,
BrowserStorageStoreMessageChannelOptions,
} from 'flurryx';
// HTTP-only — pulls @angular/common/http
import { httpErrorNormalizer } from 'flurryx/http';
Prefer flurryx. Use flurryx/http only for httpErrorNormalizer. Avoid direct @flurryx/core | @flurryx/store | @flurryx/rx unless host already depends.
Hard Rules
- Never
any - Components read signals only. Facade/service owns subscriptions + writes
- Prefer
Store.for<Config>().build()interface builder UPPER_SNAKE_CASEkeys:LIST,DETAIL,ITEMS@SkipIfCachedoutermost,@Loadingdirectly beneath- Use
@SkipIfCachedonly if cache hits are intended; else omit - Keyed resources ->
syncToKeyedStore, not hand-rolledRecordupdates - Don't subclass
BaseStoredirectly - Don't call component methods in templates -> use
computed
Core Flow
Observable -> syncToStore / syncToKeyedStore -> Store signal -> Template
ResourceState
Every slot wraps ResourceState<T>:
interface ResourceState<T> {
isLoading?: boolean;
data?: T;
status?: 'Success' | 'Error';
errors?: Array<{ code: string; message: string }>;
}
Lifecycle: idle -> loading -> Success | Error.
KeyedResourceData
Per-entity cache:
type KeyedResourceData<TKey extends string | number, TValue> =
Partial<Record<TKey, ResourceState<TValue>>>;
Helpers:
createKeyedResourceData<TKey, TValue>()->{}isKeyedResourceData(val)-> type guardisAnyKeyLoading(data)-> bool
Store Builder
Interface form (preferred)
interface ProductStoreConfig {
LIST: Product[];
DETAIL: Product;
ITEMS: KeyedResourceData<string, Item>;
}
export const ProductStore = Store.for<ProductStoreConfig>().build();
Naming: <Feature>StoreConfig + <Feature>Store. Slots are raw types; flurryx wraps in ResourceState<T>.
Enum-constrained form
const Keys = { LIST: 'LIST', DETAIL: 'DETAIL' } as const;
export const ProductStore = Store.for(Keys)
.resource('LIST').as<Product[]>()
.resource('DETAIL').as<Product>()
.build();
.build() only callable when all enum keys defined.
Fluent form
export const ProductStore = Store
.resource('LIST').as<Product[]>()
.resource('DETAIL').as<Product>()
.build();
Builder methods (all forms)
.mirror(sourceToken, sourceKey, targetKey?)-> 1:1 cross-store mirror.mirrorSelf(sourceKey, targetKey)-> alias inside same store; keys must differ.derive(sourceToken, sourceKey, targetKey?, { mapData })-> map source data into target slot.deriveSelf(sourceKey, targetKey, { mapData })-> derived alias inside same store.mirrorKeyed(sourceToken, sourceKey, { extractId }, targetKey?)-> aggregate single-entity fetches into keyed slot.build(options?: StoreOptions)->InjectionTokenregisteredprovidedIn: 'root'
StoreOptions extends StoreMessageChannelOptions -> supply channel to override default in-memory channel.
IStore API
store.get(key) returns:
- non-keyed slot ->
Signal<ResourceState<T>> - keyed slot ->
KeyedStoreSignal<TData, K>= signal +.for(resourceKey | Signal<resourceKey>)->Signal<ResourceState<TValue>>
Writes (publish broker messages)
update(key, partial, options?)-> merge partial;options.deadLetter?: StoreDeadLetterMetaclear(key)-> reset slot to idleclearAll()-> reset every slotstartLoading(key)/stopLoading(key)updateKeyedOne(key, resourceKey, entity)-> sets entity statusSuccess, recomputes top-level isLoadingclearKeyedOne(key, resourceKey)-> remove single keyed entrystartKeyedLoading(key, resourceKey)-> mark single key loading
Cache invalidation
invalidateCacheFor(key)-> invalidate slot cache only (state untouched)invalidateCacheFor(key, resourceKey)-> invalidate one keyed entry's cache
Hooks
onUpdate(key, (next, prev) => …)->() => voidcleanuponCacheInvalidate(key, ({ key, resourceKey }) => …)-> cleanup- Hook errors are caught + rethrown via
queueMicrotask/AggregateError
History / Replay
replay(id | ids[])-> re-execute persisted channel messages -> int (acked count)restoreStoreAt(index)-> snapshot navigation (no message)restoreResource(key, index?)-> restore single key from snapshotundo()/redo()-> boolgetHistory()/getHistory(key)-> readonly entriesgetMessages()/getMessages(key)-> channel recordsgetDeadLetters()-> dead-letter entriesreplayDeadLetter(id)-> boolreplayDeadLetters()-> int (acked)replayDeadLetterCommand(id, async resolver -> { resolved, clear })-> PromisegetCurrentIndex()-> int
Reactive signals on store
history: Signal<readonly StoreHistoryEntry[]>messages: Signal<readonly StoreMessageRecord[]>currentIndex: Signal<number>keys: Signal<readonly StoreKey[]>(LazyStore: grows on first access)
Global
clearAllStores()-> callsclearAll()on every tracked store. Use for logout/tenant switch.
Rx Operators
syncToStore
this.api.getProducts().pipe(
syncToStore(this.store, 'LIST', {
completeOnFirstEmission: true, // default true (take(1))
callbackAfterComplete: () => {},
errorNormalizer: defaultErrorNormalizer, // default
deadLetterCommand: { type: '...', payload: {} },
})
).subscribe();
Success -> { data, isLoading: false, status: 'Success', errors: undefined }.
Error -> { data: undefined, isLoading: false, status: 'Error', errors: normalized } + DLQ meta from HTTP-like errors.
syncToKeyedStore
this.api.getInvoice(id).pipe(
syncToKeyedStore(this.store, 'ITEMS', id, {
mapResponse: (r) => r.data, // optional response unwrap
completeOnFirstEmission: true,
callbackAfterComplete: () => {},
errorNormalizer,
deadLetterCommand,
})
).subscribe();
Bootstraps isLoading: true for that key on subscribe (via defer). Per-key Success/Error; recomputes top-level isLoading from remaining keys.
Decorators
@SkipIfCached
@SkipIfCached(
storeKey,
(i) => i.store,
returnObservable = false,
timeoutMs = DEFAULT_CACHE_TTL_MS, // CACHE_NO_TIMEOUT for infinite
)
Cache hit (skip) when: status === 'Success' OR isLoading === true, args match (JSON.stringify), TTL not expired.
Cache miss when: idle, status === 'Error', expired, or args changed.
Keyed: if first arg is string|number AND slot is KeyedResourceData, tracks cache per resourceKey automatically.
returnObservable: true -> uses shareReplay({ bufferSize: 1, refCount: true }) for in-flight dedup; method must return Observable.
@Loading
@Loading(storeKey, (i) => i.store)
Calls startLoading(key) before method. If first arg is string|number and store has startKeyedLoading, calls startKeyedLoading(key, resourceKey) instead.
Composition
@SkipIfCached MUST be outermost (short-circuits before loading). @Loading above @SkipIfCached -> potential infinite loading loops.
Standalone Functions
mirrorKey
mirrorKey(sourceStore, sourceKey, targetStore, targetKey?, options?: MirrorOptions)
// MirrorOptions: { destroyRef?, direction?: 'bidirectional' | 'source-to-target' }
// Default direction: 'bidirectional' — updates flow both ways with loop guard.
// Set direction: 'source-to-target' for one-way mirroring.
// returns cleanup () => void
deriveKey
deriveKey(source, sourceKey, target, targetKey, {
mapData: (data, state) => mappedData,
destroyRef?,
})
// returns cleanup. Mirrors isLoading/status/errors, maps data.
collectKeyed
collectKeyed(source, sourceKey, target, targetKey?, {
extractId: (entity | undefined) => key | undefined,
destroyRef?,
})
// CollectKeyedOptions. Aggregates single-entity emissions into keyed cache.
Message Channels
Default = in-memory.
Store.for<Config>().build({
channel: createLocalStorageStoreMessageChannel({
storageKey: 'app.store',
serialize?, // optional
deserialize?,
}),
});
Factories:
createInMemoryStoreMessageChannel<TData>()createStorageStoreMessageChannel({ storage, storageKey, serialize?, deserialize? })-- custom adapter; auto-evicts oldest on quota exceededcreateLocalStorageStoreMessageChannel({ storageKey, ... })-- defaults storage tolocalStoragecreateSessionStorageStoreMessageChannel({ storageKey, ... })-- session-scopedcreateCompositeStoreMessageChannel({ channels: [primary, ...replicas] })-- fan-out writes; primary handles reads + id allocation
StoreMessageChannelStorage: getItem | setItem | removeItem. Serializer handles undefined, Date, Map, Set, Array, plain objects.
Error Normalizers
defaultErrorNormalizer(err) checks in order:
{ error: { errors: [...] } }-> returns inner array{ status, message }->[{ code: String(status), message }]Error->[{ code: 'UNKNOWN', message: err.message }]- else ->
[{ code: 'UNKNOWN', message: String(err) }]
httpErrorNormalizer (from flurryx/http):
HttpErrorResponsewitherror.errorsarray -> as-is- else ->
[{ code: status, message }] - non-HTTP -> fallback
UNKNOWN
Patterns
Facade (preferred when codebase uses facades)
@Injectable()
export class ProductFacade {
private readonly api = inject(GetProductsUseCase);
readonly store = inject(ProductStore);
getProducts() { return this.store.get('LIST'); }
getProduct(id: string) { return this.store.get('ITEMS').for(id); }
@SkipIfCached('LIST', (i: ProductFacade) => i.store)
@Loading('LIST', (i: ProductFacade) => i.store)
loadProducts() {
this.api.execute().pipe(syncToStore(this.store, 'LIST')).subscribe();
}
@SkipIfCached('ITEMS', (i: ProductFacade) => i.store)
@Loading('ITEMS', (i: ProductFacade) => i.store)
loadProduct(id: string) {
this.api.byId(id).pipe(syncToKeyedStore(this.store, 'ITEMS', id)).subscribe();
}
}
store MUST be public + readonly so decorator getters can reach it.
Service-led (when no facade layer)
Same shape, @Injectable({ providedIn: 'root' }) service holds store.
Component
@Component({
template: `
@if (state().isLoading) { <app-spinner/> }
@for (p of products(); track p.id) { ... }
`,
})
export class ProductListComponent {
private readonly facade = inject(ProductFacade);
readonly state = this.facade.getProducts();
readonly products = computed(() => this.state().data ?? []);
constructor() { this.facade.loadProducts(); }
}
Read state().data | isLoading | status | errors. Use computed() for derived UI.
Keyed reads in component
readonly id = input.required<string>();
readonly invoiceState = computed(() => this.facade.store.get('ITEMS').for(this.id())());
.for(idOrSignal) is computed-safe and supports raw or signal keys. Snapshot reads state().data?.[id] still work.
Mirroring at builder level
export const SessionStore = Store.for<SessionStoreConfig>()
.mirror(CustomerStore, 'CUSTOMERS')
.mirrorSelf('CUSTOMER_DETAILS', 'CUSTOMER_SNAPSHOT')
.derive(OrdersStore, 'TOTAL', { mapData: (data) => formatTotal(data) })
.mirrorKeyed(InvoiceStore, 'DETAIL', { extractId: (inv) => inv?.id }, 'INVOICES')
.build();
Mirrors propagate update + onCacheInvalidate. Self-mirror with same source/target throws.
Lifecycle / Resets
store.clear('LIST')-> single slotstore.clearKeyedOne('ITEMS', id)-> one entry; also evicts that key's@SkipIfCachedentriesstore.invalidateCacheFor('ITEMS', id)-> invalidate cache only, keep statestore.clearAll()-> all slots in this storeclearAllStores()-> every flurryx store (logout/tenant switch)cloneValue(v)-> deep clone (Date/Map/Set/Array/plain). Class instances with constructor side-effects don't survivecreateSnapshotRestorePatch(current, snapshot)-> partial patch to restore
Replay & Dead Letters
store.undo(); store.redo();
store.restoreStoreAt(0); // snapshot nav, no broker
store.restoreResource('LIST', 5); // single-key restore
store.replay(12); // re-publish via broker
store.replay([12, 13, 14]);
store.replayDeadLetters(); // bool/int per id
store.replayDeadLetterCommand(id, async (entry) => ({ resolved: true, clear: true }));
Dead letter entry: { id, message, attempts, error, httpStatus, httpMessage, command, failedAt }.
DLQ command meta on update -> update(key, state, { deadLetter: { error, httpStatus?, httpMessage?, command? } }). syncToStore/syncToKeyedStore populate this from HTTP-like errors automatically.
Anti-Patterns
- Component subscribes to flurryx fetches
- Component mutates store directly
- Component injects
HttpClientwhen belongs in facade/adapter - DTOs into presentation models when mapper exists
@SkipIfCachedon always-fresh flows@Loadingoutside (above)@SkipIfCachedBehaviorSubjectwhere store slot would own state- Subclassing
BaseStore - Bypassing
syncToStore/syncToKeyedStorefor ad-hoc loading/error plumbing - Calling component methods in templates (use
computed)
Quick Reference
| Task | API |
|---|---|
| Define store | Store.for<Config>().build() |
| Read slot | store.get('LIST') |
| Read keyed entry | store.get('ITEMS').for(id) |
| Write slot | store.update('LIST', { data }) |
| Write keyed entry | store.updateKeyedOne('ITEMS', id, entity) |
| Sync resource | syncToStore(store, 'LIST', opts?) |
| Sync keyed | syncToKeyedStore(store, 'ITEMS', id, opts?) |
| Skip cache | @SkipIfCached(key, (i)=>i.store, retObs?, ttl?) |
| Mark loading | @Loading(key, (i)=>i.store) |
| Mirror state | `.mirror |
| Standalone mirror | `mirrorKey |
| Clear slot | store.clear('LIST') |
| Clear keyed entry | store.clearKeyedOne('ITEMS', id) |
| Invalidate cache | store.invalidateCacheFor('ITEMS', id?) |
| Reset all stores | clearAllStores() |
| History | `undo |
| Channel | `createInMemory |
| Error norm | defaultErrorNormalizer / httpErrorNormalizer (from flurryx/http) |
Signals
- GitHub stars
- 171
- Forks
- 10
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
flurryx- Source
- github.com/fmflurry/settings-opencode