flurryx

SkillDev tools

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.

Available today. Use it from your connected AI after setup.

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_CASE keys: LIST, DETAIL, ITEMS
  • @SkipIfCached outermost, @Loading directly beneath
  • Use @SkipIfCached only if cache hits are intended; else omit
  • Keyed resources -> syncToKeyedStore, not hand-rolled Record updates
  • Don't subclass BaseStore directly
  • 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 guard
  • isAnyKeyLoading(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) -> InjectionToken registered providedIn: '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?: StoreDeadLetterMeta
  • clear(key) -> reset slot to idle
  • clearAll() -> reset every slot
  • startLoading(key) / stopLoading(key)
  • updateKeyedOne(key, resourceKey, entity) -> sets entity status Success, recomputes top-level isLoading
  • clearKeyedOne(key, resourceKey) -> remove single keyed entry
  • startKeyedLoading(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) => …) -> () => void cleanup
  • onCacheInvalidate(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 snapshot
  • undo() / redo() -> bool
  • getHistory() / getHistory(key) -> readonly entries
  • getMessages() / getMessages(key) -> channel records
  • getDeadLetters() -> dead-letter entries
  • replayDeadLetter(id) -> bool
  • replayDeadLetters() -> int (acked)
  • replayDeadLetterCommand(id, async resolver -> { resolved, clear }) -> Promise
  • getCurrentIndex() -> 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() -> calls clearAll() 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 exceeded
  • createLocalStorageStoreMessageChannel({ storageKey, ... }) -- defaults storage to localStorage
  • createSessionStorageStoreMessageChannel({ storageKey, ... }) -- session-scoped
  • createCompositeStoreMessageChannel({ 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:

  1. { error: { errors: [...] } } -> returns inner array
  2. { status, message } -> [{ code: String(status), message }]
  3. Error -> [{ code: 'UNKNOWN', message: err.message }]
  4. else -> [{ code: 'UNKNOWN', message: String(err) }]

httpErrorNormalizer (from flurryx/http):

  • HttpErrorResponse with error.errors array -> 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 slot
  • store.clearKeyedOne('ITEMS', id) -> one entry; also evicts that key's @SkipIfCached entries
  • store.invalidateCacheFor('ITEMS', id) -> invalidate cache only, keep state
  • store.clearAll() -> all slots in this store
  • clearAllStores() -> every flurryx store (logout/tenant switch)
  • cloneValue(v) -> deep clone (Date/Map/Set/Array/plain). Class instances with constructor side-effects don't survive
  • createSnapshotRestorePatch(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 HttpClient when belongs in facade/adapter
  • DTOs into presentation models when mapper exists
  • @SkipIfCached on always-fresh flows
  • @Loading outside (above) @SkipIfCached
  • BehaviorSubject where store slot would own state
  • Subclassing BaseStore
  • Bypassing syncToStore / syncToKeyedStore for ad-hoc loading/error plumbing
  • Calling component methods in templates (use computed)

Quick Reference

TaskAPI
Define storeStore.for<Config>().build()
Read slotstore.get('LIST')
Read keyed entrystore.get('ITEMS').for(id)
Write slotstore.update('LIST', { data })
Write keyed entrystore.updateKeyedOne('ITEMS', id, entity)
Sync resourcesyncToStore(store, 'LIST', opts?)
Sync keyedsyncToKeyedStore(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 slotstore.clear('LIST')
Clear keyed entrystore.clearKeyedOne('ITEMS', id)
Invalidate cachestore.invalidateCacheFor('ITEMS', id?)
Reset all storesclearAllStores()
History`undo
Channel`createInMemory
Error normdefaultErrorNormalizer / 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