@ngstato/core 0.2.0 → 0.4.0

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
@@ -1,63 +1,163 @@
1
- # @ngstato/core
2
-
3
- State management framework-agnostic — Signals-first, sans RxJS obligatoire.
4
-
5
- ## Installation
6
- ```bash
7
- npm install @ngstato/core
8
- ```
9
-
10
- ## Usage
11
- ```ts
12
- import { createStore, http } from '@ngstato/core'
13
-
14
- const store = createStore({
15
- users: [] as User[],
16
- isLoading: false,
17
-
18
- actions: {
19
- async loadUsers(state) {
20
- state.isLoading = true
21
- state.users = await http.get('/users')
22
- state.isLoading = false
23
- }
24
- },
25
-
26
- selectors: {
27
- total: (state) => state.users.length
28
- },
29
-
30
- effects: [
31
- [
32
- (state) => state.users.length,
33
- (count) => console.log('Total users:', count)
34
- ]
35
- ]
36
- })
37
-
38
- await store.loadUsers()
39
- console.log(store.users) // User[]
40
- console.log(store.total) // number (selector memoïzé)
41
- ```
42
-
43
- ## Helpers
44
-
45
- | Helper | Description |
46
- |--------|-------------|
47
- | `abortable()` | Annule la requête précédente si l'action est rappelée |
48
- | `debounced()` | Debounce sans RxJS |
49
- | `throttled()` | Throttle sans RxJS |
50
- | `retryable()` | Retry avec backoff fixe ou exponentiel |
51
- | `fromStream()` | Realtime — WebSocket, Firebase, Supabase |
52
- | `optimistic()` | Optimistic update + rollback automatique |
53
- | `withPersist()` | Persistance localStorage/sessionStorage + migration |
54
-
55
- ## Nouvautés v0.2
56
-
57
- - `selectors` memoïzés (recalcul seulement quand les dépendances changent)
58
- - `effects` réactifs explicites avec cleanup automatique
59
- - `withPersist()` pour hydrater/persister le state
60
-
61
- ## Documentation
62
-
63
- Voir [github.com/becher/ngstato](https://github.com/becher/ngstato)
1
+ <div align="center">
2
+
3
+ # @ngstato/core
4
+
5
+ ### Tired of 14 lines of RxJS for a simple API call?
6
+
7
+ **Write `async/await`. Get the same result. Ship ~3 KB instead of ~50 KB.**
8
+
9
+ [![npm](https://img.shields.io/badge/npm-v0.3.0-blue)](https://www.npmjs.com/package/@ngstato/core)
10
+ [![gzip](https://img.shields.io/badge/gzip-~3KB-brightgreen)](#)
11
+ [![tests](https://img.shields.io/badge/tests-136%2B-green)](#)
12
+ [![license](https://img.shields.io/badge/license-MIT-lightgrey)](#)
13
+
14
+ [Documentation](https://becher.github.io/ngStato/) · [API Reference](https://becher.github.io/ngStato/api/core) · [Helpers](https://becher.github.io/ngStato/api/helpers)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Before / After
21
+
22
+ **NgRx** rxMethod + pipe + tap + switchMap + from + tapResponse + patchState:
23
+
24
+ ```ts
25
+ load: rxMethod<void>(pipe(
26
+ tap(() => patchState(store, { loading: true })),
27
+ switchMap(() => from(service.getAll()).pipe(
28
+ tapResponse({
29
+ next: (users) => patchState(store, { users, loading: false }),
30
+ error: (e) => patchState(store, { error: e.message })
31
+ })
32
+ ))
33
+ ))
34
+ ```
35
+
36
+ **ngStato** — async/await:
37
+
38
+ ```ts
39
+ async load(state) {
40
+ state.loading = true
41
+ state.users = await http.get('/users')
42
+ state.loading = false
43
+ }
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ npm install @ngstato/core
52
+ ```
53
+
54
+ ## 30-second example
55
+
56
+ ```ts
57
+ import { createStore } from '@ngstato/core'
58
+
59
+ const store = createStore({
60
+ count: 0,
61
+
62
+ selectors: { doubled: (s) => s.count * 2 },
63
+
64
+ actions: {
65
+ inc(state) { state.count++ },
66
+ add(state, n: number) { state.count += n },
67
+ async load(state) { state.count = await fetchCount() }
68
+ }
69
+ })
70
+
71
+ await store.inc()
72
+ store.count // 1
73
+ store.doubled // 2 (memoized)
74
+ ```
75
+
76
+ ## Real-world store
77
+
78
+ ```ts
79
+ import { createStore, http, retryable, optimistic } from '@ngstato/core'
80
+
81
+ const store = createStore({
82
+ users: [] as User[],
83
+ loading: false,
84
+ error: null as string | null,
85
+
86
+ selectors: {
87
+ total: (s) => s.users.length,
88
+ admins: (s) => s.users.filter(u => u.role === 'admin')
89
+ },
90
+
91
+ actions: {
92
+ loadUsers: retryable(async (state) => {
93
+ state.loading = true
94
+ state.users = await http.get('/users')
95
+ state.loading = false
96
+ }, { attempts: 3, backoff: 'exponential' }),
97
+
98
+ deleteUser: optimistic(
99
+ (state, id: string) => { state.users = state.users.filter(u => u.id !== id) },
100
+ async (_, id) => { await http.delete(`/users/${id}`) }
101
+ )
102
+ },
103
+
104
+ hooks: {
105
+ onInit: (store) => store.loadUsers(),
106
+ onError: (err, name) => console.error(`[${name}]`, err.message)
107
+ }
108
+ })
109
+ ```
110
+
111
+ ## Concurrency — without RxJS
112
+
113
+ ```ts
114
+ import { exclusive, abortable, queued, retryable, optimistic } from '@ngstato/core'
115
+
116
+ actions: {
117
+ submit: exclusive(async (s) => { ... }), // → exhaustMap
118
+ search: abortable(async (s, q, { signal }) => { }), // → switchMap
119
+ send: queued(async (s, msg) => { ... }), // → concatMap
120
+ load: retryable(async (s) => { ... }, opts), // → retryWhen
121
+ delete: optimistic(apply, confirm), // → manual in NgRx
122
+ }
123
+ ```
124
+
125
+ Plus `debounced` · `throttled` · `distinctUntilChanged` · `forkJoin` · `race` · `combineLatest` · `fromStream` · `pipeStream` + 12 stream operators · `createEntityAdapter` · `withEntities` · `withPersist` · `mergeFeatures` · `on()` → [Full API →](https://becher.github.io/ngStato/api/helpers)
126
+
127
+ ## Inter-store reactions
128
+
129
+ ```ts
130
+ import { on } from '@ngstato/core'
131
+
132
+ on([userStore.create, userStore.delete], (_, event) => {
133
+ console.log(`${event.name} ${event.status} in ${event.duration}ms`)
134
+ })
135
+ ```
136
+
137
+ ## Feature composition
138
+
139
+ ```ts
140
+ const store = createStore({
141
+ items: [] as Item[],
142
+ ...mergeFeatures(withLoading(), withPagination()),
143
+ actions: { async load(state) { ... } }
144
+ })
145
+ // store.loading, store.page, store.hasError — all available
146
+ ```
147
+
148
+ ## The numbers
149
+
150
+ | | NgRx v21 | ngStato |
151
+ |:--|:--|:--|
152
+ | Bundle | ~50 KB | **~3 KB** |
153
+ | CRUD store | ~90 lines | **~45 lines** |
154
+ | Concepts for async | 9 | **1** |
155
+ | RxJS required | Yes | **No** |
156
+
157
+ ## 📖 Documentation
158
+
159
+ **[becher.github.io/ngStato](https://becher.github.io/ngStato/)** — [Quick start](https://becher.github.io/ngStato/guide/start-in-5-min) · [Core concepts](https://becher.github.io/ngStato/guide/core-concepts) · [API](https://becher.github.io/ngStato/api/core) · [Helpers](https://becher.github.io/ngStato/api/helpers) · [NgRx migration](https://becher.github.io/ngStato/migration/ngrx-to-ngstato)
160
+
161
+ ## License
162
+
163
+ MIT
package/dist/index.d.mts CHANGED
@@ -44,7 +44,17 @@ declare class StatoHttpError extends Error {
44
44
  constructor(status: number, body: string);
45
45
  }
46
46
 
47
- declare function createStore<S extends object>(config: S & StatoStoreConfig<S>): any;
47
+ declare function createStore<S extends object>(config: S & StatoStoreConfig<S>, __internal?: {
48
+ skipInit?: boolean;
49
+ }): any;
50
+ type OnEvent = {
51
+ name: string;
52
+ args: unknown[];
53
+ status: 'success' | 'error';
54
+ duration: number;
55
+ error?: Error;
56
+ };
57
+ declare function on<S extends object>(sourceAction: Function | Function[], handler: (store: S, event: OnEvent) => void | Promise<void>): () => void;
48
58
 
49
59
  interface RequestOptions {
50
60
  params?: Record<string, string | number | boolean>;
@@ -106,6 +116,149 @@ declare function fromStream<S, T>(setupFn: (state: S) => StatoObservable<T>, upd
106
116
 
107
117
  declare function optimistic<S, A extends unknown[]>(immediate: (state: S, ...args: A) => void, confirm: (state: S, ...args: A) => Promise<void>): (state: S, ...args: A) => Promise<void>;
108
118
 
119
+ declare function exclusive<S, A extends unknown[]>(fn: (state: S, ...args: A) => Promise<void>): (state: S, ...args: A) => Promise<void>;
120
+
121
+ declare function queued<S, A extends unknown[]>(fn: (state: S, ...args: A) => Promise<void>): (state: S, ...args: A) => Promise<void>;
122
+
123
+ type Comparator<T> = (prev: T, next: T) => boolean;
124
+ declare function distinctUntilChanged<S, A extends unknown[], K>(fn: (state: S, ...args: A) => void | Promise<void>, keySelector: (...args: A) => K, comparator?: Comparator<K>): (state: S, ...args: A) => Promise<void>;
125
+
126
+ type TaskContext$1 = {
127
+ signal: AbortSignal;
128
+ };
129
+ type Task$1<T> = (ctx: TaskContext$1) => Promise<T> | T;
130
+ type ForkJoinOptions = {
131
+ signal?: AbortSignal;
132
+ };
133
+ declare function forkJoin<T extends Record<string, Task$1<any>>>(tasks: T, options?: ForkJoinOptions): Promise<{
134
+ [K in keyof T]: Awaited<ReturnType<T[K]>>;
135
+ }>;
136
+
137
+ type TaskContext = {
138
+ signal: AbortSignal;
139
+ };
140
+ type Task<T> = (ctx: TaskContext) => Promise<T> | T;
141
+ type RaceOptions = {
142
+ signal?: AbortSignal;
143
+ };
144
+ declare function race<T>(tasks: Array<Task<T>>, options?: RaceOptions): Promise<T>;
145
+
146
+ type DepFn<S, T> = (state: S) => T;
147
+ declare function combineLatest<S>(): <T extends unknown[]>(...deps: { [K in keyof T]: DepFn<S, T[K]>; }) => (state: S) => T;
148
+
149
+ declare function combineLatestStream<T extends unknown[]>(...sources: {
150
+ [K in keyof T]: StatoObservable<T[K]>;
151
+ }): StatoObservable<T>;
152
+
153
+ type EntityId = string | number;
154
+ interface EntityState<T> {
155
+ ids: EntityId[];
156
+ entities: Record<string, T>;
157
+ }
158
+ type Update<T> = {
159
+ id: EntityId;
160
+ changes: Partial<T>;
161
+ };
162
+ type SelectId<T> = (entity: T) => EntityId;
163
+ interface EntityAdapterOptions<T> {
164
+ selectId?: SelectId<T>;
165
+ sortComparer?: (a: T, b: T) => number;
166
+ }
167
+ declare function createEntityAdapter<T extends Record<string, any>>(options?: EntityAdapterOptions<T>): {
168
+ selectId: SelectId<T>;
169
+ sortComparer: ((a: T, b: T) => number) | undefined;
170
+ getInitialState: <E extends object = {}>(extra?: E) => EntityState<T> & E;
171
+ addOne: (entity: T, state: EntityState<T>) => void;
172
+ addMany: (entities: T[], state: EntityState<T>) => void;
173
+ setAll: (entities: T[], state: EntityState<T>) => void;
174
+ upsertOne: (entity: T, state: EntityState<T>) => void;
175
+ upsertMany: (entities: T[], state: EntityState<T>) => void;
176
+ updateOne: (update: Update<T>, state: EntityState<T>) => void;
177
+ removeOne: (id: EntityId, state: EntityState<T>) => void;
178
+ removeMany: (ids: EntityId[], state: EntityState<T>) => void;
179
+ removeAll: (state: EntityState<T>) => void;
180
+ getSelectors: <S = EntityState<T>>(selectState?: (state: S) => EntityState<T>) => {
181
+ selectIds: (state: S | EntityState<T>) => EntityId[];
182
+ selectEntities: (state: S | EntityState<T>) => Record<string, T>;
183
+ selectAll: (state: S | EntityState<T>) => T[];
184
+ selectTotal: (state: S | EntityState<T>) => number;
185
+ selectById: (state: S | EntityState<T>, id: EntityId) => T;
186
+ };
187
+ };
188
+
189
+ type EntityAdapter<T> = {
190
+ getInitialState: <E extends object = {}>(extra?: E) => EntityState<T> & E;
191
+ addOne: (entity: T, state: EntityState<T>) => void;
192
+ addMany: (entities: T[], state: EntityState<T>) => void;
193
+ setAll: (entities: T[], state: EntityState<T>) => void;
194
+ upsertOne: (entity: T, state: EntityState<T>) => void;
195
+ upsertMany: (entities: T[], state: EntityState<T>) => void;
196
+ updateOne: (update: Update<T>, state: EntityState<T>) => void;
197
+ removeOne: (id: EntityId, state: EntityState<T>) => void;
198
+ removeMany: (ids: EntityId[], state: EntityState<T>) => void;
199
+ removeAll: (state: EntityState<T>) => void;
200
+ getSelectors: <S = EntityState<T>>(selectState?: (state: S) => EntityState<T>) => {
201
+ selectIds: (state: S | EntityState<T>) => EntityId[];
202
+ selectEntities: (state: S | EntityState<T>) => Record<string, T>;
203
+ selectAll: (state: S | EntityState<T>) => T[];
204
+ selectTotal: (state: S | EntityState<T>) => number;
205
+ selectById: (state: S | EntityState<T>, id: EntityId) => T | undefined;
206
+ };
207
+ };
208
+ type WithEntitiesSelectorsNames = Partial<{
209
+ ids: string;
210
+ entities: string;
211
+ all: string;
212
+ total: string;
213
+ byId: string;
214
+ }>;
215
+ type WithEntitiesActionsNames = Partial<{
216
+ addOne: string;
217
+ addMany: string;
218
+ setAll: string;
219
+ upsertOne: string;
220
+ upsertMany: string;
221
+ updateOne: string;
222
+ removeOne: string;
223
+ removeMany: string;
224
+ removeAll: string;
225
+ }>;
226
+ type WithEntitiesOptions<T> = {
227
+ key: string;
228
+ adapter: EntityAdapter<T>;
229
+ initial?: T[];
230
+ selectors?: WithEntitiesSelectorsNames;
231
+ actions?: WithEntitiesActionsNames;
232
+ };
233
+ declare function withEntities<S extends object, T>(config: S & StatoStoreConfig<S>, options: WithEntitiesOptions<T>): S & StatoStoreConfig<S>;
234
+
235
+ type MaybeObservable<T> = StatoObservable<T> | Promise<T> | T;
236
+ type StreamOperator<I, O> = (source: StatoObservable<I>) => StatoObservable<O>;
237
+ declare function pipeStream<T>(source: StatoObservable<T>): StatoObservable<T>;
238
+ declare function pipeStream<T, A>(source: StatoObservable<T>, op1: StreamOperator<T, A>): StatoObservable<A>;
239
+ declare function pipeStream<T, A, B>(source: StatoObservable<T>, op1: StreamOperator<T, A>, op2: StreamOperator<A, B>): StatoObservable<B>;
240
+ declare function pipeStream<T, A, B, C>(source: StatoObservable<T>, op1: StreamOperator<T, A>, op2: StreamOperator<A, B>, op3: StreamOperator<B, C>): StatoObservable<C>;
241
+ declare function mapStream<I, O>(mapFn: (value: I) => O): StreamOperator<I, O>;
242
+ declare function filterStream<T>(predicate: (value: T) => boolean): StreamOperator<T, T>;
243
+ type Mapper<I, O> = (value: I, ctx: {
244
+ signal: AbortSignal;
245
+ }) => MaybeObservable<O>;
246
+ declare function switchMapStream<I, O>(mapper: Mapper<I, O>): StreamOperator<I, O>;
247
+ declare function concatMapStream<I, O>(mapper: Mapper<I, O>): StreamOperator<I, O>;
248
+ declare function exhaustMapStream<I, O>(mapper: Mapper<I, O>): StreamOperator<I, O>;
249
+ declare function mergeMapStream<I, O>(mapper: Mapper<I, O>, options?: {
250
+ concurrency?: number;
251
+ }): StreamOperator<I, O>;
252
+ declare function distinctUntilChangedStream<T, K = T>(keySelector?: (value: T) => K, comparator?: (prev: K, next: K) => boolean): StreamOperator<T, T>;
253
+ declare function debounceStream<T>(ms: number): StreamOperator<T, T>;
254
+ declare function throttleStream<T>(ms: number): StreamOperator<T, T>;
255
+ declare function catchErrorStream<T>(handler: (error: unknown) => MaybeObservable<T>): StreamOperator<T, T>;
256
+ declare function retryStream<T>(options?: {
257
+ attempts?: number;
258
+ delay?: number;
259
+ backoff?: 'fixed' | 'exponential';
260
+ }): StreamOperator<T, T>;
261
+
109
262
  interface PersistStorage {
110
263
  getItem(key: string): string | null;
111
264
  setItem(key: string, value: string): void;
@@ -121,21 +274,103 @@ interface PersistOptions<S extends object> {
121
274
  }
122
275
  declare function withPersist<S extends object>(config: S & StatoStoreConfig<S>, options: PersistOptions<StateSlice<S>>): S & StatoStoreConfig<S>;
123
276
 
277
+ interface FeatureConfig {
278
+ state?: Record<string, unknown>;
279
+ actions?: Record<string, Function>;
280
+ computed?: Record<string, Function>;
281
+ selectors?: Record<string, Function>;
282
+ effects?: EffectEntry<any>[];
283
+ hooks?: Partial<StatoHooks<any>>;
284
+ }
285
+ type MergedFeature = {
286
+ actions?: Record<string, Function>;
287
+ computed?: Record<string, Function>;
288
+ selectors?: Record<string, Function>;
289
+ effects?: EffectEntry<any>[];
290
+ hooks?: Partial<StatoHooks<any>>;
291
+ [key: string]: unknown;
292
+ };
293
+ declare function mergeFeatures(...features: FeatureConfig[]): MergedFeature;
294
+
295
+ /**
296
+ * withProps() — Attach external properties to a store config.
297
+ *
298
+ * Properties are accessible on the store instance but NOT part of the state.
299
+ * Use this to expose injected services on the store object.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * // Pattern 1: Expose services on the store
304
+ * export const UsersStore = StatoStore(() => {
305
+ * const api = inject(ApiService)
306
+ * const notifier = inject(NotificationService)
307
+ *
308
+ * const store = createStore({
309
+ * users: [] as User[],
310
+ * loading: false,
311
+ *
312
+ * actions: {
313
+ * async loadUsers(state) {
314
+ * state.loading = true
315
+ * state.users = await api.getUsers() // closure over injected service
316
+ * state.loading = false
317
+ * },
318
+ *
319
+ * async deleteUser(state, id: string) {
320
+ * await api.deleteUser(id)
321
+ * state.users = state.users.filter(u => u.id !== id)
322
+ * notifier.success('User deleted')
323
+ * }
324
+ * }
325
+ * })
326
+ *
327
+ * // Attach props to the store — accessible but not in state
328
+ * return withProps(store, { api, notifier })
329
+ * })
330
+ *
331
+ * // In a component:
332
+ * store = injectStore(UsersStore)
333
+ * store.users() // Signal<User[]>
334
+ * store.loadUsers() // action
335
+ * store.api // ApiService — read-only, not in state
336
+ * ```
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * // Pattern 2: Configuration props
341
+ * return withProps(store, {
342
+ * storeName: 'Users',
343
+ * version: '1.0.0',
344
+ * config: { pageSize: 20, cacheTTL: 60_000 }
345
+ * })
346
+ * ```
347
+ */
348
+ declare function withProps<S extends object, P extends Record<string, unknown>>(store: S, props: P): S & Readonly<P>;
349
+
124
350
  interface ActionLog {
125
351
  id: number;
126
352
  name: string;
353
+ storeName: string;
127
354
  args: unknown[];
128
355
  duration: number;
129
356
  status: 'success' | 'error';
130
357
  error?: string;
131
- prevState: unknown;
132
- nextState: unknown;
358
+ prevState: Record<string, unknown>;
359
+ nextState: Record<string, unknown>;
133
360
  at: string;
134
361
  }
135
362
  interface DevToolsState {
136
363
  logs: ActionLog[];
137
364
  isOpen: boolean;
138
365
  maxLogs: number;
366
+ activeLogId: number | null;
367
+ isTimeTraveling: boolean;
368
+ }
369
+ interface DevToolsSnapshot {
370
+ version: number;
371
+ timestamp: string;
372
+ stores: Record<string, unknown>;
373
+ logs: ActionLog[];
139
374
  }
140
375
  interface DevToolsInstance {
141
376
  state: DevToolsState;
@@ -145,9 +380,21 @@ interface DevToolsInstance {
145
380
  close: () => void;
146
381
  toggle: () => void;
147
382
  subscribe: (cb: (state: DevToolsState) => void) => () => void;
383
+ travelTo: (logId: number) => void;
384
+ undo: () => void;
385
+ redo: () => void;
386
+ resume: () => void;
387
+ replay: (logId: number) => void;
388
+ exportSnapshot: () => DevToolsSnapshot;
389
+ importSnapshot: (snapshot: DevToolsSnapshot) => void;
390
+ registerStore: (name: string, publicStore: any, internalStore: any) => void;
391
+ getStoreRegistry: () => Map<string, {
392
+ store: any;
393
+ internalStore: any;
394
+ }>;
148
395
  }
149
396
  declare function createDevTools(maxLogs?: number): DevToolsInstance;
150
397
  declare const devTools: DevToolsInstance;
151
398
  declare function connectDevTools(store: any, storeName: string): void;
152
399
 
153
- export { type ActionLog, type DevToolsInstance, type DevToolsState, type EffectDepsFn, type EffectEntry, type EffectRunner, type PersistOptions, type PersistStorage, type RequestOptions, type StatoConfig, type StatoHooks, StatoHttp, StatoHttpError, type StatoStoreConfig, type StatoStoreInstance, abortable, configureHttp, connectDevTools, createDevTools, createHttp, createStore, debounced, devTools, fromStream, http, optimistic, retryable, throttled, withPersist };
400
+ export { type ActionLog, type DevToolsInstance, type DevToolsSnapshot, type DevToolsState, type EffectDepsFn, type EffectEntry, type EffectRunner, type EntityAdapterOptions, type EntityId, type EntityState, type FeatureConfig, type MergedFeature, type OnEvent, type PersistOptions, type PersistStorage, type RequestOptions, type StatoConfig, type StatoHooks, StatoHttp, StatoHttpError, type StatoStoreConfig, type StatoStoreInstance, type Update, type WithEntitiesOptions, abortable, catchErrorStream, combineLatest, combineLatestStream, concatMapStream, configureHttp, connectDevTools, createDevTools, createEntityAdapter, createHttp, createStore, debounceStream, debounced, devTools, distinctUntilChanged, distinctUntilChangedStream, exclusive, exhaustMapStream, filterStream, forkJoin, fromStream, http, mapStream, mergeFeatures, mergeMapStream, on, optimistic, pipeStream, queued, race, retryStream, retryable, switchMapStream, throttleStream, throttled, withEntities, withPersist, withProps };