@ngstato/core 0.1.1 → 0.2.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
@@ -23,14 +23,21 @@ const store = createStore({
23
23
  }
24
24
  },
25
25
 
26
- computed: {
26
+ selectors: {
27
27
  total: (state) => state.users.length
28
- }
28
+ },
29
+
30
+ effects: [
31
+ [
32
+ (state) => state.users.length,
33
+ (count) => console.log('Total users:', count)
34
+ ]
35
+ ]
29
36
  })
30
37
 
31
38
  await store.loadUsers()
32
39
  console.log(store.users) // User[]
33
- console.log(store.total) // number
40
+ console.log(store.total) // number (selector memoïzé)
34
41
  ```
35
42
 
36
43
  ## Helpers
@@ -43,6 +50,13 @@ console.log(store.total) // number
43
50
  | `retryable()` | Retry avec backoff fixe ou exponentiel |
44
51
  | `fromStream()` | Realtime — WebSocket, Firebase, Supabase |
45
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
46
60
 
47
61
  ## Documentation
48
62
 
@@ -1,7 +1,16 @@
1
- type StateSlice<T> = Omit<T, 'actions' | 'computed' | 'hooks'>;
1
+ type StateSlice<T> = Omit<T, 'actions' | 'computed' | 'selectors' | 'hooks'>;
2
2
  type Action<S> = (state: S, ...args: any[]) => void | Promise<void> | (() => void);
3
3
  type ActionsMap<S> = Record<string, Action<S>>;
4
4
  type ComputedFn<S> = (state: S) => unknown;
5
+ type SelectorFn<S> = (state: S) => unknown;
6
+ type EffectDepsFn<S> = (state: S) => unknown | unknown[];
7
+ type EffectCleanup = void | (() => void);
8
+ type EffectRunner<S> = (depsValue: unknown | unknown[], ctx: {
9
+ state: Readonly<S>;
10
+ store: any;
11
+ prevDepsValue?: unknown | unknown[];
12
+ }) => EffectCleanup | Promise<EffectCleanup>;
13
+ type EffectEntry<S> = [EffectDepsFn<S>, EffectRunner<S>];
5
14
  interface StatoHooks<S> {
6
15
  onInit?: (store: S) => void | Promise<void>;
7
16
  onDestroy?: (store: S) => void | Promise<void>;
@@ -13,6 +22,8 @@ interface StatoHooks<S> {
13
22
  interface StatoStoreConfig<S extends object> {
14
23
  actions?: ActionsMap<StateSlice<S>>;
15
24
  computed?: Record<string, ComputedFn<StateSlice<S>> | unknown[]>;
25
+ selectors?: Record<string, SelectorFn<StateSlice<S>> | unknown[]>;
26
+ effects?: EffectEntry<StateSlice<S>>[];
16
27
  hooks?: StatoHooks<any>;
17
28
  [key: string]: unknown;
18
29
  }
@@ -95,6 +106,21 @@ declare function fromStream<S, T>(setupFn: (state: S) => StatoObservable<T>, upd
95
106
 
96
107
  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>;
97
108
 
109
+ interface PersistStorage {
110
+ getItem(key: string): string | null;
111
+ setItem(key: string, value: string): void;
112
+ removeItem(key: string): void;
113
+ }
114
+ interface PersistOptions<S extends object> {
115
+ key: string;
116
+ version?: number;
117
+ storage?: PersistStorage;
118
+ pick?: (keyof S)[];
119
+ migrate?: (data: unknown, fromVersion: number) => Partial<S>;
120
+ onError?: (error: Error) => void;
121
+ }
122
+ declare function withPersist<S extends object>(config: S & StatoStoreConfig<S>, options: PersistOptions<StateSlice<S>>): S & StatoStoreConfig<S>;
123
+
98
124
  interface ActionLog {
99
125
  id: number;
100
126
  name: string;
@@ -124,4 +150,4 @@ declare function createDevTools(maxLogs?: number): DevToolsInstance;
124
150
  declare const devTools: DevToolsInstance;
125
151
  declare function connectDevTools(store: any, storeName: string): void;
126
152
 
127
- export { type ActionLog, type DevToolsInstance, type DevToolsState, 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 };
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 };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,16 @@
1
- type StateSlice<T> = Omit<T, 'actions' | 'computed' | 'hooks'>;
1
+ type StateSlice<T> = Omit<T, 'actions' | 'computed' | 'selectors' | 'hooks'>;
2
2
  type Action<S> = (state: S, ...args: any[]) => void | Promise<void> | (() => void);
3
3
  type ActionsMap<S> = Record<string, Action<S>>;
4
4
  type ComputedFn<S> = (state: S) => unknown;
5
+ type SelectorFn<S> = (state: S) => unknown;
6
+ type EffectDepsFn<S> = (state: S) => unknown | unknown[];
7
+ type EffectCleanup = void | (() => void);
8
+ type EffectRunner<S> = (depsValue: unknown | unknown[], ctx: {
9
+ state: Readonly<S>;
10
+ store: any;
11
+ prevDepsValue?: unknown | unknown[];
12
+ }) => EffectCleanup | Promise<EffectCleanup>;
13
+ type EffectEntry<S> = [EffectDepsFn<S>, EffectRunner<S>];
5
14
  interface StatoHooks<S> {
6
15
  onInit?: (store: S) => void | Promise<void>;
7
16
  onDestroy?: (store: S) => void | Promise<void>;
@@ -13,6 +22,8 @@ interface StatoHooks<S> {
13
22
  interface StatoStoreConfig<S extends object> {
14
23
  actions?: ActionsMap<StateSlice<S>>;
15
24
  computed?: Record<string, ComputedFn<StateSlice<S>> | unknown[]>;
25
+ selectors?: Record<string, SelectorFn<StateSlice<S>> | unknown[]>;
26
+ effects?: EffectEntry<StateSlice<S>>[];
16
27
  hooks?: StatoHooks<any>;
17
28
  [key: string]: unknown;
18
29
  }
@@ -95,6 +106,21 @@ declare function fromStream<S, T>(setupFn: (state: S) => StatoObservable<T>, upd
95
106
 
96
107
  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>;
97
108
 
109
+ interface PersistStorage {
110
+ getItem(key: string): string | null;
111
+ setItem(key: string, value: string): void;
112
+ removeItem(key: string): void;
113
+ }
114
+ interface PersistOptions<S extends object> {
115
+ key: string;
116
+ version?: number;
117
+ storage?: PersistStorage;
118
+ pick?: (keyof S)[];
119
+ migrate?: (data: unknown, fromVersion: number) => Partial<S>;
120
+ onError?: (error: Error) => void;
121
+ }
122
+ declare function withPersist<S extends object>(config: S & StatoStoreConfig<S>, options: PersistOptions<StateSlice<S>>): S & StatoStoreConfig<S>;
123
+
98
124
  interface ActionLog {
99
125
  id: number;
100
126
  name: string;
@@ -124,4 +150,4 @@ declare function createDevTools(maxLogs?: number): DevToolsInstance;
124
150
  declare const devTools: DevToolsInstance;
125
151
  declare function connectDevTools(store: any, storeName: string): void;
126
152
 
127
- export { type ActionLog, type DevToolsInstance, type DevToolsState, 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 };
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 };
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  // src/store.ts
2
4
  var StatoStore = class {
3
5
  // Le state interne — jamais accessible directement
@@ -8,12 +10,44 @@ var StatoStore = class {
8
10
  _actions = {};
9
11
  // Les computed enregistrés
10
12
  _computed = {};
13
+ _selectors = {};
11
14
  // Les cleanups à appeler à la destruction
12
15
  _cleanups = [];
13
16
  // Les hooks lifecycle
14
17
  _hooks;
18
+ _publicStore = null;
19
+ _effects = [];
20
+ _createMemoizedSelector(fn) {
21
+ let initialized = false;
22
+ let cachedResult;
23
+ let trackedKeys = [];
24
+ let trackedValues = [];
25
+ return () => {
26
+ if (initialized && trackedKeys.length) {
27
+ const unchanged = trackedKeys.every(
28
+ (key, index) => Object.is(this._state[key], trackedValues[index])
29
+ );
30
+ if (unchanged) return cachedResult;
31
+ }
32
+ const reads = /* @__PURE__ */ new Set();
33
+ const trackingState = new Proxy(this._state, {
34
+ get: (target, prop, receiver) => {
35
+ if (typeof prop === "string" && prop in target) {
36
+ reads.add(prop);
37
+ }
38
+ return Reflect.get(target, prop, receiver);
39
+ }
40
+ });
41
+ const result = fn(trackingState);
42
+ trackedKeys = Array.from(reads);
43
+ trackedValues = trackedKeys.map((key) => this._state[key]);
44
+ cachedResult = result;
45
+ initialized = true;
46
+ return result;
47
+ };
48
+ }
15
49
  constructor(config) {
16
- const { actions, computed, hooks, ...initialState } = config;
50
+ const { actions, computed, selectors, effects, hooks, ...initialState } = config;
17
51
  this._state = initialState;
18
52
  this._hooks = hooks ?? {};
19
53
  if (actions) {
@@ -28,6 +62,27 @@ var StatoStore = class {
28
62
  }
29
63
  }
30
64
  }
65
+ if (selectors) {
66
+ for (const [name, fn] of Object.entries(selectors)) {
67
+ if (typeof fn === "function") {
68
+ this._selectors[name] = this._createMemoizedSelector(fn);
69
+ }
70
+ }
71
+ }
72
+ if (effects) {
73
+ for (const entry of effects) {
74
+ const [deps, run] = entry;
75
+ if (typeof deps === "function" && typeof run === "function") {
76
+ this._effects.push({
77
+ deps,
78
+ run,
79
+ hasRun: false,
80
+ running: false,
81
+ rerunRequested: false
82
+ });
83
+ }
84
+ }
85
+ }
31
86
  }
32
87
  // ── Lire le state ──────────────────────────────────
33
88
  getState() {
@@ -36,8 +91,56 @@ var StatoStore = class {
36
91
  // ── Modifier le state — usage interne uniquement ───
37
92
  _setState(partial) {
38
93
  this._state = { ...this._state, ...partial };
94
+ this._runEffects();
39
95
  this._notify();
40
96
  }
97
+ _normalizeDeps(value) {
98
+ return Array.isArray(value) ? value : [value];
99
+ }
100
+ _depsChanged(prev, next) {
101
+ if (!prev) return true;
102
+ if (prev.length !== next.length) return true;
103
+ for (let i = 0; i < next.length; i++) {
104
+ if (!Object.is(prev[i], next[i])) return true;
105
+ }
106
+ return false;
107
+ }
108
+ _runEffects(force = false) {
109
+ for (const effect of this._effects) {
110
+ const depsValue = effect.deps(this._state);
111
+ const depsArray = this._normalizeDeps(depsValue);
112
+ const shouldRun = force || this._depsChanged(effect.prevDeps, depsArray);
113
+ if (!shouldRun) continue;
114
+ const execute = async () => {
115
+ if (effect.running) {
116
+ effect.rerunRequested = true;
117
+ return;
118
+ }
119
+ effect.running = true;
120
+ effect.rerunRequested = false;
121
+ const prevDepsValue = effect.prevDeps;
122
+ effect.prevDeps = depsArray;
123
+ try {
124
+ effect.cleanup?.();
125
+ const maybeCleanup = await effect.run(depsValue, {
126
+ state: { ...this._state },
127
+ store: this._publicStore,
128
+ prevDepsValue
129
+ });
130
+ effect.cleanup = typeof maybeCleanup === "function" ? maybeCleanup : void 0;
131
+ effect.hasRun = true;
132
+ } catch (error) {
133
+ this._hooks.onError?.(error, "effect");
134
+ } finally {
135
+ effect.running = false;
136
+ if (effect.rerunRequested) {
137
+ this._runEffects();
138
+ }
139
+ }
140
+ };
141
+ void execute();
142
+ }
143
+ }
41
144
  // ── Notifier tous les abonnés ──────────────────────
42
145
  _notify() {
43
146
  for (const subscriber of this._subscribers) {
@@ -80,16 +183,34 @@ var StatoStore = class {
80
183
  if (!fn) throw new Error(`[Stato] Computed "${name}" introuvable`);
81
184
  return fn();
82
185
  }
186
+ getSelector(name) {
187
+ const fn = this._selectors[name];
188
+ if (!fn) throw new Error(`[Stato] Selector "${name}" introuvable`);
189
+ return fn();
190
+ }
83
191
  // ── Enregistrer un cleanup (pour fromStream) ───────
84
192
  registerCleanup(fn) {
85
193
  this._cleanups.push(fn);
86
194
  }
195
+ hydrate(partial) {
196
+ this._setState(partial);
197
+ }
198
+ setPublicStore(publicStore) {
199
+ this._publicStore = publicStore;
200
+ this._runEffects(true);
201
+ }
87
202
  // ── Lifecycle — appelé par l'adaptateur Angular ────
88
203
  init(publicStore) {
204
+ this._publicStore = publicStore;
89
205
  this._hooks.onInit?.(publicStore);
206
+ this._runEffects(true);
90
207
  }
91
208
  destroy(publicStore) {
92
209
  this._hooks.onDestroy?.(publicStore);
210
+ for (const effect of this._effects) {
211
+ effect.cleanup?.();
212
+ effect.cleanup = void 0;
213
+ }
93
214
  for (const cleanup of this._cleanups) {
94
215
  cleanup();
95
216
  }
@@ -117,7 +238,7 @@ function createStore(config) {
117
238
  configurable: true
118
239
  });
119
240
  }
120
- const { actions, computed } = config;
241
+ const { actions, computed, selectors } = config;
121
242
  if (actions) {
122
243
  for (const name of Object.keys(actions)) {
123
244
  publicStore[name] = (...args) => store.dispatch(name, ...args);
@@ -132,6 +253,16 @@ function createStore(config) {
132
253
  });
133
254
  }
134
255
  }
256
+ if (selectors) {
257
+ for (const name of Object.keys(selectors)) {
258
+ Object.defineProperty(publicStore, name, {
259
+ get: () => store.getSelector(name),
260
+ enumerable: true,
261
+ configurable: true
262
+ });
263
+ }
264
+ }
265
+ store.setPublicStore(publicStore);
135
266
  return publicStore;
136
267
  }
137
268
 
@@ -364,6 +495,85 @@ function optimistic(immediate, confirm) {
364
495
  };
365
496
  }
366
497
 
498
+ // src/helpers/with-persist.ts
499
+ function resolveStorage(custom) {
500
+ if (custom) return custom;
501
+ if (typeof window === "undefined") return null;
502
+ try {
503
+ return window.localStorage;
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+ function pickState(state, keys) {
509
+ if (!keys?.length) return state;
510
+ const picked = {};
511
+ for (const key of keys) {
512
+ picked[key] = state[key];
513
+ }
514
+ return picked;
515
+ }
516
+ function withPersist(config, options) {
517
+ const {
518
+ key,
519
+ version = 1,
520
+ storage: customStorage,
521
+ pick,
522
+ migrate,
523
+ onError
524
+ } = options;
525
+ const storage = resolveStorage(customStorage);
526
+ const userHooks = config.hooks ?? {};
527
+ const mergedHooks = {
528
+ ...userHooks,
529
+ onInit(store) {
530
+ try {
531
+ if (!storage) return userHooks.onInit?.(store);
532
+ const raw = storage.getItem(key);
533
+ if (!raw) return userHooks.onInit?.(store);
534
+ const parsed = JSON.parse(raw);
535
+ const data = parsed.v === version ? parsed.data : migrate ? migrate(parsed.data, parsed.v) : parsed.data;
536
+ if (data && typeof data === "object") {
537
+ store.__store__?.hydrate?.(data);
538
+ }
539
+ } catch (error) {
540
+ onError?.(error);
541
+ }
542
+ return userHooks.onInit?.(store);
543
+ },
544
+ onStateChange(prev, next) {
545
+ try {
546
+ if (storage) {
547
+ const payload = {
548
+ v: version,
549
+ data: pickState(next, pick)
550
+ };
551
+ storage.setItem(key, JSON.stringify(payload));
552
+ }
553
+ } catch (error) {
554
+ onError?.(error);
555
+ }
556
+ userHooks.onStateChange?.(prev, next);
557
+ },
558
+ onDestroy(store) {
559
+ return userHooks.onDestroy?.(store);
560
+ },
561
+ onAction(name, args) {
562
+ return userHooks.onAction?.(name, args);
563
+ },
564
+ onActionDone(name, duration) {
565
+ return userHooks.onActionDone?.(name, duration);
566
+ },
567
+ onError(error, actionName) {
568
+ return userHooks.onError?.(error, actionName);
569
+ }
570
+ };
571
+ return {
572
+ ...config,
573
+ hooks: mergedHooks
574
+ };
575
+ }
576
+
367
577
  // src/devtools.ts
368
578
  function createDevTools(maxLogs = 50) {
369
579
  let counter = 0;
@@ -450,6 +660,21 @@ function connectDevTools(store, storeName) {
450
660
  };
451
661
  }
452
662
 
453
- export { StatoHttp, StatoHttpError, abortable, configureHttp, connectDevTools, createDevTools, createHttp, createStore, debounced, devTools, fromStream, http, optimistic, retryable, throttled };
663
+ exports.StatoHttp = StatoHttp;
664
+ exports.StatoHttpError = StatoHttpError;
665
+ exports.abortable = abortable;
666
+ exports.configureHttp = configureHttp;
667
+ exports.connectDevTools = connectDevTools;
668
+ exports.createDevTools = createDevTools;
669
+ exports.createHttp = createHttp;
670
+ exports.createStore = createStore;
671
+ exports.debounced = debounced;
672
+ exports.devTools = devTools;
673
+ exports.fromStream = fromStream;
674
+ exports.http = http;
675
+ exports.optimistic = optimistic;
676
+ exports.retryable = retryable;
677
+ exports.throttled = throttled;
678
+ exports.withPersist = withPersist;
454
679
  //# sourceMappingURL=index.js.map
455
680
  //# sourceMappingURL=index.js.map