{"id":48708392,"url":"https://github.com/fmflurry/flurryx","last_synced_at":"2026-04-13T11:01:15.445Z","repository":{"id":341717120,"uuid":"1170592019","full_name":"fmflurry/flurryx","owner":"fmflurry","description":"flurryx is a signal-first reactive state toolkit for Angular that bridges RxJS streams into structured, cache-aware stores.","archived":false,"fork":false,"pushed_at":"2026-04-10T14:43:53.000Z","size":2801,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-04-10T16:27:05.370Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fmflurry.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-02T09:47:39.000Z","updated_at":"2026-04-10T14:43:57.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/fmflurry/flurryx","commit_stats":null,"previous_names":["fmflurry/flurryx"],"tags_count":19,"template":false,"template_full_name":null,"purl":"pkg:github/fmflurry/flurryx","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmflurry%2Fflurryx","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmflurry%2Fflurryx/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmflurry%2Fflurryx/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmflurry%2Fflurryx/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fmflurry","download_url":"https://codeload.github.com/fmflurry/flurryx/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmflurry%2Fflurryx/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31749763,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-13T09:16:15.125Z","status":"ssl_error","status_checked_at":"2026-04-13T09:16:05.023Z","response_time":93,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-11T13:00:21.204Z","updated_at":"2026-04-13T11:01:15.436Z","avatar_url":"https://github.com/fmflurry.png","language":"TypeScript","readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"assets/picto.svg\" alt=\"\" width=\"64\" /\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cpicture\u003e\n    \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"assets/logo-dark.svg\" /\u003e\n    \u003csource media=\"(prefers-color-scheme: light)\" srcset=\"assets/logo.svg\" /\u003e\n    \u003cimg src=\"assets/logo.svg\" alt=\"flurryx\" width=\"480\" /\u003e\n  \u003c/picture\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://www.npmjs.com/package/flurryx\"\u003e\n    \u003cimg src=\"https://img.shields.io/npm/v/flurryx?color=dd0031\" alt=\"flurryx version\" /\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://github.com/fmflurry/flurryx/actions/workflows/ci.yml\"\u003e\n    \u003cimg src=\"https://github.com/fmflurry/flurryx/actions/workflows/ci.yml/badge.svg?branch=master\" alt=\"Build status\" /\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://github.com/fmflurry/flurryx/actions/workflows/ci.yml\"\u003e\n    \u003cimg src=\"https://img.shields.io/badge/coverage-86%25-brightgreen\" alt=\"Coverage 86%\" /\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://angular.dev/\"\u003e\n    \u003cimg src=\"https://img.shields.io/badge/Angular-%3E%3D17-dd0031\" alt=\"Angular \u003e=17\" /\u003e\n  \u003c/a\u003e\n  \u003ca href=\"LICENSE\"\u003e\n    \u003cimg src=\"https://img.shields.io/badge/license-MIT-blue\" alt=\"MIT license\" /\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eSignal-first reactive state management for Angular.\u003c/strong\u003e\u003cbr /\u003e\n  Bridge RxJS streams into cache-aware stores, keyed resources, mirrored state, and replayable history.\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://fmflurry.github.io/flurryx/\"\u003eLive demo\u003c/a\u003e\n  ·\n  \u003ca href=\"#in-action\"\u003eIn action\u003c/a\u003e\n  ·\n  \u003ca href=\"#feature-summary\"\u003eFeature summary\u003c/a\u003e\n  ·\n  \u003ca href=\"#getting-started\"\u003eGetting started\u003c/a\u003e\n  ·\n  \u003ca href=\"samples/taskflurry\"\u003eTaskflurry sample\u003c/a\u003e\n\u003c/p\u003e\n\n\u003e **See it in action** — [**Taskflurry**](https://fmflurry.github.io/flurryx/) is a live demo app built with Angular 21 (zoneless, no zone.js dependency) and flurryx. It showcases store definitions, the facade pattern with `@SkipIfCached` and `@Loading`, keyed resources with per-entity loading and errors, and Clean Architecture layering. Try the [live demo](https://fmflurry.github.io/flurryx/) or browse the [source code](samples/taskflurry).\n\nflurryx bridges the gap between RxJS async operations and Angular signals. Define a store, pipe your HTTP calls through an operator, read signals in your templates, queue store messages when you need to batch updates, and replay history when you need deterministic state transitions. No actions, no reducers, no effects boilerplate.\n\n## In Action\n\nTaskFlurry is a demo Angular application built with Flurryx to showcase the library’s capabilities.\n\nIt demonstrates how to manage shared state, structure facade-driven workflows, and leverage built-in history to make state transitions explicit and easy to inspect.\n\n### Store History Time Travel\n\n\u003e **Replayable state out of the box. Jump back, restore, move on.** A deleted task is restored by jumping directly to the exact store history entry that brought it out of the list. This is the kind of inspectable, replayable state flow flurryx gives you without building custom devtools first.\n\n\u003cimg src=\"assets/readme/history-time-travel.gif\" alt=\"Store history time travel in Taskflurry\" /\u003e\n\n### Projects to Tasks Drill-down\n\n\u003e **Derived state without UI drift.** Selecting a project immediately reshapes the task view from shared store state. The UI stays coherent because the project context and the derived task list are driven from the same reactive foundation.\n\n\u003cimg src=\"assets/readme/projects-to-tasks.gif\" alt=\"Projects to tasks drill-down in Taskflurry\" /\u003e\n\n### Task Creation Flow\n\n\u003e **Fast workflows, no ceremony.** From context to success state, no reducer overhead. It shows how flurryx keeps normal app workflows simple without pushing everything through reducer-heavy ceremony.\n\n\u003cimg src=\"assets/readme/create-task.gif\" alt=\"Task creation flow in Taskflurry\" /\u003e\n\n### Task Update Flow\n\n\u003e **Edit in place. Stay in sync.** Editing a task updates the detail view in place with the new status and content. Update once, UI follows, history included.\n\n\u003cimg src=\"assets/readme/update-task.gif\" alt=\"Task update flow in Taskflurry\" /\u003e\n\n### Delete Task Flow\n\n\u003e **Simple changes, fully traceable.** Deleting from the list immediately updates the visible state with no extra reducer wiring or action choreography. Minimal logic, full visibility.\n\n\u003cimg src=\"assets/readme/delete-task.gif\" alt=\"Delete task flow in Taskflurry\" /\u003e\n\n## What It Looks Like\n\nDefine a store. Inject it. Read signals. That's it.\n\n```typescript\nimport { Store } from \"flurryx\";\n\ninterface ProductStoreConfig {\n  LIST: Product[];\n  DETAIL: Product;\n}\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build();\n```\n\nOne interface, one line — you get a fully typed, injectable store with loading state, error tracking, and history built in.\n\n```typescript\n@Component({\n  template: `\n    @if (state().isLoading) { \u003cspinner /\u003e } @if (state().status === 'Error') {\n    \u003cerror-banner [errors]=\"state().errors\" /\u003e } @for (product of state().data;\n    track product.id) {\n    \u003cproduct-card [product]=\"product\" /\u003e\n    }\n  `,\n})\nexport class ProductListComponent {\n  private readonly store = inject(ProductStore);\n  readonly state = this.store.get(\"LIST\");\n}\n```\n\nNo `async` pipe. No `subscribe`. No manual unsubscription. `isLoading`, `status`, and `errors` are always there — you just read them.\n\n**Need HTTP?** Pipe it straight into the store:\n\n```typescript\nthis.http\n  .get\u003cProduct[]\u003e(\"/api/products\")\n  .pipe(syncToStore(this.store, \"LIST\"))\n  .subscribe();\n```\n\n**Need caching?** Add a decorator — the method is skipped when data is fresh:\n\n```typescript\n@SkipIfCached(\"LIST\", (i: ProductFacade) =\u003e i.store)\n@Loading(\"LIST\", (i: ProductFacade) =\u003e i.store)\nloadProducts() { /* only runs on cache miss */ }\n```\n\n**Need undo/redo?** It's already there:\n\n```typescript\nstore.undo();\nstore.redo();\nstore.restoreStoreAt(0); // back to initial state\n```\n\nThe store is the foundation. Layer on facades, decorators, mirroring, and message channels when your app needs them — not before.\n\n---\n\n## Why flurryx?\n\nAngular signals are great for synchronous reactivity, but real applications still need RxJS for HTTP calls, WebSockets, and other async sources. The space between \"I fired a request\" and \"my template shows the result\" is where complexity piles up:\n\n| Problem            | Without flurryx                              | With flurryx                                   |\n| ------------------ | -------------------------------------------- | ---------------------------------------------- |\n| Loading spinners   | Manual boolean flags, race conditions        | `store.get(key)().isLoading`                   |\n| Error handling     | Scattered `catchError`, inconsistent shapes  | Normalized `{ code, message }[]` on every slot |\n| Caching            | Custom `shareReplay` / `BehaviorSubject`     | `@SkipIfCached` — one decorator                |\n| Duplicate requests | Manual inflight tracking                     | `@SkipIfCached` deduplicates while loading     |\n| Keyed resources    | Separate state per ID, boilerplate explosion | `KeyedResourceData` with per-key loading/error |\n| Replay and history | Ad hoc logging, custom devtools              | Built-in message log, undo, redo, replay by id |\n\nflurryx stays small on purpose: a typed store builder, a small RxJS bridge, cache/loading decorators, store composition helpers, and a message broker with pluggable channels.\n\n### How it stacks up\n\n| Capability                        | NgRx                                     | NGXS                  | Elf                | **flurryx**                                              |\n| --------------------------------- | ---------------------------------------- | --------------------- | ------------------ | -------------------------------------------------------- |\n| Store definition                  | Actions + Reducers + Selectors           | State class + Actions | Repository + Store | **One interface**                                        |\n| Boilerplate for a CRUD feature    | ~8 files                                 | ~5 files              | ~4 files           | **~2 files**                                             |\n| Signal-native                     | Adapter needed                           | No                    | No                 | **Built-in**                                             |\n| Loading / error per slot          | Manual                                   | Manual                | Partial            | **Automatic**                                            |\n| Per-entity keyed state            | @ngrx/entity (extra package)             | Manual                | Manual             | **Built-in KeyedResourceData**                           |\n| Cache deduplication               | Manual                                   | Manual                | Manual             | **@SkipIfCached decorator**                              |\n| **Built-in undo / redo / replay** | **No**                                   | **No**                | **No**             | **Yes — with dead-letter recovery**                      |\n| Message persistence               | No                                       | No                    | No                 | **Pluggable channels (localStorage, etc.)**              |\n| Bundle size impact                | Large (multiple packages)                | Medium                | Small              | **Small (no components, just signals)**                  |\n| Learning curve                    | Steep (Redux concepts)                   | Moderate              | Low-moderate       | **Low (signals + RxJS you already know)**                |\n| Best for                          | Teams already invested in Redux patterns | Medium-large apps     | Any size, flexible | **Any size — from simple CRUD to complex stateful apps** |\n\n---\n\n## Feature Summary\n\n\u003ctable\u003e\n\u003ctr\u003e\n\u003ctd width=\"50%\" valign=\"top\"\u003e\n\n### Store \u0026 Signals\n\n- **Typed signal stores** — interface in, signals out\n- **Loading \u0026 error lifecycle** — automatic on every slot\n- **Keyed entity caches** — per-entity loading, status, errors\n- **Cache invalidation** — slot, store, or app-wide\n\n\u003c/td\u003e\n\u003ctd width=\"50%\" valign=\"top\"\u003e\n\n### RxJS Bridge\n\n- **syncToStore** — pipe HTTP calls into the store\n- **@SkipIfCached** — skip when data is fresh\n- **@Loading** — auto-set loading flags\n\n\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd width=\"50%\" valign=\"top\"\u003e\n\n### Message Broker\n\n- **Message queueing** — typed, immutable, traceable\n- **History \u0026 time travel** — undo, redo, restoreStoreAt, restoreResource\n- **Replay** — re-execute messages by id\n- **Dead-letter recovery** — retry failed mutations\n- **Pluggable channels** — memory, localStorage, sessionStorage, composite\n- **Serialization** — Date, Map, Set round-trip through storage\n\n\u003c/td\u003e\n\u003ctd width=\"50%\" valign=\"top\"\u003e\n\n### Store Composition\n\n- **Mirroring** — `.mirror()`, `.mirrorSelf()`, `.mirrorKeyed()`\n- **mirrorKey / collectKeyed** — imperative wiring with cleanup\n\n\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/table\u003e\n\n---\n\n## Table of Contents\n\n- [What It Looks Like](#what-it-looks-like)\n- [Why flurryx?](#why-flurryx)\n- [In Action](#in-action)\n- [Feature Summary](#feature-summary)\n- [Packages](#packages)\n- [How to Install](#how-to-install)\n- [Getting Started](#getting-started)\n- [How to Use](#how-to-use)\n  - [ResourceState](#resourcestate)\n  - [Store API](#store-api)\n  - [Store Creation Styles](#store-creation-styles)\n  - [syncToStore](#synctostore)\n  - [syncToKeyedStore](#synctokeyedstore)\n  - [@SkipIfCached](#skipifcached)\n  - [@Loading](#loading)\n  - [Error Normalization](#error-normalization)\n  - [Constants](#constants)\n- [Keyed Resources](#keyed-resources)\n- [Clearing Store Data](#clearing-store-data)\n- [Message Queueing and History](#message-queueing-and-history)\n  - [Message Lifecycle](#message-lifecycle)\n  - [Message Types](#message-types)\n  - [History and Time Travel](#history-and-time-travel)\n  - [Message Replay](#message-replay)\n  - [Dead-Letter Recovery](#dead-letter-recovery)\n- [Message Channels](#message-channels)\n  - [Channel Types](#channel-types)\n  - [Built-in Serialization](#built-in-serialization)\n- [Store Mirroring](#store-mirroring)\n  - [Builder .mirror()](#builder-mirror)\n  - [Builder .mirrorSelf()](#builder-mirrorself)\n  - [Builder .mirrorKeyed()](#builder-mirrorkeyed)\n  - [mirrorKey](#mirrorkey)\n  - [collectKeyed](#collectkeyed)\n- [AI Coding](#ai-coding)\n- [Design Decisions](#design-decisions)\n- [Contributing](#contributing)\n- [License](#license)\n\n---\n\n## Packages\n\n| Package          | Purpose                                                                                      |\n| ---------------- | -------------------------------------------------------------------------------------------- |\n| `flurryx`        | The umbrella package. Import the full toolkit from a single entry point.                     |\n| `@flurryx/core`  | Shared types, keyed resource helpers, and cache constants.                                   |\n| `@flurryx/store` | Signal-backed stores, invalidation helpers, mirroring utilities, and replay/history control. |\n| `@flurryx/rx`    | RxJS bridge operators, decorators, and pluggable error normalization.                        |\n\n---\n\n## How to Install\n\n```bash\nnpm install flurryx\n```\n\nThat's it. The `flurryx` package re-exports everything from the three internal packages (`@flurryx/core`, `@flurryx/store`, `@flurryx/rx`), so every import comes from a single place:\n\n```typescript\nimport { Store, syncToStore, SkipIfCached, Loading } from \"flurryx\";\nimport type { ResourceState, KeyedResourceData } from \"flurryx\";\n```\n\nFor the Angular HTTP error normalizer (optional — keeps `@angular/common/http` out of your bundle unless you need it):\n\n```typescript\nimport { httpErrorNormalizer } from \"flurryx/http\";\n```\n\n**Peer dependencies** (you likely already have these):\n\n| Peer              | Version                           |\n| ----------------- | --------------------------------- |\n| `@angular/core`   | `\u003e=17`                            |\n| `rxjs`            | `\u003e=7`                             |\n| `@angular/common` | optional, only for `flurryx/http` |\n\n\u003e **Note:** Your `tsconfig.json` must include `\"experimentalDecorators\": true` if you use `@SkipIfCached` or `@Loading`.\n\n\u003cdetails\u003e\n\u003csummary\u003eIndividual packages\u003c/summary\u003e\n\nIf you prefer granular control over your dependency tree, the internal packages are published independently:\n\n```\n@flurryx/core   →  Types, models, utilities             (0 runtime deps)\n@flurryx/store  →  BaseStore with Angular signals        (peer: @angular/core \u003e=17)\n@flurryx/rx     →  RxJS operators + decorators           (peer: rxjs \u003e=7, @angular/core \u003e=17)\n```\n\n```bash\nnpm install @flurryx/core @flurryx/store @flurryx/rx\n```\n\n```\n@flurryx/core  ←── @flurryx/store\n                        ↑\n                   @flurryx/rx\n```\n\n\u003c/details\u003e\n\n---\n\n## Getting Started\n\n### Step 1 — Define your store\n\nDefine a TypeScript interface mapping slot names to their data types, then pass it to the `Store` builder:\n\n```typescript\nimport { Store } from \"flurryx\";\n\ninterface ProductStoreConfig {\n  LIST: Product[];\n  DETAIL: Product;\n}\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build();\n```\n\nThat's it. The interface is type-only — zero runtime cost. The builder returns an `InjectionToken` with `providedIn: 'root'`. Every call to `store.get('LIST')` returns `Signal\u003cResourceState\u003cProduct[]\u003e\u003e`, and invalid keys or mismatched types are caught at compile time.\n\n### Step 2 — Create a facade\n\nThe facade owns the store and exposes signals + data-fetching methods.\n\n```typescript\nimport { Injectable, inject } from \"@angular/core\";\nimport { HttpClient } from \"@angular/common/http\";\nimport { syncToStore, SkipIfCached, Loading } from \"flurryx\";\n\n@Injectable()\nexport class ProductFacade {\n  private readonly http = inject(HttpClient);\n  readonly store = inject(ProductStore);\n\n  getProducts() {\n    return this.store.get(\"LIST\");\n  }\n\n  getProductDetail() {\n    return this.store.get(\"DETAIL\");\n  }\n\n  @SkipIfCached(\"LIST\", (i: ProductFacade) =\u003e i.store)\n  @Loading(\"LIST\", (i: ProductFacade) =\u003e i.store)\n  loadProducts() {\n    this.http\n      .get\u003cProduct[]\u003e(\"/api/products\")\n      .pipe(syncToStore(this.store, \"LIST\"))\n      .subscribe();\n  }\n\n  @Loading(\"DETAIL\", (i: ProductFacade) =\u003e i.store)\n  loadProduct(id: string) {\n    this.http\n      .get\u003cProduct\u003e(`/api/products/${id}`)\n      .pipe(syncToStore(this.store, \"DETAIL\"))\n      .subscribe();\n  }\n}\n```\n\n### Step 3 — Use in your component\n\n```typescript\n@Component({\n  template: `\n    @if (productsState().isLoading) {\n    \u003cspinner /\u003e\n    } @if (productsState().status === 'Success') { @for (product of\n    productsState().data; track product.id) {\n    \u003cproduct-card [product]=\"product\" /\u003e\n    } } @if (productsState().status === 'Error') {\n    \u003cerror-banner [errors]=\"productsState().errors\" /\u003e\n    }\n  `,\n})\nexport class ProductListComponent {\n  private readonly facade = inject(ProductFacade);\n  readonly productsState = this.facade.getProducts();\n\n  constructor() {\n    this.facade.loadProducts();\n  }\n}\n```\n\nThe component reads signals directly. No `async` pipe, no `subscribe`, no `OnDestroy` cleanup.\n\n---\n\n## How to Use\n\n### ResourceState\n\nThe fundamental unit of state. Every store slot holds one:\n\n```typescript\ninterface ResourceState\u003cT\u003e {\n  isLoading?: boolean;\n  data?: T;\n  status?: \"Success\" | \"Error\";\n  errors?: Array\u003c{ code: string; message: string }\u003e;\n}\n```\n\nA slot starts as `{ data: undefined, isLoading: false, status: undefined, errors: undefined }` and transitions through a predictable lifecycle:\n\n```\n  ┌─────────┐   startLoading   ┌───────────┐   next    ┌─────────┐\n  │  IDLE   │ ───────────────→ │  LOADING  │ ────────→ │ SUCCESS │\n  └─────────┘                  └───────────┘           └─────────┘\n                                     │\n                                     │ error\n                                     ▼\n                               ┌───────────┐\n                               │   ERROR   │\n                               └───────────┘\n```\n\n### Store API\n\nThe `Store` builder creates a store backed by `Signal\u003cResourceState\u003e` per slot. Three creation styles are available:\n\n```typescript\n// 1. Interface-based (recommended) — type-safe with zero boilerplate\ninterface MyStoreConfig {\n  USERS: User[];\n  SELECTED: User;\n}\nexport const MyStore = Store.for\u003cMyStoreConfig\u003e().build();\n\n// 2. Fluent chaining — inline slot definitions\nexport const MyStore = Store.resource(\"USERS\")\n  .as\u003cUser[]\u003e()\n  .resource(\"SELECTED\")\n  .as\u003cUser\u003e()\n  .build();\n\n// 3. Enum-constrained — validates keys against a runtime enum\nexport const MyStore = Store.for(MyStoreEnum)\n  .resource(\"USERS\")\n  .as\u003cUser[]\u003e()\n  .resource(\"SELECTED\")\n  .as\u003cUser\u003e()\n  .build();\n```\n\nOnce injected, the store exposes these methods:\n\n| Method                    | Description                                                                           |\n| ------------------------- | ------------------------------------------------------------------------------------- |\n| `get(key)`                | Returns the `Signal` for a slot                                                       |\n| `update(key, partial)`    | Merges partial state (immutable spread)                                               |\n| `clear(key)`              | Resets a slot to its initial empty state                                              |\n| `clearAll()`              | Resets every slot                                                                     |\n| `startLoading(key)`       | Sets `isLoading: true`, clears `status` and `errors`                                  |\n| `stopLoading(key)`        | Sets `isLoading: false`, clears `status` and `errors`                                 |\n| `onUpdate(key, callback)` | Registers a listener fired after `update` or `clear`. Returns an unsubscribe function |\n\n**Keyed methods** (for `KeyedResourceData` slots):\n\n| Method                                     | Description                            |\n| ------------------------------------------ | -------------------------------------- |\n| `updateKeyedOne(key, resourceKey, entity)` | Merges one entity into a keyed slot    |\n| `clearKeyedOne(key, resourceKey)`          | Removes one entity from a keyed slot   |\n| `startKeyedLoading(key, resourceKey)`      | Sets loading for a single resource key |\n\n\u003e Update hooks are stored in a `WeakMap` keyed by store instance, so garbage collection works naturally across multiple store lifetimes.\n\n#### Read-only signals\n\n`get(key)` returns a **read-only `Signal`**, not a `WritableSignal`. Consumers can read state but cannot mutate it directly — all writes must go through the store's own methods (`update`, `clear`, `startLoading`, …). This enforces strict encapsulation: the store is the single owner of its state, and external code can only observe it.\n\n### Store Creation Styles\n\n#### Interface-based: `Store.for\u003cConfig\u003e().build()`\n\nThe recommended approach. Define a TypeScript interface where keys are slot names and values are the data types:\n\n```typescript\nimport { Store } from \"flurryx\";\n\ninterface ChatStoreConfig {\n  SESSIONS: ChatSession[];\n  CURRENT_SESSION: ChatSession;\n  MESSAGES: ChatMessage[];\n}\n\nexport const ChatStore = Store.for\u003cChatStoreConfig\u003e().build();\n```\n\nThe generic argument is type-only — there is no runtime enum or config object. Under the hood, the store lazily creates signals on first access, so un-accessed keys have zero overhead.\n\nType safety is fully enforced:\n\n```typescript\nconst store = inject(ChatStore);\n\nstore.get(\"SESSIONS\"); // Signal\u003cResourceState\u003cChatSession[]\u003e\u003e\nstore.update(\"SESSIONS\", { data: [session] }); // ✅ type-checked\nstore.update(\"SESSIONS\", { data: 42 }); // ❌ TS error — number is not ChatSession[]\nstore.get(\"INVALID\"); // ❌ TS error — key does not exist\n```\n\n#### Fluent chaining: `Store.resource().as\u003cT\u003e().build()`\n\nDefine slots inline without a separate interface:\n\n```typescript\nexport const ChatStore = Store.resource(\"SESSIONS\")\n  .as\u003cChatSession[]\u003e()\n  .resource(\"CURRENT_SESSION\")\n  .as\u003cChatSession\u003e()\n  .resource(\"MESSAGES\")\n  .as\u003cChatMessage[]\u003e()\n  .build();\n```\n\n#### Enum-constrained: `Store.for(enum).resource().as\u003cT\u003e().build()`\n\nWhen you have a runtime enum (e.g. shared with backend code), pass it to `.for()` to ensure every key is accounted for:\n\n```typescript\nconst ChatStoreEnum = {\n  SESSIONS: \"SESSIONS\",\n  CURRENT_SESSION: \"CURRENT_SESSION\",\n  MESSAGES: \"MESSAGES\",\n} as const;\n\nexport const ChatStore = Store.for(ChatStoreEnum)\n  .resource(\"SESSIONS\")\n  .as\u003cChatSession[]\u003e()\n  .resource(\"CURRENT_SESSION\")\n  .as\u003cChatSession\u003e()\n  .resource(\"MESSAGES\")\n  .as\u003cChatMessage[]\u003e()\n  .build();\n```\n\nThe builder only allows keys from the enum, and `.build()` is only available once all keys have been defined.\n\n### syncToStore\n\nRxJS pipeable operator that bridges an `Observable` to a store slot.\n\n```typescript\nthis.http\n  .get\u003cProduct[]\u003e(\"/api/products\")\n  .pipe(syncToStore(this.store, \"LIST\"))\n  .subscribe();\n```\n\n**What it does:**\n\n- On `next` — writes `{ data, isLoading: false, status: 'Success', errors: undefined }`\n- On `error` — writes `{ data: undefined, isLoading: false, status: 'Error', errors: [...] }`\n- Completes after first emission by default (`take(1)`)\n\n**Options:**\n\n```typescript\nsyncToStore(store, key, {\n  completeOnFirstEmission: true, // default: true — applies take(1)\n  callbackAfterComplete: () =\u003e {}, // runs in finalize()\n  errorNormalizer: myNormalizer, // default: defaultErrorNormalizer\n});\n```\n\n### syncToKeyedStore\n\nSame pattern, but targets a specific resource key within a `KeyedResourceData` slot:\n\n```typescript\nthis.http\n  .get\u003cInvoice\u003e(`/api/invoices/${id}`)\n  .pipe(syncToKeyedStore(this.store, \"ITEMS\", id))\n  .subscribe();\n```\n\nOnly the targeted resource key is updated. Other keys in the same slot are untouched.\n\n**`mapResponse`** — transform the API response before writing to the store:\n\n```typescript\nsyncToKeyedStore(this.store, \"ITEMS\", id, {\n  mapResponse: (response) =\u003e response.data,\n});\n```\n\n### @SkipIfCached\n\nMethod decorator that skips execution when the store already has valid data.\n\n```typescript\n@SkipIfCached('LIST', (i) =\u003e i.store)\nloadProducts() { /* only runs when cache is stale */ }\n```\n\n**Cache hit** (method skipped) when:\n\n- `status === 'Success'` or `isLoading === true`\n- Timeout has not expired (default: 5 minutes)\n- Method arguments match (compared via `JSON.stringify`)\n\n**Cache miss** (method executes) when:\n\n- Initial state (no status, not loading)\n- `status === 'Error'` (errors are never cached)\n- Timeout expired\n- Arguments changed\n\n**Parameters:**\n\n```typescript\n@SkipIfCached(\n  'LIST',                       // which store slot to check\n  (instance) =\u003e instance.store, // how to get the store from `this`\n  returnObservable?,            // false (default): void methods; true: returns Observable\n  timeoutMs?                    // default: 300_000 (5 min). Use CACHE_NO_TIMEOUT for infinite\n)\n```\n\n**Observable mode** (`returnObservable: true`):\n\n- Cache hit returns `of(cachedData)` or coalesces onto the in-flight `Observable` via `shareReplay`\n- Cache miss executes the method and wraps the result with inflight tracking\n\n**Keyed resources**: When the first argument is a `string | number` and the store data is a `KeyedResourceData`, cache entries are tracked per resource key automatically.\n\n### @Loading\n\nMethod decorator that calls `store.startLoading(key)` before the original method executes.\n\n```typescript\n@Loading('LIST', (i) =\u003e i.store)\nloadProducts() { /* store.isLoading is already true when this runs */ }\n```\n\n**Keyed detection**: If the first argument is a `string | number` and the store has `startKeyedLoading`, it calls that instead for per-key loading state.\n\n**Compose both decorators** for the common pattern:\n\n```typescript\n@SkipIfCached('LIST', (i) =\u003e i.store)\n@Loading('LIST', (i) =\u003e i.store)\nloadProducts() {\n  this.http.get('/api/products')\n    .pipe(syncToStore(this.store, 'LIST'))\n    .subscribe();\n}\n```\n\nOrder matters: `@SkipIfCached` is outermost so it can short-circuit before `@Loading` sets the loading flag.\n\n### Error Normalization\n\nOperators accept a pluggable `errorNormalizer` instead of coupling to Angular's `HttpErrorResponse`:\n\n```typescript\ntype ErrorNormalizer = (error: unknown) =\u003e ResourceErrors;\n```\n\n**`defaultErrorNormalizer`** (used by default) handles:\n\n1. `{ error: { errors: [...] } }` — extracts the nested array\n2. `{ status: number, message: string }` — wraps into `[{ code, message }]`\n3. `Error` instances — wraps `error.message`\n4. Anything else — `[{ code: 'UNKNOWN', message: String(error) }]`\n\n**`httpErrorNormalizer`** — for Angular's `HttpErrorResponse`, available from a separate entry point to keep `@angular/common/http` out of your bundle unless you need it:\n\n```typescript\nimport { httpErrorNormalizer } from \"flurryx/http\";\n\nthis.http\n  .get(\"/api/data\")\n  .pipe(\n    syncToStore(this.store, \"DATA\", {\n      errorNormalizer: httpErrorNormalizer,\n    }),\n  )\n  .subscribe();\n```\n\n**Custom normalizer** — implement your own for any backend error shape:\n\n```typescript\nconst myNormalizer: ErrorNormalizer = (error) =\u003e {\n  const typed = error as MyBackendError;\n  return typed.details.map((d) =\u003e ({\n    code: d.errorCode,\n    message: d.userMessage,\n  }));\n};\n```\n\n### Constants\n\n```typescript\nimport { CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS } from \"flurryx\";\n\nCACHE_NO_TIMEOUT; // Infinity — cache never expires\nDEFAULT_CACHE_TTL_MS; // 300_000 (5 minutes)\n```\n\n---\n\n## Keyed Resources\n\nFor data indexed by ID (user profiles, invoices, config entries), use `KeyedResourceData`:\n\n```typescript\ninterface KeyedResourceData\u003cTKey extends string | number, TValue\u003e {\n  entities: Partial\u003cRecord\u003cTKey, TValue\u003e\u003e;\n  isLoading: Partial\u003cRecord\u003cTKey, boolean\u003e\u003e;\n  status: Partial\u003cRecord\u003cTKey, ResourceStatus\u003e\u003e;\n  errors: Partial\u003cRecord\u003cTKey, ResourceErrors\u003e\u003e;\n}\n```\n\nEach resource key gets **independent** loading, status, and error tracking. The top-level `ResourceState.isLoading` reflects whether _any_ key is loading.\n\n**Full example:**\n\n```typescript\n// Store\nimport { Store } from \"flurryx\";\nimport type { KeyedResourceData } from \"flurryx\";\n\nexport const InvoiceStore = Store.resource(\"ITEMS\")\n  .as\u003cKeyedResourceData\u003cstring, Invoice\u003e\u003e()\n  .build();\n\n// Facade\n@Injectable({ providedIn: \"root\" })\nexport class InvoiceFacade {\n  private readonly http = inject(HttpClient);\n  readonly store = inject(InvoiceStore);\n  readonly items = this.store.get(\"ITEMS\");\n\n  @SkipIfCached(\"ITEMS\", (i: InvoiceFacade) =\u003e i.store)\n  @Loading(\"ITEMS\", (i: InvoiceFacade) =\u003e i.store)\n  loadInvoice(id: string) {\n    this.http\n      .get\u003cInvoice\u003e(`/api/invoices/${id}`)\n      .pipe(syncToKeyedStore(this.store, \"ITEMS\", id))\n      .subscribe();\n  }\n}\n\n// Component\nconst data = this.facade.items().data; // KeyedResourceData\nconst invoice = data?.entities[\"inv-123\"]; // Invoice | undefined\nconst loading = data?.isLoading[\"inv-123\"]; // boolean | undefined\nconst errors = data?.errors[\"inv-123\"]; // ResourceErrors | undefined\n```\n\n**Utilities:**\n\n```typescript\nimport {\n  createKeyedResourceData, // factory — returns empty { entities: {}, isLoading: {}, ... }\n  isKeyedResourceData, // type guard\n  isAnyKeyLoading, // (loading: Record) =\u003e boolean\n} from \"flurryx\";\n```\n\n---\n\n## Clearing Store Data\n\nflurryx provides two levels of cache invalidation: **whole-slot clearing** and **per-key clearing** for keyed resources.\n\n### Whole-slot clearing\n\nReset an entire store slot back to its initial empty state:\n\n```typescript\nconst store = inject(ProductStore);\n\n// Clear a single slot\nstore.clear(\"LIST\");\n// LIST is now { data: undefined, isLoading: false, status: undefined, errors: undefined }\n\n// Clear every slot in the store\nstore.clearAll();\n\n// Clear every tracked store instance in the app\nclearAllStores();\n```\n\nThis is the right choice when the slot holds a single value (e.g. `Product`, `User[]`).\n\nFor app-wide cache resets such as logout or tenant switching, use the global helper:\n\n```typescript\nimport { clearAllStores } from \"flurryx\";\n\nlogout() {\n  clearAllStores();\n}\n```\n\n### Per-key clearing for keyed resources\n\nWhen a slot holds a `KeyedResourceData` (a map of entities indexed by ID), `clear('ITEMS')` wipes **every** cached entity. If you only need to invalidate one entry — for example after a delete or an edit — use `clearKeyedOne`:\n\n```typescript\nconst store = inject(InvoiceStore);\n\n// Remove only invoice \"inv-42\" from the cache.\n// All other cached invoices remain untouched.\nstore.clearKeyedOne(\"ITEMS\", \"inv-42\");\n```\n\n`clearKeyedOne` removes the entity, its loading flag, status, and errors for that single key, then recalculates the top-level `isLoading` based on the remaining keys.\n\n**Facade example — delete an invoice and evict it from cache:**\n\n```typescript\n@Injectable({ providedIn: \"root\" })\nexport class InvoiceFacade {\n  private readonly http = inject(HttpClient);\n  readonly store = inject(InvoiceStore);\n\n  deleteInvoice(id: string) {\n    this.http.delete(`/api/invoices/${id}`).subscribe(() =\u003e {\n      // Remove only this invoice from the keyed cache\n      this.store.clearKeyedOne(\"ITEMS\", id);\n    });\n  }\n}\n```\n\n**Comparison:**\n\n| Method                            | Scope                         | Use when                                    |\n| --------------------------------- | ----------------------------- | ------------------------------------------- |\n| `clear(key)`                      | Entire slot                   | Logging out, resetting a form, full refresh |\n| `clearAll()`                      | Every slot in one store       | Reset one feature store                     |\n| `clearAllStores()`                | Every tracked store instance  | Logout, tenant switch, full app cache reset |\n| `clearKeyedOne(key, resourceKey)` | Single entity in a keyed slot | Deleting or invalidating one cached item    |\n\n---\n\n## Message Queueing and History\n\nEvery store mutation in flurryx is a **typed message** published to an internal broker channel. The broker is not a traditional async message queue — consumption is **synchronous** within the same JavaScript call stack. This means there are no race conditions, no ordering ambiguity, and no worker threads. The channel acts as a **transactional log** that enables message introspection, replay, undo/redo, and dead-letter recovery.\n\n### Message Lifecycle\n\n```\nstore.update('CUSTOMERS', { data: [...], status: 'Success' })\n  │\n  ▼\n┌─────────────────────────────┐\n│  Create typed StoreMessage  │   { type: 'update', key: 'CUSTOMERS', state: { ... } }\n└──────────────┬──────────────┘\n               ▼\n┌─────────────────────────────┐\n│  Publish to channel         │   Assigns stable numeric id, status = 'pending'\n└──────────────┬──────────────┘\n               ▼\n┌─────────────────────────────┐\n│  Consume (apply to signal)  │   Angular Signal updated, onUpdate hooks fired\n└──────────┬──────────┬───────┘\n           │          │\n       success      failure\n           │          │\n           ▼          ▼\n    ┌────────────┐  ┌──────────────┐\n    │ Acknowledged│  │ Dead-letter  │   Tracked with error + attempt count\n    └──────┬─────┘  └──────────────┘\n           ▼\n    ┌────────────┐\n    │  Snapshot   │   Full store state captured for history\n    └────────────┘\n```\n\nAll of this happens **synchronously** in a single call. When `store.update()` returns, the message is already acknowledged, the signal is updated, and the snapshot is recorded.\n\n### Message Types\n\nEvery store method produces one of these typed messages:\n\n| Message type        | Produced by                       | Payload                        |\n| ------------------- | --------------------------------- | ------------------------------ |\n| `update`            | `update(key, partial)`            | `key`, `state` (partial merge) |\n| `clear`             | `clear(key)`                      | `key`                          |\n| `clearAll`          | `clearAll()`                      | _(none — affects all slots)_   |\n| `startLoading`      | `startLoading(key)`               | `key`                          |\n| `stopLoading`       | `stopLoading(key)`                | `key`                          |\n| `updateKeyedOne`    | `updateKeyedOne(key, rk, entity)` | `key`, `resourceKey`, `entity` |\n| `clearKeyedOne`     | `clearKeyedOne(key, rk)`          | `key`, `resourceKey`           |\n| `startKeyedLoading` | `startKeyedLoading(key, rk)`      | `key`, `resourceKey`           |\n\nMessages are immutable and deep-cloned on publish to prevent external mutation.\n\n### History and Time Travel\n\nAfter every acknowledged message, flurryx captures a **full snapshot** of the store. The first entry (index `0`) is always the initial state captured when the store was created.\n\n```typescript\nconst store = inject(ProductStore);\n\n// Inspect the full history\nconst history = store.getHistory();\n// [\n//   { index: 0, id: null,  message: null, snapshot: { LIST: {...}, DETAIL: {...} } },\n//   { index: 1, id: 1,     message: { type: 'startLoading', key: 'LIST' }, snapshot: {...} },\n//   { index: 2, id: 2,     message: { type: 'update', key: 'LIST', ... }, snapshot: {...} },\n// ]\n\n// Filter history for a specific key\nconst listHistory = store.getHistory(\"LIST\");\n\n// Check current position\nconst currentIndex = store.getCurrentIndex(); // 2\n\n// Jump to any recorded snapshot\nstore.restoreStoreAt(0); // restore initial state\nstore.restoreStoreAt(2); // jump back to latest\n\n// Restore a single key without affecting others\nstore.restoreResource(\"LIST\", 0); // restore only LIST to its state at snapshot 0\nstore.restoreResource(\"LIST\"); // restore LIST to its state at the current index\n\n// Step-by-step navigation\nstore.undo(); // move to previous snapshot — returns false if already at index 0\nstore.redo(); // move to next snapshot — returns false if already at latest\n```\n\n`restoreStoreAt`, `undo`, and `redo` restore snapshots only — they do **not** re-execute messages or create new history entries.\n\n`restoreResource(key, index?)` restores a **single key** from a snapshot without affecting other keys. This is useful when viewing history filtered by key — you can restore `TASKS` to a previous state without losing `SELECTED_PROJECT`. Like `restoreStoreAt`, it does not create new history entries.\n\n### Message Replay\n\nUnlike time travel, **replay re-executes messages** through the full broker/consumer path. This creates new acknowledged history entries and can truncate future history if called after time travel.\n\n```typescript\n// Inspect all persisted messages\nconst messages = store.getMessages();\n// [\n//   { id: 1, message: {...}, status: 'acknowledged', attempts: 1, ... },\n//   { id: 2, message: {...}, status: 'acknowledged', attempts: 1, ... },\n// ]\n\n// Filter messages for a specific key\nconst listMessages = store.getMessages(\"LIST\");\n\n// Re-execute a single message by its stable id\nstore.replay(1); // returns count of acknowledged messages (0 or 1)\n\n// Re-execute multiple messages in the provided order\nstore.replay([1, 2, 3]); // returns count of acknowledged messages\n```\n\n**When to use replay vs. time travel:**\n\n|                        | `restoreStoreAt` / `undo` / `redo`  | `restoreResource`                        | `replay`                                     |\n| ---------------------- | ----------------------------------- | ---------------------------------------- | -------------------------------------------- |\n| Mechanism              | Restores full snapshot              | Restores single key from snapshot        | Re-executes message(s) through broker        |\n| Affects other keys     | Yes — entire store                  | No — only specified key                  | Depends on message                           |\n| Creates new history    | No                                  | No                                       | Yes                                          |\n| Fires `onUpdate` hooks | Yes                                 | Yes (for restored key only)              | Yes                                          |\n| Use case               | Inspecting past state, undo/redo UX | Key-scoped time travel, devtools history | Deterministic state reconstruction, recovery |\n\n### Dead-Letter Recovery\n\nWhen a message fails broker acknowledgement, it is moved to the **dead-letter queue** instead of crashing the application. Dead letters track the error message and attempt count.\n\n```typescript\n// Inspect failed messages\nconst deadLetters = store.getDeadLetters();\n// [\n//   { id: 3, message: {...}, attempts: 1, error: 'Message was not acknowledged', failedAt: 1712... },\n// ]\n\n// Retry a single dead letter by its id\nconst recovered = store.replayDeadLetter(3); // true if acknowledged, false if failed again\n\n// Retry all dead letters at once\nconst count = store.replayDeadLetters(); // returns number of newly acknowledged messages\n```\n\nSuccessfully replayed dead letters are removed from the queue and produce new history entries. Failures remain with incremented attempt counts.\n\n---\n\n## Message Channels\n\nThe message broker persists messages through a pluggable **channel** interface. The channel controls where messages are stored, how they are serialized, and how they survive (or don't survive) page refreshes.\n\n### Channel Types\n\n**In-memory** (default) — messages live in a JavaScript array. Fast, zero serialization overhead, but lost on page refresh.\n\n```typescript\nimport { Store } from \"flurryx\";\n\n// Default — no configuration needed\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build();\n```\n\n**localStorage** — messages survive page refreshes and browser restarts. Same-origin only.\n\n```typescript\nimport { Store, createLocalStorageStoreMessageChannel } from \"flurryx\";\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build({\n  channel: createLocalStorageStoreMessageChannel({\n    storageKey: \"product-store\",\n  }),\n});\n```\n\n**sessionStorage** — messages survive page refreshes but are lost when the tab closes.\n\n```typescript\nimport { Store, createSessionStorageStoreMessageChannel } from \"flurryx\";\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build({\n  channel: createSessionStorageStoreMessageChannel({\n    storageKey: \"product-store-session\",\n  }),\n});\n```\n\n**Composite** — fan-out to multiple channels. The first channel is the primary (handles reads and id allocation); all channels receive writes.\n\n```typescript\nimport {\n  Store,\n  createCompositeStoreMessageChannel,\n  createInMemoryStoreMessageChannel,\n  createLocalStorageStoreMessageChannel,\n} from \"flurryx\";\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build({\n  channel: createCompositeStoreMessageChannel({\n    channels: [\n      createInMemoryStoreMessageChannel(), // primary — fast reads\n      createLocalStorageStoreMessageChannel({\n        // replica — persistent backup\n        storageKey: \"product-store-backup\",\n      }),\n    ],\n  }),\n});\n```\n\n**Custom storage adapter** — bring your own `{ getItem, setItem, removeItem }` implementation (e.g. IndexedDB, a remote API, or an encrypted store).\n\n```typescript\nimport { Store, createStorageStoreMessageChannel } from \"flurryx\";\n\nexport const ProductStore = Store.for\u003cProductStoreConfig\u003e().build({\n  channel: createStorageStoreMessageChannel({\n    storage: myCustomAdapter,\n    storageKey: \"product-store\",\n  }),\n});\n```\n\n### Built-in Serialization\n\nStorage-backed channels automatically serialize and deserialize rich JavaScript types that `JSON.stringify` would lose:\n\n| Type        | Serialized as                                            |\n| ----------- | -------------------------------------------------------- |\n| `Date`      | `{ __flurryxType: 'date', value: '\u003cISO string\u003e' }`       |\n| `Map`       | `{ __flurryxType: 'map', entries: [[key, value], ...] }` |\n| `Set`       | `{ __flurryxType: 'set', values: [...] }`                |\n| `undefined` | `{ __flurryxType: 'undefined' }`                         |\n| Primitives  | Pass through unchanged                                   |\n\nThis means your store state can contain `Date` objects, `Map`s, and `Set`s and they will round-trip correctly through `localStorage` or `sessionStorage` without manual conversion.\n\nYou can override serialization with custom `serialize` / `deserialize` hooks:\n\n```typescript\ncreateLocalStorageStoreMessageChannel({\n  storageKey: \"product-store\",\n  serialize: (state) =\u003e JSON.stringify(state),\n  deserialize: (json) =\u003e JSON.parse(json),\n});\n```\n\nThe `cloneValue` utility used internally is also exported for your own deep-clone needs:\n\n```typescript\nimport { cloneValue } from \"flurryx\";\n\nconst copy = cloneValue(original); // handles Date, Map, Set, circular refs\n```\n\n---\n\n## Store Mirroring\n\nWhen building session or aggregation stores that combine state from multiple feature stores, you typically need `onUpdate` listeners, cleanup arrays, and `DestroyRef` wiring. The `mirrorKey` and `collectKeyed` utilities reduce that to a single call.\n\n```\n+--------------------+                    +--------------------+\n| Feature Store A    |                    |                    |\n| (CUSTOMERS)        |-- mirrorKey ------\u003e|                    |\n+--------------------+                    |                    |\n                                          |  Session Store     |\n+--------------------+                    |  (aggregated)      |\n| Feature Store B    |                    |                    |\n| (ORDERS)           |-- mirrorKey ------\u003e|  CUSTOMERS      +  |\n+--------------------+                    |  ORDERS         +  |\n                                          |  CUSTOMER_CACHE +  |\n+--------------------+                    |  ORDER_CACHE    +  |\n| Feature Store C    |                    |                    |\n| (CUSTOMER_DETAIL)  |-- collectKeyed ---\u003e|                    |\n+--------------------+                    |                    |\n                                          |                    |\n+--------------------+                    |                    |\n| Feature Store D    |                    |                    |\n| (ORDER_DETAIL)     |-- mirrorKeyed ---\u003e|                    |\n+--------------------+                    +--------------------+\n```\n\n```typescript\nimport { Store, mirrorKey, collectKeyed } from \"flurryx\";\n```\n\n### Builder .mirror()\n\nThe simplest way to set up mirroring is directly in the store builder. Chain `.mirror()` to declare which source stores to mirror from — the wiring happens automatically when Angular creates the store.\n\n```typescript\n// Feature stores\ninterface CustomerStoreConfig {\n  CUSTOMERS: Customer[];\n}\nexport const CustomerStore = Store.for\u003cCustomerStoreConfig\u003e().build();\n\ninterface OrderStoreConfig {\n  ORDERS: Order[];\n}\nexport const OrderStore = Store.for\u003cOrderStoreConfig\u003e().build();\n```\n\n**Interface-based builder** (recommended):\n\n```typescript\ninterface SessionStoreConfig {\n  CUSTOMERS: Customer[];\n  ORDERS: Order[];\n}\n\nexport const SessionStore = Store.for\u003cSessionStoreConfig\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirror(OrderStore, \"ORDERS\")\n  .build();\n```\n\n**Fluent chaining:**\n\n```typescript\nexport const SessionStore = Store.resource(\"CUSTOMERS\")\n  .as\u003cCustomer[]\u003e()\n  .resource(\"ORDERS\")\n  .as\u003cOrder[]\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirror(OrderStore, \"ORDERS\")\n  .build();\n```\n\n**Enum-constrained:**\n\n```typescript\nconst SessionEnum = { CUSTOMERS: \"CUSTOMERS\", ORDERS: \"ORDERS\" } as const;\n\nexport const SessionStore = Store.for(SessionEnum)\n  .resource(\"CUSTOMERS\")\n  .as\u003cCustomer[]\u003e()\n  .resource(\"ORDERS\")\n  .as\u003cOrder[]\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirror(OrderStore, \"ORDERS\")\n  .build();\n```\n\n**Different source and target keys:**\n\n```typescript\nexport const SessionStore = Store.for\u003c{ ARTICLES: Item[] }\u003e()\n  .mirror(ItemStore, \"ITEMS\", \"ARTICLES\")\n  .build();\n```\n\nThe builder calls `inject()` under the hood, so source stores are resolved through Angular's DI. Everything — data, loading, status, errors — is mirrored automatically. No manual cleanup needed; the mirrors live as long as the store.\n\n### Builder .mirrorSelf()\n\nUse `.mirrorSelf()` when one slot in a store should mirror another slot in the same store. It is useful for aliases, local snapshots, or secondary slots that should stay in sync with a primary slot without wiring `onUpdate` manually.\n\n```typescript\ninterface SessionStoreConfig {\n  CUSTOMER_DETAILS: Customer;\n  CUSTOMER_SNAPSHOT: Customer;\n}\n\nexport const SessionStore = Store.for\u003cSessionStoreConfig\u003e()\n  .mirrorSelf(\"CUSTOMER_DETAILS\", \"CUSTOMER_SNAPSHOT\")\n  .build();\n```\n\nIt mirrors the full resource state one way — `data`, `isLoading`, `status`, and `errors` all flow from the source key to the target key. The target key must be different from the source key.\n\nBecause it listens to updates on the built store itself, `.mirrorSelf()` also reacts when the source key is updated by another mirror:\n\n```typescript\ninterface CustomerStoreConfig {\n  CUSTOMERS: Customer[];\n}\n\ninterface SessionStoreConfig {\n  CUSTOMERS: Customer[];\n  CUSTOMER_COPY: Customer[];\n}\n\nexport const CustomerStore = Store.for\u003cCustomerStoreConfig\u003e().build();\n\nexport const SessionStore = Store.for\u003cSessionStoreConfig\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirrorSelf(\"CUSTOMERS\", \"CUSTOMER_COPY\")\n  .build();\n```\n\n`.mirrorSelf()` is available on all builder styles. For fluent builders, declare both slots first, then chain `.mirrorSelf(sourceKey, targetKey)` before `.build()`.\n\n### Builder .mirrorKeyed()\n\nWhen the source store holds a single-entity slot (e.g. `CUSTOMER_DETAILS: Customer`) and you want to accumulate those fetches into a `KeyedResourceData` cache on the target, use `.mirrorKeyed()`. It is the builder equivalent of [`collectKeyed`](#collectkeyed).\n\n```typescript\n// Feature store — fetches one customer at a time\ninterface CustomerStoreConfig {\n  CUSTOMERS: Customer[];\n  CUSTOMER_DETAILS: Customer;\n}\nexport const CustomerStore = Store.for\u003cCustomerStoreConfig\u003e().build();\n```\n\n**Interface-based builder** (recommended):\n\n```typescript\ninterface SessionStoreConfig {\n  CUSTOMERS: Customer[];\n  CUSTOMER_CACHE: KeyedResourceData\u003cstring, Customer\u003e;\n}\n\nexport const SessionStore = Store.for\u003cSessionStoreConfig\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirrorKeyed(\n    CustomerStore,\n    \"CUSTOMER_DETAILS\",\n    {\n      extractId: (data) =\u003e data?.id,\n    },\n    \"CUSTOMER_CACHE\",\n  )\n  .build();\n```\n\n**Fluent chaining:**\n\n```typescript\nexport const SessionStore = Store.resource(\"CUSTOMERS\")\n  .as\u003cCustomer[]\u003e()\n  .resource(\"CUSTOMER_CACHE\")\n  .as\u003cKeyedResourceData\u003cstring, Customer\u003e\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirrorKeyed(\n    CustomerStore,\n    \"CUSTOMER_DETAILS\",\n    {\n      extractId: (data) =\u003e data?.id,\n    },\n    \"CUSTOMER_CACHE\",\n  )\n  .build();\n```\n\n**Enum-constrained:**\n\n```typescript\nconst SessionEnum = {\n  CUSTOMERS: \"CUSTOMERS\",\n  CUSTOMER_CACHE: \"CUSTOMER_CACHE\",\n} as const;\n\nexport const SessionStore = Store.for(SessionEnum)\n  .resource(\"CUSTOMERS\")\n  .as\u003cCustomer[]\u003e()\n  .resource(\"CUSTOMER_CACHE\")\n  .as\u003cKeyedResourceData\u003cstring, Customer\u003e\u003e()\n  .mirror(CustomerStore, \"CUSTOMERS\")\n  .mirrorKeyed(\n    CustomerStore,\n    \"CUSTOMER_DETAILS\",\n    {\n      extractId: (data) =\u003e data?.id,\n    },\n    \"CUSTOMER_CACHE\",\n  )\n  .build();\n```\n\n**Same source and target key** — when the key names match, the last argument can be omitted:\n\n```typescript\nexport const SessionStore = Store.for\u003c{\n  CUSTOMER_DETAILS: KeyedResourceData\u003cstring, Customer\u003e;\n}\u003e()\n  .mirrorKeyed(CustomerStore, \"CUSTOMER_DETAILS\", {\n    extractId: (data) =\u003e data?.id,\n  })\n  .build();\n```\n\nEach entity fetched through the source slot is accumulated by ID into the target's `KeyedResourceData`. Loading, status, and errors are tracked per entity. When the source is cleared, the corresponding entity is removed from the cache.\n\n### mirrorKey\n\nMirrors a resource key from one store to another. When the source updates, the target is updated with the same state.\n\n```\n+------------------+--------------------------------+------------------+\n| CustomerStore    |          mirrorKey             | SessionStore     |\n|                  |                                |                  |\n| CUSTOMERS -------|--- onUpdate --\u003e update -------\u003e| CUSTOMERS        |\n|                  |   (same key or different)      |                  |\n| { data,          |                                | { data,          |\n|   status,        |                                |   status,        |\n|   isLoading }    |                                |   isLoading }    |\n+------------------+--------------------------------+------------------+\n\nsource.update('CUSTOMERS', { data: [...], status: 'Success' })\n     |\n     '--\u003e target is automatically updated with the same state\n```\n\nYou wire it once. Every future update — data, loading, errors — flows automatically. Call the cleanup function or use `destroyRef` to stop.\n\n```typescript\n// Same key on both stores (default)\nmirrorKey(customersStore, \"CUSTOMERS\", sessionStore);\n\n// Different keys\nmirrorKey(customersStore, \"ITEMS\", sessionStore, \"ARTICLES\");\n\n// Manual cleanup\nconst cleanup = mirrorKey(customersStore, \"CUSTOMERS\", sessionStore);\ncleanup(); // stop mirroring\n\n// Auto-cleanup with Angular DestroyRef\nmirrorKey(customersStore, \"CUSTOMERS\", sessionStore, { destroyRef });\nmirrorKey(customersStore, \"ITEMS\", sessionStore, \"ARTICLES\", { destroyRef });\n```\n\n**Full example — session store that aggregates feature stores:**\n\nFor simple aggregation, prefer the [builder `.mirror()` approach](#builder-mirror). Use `mirrorKey` when you need imperative control — e.g. conditional mirroring, late setup, or `DestroyRef`-based cleanup:\n\n```typescript\n@Injectable({ providedIn: \"root\" })\nexport class SessionStore {\n  private readonly customerStore = inject(CustomerStore);\n  private readonly orderStore = inject(OrderStore);\n  private readonly store = inject(Store.for\u003cSessionStoreConfig\u003e().build());\n  private readonly destroyRef = inject(DestroyRef);\n\n  readonly customers = this.store.get(\"CUSTOMERS\");\n  readonly orders = this.store.get(\"ORDERS\");\n\n  constructor() {\n    mirrorKey(this.customerStore, \"CUSTOMERS\", this.store, {\n      destroyRef: this.destroyRef,\n    });\n    mirrorKey(this.orderStore, \"ORDERS\", this.store, {\n      destroyRef: this.destroyRef,\n    });\n  }\n}\n```\n\nEverything — loading flags, data, status, errors — is mirrored automatically. No manual `onUpdate` + cleanup boilerplate.\n\n### collectKeyed\n\nAccumulates single-entity fetches into a `KeyedResourceData` cache on a target store. Each time the source emits a successful entity, it is merged into the target's keyed map by a user-provided `extractId` function.\n\n```\n+--------------------+-----------------+--------------------------+\n| CustomerStore      |  collectKeyed   | SessionStore             |\n|                    |                 |                          |\n| CUSTOMER_DETAILS   | extractId(data) | CUSTOMER_CACHE           |\n| (one at a time)    | finds the key   | (KeyedResourceData)      |\n+--------+-----------+-----------------+                          |\n         |                             | entities:                |\n         |  fetch(\"c1\") -\u003e Success     |   c1: { id, name }       |\n         |  fetch(\"c2\") -\u003e Success     |   c2: { id, name }       |\n         |  fetch(\"c3\") -\u003e Error       |                          |\n         |                             | isLoading:               |\n         |  clear() -\u003e removes last    |   c1: false              |\n         |              entity         |   c2: false              |\n         |                             |                          |\n         '---- accumulates -----------\u003e| status:                  |\n                                       |   c1: 'Success'          |\n                                       |   c2: 'Success'          |\n                                       |   c3: 'Error'            |\n                                       |                          |\n                                       | errors:                  |\n                                       |   c3: [{ code, msg }]    |\n                                       +--------------------------+\n```\n\nEach entity is tracked independently — its own loading flag, status, and errors. The source store fetches one entity at a time; `collectKeyed` builds up the full cache on the target.\n\n```typescript\n// Same key on both stores\ncollectKeyed(customerStore, \"CUSTOMER_DETAILS\", sessionStore, {\n  extractId: (data) =\u003e data?.id,\n  destroyRef,\n});\n\n// Different keys\ncollectKeyed(\n  customerStore,\n  \"CUSTOMER_DETAILS\",\n  sessionStore,\n  \"CUSTOMER_CACHE\",\n  {\n    extractId: (data) =\u003e data?.id,\n    destroyRef,\n  },\n);\n```\n\n**What it does on each source update:**\n\n| Source state                         | Action                                 |\n| ------------------------------------ | -------------------------------------- |\n| `status: 'Success'` + valid ID       | Merges entity into target's keyed data |\n| `status: 'Error'` + valid ID         | Records per-key error and status       |\n| `isLoading: true` + valid ID         | Sets per-key loading flag              |\n| Data cleared (e.g. `source.clear()`) | Removes previous entity from target    |\n\n**Full example — collect individual customer lookups into a cache:**\n\n```typescript\n// Feature store — fetches one customer at a time\ninterface CustomerStoreConfig {\n  CUSTOMER_DETAILS: Customer;\n}\nexport const CustomerStore = Store.for\u003cCustomerStoreConfig\u003e().build();\n\n// Session store — accumulates all fetched customers\ninterface SessionStoreConfig {\n  CUSTOMER_CACHE: KeyedResourceData\u003cstring, Customer\u003e;\n}\n\n@Injectable({ providedIn: \"root\" })\nexport class SessionStore {\n  private readonly customerStore = inject(CustomerStore);\n  private readonly store = inject(Store.for\u003cSessionStoreConfig\u003e().build());\n  private readonly destroyRef = inject(DestroyRef);\n\n  readonly customerCache = this.store.get(\"CUSTOMER_CACHE\");\n\n  constructor() {\n    collectKeyed(\n      this.customerStore,\n      \"CUSTOMER_DETAILS\",\n      this.store,\n      \"CUSTOMER_CACHE\",\n      {\n        extractId: (data) =\u003e data?.id,\n        destroyRef: this.destroyRef,\n      },\n    );\n  }\n\n  // After loading customers \"c1\" and \"c2\", the cache contains:\n  // {\n  //   entities: { c1: Customer, c2: Customer },\n  //   isLoading: { c1: false, c2: false },\n  //   status: { c1: 'Success', c2: 'Success' },\n  //   errors: {}\n  // }\n}\n```\n\n---\n\n## AI Coding\n\nflurryx ships a [`skills/flurryx/SKILL.md`](skills/flurryx/SKILL.md) file that teaches AI coding assistants the library's patterns and conventions. When loaded through a skill-aware harness, it helps generated stores, facades, services, and decorators follow flurryx conventions from the start.\n\n### Set Up\n\nFor harnesses that support skill loading, install the skill using this directory layout:\n\n```text\nskills/\n  flurryx/\n    SKILL.md\n```\n\n- The harness should load the skill from `skills/flurryx/SKILL.md`\n- Do not copy the skill instructions into `AGENTS.md`, `.claude/CLAUDE.md`, or similar agent prompt files\n- Keep the skill as a dedicated loader entry so it remains reusable and versionable\n\nIf your tool is not skill-aware, you can still point it at `skills/flurryx/SKILL.md` as reference documentation.\n\n### Why Use the Skill Loader\n\n- Keeps flurryx guidance in one dedicated file\n- Avoids bloating generic agent instruction files\n- Makes the library conventions easy to install, update, and reuse across projects\n- Preserves the harness-native loading model instead of relying on ad hoc prompt wiring\n\n### What It Covers\n\n- Store definition (interface-based, fluent, enum-constrained)\n- Architecture-agnostic orchestration guidance\n- Facade and service-led patterns\n- `@SkipIfCached` usage rules and decorator ordering with `@Loading`\n- Component patterns (read signals, never subscribe manually)\n- Keyed resources for per-entity caching\n- Store mirroring (`mirror`, `mirrorSelf`, `mirrorKeyed`)\n- Message channels and persistence\n- Time travel, replay, and dead-letter recovery\n- Error normalization (default, HTTP, custom)\n- Anti-patterns to avoid (no `any`, avoid accidental caching, decorator ordering)\n\n---\n\n## Design Decisions\n\n**Why signals instead of BehaviorSubject?**\nAngular signals are synchronous, glitch-free, and template-native. They eliminate the need for `async` pipe, `shareReplay`, and manual unsubscription in components. RxJS stays in the service/facade layer where it belongs — for async operations.\n\n**Why not NgRx / NGXS / Elf?**\nThose are general-purpose state management libraries with actions, reducers, and effects. flurryx solves a narrower problem: the loading/data/error lifecycle of API calls. If your needs are \"fetch data, show loading, handle errors, cache results\", flurryx is the right size.\n\n**Why `Partial\u003cRecord\u003e` instead of `Map` for keyed data?**\nPlain objects work with Angular's change detection and signals out of the box. Maps require additional serialization. This also means zero migration friction.\n\n**Why `experimentalDecorators`?**\nThe decorators use TypeScript's legacy decorator syntax. TC39 decorator migration is planned for a future release.\n\n**Why a synchronous broker instead of an async message queue?**\nJavaScript is single-threaded. Every store mutation — publish, consume, acknowledge, snapshot — completes in one synchronous call stack. This eliminates race conditions, ordering ambiguity, and the need for locks or semaphores. The broker is a transactional log, not a deferred queue: you get replay, undo/redo, and dead-letter recovery without async complexity.\n\n**Why snapshot-based undo/redo instead of command replay?**\nReplaying every message from the beginning is O(n) in the number of past mutations. Snapshot restoration is O(1) — jump to any point in history by restoring a pre-captured state object. The trade-off is memory (one snapshot per acknowledged message), but in practice store state is small and snapshots are cheap.\n\n**Why pluggable message channels?**\nDifferent apps have different persistence needs. A dev tool wants in-memory history that disappears on refresh. A form-heavy app wants `localStorage` so users don't lose drafts. An audit-sensitive workflow might want a composite channel that fans out to both memory and a remote API. The channel interface (`publish`, `getMessage`, `getMessages`, `saveMessage`) is intentionally minimal so custom adapters are easy to build.\n\n**Why tsup instead of ng-packagr?**\nflurryx contains no Angular components, templates, or directives — just TypeScript that calls `signal()` at runtime. Angular Package Format (APF) adds complexity without benefit here. tsup produces ESM + CJS + `.d.ts` in milliseconds.\n\n---\n\n## Contributing\n\n```bash\ngit clone https://github.com/fmflurry/flurryx.git\ncd flurryx\nnpm install\nnpm run build\nnpm run test\n```\n\n| Command                 | What it does                                     |\n| ----------------------- | ------------------------------------------------ |\n| `npm run build`         | Builds all packages (ESM + CJS + .d.ts) via tsup |\n| `npm run test`          | Runs vitest across all packages                  |\n| `npm run test:coverage` | Tests with v8 coverage report                    |\n| `npm run typecheck`     | `tsc --noEmit` across all packages               |\n\nMonorepo managed with **npm workspaces**. Versioning with [changesets](https://github.com/changesets/changesets).\n\n---\n\n## License\n\n[MIT](LICENSE)\n","funding_links":[],"categories":["State Management"],"sub_categories":["Other State Libraries"],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffmflurry%2Fflurryx","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffmflurry%2Fflurryx","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffmflurry%2Fflurryx/lists"}