Imported from fmflurry/settings-opencode (
skills/flurryx/SKILL.md). Install upstream withnpx skills add fmflurry/settings-opencode --skill flurryx. Copyright stays with the author.
flurryx
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) |