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.
- package/README.md +566 -0
- 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.
|
|
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": [
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
33
35
|
"scripts": {
|
|
34
36
|
"build": "tsup",
|
|
35
37
|
"typecheck": "tsc --noEmit"
|