flurryx 0.7.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,10 @@ No `async` pipe. No `subscribe` in templates. No manual unsubscription.
65
65
  - [Error Normalization](#error-normalization)
66
66
  - [Constants](#constants)
67
67
  - [Keyed Resources](#keyed-resources)
68
+ - [Store Mirroring](#store-mirroring)
69
+ - [Builder .mirror()](#builder-mirror)
70
+ - [mirrorKey](#mirrorkey)
71
+ - [collectKeyed](#collectkeyed)
68
72
  - [Design Decisions](#design-decisions)
69
73
  - [Contributing](#contributing)
70
74
  - [License](#license)
@@ -592,6 +596,253 @@ import {
592
596
 
593
597
  ---
594
598
 
599
+ ## Store Mirroring
600
+
601
+ When 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.
602
+
603
+ ```
604
+ +--------------------+ +--------------------+
605
+ | Feature Store A | | |
606
+ | (CUSTOMERS) |-- mirrorKey ------>| |
607
+ +--------------------+ | |
608
+ | Session Store |
609
+ +--------------------+ | (aggregated) |
610
+ | Feature Store B | | |
611
+ | (ORDERS) |-- mirrorKey ------>| CUSTOMERS + |
612
+ +--------------------+ | ORDERS + |
613
+ | CUSTOMER_CACHE + |
614
+ +--------------------+ | |
615
+ | Feature Store C | | |
616
+ | (CUSTOMER_DETAIL) |-- collectKeyed --->| |
617
+ +--------------------+ +--------------------+
618
+ ```
619
+
620
+ ```typescript
621
+ import { Store, mirrorKey, collectKeyed } from "flurryx";
622
+ ```
623
+
624
+ ### Builder .mirror()
625
+
626
+ The 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.
627
+
628
+ ```typescript
629
+ // Feature stores
630
+ interface CustomerStoreConfig {
631
+ CUSTOMERS: Customer[];
632
+ }
633
+ export const CustomerStore = Store.for<CustomerStoreConfig>().build();
634
+
635
+ interface OrderStoreConfig {
636
+ ORDERS: Order[];
637
+ }
638
+ export const OrderStore = Store.for<OrderStoreConfig>().build();
639
+ ```
640
+
641
+ **Interface-based builder** (recommended):
642
+
643
+ ```typescript
644
+ interface SessionStoreConfig {
645
+ CUSTOMERS: Customer[];
646
+ ORDERS: Order[];
647
+ }
648
+
649
+ export const SessionStore = Store.for<SessionStoreConfig>()
650
+ .mirror(CustomerStore, 'CUSTOMERS')
651
+ .mirror(OrderStore, 'ORDERS')
652
+ .build();
653
+ ```
654
+
655
+ **Fluent chaining:**
656
+
657
+ ```typescript
658
+ export const SessionStore = Store
659
+ .resource('CUSTOMERS').as<Customer[]>()
660
+ .resource('ORDERS').as<Order[]>()
661
+ .mirror(CustomerStore, 'CUSTOMERS')
662
+ .mirror(OrderStore, 'ORDERS')
663
+ .build();
664
+ ```
665
+
666
+ **Enum-constrained:**
667
+
668
+ ```typescript
669
+ const SessionEnum = { CUSTOMERS: 'CUSTOMERS', ORDERS: 'ORDERS' } as const;
670
+
671
+ export const SessionStore = Store.for(SessionEnum)
672
+ .resource('CUSTOMERS').as<Customer[]>()
673
+ .resource('ORDERS').as<Order[]>()
674
+ .mirror(CustomerStore, 'CUSTOMERS')
675
+ .mirror(OrderStore, 'ORDERS')
676
+ .build();
677
+ ```
678
+
679
+ **Different source and target keys:**
680
+
681
+ ```typescript
682
+ export const SessionStore = Store.for<{ ARTICLES: Item[] }>()
683
+ .mirror(ItemStore, 'ITEMS', 'ARTICLES')
684
+ .build();
685
+ ```
686
+
687
+ The 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.
688
+
689
+ ### mirrorKey
690
+
691
+ Mirrors a resource key from one store to another. When the source updates, the target is updated with the same state.
692
+
693
+ ```
694
+ +------------------+--------------------------------+------------------+
695
+ | CustomerStore | mirrorKey | SessionStore |
696
+ | | | |
697
+ | CUSTOMERS -------|--- onUpdate --> update ------->| CUSTOMERS |
698
+ | | (same key or different) | |
699
+ | { data, | | { data, |
700
+ | status, | | status, |
701
+ | isLoading } | | isLoading } |
702
+ +------------------+--------------------------------+------------------+
703
+
704
+ source.update('CUSTOMERS', { data: [...], status: 'Success' })
705
+ |
706
+ '--> target is automatically updated with the same state
707
+ ```
708
+
709
+ You wire it once. Every future update — data, loading, errors — flows automatically. Call the cleanup function or use `destroyRef` to stop.
710
+
711
+ ```typescript
712
+ // Same key on both stores (default)
713
+ mirrorKey(customersStore, 'CUSTOMERS', sessionStore);
714
+
715
+ // Different keys
716
+ mirrorKey(customersStore, 'ITEMS', sessionStore, 'ARTICLES');
717
+
718
+ // Manual cleanup
719
+ const cleanup = mirrorKey(customersStore, 'CUSTOMERS', sessionStore);
720
+ cleanup(); // stop mirroring
721
+
722
+ // Auto-cleanup with Angular DestroyRef
723
+ mirrorKey(customersStore, 'CUSTOMERS', sessionStore, { destroyRef });
724
+ mirrorKey(customersStore, 'ITEMS', sessionStore, 'ARTICLES', { destroyRef });
725
+ ```
726
+
727
+ **Full example — session store that aggregates feature stores:**
728
+
729
+ For 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:
730
+
731
+ ```typescript
732
+ @Injectable({ providedIn: 'root' })
733
+ export class SessionStore {
734
+ private readonly customerStore = inject(CustomerStore);
735
+ private readonly orderStore = inject(OrderStore);
736
+ private readonly store = inject(Store.for<SessionStoreConfig>().build());
737
+ private readonly destroyRef = inject(DestroyRef);
738
+
739
+ readonly customers = this.store.get('CUSTOMERS');
740
+ readonly orders = this.store.get('ORDERS');
741
+
742
+ constructor() {
743
+ mirrorKey(this.customerStore, 'CUSTOMERS', this.store, { destroyRef: this.destroyRef });
744
+ mirrorKey(this.orderStore, 'ORDERS', this.store, { destroyRef: this.destroyRef });
745
+ }
746
+ }
747
+ ```
748
+
749
+ Everything — loading flags, data, status, errors — is mirrored automatically. No manual `onUpdate` + cleanup boilerplate.
750
+
751
+ ### collectKeyed
752
+
753
+ Accumulates 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.
754
+
755
+ ```
756
+ +--------------------+-----------------+--------------------------+
757
+ | CustomerStore | collectKeyed | SessionStore |
758
+ | | | |
759
+ | CUSTOMER_DETAILS | extractId(data) | CUSTOMER_CACHE |
760
+ | (one at a time) | finds the key | (KeyedResourceData) |
761
+ +--------+-----------+-----------------+ |
762
+ | | entities: |
763
+ | fetch("c1") -> Success | c1: { id, name } |
764
+ | fetch("c2") -> Success | c2: { id, name } |
765
+ | fetch("c3") -> Error | |
766
+ | | isLoading: |
767
+ | clear() -> removes last | c1: false |
768
+ | entity | c2: false |
769
+ | | |
770
+ '---- accumulates ----------->| status: |
771
+ | c1: 'Success' |
772
+ | c2: 'Success' |
773
+ | c3: 'Error' |
774
+ | |
775
+ | errors: |
776
+ | c3: [{ code, msg }] |
777
+ +--------------------------+
778
+ ```
779
+
780
+ Each 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.
781
+
782
+ ```typescript
783
+ // Same key on both stores
784
+ collectKeyed(customerStore, 'CUSTOMER_DETAILS', sessionStore, {
785
+ extractId: (data) => data?.id,
786
+ destroyRef,
787
+ });
788
+
789
+ // Different keys
790
+ collectKeyed(customerStore, 'CUSTOMER_DETAILS', sessionStore, 'CUSTOMER_CACHE', {
791
+ extractId: (data) => data?.id,
792
+ destroyRef,
793
+ });
794
+ ```
795
+
796
+ **What it does on each source update:**
797
+
798
+ | Source state | Action |
799
+ |---|---|
800
+ | `status: 'Success'` + valid ID | Merges entity into target's keyed data |
801
+ | `status: 'Error'` + valid ID | Records per-key error and status |
802
+ | `isLoading: true` + valid ID | Sets per-key loading flag |
803
+ | Data cleared (e.g. `source.clear()`) | Removes previous entity from target |
804
+
805
+ **Full example — collect individual customer lookups into a cache:**
806
+
807
+ ```typescript
808
+ // Feature store — fetches one customer at a time
809
+ interface CustomerStoreConfig {
810
+ CUSTOMER_DETAILS: Customer;
811
+ }
812
+ export const CustomerStore = Store.for<CustomerStoreConfig>().build();
813
+
814
+ // Session store — accumulates all fetched customers
815
+ interface SessionStoreConfig {
816
+ CUSTOMER_CACHE: KeyedResourceData<string, Customer>;
817
+ }
818
+
819
+ @Injectable({ providedIn: 'root' })
820
+ export class SessionStore {
821
+ private readonly customerStore = inject(CustomerStore);
822
+ private readonly store = inject(Store.for<SessionStoreConfig>().build());
823
+ private readonly destroyRef = inject(DestroyRef);
824
+
825
+ readonly customerCache = this.store.get('CUSTOMER_CACHE');
826
+
827
+ constructor() {
828
+ collectKeyed(this.customerStore, 'CUSTOMER_DETAILS', this.store, 'CUSTOMER_CACHE', {
829
+ extractId: (data) => data?.id,
830
+ destroyRef: this.destroyRef,
831
+ });
832
+ }
833
+
834
+ // After loading customers "c1" and "c2", the cache contains:
835
+ // {
836
+ // entities: { c1: Customer, c2: Customer },
837
+ // isLoading: { c1: false, c2: false },
838
+ // status: { c1: 'Success', c2: 'Success' },
839
+ // errors: {}
840
+ // }
841
+ }
842
+ ```
843
+
844
+ ---
845
+
595
846
  ## Design Decisions
596
847
 
597
848
  **Why signals instead of BehaviorSubject?**
package/dist/index.cjs CHANGED
@@ -26,10 +26,12 @@ __export(index_exports, {
26
26
  Loading: () => import_rx.Loading,
27
27
  SkipIfCached: () => import_rx.SkipIfCached,
28
28
  Store: () => import_store.Store,
29
+ collectKeyed: () => import_store.collectKeyed,
29
30
  createKeyedResourceData: () => import_core.createKeyedResourceData,
30
31
  defaultErrorNormalizer: () => import_rx.defaultErrorNormalizer,
31
32
  isAnyKeyLoading: () => import_core.isAnyKeyLoading,
32
33
  isKeyedResourceData: () => import_core.isKeyedResourceData,
34
+ mirrorKey: () => import_store.mirrorKey,
33
35
  syncToKeyedStore: () => import_rx.syncToKeyedStore,
34
36
  syncToStore: () => import_rx.syncToStore
35
37
  });
@@ -45,10 +47,12 @@ var import_rx = require("@flurryx/rx");
45
47
  Loading,
46
48
  SkipIfCached,
47
49
  Store,
50
+ collectKeyed,
48
51
  createKeyedResourceData,
49
52
  defaultErrorNormalizer,
50
53
  isAnyKeyLoading,
51
54
  isKeyedResourceData,
55
+ mirrorKey,
52
56
  syncToKeyedStore,
53
57
  syncToStore
54
58
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @flurryx/core\nexport type {\n ResourceState,\n StoreEnum,\n KeyedResourceData,\n KeyedResourceKey,\n ResourceStatus,\n ResourceErrors,\n} from \"@flurryx/core\";\nexport {\n isKeyedResourceData,\n createKeyedResourceData,\n isAnyKeyLoading,\n CACHE_NO_TIMEOUT,\n DEFAULT_CACHE_TTL_MS,\n} from \"@flurryx/core\";\n\n// @flurryx/store\nexport { BaseStore, Store } from \"@flurryx/store\";\n\n// @flurryx/rx\nexport {\n syncToStore,\n syncToKeyedStore,\n SkipIfCached,\n Loading,\n defaultErrorNormalizer,\n} from \"@flurryx/rx\";\nexport type {\n SyncToStoreOptions,\n SyncToKeyedStoreOptions,\n ErrorNormalizer,\n} from \"@flurryx/rx\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,kBAMO;AAGP,mBAAiC;AAGjC,gBAMO;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @flurryx/core\nexport type {\n ResourceState,\n StoreEnum,\n KeyedResourceData,\n KeyedResourceKey,\n ResourceStatus,\n ResourceErrors,\n} from \"@flurryx/core\";\nexport {\n isKeyedResourceData,\n createKeyedResourceData,\n isAnyKeyLoading,\n CACHE_NO_TIMEOUT,\n DEFAULT_CACHE_TTL_MS,\n} from \"@flurryx/core\";\n\n// @flurryx/store\nexport { BaseStore, Store, mirrorKey, collectKeyed } from \"@flurryx/store\";\nexport type { MirrorOptions, CollectKeyedOptions } from \"@flurryx/store\";\n\n// @flurryx/rx\nexport {\n syncToStore,\n syncToKeyedStore,\n SkipIfCached,\n Loading,\n defaultErrorNormalizer,\n} from \"@flurryx/rx\";\nexport type {\n SyncToStoreOptions,\n SyncToKeyedStoreOptions,\n ErrorNormalizer,\n} from \"@flurryx/rx\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,kBAMO;AAGP,mBAA0D;AAI1D,gBAMO;","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS, KeyedResourceData, KeyedResourceKey, ResourceErrors, ResourceState, ResourceStatus, StoreEnum, createKeyedResourceData, isAnyKeyLoading, isKeyedResourceData } from '@flurryx/core';
2
- export { BaseStore, Store } from '@flurryx/store';
2
+ export { BaseStore, CollectKeyedOptions, MirrorOptions, Store, collectKeyed, mirrorKey } from '@flurryx/store';
3
3
  export { ErrorNormalizer, Loading, SkipIfCached, SyncToKeyedStoreOptions, SyncToStoreOptions, defaultErrorNormalizer, syncToKeyedStore, syncToStore } from '@flurryx/rx';
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS, KeyedResourceData, KeyedResourceKey, ResourceErrors, ResourceState, ResourceStatus, StoreEnum, createKeyedResourceData, isAnyKeyLoading, isKeyedResourceData } from '@flurryx/core';
2
- export { BaseStore, Store } from '@flurryx/store';
2
+ export { BaseStore, CollectKeyedOptions, MirrorOptions, Store, collectKeyed, mirrorKey } from '@flurryx/store';
3
3
  export { ErrorNormalizer, Loading, SkipIfCached, SyncToKeyedStoreOptions, SyncToStoreOptions, defaultErrorNormalizer, syncToKeyedStore, syncToStore } from '@flurryx/rx';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  CACHE_NO_TIMEOUT,
7
7
  DEFAULT_CACHE_TTL_MS
8
8
  } from "@flurryx/core";
9
- import { BaseStore, Store } from "@flurryx/store";
9
+ import { BaseStore, Store, mirrorKey, collectKeyed } from "@flurryx/store";
10
10
  import {
11
11
  syncToStore,
12
12
  syncToKeyedStore,
@@ -21,10 +21,12 @@ export {
21
21
  Loading,
22
22
  SkipIfCached,
23
23
  Store,
24
+ collectKeyed,
24
25
  createKeyedResourceData,
25
26
  defaultErrorNormalizer,
26
27
  isAnyKeyLoading,
27
28
  isKeyedResourceData,
29
+ mirrorKey,
28
30
  syncToKeyedStore,
29
31
  syncToStore
30
32
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @flurryx/core\nexport type {\n ResourceState,\n StoreEnum,\n KeyedResourceData,\n KeyedResourceKey,\n ResourceStatus,\n ResourceErrors,\n} from \"@flurryx/core\";\nexport {\n isKeyedResourceData,\n createKeyedResourceData,\n isAnyKeyLoading,\n CACHE_NO_TIMEOUT,\n DEFAULT_CACHE_TTL_MS,\n} from \"@flurryx/core\";\n\n// @flurryx/store\nexport { BaseStore, Store } from \"@flurryx/store\";\n\n// @flurryx/rx\nexport {\n syncToStore,\n syncToKeyedStore,\n SkipIfCached,\n Loading,\n defaultErrorNormalizer,\n} from \"@flurryx/rx\";\nexport type {\n SyncToStoreOptions,\n SyncToKeyedStoreOptions,\n ErrorNormalizer,\n} from \"@flurryx/rx\";\n"],"mappings":";AASA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,WAAW,aAAa;AAGjC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// @flurryx/core\nexport type {\n ResourceState,\n StoreEnum,\n KeyedResourceData,\n KeyedResourceKey,\n ResourceStatus,\n ResourceErrors,\n} from \"@flurryx/core\";\nexport {\n isKeyedResourceData,\n createKeyedResourceData,\n isAnyKeyLoading,\n CACHE_NO_TIMEOUT,\n DEFAULT_CACHE_TTL_MS,\n} from \"@flurryx/core\";\n\n// @flurryx/store\nexport { BaseStore, Store, mirrorKey, collectKeyed } from \"@flurryx/store\";\nexport type { MirrorOptions, CollectKeyedOptions } from \"@flurryx/store\";\n\n// @flurryx/rx\nexport {\n syncToStore,\n syncToKeyedStore,\n SkipIfCached,\n Loading,\n defaultErrorNormalizer,\n} from \"@flurryx/rx\";\nexport type {\n SyncToStoreOptions,\n SyncToKeyedStoreOptions,\n ErrorNormalizer,\n} from \"@flurryx/rx\";\n"],"mappings":";AASA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,WAAW,OAAO,WAAW,oBAAoB;AAI1D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flurryx",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Signal-first reactive state management for Angular",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "sideEffects": false,
40
40
  "dependencies": {
41
- "@flurryx/core": "0.7.0",
42
- "@flurryx/store": "0.7.0",
43
- "@flurryx/rx": "0.7.0"
41
+ "@flurryx/core": "0.7.3",
42
+ "@flurryx/store": "0.7.3",
43
+ "@flurryx/rx": "0.7.3"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@angular/core": ">=17.0.0",