@effuse/store 1.0.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { signal, type Signal } from '@effuse/core';
26
+ import type { Store } from '../core/types.js';
27
+ import {
28
+ createCancellationToken,
29
+ type CancellationToken,
30
+ } from '../actions/cancellation.js';
31
+
32
+ // Shallow value comparison
33
+ export const shallowEqual = <T>(a: T, b: T): boolean => {
34
+ if (Object.is(a, b)) return true;
35
+ if (
36
+ typeof a !== 'object' ||
37
+ a === null ||
38
+ typeof b !== 'object' ||
39
+ b === null
40
+ ) {
41
+ return false;
42
+ }
43
+
44
+ if (Array.isArray(a) && Array.isArray(b)) {
45
+ if (a.length !== b.length) return false;
46
+ for (let i = 0; i < a.length; i++) {
47
+ if (!Object.is(a[i], b[i])) return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ const keysA = Object.keys(a);
53
+ const keysB = Object.keys(b);
54
+ if (keysA.length !== keysB.length) return false;
55
+
56
+ for (const key of keysA) {
57
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
58
+ if (
59
+ !Object.is(
60
+ (a as Record<string, unknown>)[key],
61
+ (b as Record<string, unknown>)[key]
62
+ )
63
+ ) {
64
+ return false;
65
+ }
66
+ }
67
+ return true;
68
+ };
69
+
70
+ // State extraction selector
71
+ export type Selector<T, R> = (state: T) => R;
72
+
73
+ // State equality check
74
+ export type EqualityFn<T> = (a: T, b: T) => boolean;
75
+
76
+ // Build memoized selector signal
77
+ export const createSelector = <T, R>(
78
+ store: Store<T>,
79
+ selector: Selector<ReturnType<Store<T>['getSnapshot']>, R>,
80
+ equalityFn: EqualityFn<R> = shallowEqual
81
+ ): Signal<R> => {
82
+ const snapshot = store.getSnapshot();
83
+ const initialValue = selector(snapshot);
84
+ const derived = signal(initialValue);
85
+
86
+ store.subscribe(() => {
87
+ const currentSnapshot = store.getSnapshot();
88
+ const newValue = selector(currentSnapshot);
89
+ if (!equalityFn(derived.value, newValue)) {
90
+ derived.value = newValue;
91
+ }
92
+ });
93
+
94
+ return derived;
95
+ };
96
+
97
+ // Asynchronous state selector
98
+ export type AsyncSelector<T, R> = (
99
+ state: T,
100
+ token: CancellationToken
101
+ ) => Promise<R>;
102
+
103
+ // Build asynchronous selector signal
104
+ export const createSelectorAsync = <T, R>(
105
+ store: Store<T>,
106
+ asyncSelector: AsyncSelector<ReturnType<Store<T>['getSnapshot']>, R>,
107
+ initialValue: R
108
+ ): Signal<R> & { pending: Signal<boolean>; cleanup: () => void } => {
109
+ const derived = signal<R>(initialValue);
110
+ const pending = signal<boolean>(false);
111
+ let currentToken = createCancellationToken();
112
+
113
+ const update = () => {
114
+ currentToken.cancel();
115
+ currentToken = createCancellationToken();
116
+ const myToken = currentToken;
117
+ pending.value = true;
118
+
119
+ const snapshot = store.getSnapshot();
120
+ asyncSelector(snapshot, myToken)
121
+ .then((newValue) => {
122
+ if (!myToken.isCancelled && derived.value !== newValue) {
123
+ derived.value = newValue;
124
+ }
125
+ })
126
+ .catch(() => {})
127
+ .finally(() => {
128
+ if (!myToken.isCancelled) {
129
+ pending.value = false;
130
+ }
131
+ });
132
+ };
133
+
134
+ const unsub = store.subscribe(update);
135
+ update();
136
+
137
+ const result = derived as Signal<R> & {
138
+ pending: Signal<boolean>;
139
+ cleanup: () => void;
140
+ };
141
+ result.pending = pending;
142
+ result.cleanup = () => {
143
+ currentToken.cancel();
144
+ unsub();
145
+ };
146
+
147
+ return result;
148
+ };
149
+
150
+ // Build property pick selector
151
+ export const pick = <T, K extends keyof T>(
152
+ store: Store<T>,
153
+ keys: K[]
154
+ ): Signal<Pick<T, K>> => {
155
+ return createSelector(store, (state) => {
156
+ const picked = {} as Pick<T, K>;
157
+ for (const key of keys) {
158
+ if (key in (state as object)) {
159
+ picked[key] = (state as T)[key];
160
+ }
161
+ }
162
+ return picked;
163
+ });
164
+ };
165
+
166
+ // Build combined selector signal
167
+ export const combineSelectors = <T, R extends Record<string, unknown>>(
168
+ store: Store<T>,
169
+ selectors: {
170
+ [K in keyof R]: Selector<ReturnType<Store<T>['getSnapshot']>, R[K]>;
171
+ }
172
+ ): Signal<R> => {
173
+ return createSelector(store, (state) => {
174
+ const result = {} as R;
175
+ for (const key of Object.keys(selectors) as (keyof R)[]) {
176
+ result[key] = selectors[key](state);
177
+ }
178
+ return result;
179
+ });
180
+ };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import type { Store } from '../core/types.js';
26
+ import {
27
+ createCancellationToken,
28
+ type CancellationToken,
29
+ } from '../actions/cancellation.js';
30
+
31
+ // Store state change stream
32
+ export interface StoreStream<T> {
33
+ subscribe: (handler: (value: T) => void) => () => void;
34
+ map: <R>(fn: (value: T) => R) => StoreStream<R>;
35
+ filter: (predicate: (value: T) => boolean) => StoreStream<T>;
36
+ debounce: (ms: number) => StoreStream<T>;
37
+ throttle: (ms: number) => StoreStream<T>;
38
+ takeLatest: <R>(
39
+ asyncHandler: (value: T, token: CancellationToken) => Promise<R>
40
+ ) => StoreStream<R>;
41
+ }
42
+
43
+ const createBaseStream = <T>(
44
+ addListener: (handler: (value: T) => void) => () => void
45
+ ): StoreStream<T> => {
46
+ return {
47
+ subscribe: addListener,
48
+
49
+ map: <R>(fn: (value: T) => R): StoreStream<R> => {
50
+ const mappedListeners = new Set<(value: R) => void>();
51
+ addListener((value) => {
52
+ const mapped = fn(value);
53
+ for (const h of mappedListeners) h(mapped);
54
+ });
55
+ return createBaseStream((h) => {
56
+ mappedListeners.add(h);
57
+ return () => mappedListeners.delete(h);
58
+ });
59
+ },
60
+
61
+ filter: (predicate): StoreStream<T> => {
62
+ const filteredListeners = new Set<(value: T) => void>();
63
+ addListener((value) => {
64
+ if (predicate(value)) {
65
+ for (const h of filteredListeners) h(value);
66
+ }
67
+ });
68
+ return createBaseStream((h) => {
69
+ filteredListeners.add(h);
70
+ return () => filteredListeners.delete(h);
71
+ });
72
+ },
73
+
74
+ debounce: (ms): StoreStream<T> => {
75
+ const debouncedListeners = new Set<(value: T) => void>();
76
+ let timeout: ReturnType<typeof setTimeout> | null = null;
77
+ let latestValue: T | undefined;
78
+ let currentToken = createCancellationToken();
79
+
80
+ addListener((value) => {
81
+ latestValue = value;
82
+ if (timeout) {
83
+ clearTimeout(timeout);
84
+ currentToken.cancel();
85
+ }
86
+ currentToken = createCancellationToken();
87
+ const myToken = currentToken;
88
+
89
+ timeout = setTimeout(() => {
90
+ if (!myToken.isCancelled && latestValue !== undefined) {
91
+ for (const h of debouncedListeners) h(latestValue);
92
+ }
93
+ }, ms);
94
+ });
95
+
96
+ return createBaseStream((h) => {
97
+ debouncedListeners.add(h);
98
+ return () => debouncedListeners.delete(h);
99
+ });
100
+ },
101
+
102
+ throttle: (ms): StoreStream<T> => {
103
+ const throttledListeners = new Set<(value: T) => void>();
104
+ let lastEmitTime = 0;
105
+
106
+ addListener((value) => {
107
+ const now = Date.now();
108
+ if (now - lastEmitTime >= ms) {
109
+ lastEmitTime = now;
110
+ for (const h of throttledListeners) h(value);
111
+ }
112
+ });
113
+
114
+ return createBaseStream((h) => {
115
+ throttledListeners.add(h);
116
+ return () => throttledListeners.delete(h);
117
+ });
118
+ },
119
+
120
+ takeLatest: <R>(
121
+ asyncHandler: (value: T, token: CancellationToken) => Promise<R>
122
+ ): StoreStream<R> => {
123
+ const latestListeners = new Set<(value: R) => void>();
124
+ let currentToken = createCancellationToken();
125
+
126
+ addListener((value) => {
127
+ currentToken.cancel();
128
+ currentToken = createCancellationToken();
129
+ const myToken = currentToken;
130
+
131
+ asyncHandler(value, myToken)
132
+ .then((result) => {
133
+ if (!myToken.isCancelled) {
134
+ for (const h of latestListeners) h(result);
135
+ }
136
+ })
137
+ .catch(() => {});
138
+ });
139
+
140
+ return createBaseStream((h) => {
141
+ latestListeners.add(h);
142
+ return () => latestListeners.delete(h);
143
+ });
144
+ },
145
+ };
146
+ };
147
+
148
+ // Build store property stream
149
+ export const createStoreStream = <T, K extends keyof T>(
150
+ store: Store<T>,
151
+ key: K
152
+ ): StoreStream<T[K]> => {
153
+ const listeners = new Set<(value: T[K]) => void>();
154
+ let lastValue: T[K] = (store.getSnapshot() as Record<string, unknown>)[
155
+ key as string
156
+ ] as T[K];
157
+
158
+ store.subscribe(() => {
159
+ const snapshot = store.getSnapshot() as Record<string, unknown>;
160
+ const newValue = snapshot[key as string] as T[K];
161
+ if (newValue !== lastValue) {
162
+ lastValue = newValue;
163
+ for (const listener of listeners) {
164
+ listener(newValue);
165
+ }
166
+ }
167
+ });
168
+
169
+ return createBaseStream((handler) => {
170
+ listeners.add(handler);
171
+ return () => listeners.delete(handler);
172
+ });
173
+ };
174
+
175
+ // Observe entire store stream
176
+ export const streamAll = <T>(
177
+ store: Store<T>
178
+ ): StoreStream<ReturnType<Store<T>['getSnapshot']>> => {
179
+ const listeners = new Set<
180
+ (value: ReturnType<Store<T>['getSnapshot']>) => void
181
+ >();
182
+
183
+ store.subscribe(() => {
184
+ const snapshot = store.getSnapshot();
185
+ for (const listener of listeners) {
186
+ listener(snapshot);
187
+ }
188
+ });
189
+
190
+ return createBaseStream((handler) => {
191
+ listeners.add(handler);
192
+ return () => listeners.delete(handler);
193
+ });
194
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+ import type { Store } from '../core/types.js';
25
+ import { getStoreConfig } from '../config/index.js';
26
+ import { StoreNotFoundError } from '../errors.js';
27
+
28
+ const stores = new Map<string, Store<unknown>>();
29
+
30
+ // Register store instance
31
+ export const registerStore = <T>(name: string, store: Store<T>): void => {
32
+ if (stores.has(name) && getStoreConfig().debug) {
33
+ // eslint-disable-next-line no-console
34
+ console.warn(`[store] Overwriting existing store: ${name}`);
35
+ }
36
+ stores.set(name, store as Store<unknown>);
37
+ };
38
+
39
+ // Access registered store
40
+ export const getStore = <T>(name: string): Store<T> => {
41
+ const store = stores.get(name);
42
+ if (!store) {
43
+ throw new StoreNotFoundError({ name });
44
+ }
45
+ return store as Store<T>;
46
+ };
47
+
48
+ // Detect registered store
49
+ export const hasStore = (name: string): boolean => stores.has(name);
50
+
51
+ // Remove registered store
52
+ export const removeStore = (name: string): boolean => stores.delete(name);
53
+
54
+ // Reset store registry
55
+ export const clearStores = (): void => {
56
+ stores.clear();
57
+ };
58
+
59
+ // Access store names
60
+ export const getStoreNames = (): string[] => Array.from(stores.keys());
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ export {
26
+ validateState,
27
+ validateStateAsync,
28
+ createValidatedSetter,
29
+ createFieldValidator,
30
+ createSafeFieldSetter,
31
+ type StateSchema,
32
+ type ValidationResult,
33
+ } from './schema.js';
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { Effect, Schema, Duration, Predicate } from 'effect';
26
+ import { TimeoutError } from '../errors.js';
27
+ import { DEFAULT_TIMEOUT_MS } from '../config/constants.js';
28
+
29
+ // State validation schema type
30
+ export type StateSchema<T> = Schema.Schema<T, T>;
31
+
32
+ // Validation check result
33
+ export interface ValidationResult<T> {
34
+ success: boolean;
35
+ data: T | null;
36
+ errors: string[];
37
+ }
38
+
39
+ // Validate state synchronously
40
+ export const validateState = <T>(
41
+ schema: StateSchema<T>,
42
+ state: unknown
43
+ ): ValidationResult<T> => {
44
+ const result = Effect.runSync(
45
+ Schema.decodeUnknown(schema)(state).pipe(
46
+ Effect.map((data) => ({
47
+ success: true as const,
48
+ data,
49
+ errors: [] as string[],
50
+ })),
51
+ Effect.catchAll((error) =>
52
+ Effect.succeed({
53
+ success: false as const,
54
+ data: null,
55
+ errors: [String(error)],
56
+ })
57
+ )
58
+ )
59
+ );
60
+ return result;
61
+ };
62
+
63
+ // Validate state asynchronously
64
+ export const validateStateAsync = <T>(
65
+ schema: StateSchema<T>,
66
+ state: unknown,
67
+ timeoutMs = DEFAULT_TIMEOUT_MS
68
+ ): Promise<ValidationResult<T>> => {
69
+ return Effect.runPromise(
70
+ Schema.decodeUnknown(schema)(state).pipe(
71
+ Effect.map((data) => ({
72
+ success: true as const,
73
+ data,
74
+ errors: [] as string[],
75
+ })),
76
+ Effect.timeoutFail({
77
+ duration: Duration.millis(timeoutMs),
78
+ onTimeout: () => new TimeoutError({ ms: timeoutMs }),
79
+ }),
80
+ Effect.catchAll((error) =>
81
+ Effect.succeed({
82
+ success: false as const,
83
+ data: null,
84
+ errors: [
85
+ error instanceof TimeoutError
86
+ ? `Validation timed out after ${String(timeoutMs)}ms`
87
+ : String(error),
88
+ ],
89
+ })
90
+ )
91
+ )
92
+ );
93
+ };
94
+
95
+ // Build validated state setter
96
+ export const createValidatedSetter = <T extends Record<string, unknown>>(
97
+ schema: StateSchema<T>,
98
+ onValid: (state: T) => void,
99
+ onInvalid?: (errors: string[]) => void
100
+ ): ((state: unknown) => boolean) => {
101
+ return (state: unknown): boolean => {
102
+ const result = validateState(schema, state);
103
+ if (result.success && result.data) {
104
+ onValid(result.data);
105
+ return true;
106
+ }
107
+ if (Predicate.isNotNullable(onInvalid)) {
108
+ onInvalid(result.errors);
109
+ }
110
+ return false;
111
+ };
112
+ };
113
+
114
+ // Build field validator
115
+ export const createFieldValidator = <T>(
116
+ schema: Schema.Schema<T, T>
117
+ ): ((value: unknown) => T) => {
118
+ return (value: unknown): T => {
119
+ return Effect.runSync(Schema.decodeUnknown(schema)(value));
120
+ };
121
+ };
122
+
123
+ // Build safe field setter
124
+ export const createSafeFieldSetter = <T>(
125
+ schema: Schema.Schema<T, T>,
126
+ setter: (value: T) => void
127
+ ): ((value: unknown) => boolean) => {
128
+ return (value: unknown): boolean => {
129
+ const result = Effect.runSync(
130
+ Schema.decodeUnknown(schema)(value).pipe(
131
+ Effect.map((decoded) => {
132
+ setter(decoded);
133
+ return true;
134
+ }),
135
+ Effect.catchAll(() => Effect.succeed(false))
136
+ )
137
+ );
138
+ return result;
139
+ };
140
+ };
141
+
142
+ export { Schema };