skills/flurryx/SKILL.md
Signal-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.
npx skillsauth add fmflurry/settings-opencode flurryxInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Signal-first reactive state management for Angular. RxJS in, signals out.
Store, syncToStore, syncToKeyedStoreKeyedResourceData<TKey, TValue>mirror, mirrorSelf, derive, deriveSelf, mirrorKeyed, mirrorKey, deriveKey, collectKeyed// 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.
anyStore.for<Config>().build() interface builderUPPER_SNAKE_CASE keys: LIST, DETAIL, ITEMS@SkipIfCached outermost, @Loading directly beneath@SkipIfCached only if cache hits are intended; else omitsyncToKeyedStore, not hand-rolled Record updatesBaseStore directlycomputedObservable -> syncToStore / syncToKeyedStore -> Store signal -> Template
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.
Per-entity cache:
type KeyedResourceData<TKey extends string | number, TValue> =
Partial<Record<TKey, ResourceState<TValue>>>;
Helpers:
createKeyedResourceData<TKey, TValue>() -> {}isKeyedResourceData(val) -> type guardisAnyKeyLoading(data) -> boolinterface 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>.
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.
export const ProductStore = Store
.resource('LIST').as<Product[]>()
.resource('DETAIL').as<Product>()
.build();
.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) -> InjectionToken registered providedIn: 'root'StoreOptions extends StoreMessageChannelOptions -> supply channel to override default in-memory channel.
store.get(key) returns:
Signal<ResourceState<T>>KeyedStoreSignal<TData, K> = signal + .for(resourceKey | Signal<resourceKey>) -> Signal<ResourceState<TValue>>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 status Success, recomputes top-level isLoadingclearKeyedOne(key, resourceKey) -> remove single keyed entrystartKeyedLoading(key, resourceKey) -> mark single key loadinginvalidateCacheFor(key) -> invalidate slot cache only (state untouched)invalidateCacheFor(key, resourceKey) -> invalidate one keyed entry's cacheonUpdate(key, (next, prev) => …) -> () => void cleanuponCacheInvalidate(key, ({ key, resourceKey }) => …) -> cleanupqueueMicrotask/AggregateErrorreplay(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 }) -> Promise<bool>getCurrentIndex() -> inthistory: Signal<readonly StoreHistoryEntry[]>messages: Signal<readonly StoreMessageRecord[]>currentIndex: Signal<number>keys: Signal<readonly StoreKey[]> (LazyStore: grows on first access)clearAllStores() -> calls clearAll() on every tracked store. Use for logout/tenant switch.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.
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.
@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(storeKey, (i) => i.store)
Calls startLoading(key) before method. If first arg is string|number and store has startKeyedLoading, calls startKeyedLoading(key, resourceKey) instead.
@SkipIfCached MUST be outermost (short-circuits before loading). @Loading above @SkipIfCached -> potential infinite loading loops.
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(source, sourceKey, target, targetKey, {
mapData: (data, state) => mappedData,
destroyRef?,
})
// returns cleanup. Mirrors isLoading/status/errors, maps data.
collectKeyed(source, sourceKey, target, targetKey?, {
extractId: (entity | undefined) => key | undefined,
destroyRef?,
})
// CollectKeyedOptions. Aggregates single-entity emissions into keyed cache.
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 to localStoragecreateSessionStorageStoreMessageChannel({ storageKey, ... }) -- session-scopedcreateCompositeStoreMessageChannel({ channels: [primary, ...replicas] }) -- fan-out writes; primary handles reads + id allocationStoreMessageChannelStorage: getItem | setItem | removeItem. Serializer handles undefined, Date, Map, Set, Array, plain objects.
defaultErrorNormalizer(err) checks in order:
{ error: { errors: [...] } } -> returns inner array{ status, message } -> [{ code: String(status), message }]Error -> [{ code: 'UNKNOWN', message: err.message }][{ code: 'UNKNOWN', message: String(err) }]httpErrorNormalizer (from flurryx/http):
HttpErrorResponse with error.errors array -> as-is[{ code: status, message }]UNKNOWN@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.
Same shape, @Injectable({ providedIn: 'root' }) service holds store.
@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.
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.
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.
store.clear('LIST') -> single slotstore.clearKeyedOne('ITEMS', id) -> one entry; also evicts that key's @SkipIfCached entriesstore.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 restorestore.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.
HttpClient when belongs in facade/adapter@SkipIfCached on always-fresh flows@Loading outside (above) @SkipIfCachedBehaviorSubject where store slot would own stateBaseStoresyncToStore / syncToKeyedStore for ad-hoc loading/error plumbingcomputed)| 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 | .mirrorSelf | .mirrorKeyed | .derive | .deriveSelf (builder) |
| Standalone mirror | mirrorKey | deriveKey | collectKeyed |
| Clear slot | store.clear('LIST') |
| Clear keyed entry | store.clearKeyedOne('ITEMS', id) |
| Invalidate cache | store.invalidateCacheFor('ITEMS', id?) |
| Reset all stores | clearAllStores() |
| History | undo | redo | restoreStoreAt | restoreResource | replay | replayDeadLetters |
| Channel | createInMemory | LocalStorage | SessionStorage | Storage | Composite ...MessageChannel |
| Error norm | defaultErrorNormalizer / httpErrorNormalizer (from flurryx/http) |
development
Scaffolds and extends Angular 18+ standalone features using Clean Architecture with DDD layering (presentation/application/domain/infrastructure), custom signal-based stores, facade pattern, and ports/adapters dependency inversion. Use when creating new Angular features/domains, adding use cases/facades/stores/ports/adapters, refactoring legacy NgModule/NgRx code toward clean architecture, or working with cross-domain communication via context registry.
development
Pre-merge code review for Angular + TypeScript pull requests. Diffs current branch against a target branch, applies Angular-specific checklists (signals, RxJS, clean architecture, flurryx, TS strict), runs lint + tsc, and emits a tiered report (verbose for juniors, terse for seniors). Auto-loads project AGENTS.md rules. Use when user runs /cop-review, says "pre-merge review", "review before merging", "check my PR against <branch>", or invokes the merge-cop agent.
testing
Use this skill for any git work such as creating branches, staging changes, writing commit messages, pushing branches, or preparing pull requests. Delegates git execution to the git-specialist agent.
development
evidence-first decision-gating for planning, design, architecture, and refactor requests with unresolved requirements, scope, constraints, facts, or tradeoffs. use when the next safe step is to ask exactly one dependency-safe question before proposing a plan or implementation, especially when critical decisions are not yet closed.