flurryx 0.0.2 → 0.0.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.
Files changed (2) hide show
  1. package/README.md +566 -0
  2. package/package.json +4 -2
package/README.md ADDED
@@ -0,0 +1,566 @@
1
+ <p align="center">
2
+ <img src="assets/picto.svg" alt="" width="64" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <picture>
7
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg" />
8
+ <source media="(prefers-color-scheme: light)" srcset="assets/logo.svg" />
9
+ <img src="assets/logo.svg" alt="flurryx" width="480" />
10
+ </picture>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <img src="https://img.shields.io/npm/v/flurryx?color=dd0031" alt="flurryx version" />
15
+ <img src="https://img.shields.io/badge/Angular-%3E%3D17-dd0031" alt="Angular >=17" />
16
+ <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
17
+ </p>
18
+
19
+ **Signal-first reactive state management for Angular.**
20
+
21
+ flurryx 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. No actions, no reducers, no effects boilerplate.
22
+
23
+ ```typescript
24
+ // Facade
25
+ @SkipIfCached(StoreKey.LIST, (i) => i.store)
26
+ @Loading(StoreKey.LIST, (i) => i.store)
27
+ loadProducts() {
28
+ this.http.get<Product[]>('/api/products')
29
+ .pipe(syncToStore(this.store, StoreKey.LIST))
30
+ .subscribe();
31
+ }
32
+
33
+ // Component template — just read the signal
34
+ @if (facade.list().isLoading) { <spinner /> }
35
+ @for (product of facade.list().data; track product.id) {
36
+ <product-card [product]="product" />
37
+ }
38
+ ```
39
+
40
+ No `async` pipe. No `subscribe` in templates. No manual unsubscription.
41
+
42
+ ---
43
+
44
+ ## Table of Contents
45
+
46
+ - [Why flurryx?](#why-flurryx)
47
+ - [Packages](#packages)
48
+ - [How to Install](#how-to-install)
49
+ - [Getting Started](#getting-started)
50
+ - [How to Use](#how-to-use)
51
+ - [ResourceState](#resourcestate)
52
+ - [BaseStore API](#basestore-api)
53
+ - [syncToStore](#synctostore)
54
+ - [syncToKeyedStore](#synctokeyedstore)
55
+ - [@SkipIfCached](#skipifcached)
56
+ - [@Loading](#loading)
57
+ - [Error Normalization](#error-normalization)
58
+ - [Constants](#constants)
59
+ - [Keyed Resources](#keyed-resources)
60
+ - [Design Decisions](#design-decisions)
61
+ - [Contributing](#contributing)
62
+ - [License](#license)
63
+
64
+ ---
65
+
66
+ ## Why flurryx?
67
+
68
+ Angular 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:
69
+
70
+ | Problem | Without flurryx | With flurryx |
71
+ | ------------------ | -------------------------------------------------- | ---------------------------------------------- |
72
+ | Loading spinners | Manual boolean flags, race conditions | `store.get(key)().isLoading` |
73
+ | Error handling | Scattered `catchError` blocks, inconsistent shapes | Normalized `{ code, message }[]` on every slot |
74
+ | Caching | Custom `shareReplay` / `BehaviorSubject` wiring | `@SkipIfCached` decorator, one line |
75
+ | Duplicate requests | Manual `distinctUntilChanged`, inflight tracking | `@SkipIfCached` deduplicates while loading |
76
+ | Keyed resources | Separate state per ID, boilerplate explosion | `KeyedResourceData` with per-key loading/error |
77
+
78
+ flurryx gives you **one abstract class**, **two RxJS operators**, and **two decorators**. That's the entire API.
79
+
80
+ ---
81
+
82
+ ## How to Install
83
+
84
+ ```bash
85
+ npm install flurryx
86
+ ```
87
+
88
+ That'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:
89
+
90
+ ```typescript
91
+ import { BaseStore, syncToStore, SkipIfCached, Loading } from "flurryx";
92
+ import type { ResourceState, KeyedResourceData } from "flurryx";
93
+ ```
94
+
95
+ For the Angular HTTP error normalizer (optional — keeps `@angular/common/http` out of your bundle unless you need it):
96
+
97
+ ```typescript
98
+ import { httpErrorNormalizer } from "flurryx/http";
99
+ ```
100
+
101
+ **Peer dependencies** (you likely already have these):
102
+
103
+ | Peer | Version |
104
+ | ----------------- | --------------------------------- |
105
+ | `@angular/core` | `>=17` |
106
+ | `rxjs` | `>=7` |
107
+ | `@angular/common` | optional, only for `flurryx/http` |
108
+
109
+ > **Note:** Your `tsconfig.json` must include `"experimentalDecorators": true` if you use `@SkipIfCached` or `@Loading`.
110
+
111
+ <details>
112
+ <summary>Individual packages</summary>
113
+
114
+ If you prefer granular control over your dependency tree, the internal packages are published independently:
115
+
116
+ ```
117
+ @flurryx/core → Types, models, utilities (0 runtime deps)
118
+ @flurryx/store → BaseStore with Angular signals (peer: @angular/core >=17)
119
+ @flurryx/rx → RxJS operators + decorators (peer: rxjs >=7, @angular/core >=17)
120
+ ```
121
+
122
+ ```bash
123
+ npm install @flurryx/core @flurryx/store @flurryx/rx
124
+ ```
125
+
126
+ ```
127
+ @flurryx/core ←── @flurryx/store
128
+
129
+ @flurryx/rx
130
+ ```
131
+
132
+ </details>
133
+
134
+ ---
135
+
136
+ ## Getting Started
137
+
138
+ ### Step 1 — Define your store
139
+
140
+ A store is an enum of named slots + a type map. Each slot holds a `ResourceState<T>`.
141
+
142
+ ```typescript
143
+ import { Injectable } from "@angular/core";
144
+ import { BaseStore, type ResourceState } from "flurryx";
145
+
146
+ enum ProductStoreEnum {
147
+ LIST = "LIST",
148
+ DETAIL = "DETAIL",
149
+ }
150
+
151
+ interface ProductStoreData {
152
+ [ProductStoreEnum.LIST]: ResourceState<Product[]>;
153
+ [ProductStoreEnum.DETAIL]: ResourceState<Product>;
154
+ }
155
+
156
+ @Injectable({ providedIn: "root" })
157
+ export class ProductStore extends BaseStore<
158
+ typeof ProductStoreEnum,
159
+ ProductStoreData
160
+ > {
161
+ constructor() {
162
+ super(ProductStoreEnum);
163
+ }
164
+ }
165
+ ```
166
+
167
+ ### Step 2 — Create a facade
168
+
169
+ The facade owns the store and exposes signals + data-fetching methods.
170
+
171
+ ```typescript
172
+ import { Injectable, inject } from "@angular/core";
173
+ import { HttpClient } from "@angular/common/http";
174
+ import { syncToStore, SkipIfCached, Loading } from "flurryx";
175
+
176
+ @Injectable({ providedIn: "root" })
177
+ export class ProductFacade {
178
+ private readonly http = inject(HttpClient);
179
+ readonly store = inject(ProductStore);
180
+
181
+ // Expose signals for templates
182
+ readonly list = this.store.get(ProductStoreEnum.LIST);
183
+ readonly detail = this.store.get(ProductStoreEnum.DETAIL);
184
+
185
+ @SkipIfCached(ProductStoreEnum.LIST, (i: ProductFacade) => i.store)
186
+ @Loading(ProductStoreEnum.LIST, (i: ProductFacade) => i.store)
187
+ loadProducts() {
188
+ this.http
189
+ .get<Product[]>("/api/products")
190
+ .pipe(syncToStore(this.store, ProductStoreEnum.LIST))
191
+ .subscribe();
192
+ }
193
+
194
+ @Loading(ProductStoreEnum.DETAIL, (i: ProductFacade) => i.store)
195
+ loadProduct(id: string) {
196
+ this.http
197
+ .get<Product>(`/api/products/${id}`)
198
+ .pipe(syncToStore(this.store, ProductStoreEnum.DETAIL))
199
+ .subscribe();
200
+ }
201
+ }
202
+ ```
203
+
204
+ ### Step 3 — Use in your component
205
+
206
+ ```typescript
207
+ @Component({
208
+ template: `
209
+ @if (facade.list().isLoading) {
210
+ <spinner />
211
+ } @if (facade.list().status === 'Success') { @for (product of
212
+ facade.list().data; track product.id) {
213
+ <product-card [product]="product" />
214
+ } } @if (facade.list().status === 'Error') {
215
+ <error-banner [errors]="facade.list().errors" />
216
+ }
217
+ `,
218
+ })
219
+ export class ProductListComponent {
220
+ readonly facade = inject(ProductFacade);
221
+
222
+ constructor() {
223
+ this.facade.loadProducts();
224
+ }
225
+ }
226
+ ```
227
+
228
+ The component reads signals directly. No `async` pipe, no `subscribe`, no `OnDestroy` cleanup.
229
+
230
+ ---
231
+
232
+ ## How to Use
233
+
234
+ ### ResourceState
235
+
236
+ The fundamental unit of state. Every store slot holds one:
237
+
238
+ ```typescript
239
+ interface ResourceState<T> {
240
+ isLoading?: boolean;
241
+ data?: T;
242
+ status?: "Success" | "Error";
243
+ errors?: Array<{ code: string; message: string }>;
244
+ }
245
+ ```
246
+
247
+ A slot starts as `{ data: undefined, isLoading: false, status: undefined, errors: undefined }` and transitions through a predictable lifecycle:
248
+
249
+ ```
250
+ ┌─────────┐ startLoading ┌───────────┐ next ┌─────────┐
251
+ │ IDLE │ ───────────────→ │ LOADING │ ────────→ │ SUCCESS │
252
+ └─────────┘ └───────────┘ └─────────┘
253
+
254
+ │ error
255
+
256
+ ┌───────────┐
257
+ │ ERROR │
258
+ └───────────┘
259
+ ```
260
+
261
+ ### BaseStore API
262
+
263
+ `BaseStore<TEnum, TData>` manages a `Map<keyof TEnum, WritableSignal<ResourceState>>`.
264
+
265
+ | Method | Description |
266
+ | ------------------------- | ------------------------------------------------------------------------------------- |
267
+ | `get(key)` | Returns the `WritableSignal` for a slot |
268
+ | `update(key, partial)` | Merges partial state (immutable spread) |
269
+ | `clear(key)` | Resets a slot to its initial empty state |
270
+ | `clearAll()` | Resets every slot |
271
+ | `startLoading(key)` | Sets `isLoading: true`, clears `status` and `errors` |
272
+ | `stopLoading(key)` | Sets `isLoading: false`, clears `status` and `errors` |
273
+ | `onUpdate(key, callback)` | Registers a listener fired after `update` or `clear`. Returns an unsubscribe function |
274
+
275
+ **Keyed methods** (for `KeyedResourceData` slots):
276
+
277
+ | Method | Description |
278
+ | ------------------------------------------ | -------------------------------------- |
279
+ | `updateKeyedOne(key, resourceKey, entity)` | Merges one entity into a keyed slot |
280
+ | `clearKeyedOne(key, resourceKey)` | Removes one entity from a keyed slot |
281
+ | `startKeyedLoading(key, resourceKey)` | Sets loading for a single resource key |
282
+
283
+ > Update hooks are stored in a `WeakMap` keyed by store instance, so garbage collection works naturally across multiple store lifetimes.
284
+
285
+ ### syncToStore
286
+
287
+ RxJS pipeable operator that bridges an `Observable` to a store slot.
288
+
289
+ ```typescript
290
+ this.http
291
+ .get<Product[]>("/api/products")
292
+ .pipe(syncToStore(this.store, ProductStoreEnum.LIST))
293
+ .subscribe();
294
+ ```
295
+
296
+ **What it does:**
297
+
298
+ - On `next` — writes `{ data, isLoading: false, status: 'Success', errors: undefined }`
299
+ - On `error` — writes `{ data: undefined, isLoading: false, status: 'Error', errors: [...] }`
300
+ - Completes after first emission by default (`take(1)`)
301
+
302
+ **Options:**
303
+
304
+ ```typescript
305
+ syncToStore(store, key, {
306
+ completeOnFirstEmission: true, // default: true — applies take(1)
307
+ callbackAfterComplete: () => {}, // runs in finalize()
308
+ errorNormalizer: myNormalizer, // default: defaultErrorNormalizer
309
+ });
310
+ ```
311
+
312
+ ### syncToKeyedStore
313
+
314
+ Same pattern, but targets a specific resource key within a `KeyedResourceData` slot:
315
+
316
+ ```typescript
317
+ this.http
318
+ .get<Invoice>(`/api/invoices/${id}`)
319
+ .pipe(syncToKeyedStore(this.store, InvoiceStoreEnum.ITEMS, id))
320
+ .subscribe();
321
+ ```
322
+
323
+ Only the targeted resource key is updated. Other keys in the same slot are untouched.
324
+
325
+ **`mapResponse`** — transform the API response before writing to the store:
326
+
327
+ ```typescript
328
+ syncToKeyedStore(this.store, StoreKey.ITEMS, id, {
329
+ mapResponse: (response) => response.data,
330
+ });
331
+ ```
332
+
333
+ ### @SkipIfCached
334
+
335
+ Method decorator that skips execution when the store already has valid data.
336
+
337
+ ```typescript
338
+ @SkipIfCached(StoreKey.LIST, (i) => i.store)
339
+ loadProducts() { /* only runs when cache is stale */ }
340
+ ```
341
+
342
+ **Cache hit** (method skipped) when:
343
+
344
+ - `status === 'Success'` or `isLoading === true`
345
+ - Timeout has not expired (default: 5 minutes)
346
+ - Method arguments match (compared via `JSON.stringify`)
347
+
348
+ **Cache miss** (method executes) when:
349
+
350
+ - Initial state (no status, not loading)
351
+ - `status === 'Error'` (errors are never cached)
352
+ - Timeout expired
353
+ - Arguments changed
354
+
355
+ **Parameters:**
356
+
357
+ ```typescript
358
+ @SkipIfCached(
359
+ storeKey, // which store slot to check
360
+ (instance) => instance.store, // how to get the store from `this`
361
+ returnObservable?, // false (default): void methods; true: returns Observable
362
+ timeoutMs? // default: 300_000 (5 min). Use CACHE_NO_TIMEOUT for infinite
363
+ )
364
+ ```
365
+
366
+ **Observable mode** (`returnObservable: true`):
367
+
368
+ - Cache hit returns `of(cachedData)` or coalesces onto the in-flight `Observable` via `shareReplay`
369
+ - Cache miss executes the method and wraps the result with inflight tracking
370
+
371
+ **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.
372
+
373
+ ### @Loading
374
+
375
+ Method decorator that calls `store.startLoading(key)` before the original method executes.
376
+
377
+ ```typescript
378
+ @Loading(StoreKey.LIST, (i) => i.store)
379
+ loadProducts() { /* store.isLoading is already true when this runs */ }
380
+ ```
381
+
382
+ **Keyed detection**: If the first argument is a `string | number` and the store has `startKeyedLoading`, it calls that instead for per-key loading state.
383
+
384
+ **Compose both decorators** for the common pattern:
385
+
386
+ ```typescript
387
+ @SkipIfCached(StoreKey.LIST, (i) => i.store)
388
+ @Loading(StoreKey.LIST, (i) => i.store)
389
+ loadProducts() {
390
+ this.http.get('/api/products')
391
+ .pipe(syncToStore(this.store, StoreKey.LIST))
392
+ .subscribe();
393
+ }
394
+ ```
395
+
396
+ Order matters: `@SkipIfCached` is outermost so it can short-circuit before `@Loading` sets the loading flag.
397
+
398
+ ### Error Normalization
399
+
400
+ Operators accept a pluggable `errorNormalizer` instead of coupling to Angular's `HttpErrorResponse`:
401
+
402
+ ```typescript
403
+ type ErrorNormalizer = (error: unknown) => ResourceErrors;
404
+ ```
405
+
406
+ **`defaultErrorNormalizer`** (used by default) handles:
407
+
408
+ 1. `{ error: { errors: [...] } }` — extracts the nested array
409
+ 2. `{ status: number, message: string }` — wraps into `[{ code, message }]`
410
+ 3. `Error` instances — wraps `error.message`
411
+ 4. Anything else — `[{ code: 'UNKNOWN', message: String(error) }]`
412
+
413
+ **`httpErrorNormalizer`** — for Angular's `HttpErrorResponse`, available from a separate entry point to keep `@angular/common/http` out of your bundle unless you need it:
414
+
415
+ ```typescript
416
+ import { httpErrorNormalizer } from "flurryx/http";
417
+
418
+ this.http
419
+ .get("/api/data")
420
+ .pipe(
421
+ syncToStore(this.store, Key.DATA, {
422
+ errorNormalizer: httpErrorNormalizer,
423
+ }),
424
+ )
425
+ .subscribe();
426
+ ```
427
+
428
+ **Custom normalizer** — implement your own for any backend error shape:
429
+
430
+ ```typescript
431
+ const myNormalizer: ErrorNormalizer = (error) => {
432
+ const typed = error as MyBackendError;
433
+ return typed.details.map((d) => ({
434
+ code: d.errorCode,
435
+ message: d.userMessage,
436
+ }));
437
+ };
438
+ ```
439
+
440
+ ### Constants
441
+
442
+ ```typescript
443
+ import { CACHE_NO_TIMEOUT, DEFAULT_CACHE_TTL_MS } from "flurryx";
444
+
445
+ CACHE_NO_TIMEOUT; // Infinity — cache never expires
446
+ DEFAULT_CACHE_TTL_MS; // 300_000 (5 minutes)
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Keyed Resources
452
+
453
+ For data indexed by ID (user profiles, invoices, config entries), use `KeyedResourceData`:
454
+
455
+ ```typescript
456
+ interface KeyedResourceData<TKey extends string | number, TValue> {
457
+ entities: Partial<Record<TKey, TValue>>;
458
+ isLoading: Partial<Record<TKey, boolean>>;
459
+ status: Partial<Record<TKey, ResourceStatus>>;
460
+ errors: Partial<Record<TKey, ResourceErrors>>;
461
+ }
462
+ ```
463
+
464
+ Each resource key gets **independent** loading, status, and error tracking. The top-level `ResourceState.isLoading` reflects whether _any_ key is loading.
465
+
466
+ **Full example:**
467
+
468
+ ```typescript
469
+ // Store
470
+ enum InvoiceStoreEnum {
471
+ ITEMS = "ITEMS",
472
+ }
473
+
474
+ interface InvoiceStoreData {
475
+ [InvoiceStoreEnum.ITEMS]: ResourceState<KeyedResourceData<string, Invoice>>;
476
+ }
477
+
478
+ @Injectable({ providedIn: "root" })
479
+ export class InvoiceStore extends BaseStore<
480
+ typeof InvoiceStoreEnum,
481
+ InvoiceStoreData
482
+ > {
483
+ constructor() {
484
+ super(InvoiceStoreEnum);
485
+ }
486
+ }
487
+
488
+ // Facade
489
+ @Injectable({ providedIn: "root" })
490
+ export class InvoiceFacade {
491
+ private readonly http = inject(HttpClient);
492
+ readonly store = inject(InvoiceStore);
493
+ readonly items = this.store.get(InvoiceStoreEnum.ITEMS);
494
+
495
+ @SkipIfCached(InvoiceStoreEnum.ITEMS, (i: InvoiceFacade) => i.store)
496
+ @Loading(InvoiceStoreEnum.ITEMS, (i: InvoiceFacade) => i.store)
497
+ loadInvoice(id: string) {
498
+ this.http
499
+ .get<Invoice>(`/api/invoices/${id}`)
500
+ .pipe(syncToKeyedStore(this.store, InvoiceStoreEnum.ITEMS, id))
501
+ .subscribe();
502
+ }
503
+ }
504
+
505
+ // Component
506
+ const data = this.facade.items().data; // KeyedResourceData
507
+ const invoice = data?.entities["inv-123"]; // Invoice | undefined
508
+ const loading = data?.isLoading["inv-123"]; // boolean | undefined
509
+ const errors = data?.errors["inv-123"]; // ResourceErrors | undefined
510
+ ```
511
+
512
+ **Utilities:**
513
+
514
+ ```typescript
515
+ import {
516
+ createKeyedResourceData, // factory — returns empty { entities: {}, isLoading: {}, ... }
517
+ isKeyedResourceData, // type guard
518
+ isAnyKeyLoading, // (loading: Record) => boolean
519
+ } from "flurryx";
520
+ ```
521
+
522
+ ---
523
+
524
+ ## Design Decisions
525
+
526
+ **Why signals instead of BehaviorSubject?**
527
+ Angular 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.
528
+
529
+ **Why not NgRx / NGXS / Elf?**
530
+ Those 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.
531
+
532
+ **Why `Partial<Record>` instead of `Map` for keyed data?**
533
+ Plain objects work with Angular's change detection and signals out of the box. Maps require additional serialization. This also means zero migration friction.
534
+
535
+ **Why `experimentalDecorators`?**
536
+ The decorators use TypeScript's legacy decorator syntax. TC39 decorator migration is planned for a future release.
537
+
538
+ **Why tsup instead of ng-packagr?**
539
+ flurryx 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.
540
+
541
+ ---
542
+
543
+ ## Contributing
544
+
545
+ ```bash
546
+ git clone https://github.com/fmflurry/flurryx.git
547
+ cd flurryx
548
+ npm install
549
+ npm run build
550
+ npm run test
551
+ ```
552
+
553
+ | Command | What it does |
554
+ | ----------------------- | ------------------------------------------------ |
555
+ | `npm run build` | Builds all packages (ESM + CJS + .d.ts) via tsup |
556
+ | `npm run test` | Runs vitest across all packages |
557
+ | `npm run test:coverage` | Tests with v8 coverage report |
558
+ | `npm run typecheck` | `tsc --noEmit` across all packages |
559
+
560
+ Monorepo managed with **npm workspaces**. Versioning with [changesets](https://github.com/changesets/changesets).
561
+
562
+ ---
563
+
564
+ ## License
565
+
566
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flurryx",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Signal-first reactive state management for Angular",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,9 @@
29
29
  }
30
30
  }
31
31
  },
32
- "files": ["dist"],
32
+ "files": [
33
+ "dist"
34
+ ],
33
35
  "scripts": {
34
36
  "build": "tsup",
35
37
  "typecheck": "tsc --noEmit"