@effuse/store 1.0.2 → 1.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.
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { signal } from '@effuse/core';
3
+ import {
4
+ setValue,
5
+ resetState,
6
+ batchUpdates,
7
+ updateState,
8
+ getSnapshot,
9
+ } from './operations.js';
10
+ import type { StoreHandlerDeps, StoreInternals } from './types.js';
11
+ import { createCancellationScope } from '../actions/cancellation.js';
12
+ import { createAtomicState } from '../core/state.js';
13
+ import { createMiddlewareManager } from '../middleware/index.js';
14
+ import { createMemoryAdapter } from '../persistence/adapters.js';
15
+
16
+ const createMockDeps = (
17
+ initialState: Record<string, unknown> = {}
18
+ ): StoreHandlerDeps => {
19
+ const signalMap = new Map<string, ReturnType<typeof signal>>();
20
+ for (const [key, value] of Object.entries(initialState)) {
21
+ signalMap.set(key, signal(value));
22
+ }
23
+
24
+ const internals: StoreInternals = {
25
+ signalMap,
26
+ initialState: { ...initialState },
27
+ actions: {},
28
+ subscribers: new Set(),
29
+ keySubscribers: new Map(),
30
+ computedSelectors: new Map(),
31
+ isBatching: false,
32
+ cancellationScope: createCancellationScope(),
33
+ pendingActions: new Map(),
34
+ };
35
+
36
+ return {
37
+ internals,
38
+ atomicState: createAtomicState(initialState),
39
+ middlewareManager: createMiddlewareManager(),
40
+ config: {
41
+ name: 'testStore',
42
+ shouldPersist: false,
43
+ storageKey: 'test_store',
44
+ enableDevtools: false,
45
+ adapter: createMemoryAdapter(),
46
+ },
47
+ };
48
+ };
49
+
50
+ describe('operations handlers', () => {
51
+ describe('setValue', () => {
52
+ it('should set a value in the signal map', () => {
53
+ const deps = createMockDeps({ count: 0 });
54
+ setValue(deps, { prop: 'count', value: 5 });
55
+ expect(deps.internals.signalMap.get('count')?.value).toBe(5);
56
+ });
57
+
58
+ it('should create new signal for non-existent property', () => {
59
+ const deps = createMockDeps({});
60
+ setValue(deps, { prop: 'newProp', value: 'hello' });
61
+ expect(deps.internals.signalMap.get('newProp')?.value).toBe('hello');
62
+ });
63
+
64
+ it('should notify subscribers after value change', () => {
65
+ const deps = createMockDeps({ count: 0 });
66
+ const subscriber = vi.fn();
67
+ deps.internals.subscribers.add(subscriber);
68
+ setValue(deps, { prop: 'count', value: 10 });
69
+ expect(subscriber).toHaveBeenCalled();
70
+ });
71
+
72
+ it('should notify key subscribers with new value', () => {
73
+ const deps = createMockDeps({ count: 0 });
74
+ const keySubscriber = vi.fn();
75
+ deps.internals.keySubscribers.set('count', new Set([keySubscriber]));
76
+ setValue(deps, { prop: 'count', value: 42 });
77
+ expect(keySubscriber).toHaveBeenCalledWith(42);
78
+ });
79
+
80
+ it('should NOT notify subscribers when batching', () => {
81
+ const deps = createMockDeps({ count: 0 });
82
+ deps.internals.isBatching = true;
83
+ const subscriber = vi.fn();
84
+ deps.internals.subscribers.add(subscriber);
85
+ setValue(deps, { prop: 'count', value: 5 });
86
+ expect(subscriber).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should handle setting undefined value', () => {
90
+ const deps = createMockDeps({ count: 0 });
91
+ setValue(deps, { prop: 'count', value: undefined });
92
+ expect(deps.internals.signalMap.get('count')?.value).toBe(undefined);
93
+ });
94
+
95
+ it('should handle setting null value', () => {
96
+ const deps = createMockDeps({ name: 'test' });
97
+ setValue(deps, { prop: 'name', value: null });
98
+ expect(deps.internals.signalMap.get('name')?.value).toBe(null);
99
+ });
100
+
101
+ it('should handle complex object values', () => {
102
+ const deps = createMockDeps({ user: null });
103
+ const complexValue = { id: 1, nested: { a: [1, 2, 3] } };
104
+ setValue(deps, { prop: 'user', value: complexValue });
105
+ expect(deps.internals.signalMap.get('user')?.value).toEqual(complexValue);
106
+ });
107
+ });
108
+
109
+ describe('resetState', () => {
110
+ it('should reset all values to initial state', () => {
111
+ const deps = createMockDeps({ count: 0, name: 'initial' });
112
+ setValue(deps, { prop: 'count', value: 100 });
113
+ setValue(deps, { prop: 'name', value: 'changed' });
114
+ resetState(deps);
115
+ expect(deps.internals.signalMap.get('count')?.value).toBe(0);
116
+ expect(deps.internals.signalMap.get('name')?.value).toBe('initial');
117
+ });
118
+
119
+ it('should notify subscribers after reset', () => {
120
+ const deps = createMockDeps({ count: 0 });
121
+ const subscriber = vi.fn();
122
+ deps.internals.subscribers.add(subscriber);
123
+ setValue(deps, { prop: 'count', value: 50 });
124
+ subscriber.mockClear();
125
+ resetState(deps);
126
+ expect(subscriber).toHaveBeenCalled();
127
+ });
128
+
129
+ it('should handle empty initial state', () => {
130
+ const deps = createMockDeps({});
131
+ expect(() => resetState(deps)).not.toThrow();
132
+ });
133
+ });
134
+
135
+ describe('batchUpdates', () => {
136
+ it('should batch multiple updates without intermediate notifications', () => {
137
+ const deps = createMockDeps({ a: 0, b: 0 });
138
+ const subscriber = vi.fn();
139
+ deps.internals.subscribers.add(subscriber);
140
+ batchUpdates(deps, () => {
141
+ setValue(deps, { prop: 'a', value: 1 });
142
+ setValue(deps, { prop: 'b', value: 2 });
143
+ });
144
+ expect(subscriber).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('should execute all updates within batch', () => {
148
+ const deps = createMockDeps({ a: 0, b: 0, c: 0 });
149
+ batchUpdates(deps, () => {
150
+ setValue(deps, { prop: 'a', value: 10 });
151
+ setValue(deps, { prop: 'b', value: 20 });
152
+ setValue(deps, { prop: 'c', value: 30 });
153
+ });
154
+ expect(deps.internals.signalMap.get('a')?.value).toBe(10);
155
+ expect(deps.internals.signalMap.get('b')?.value).toBe(20);
156
+ expect(deps.internals.signalMap.get('c')?.value).toBe(30);
157
+ });
158
+
159
+ it('should reset batching flag even if updates throw', () => {
160
+ const deps = createMockDeps({ count: 0 });
161
+ try {
162
+ batchUpdates(deps, () => {
163
+ setValue(deps, { prop: 'count', value: 5 });
164
+ throw new Error('test error');
165
+ });
166
+ } catch {
167
+ // Expected
168
+ }
169
+ expect(deps.internals.isBatching).toBe(false);
170
+ });
171
+
172
+ it('should handle nested batches correctly', () => {
173
+ const deps = createMockDeps({ a: 0 });
174
+ const subscriber = vi.fn();
175
+ deps.internals.subscribers.add(subscriber);
176
+ batchUpdates(deps, () => {
177
+ setValue(deps, { prop: 'a', value: 1 });
178
+ batchUpdates(deps, () => {
179
+ setValue(deps, { prop: 'a', value: 2 });
180
+ });
181
+ });
182
+ expect(deps.internals.signalMap.get('a')?.value).toBe(2);
183
+ });
184
+ });
185
+
186
+ describe('updateState', () => {
187
+ it('should update state via draft function', () => {
188
+ const deps = createMockDeps({ count: 0, items: [] });
189
+ updateState(deps, {
190
+ updater: (draft) => {
191
+ draft.count = 10;
192
+ },
193
+ });
194
+ expect(deps.internals.signalMap.get('count')?.value).toBe(10);
195
+ });
196
+
197
+ it('should handle multiple property updates', () => {
198
+ const deps = createMockDeps({ a: 1, b: 2, c: 3 });
199
+ updateState(deps, {
200
+ updater: (draft) => {
201
+ draft.a = 100;
202
+ draft.b = 200;
203
+ draft.c = 300;
204
+ },
205
+ });
206
+ expect(deps.internals.signalMap.get('a')?.value).toBe(100);
207
+ expect(deps.internals.signalMap.get('b')?.value).toBe(200);
208
+ expect(deps.internals.signalMap.get('c')?.value).toBe(300);
209
+ });
210
+ });
211
+
212
+ describe('getSnapshot', () => {
213
+ it('should return snapshot of current state', () => {
214
+ const deps = createMockDeps({ count: 5, name: 'test' });
215
+ const snapshot = getSnapshot(deps.internals.signalMap);
216
+ expect(snapshot).toEqual({ count: 5, name: 'test' });
217
+ });
218
+
219
+ it('should return empty object for empty state', () => {
220
+ const deps = createMockDeps({});
221
+ const snapshot = getSnapshot(deps.internals.signalMap);
222
+ expect(snapshot).toEqual({});
223
+ });
224
+
225
+ it('should return copy not reference', () => {
226
+ const deps = createMockDeps({ items: [1, 2, 3] });
227
+ const snapshot1 = getSnapshot(deps.internals.signalMap);
228
+ const snapshot2 = getSnapshot(deps.internals.signalMap);
229
+ expect(snapshot1).not.toBe(snapshot2);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,214 @@
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 {
27
+ StoreHandlerDeps,
28
+ SetValueInput,
29
+ UpdateStateInput,
30
+ } from './types.js';
31
+ import { runAdapter } from '../persistence/index.js';
32
+
33
+ const getSnapshot = (
34
+ signalMap: Map<string, Signal<unknown>>
35
+ ): Record<string, unknown> => {
36
+ const snapshot: Record<string, unknown> = {};
37
+ for (const [key, sig] of signalMap) {
38
+ snapshot[key] = sig.value;
39
+ }
40
+ return snapshot;
41
+ };
42
+
43
+ const notifyAll = (deps: StoreHandlerDeps): void => {
44
+ if (deps.internals.isBatching) return;
45
+ for (const callback of deps.internals.subscribers) callback();
46
+ };
47
+
48
+ const notifyKey = (
49
+ deps: StoreHandlerDeps,
50
+ key: string,
51
+ value: unknown
52
+ ): void => {
53
+ if (deps.internals.isBatching) return;
54
+ const subs = deps.internals.keySubscribers.get(key);
55
+ if (subs) for (const cb of subs) cb(value);
56
+ };
57
+
58
+ const persist = (deps: StoreHandlerDeps): void => {
59
+ if (!deps.config.shouldPersist) return;
60
+ const snapshot = getSnapshot(deps.internals.signalMap);
61
+ runAdapter.setItem(
62
+ deps.config.adapter,
63
+ deps.config.storageKey,
64
+ JSON.stringify(snapshot)
65
+ );
66
+ };
67
+
68
+ const updateComputed = (deps: StoreHandlerDeps): void => {
69
+ const snapshot = getSnapshot(deps.internals.signalMap);
70
+ for (const [selector, sig] of deps.internals.computedSelectors) {
71
+ const newValue = selector(snapshot);
72
+ if (sig.value !== newValue) sig.value = newValue;
73
+ }
74
+ };
75
+
76
+ const logDevtools = (
77
+ deps: StoreHandlerDeps,
78
+ actionType: string,
79
+ payload: unknown,
80
+ prevState: Record<string, unknown>,
81
+ nextState: Record<string, unknown>
82
+ ): void => {
83
+ if (!deps.config.enableDevtools) return;
84
+ const time = new Date().toLocaleTimeString();
85
+ // eslint-disable-next-line no-console
86
+ console.groupCollapsed(
87
+ `%caction %c${deps.config.name}/${actionType} %c@ ${time}`,
88
+ 'color: gray; font-weight: lighter;',
89
+ 'color: inherit; font-weight: bold;',
90
+ 'color: gray; font-weight: lighter;'
91
+ );
92
+ // eslint-disable-next-line no-console
93
+ console.log('%cprev state', 'color: #9E9E9E; font-weight: bold;', prevState);
94
+ // eslint-disable-next-line no-console
95
+ console.log('%caction', 'color: #03A9F4; font-weight: bold;', {
96
+ type: actionType,
97
+ payload,
98
+ });
99
+ // eslint-disable-next-line no-console
100
+ console.log('%cnext state', 'color: #4CAF50; font-weight: bold;', nextState);
101
+ // eslint-disable-next-line no-console
102
+ console.groupEnd();
103
+ };
104
+
105
+ export const setValue = (
106
+ deps: StoreHandlerDeps,
107
+ input: SetValueInput
108
+ ): boolean => {
109
+ const { prop, value } = input;
110
+ const { internals, atomicState, middlewareManager, config } = deps;
111
+
112
+ if (!internals.signalMap.has(prop)) {
113
+ internals.signalMap.set(prop, signal(value));
114
+ }
115
+
116
+ const sig = internals.signalMap.get(prop);
117
+ if (!sig) return false;
118
+
119
+ const prevState = config.enableDevtools
120
+ ? getSnapshot(internals.signalMap)
121
+ : {};
122
+
123
+ const newState = middlewareManager.execute(
124
+ { ...atomicState.get(), [prop]: value },
125
+ `set:${prop}`,
126
+ [value]
127
+ );
128
+
129
+ sig.value = newState[prop];
130
+ atomicState.update((s) => ({ ...s, [prop]: newState[prop] }));
131
+
132
+ logDevtools(deps, `set:${prop}`, value, prevState, {
133
+ ...atomicState.get(),
134
+ [prop]: newState[prop],
135
+ });
136
+
137
+ notifyAll(deps);
138
+ notifyKey(deps, prop, newState[prop]);
139
+ persist(deps);
140
+ updateComputed(deps);
141
+
142
+ return true;
143
+ };
144
+
145
+ export const resetState = (deps: StoreHandlerDeps): void => {
146
+ const { internals, atomicState, config } = deps;
147
+ const prevState = config.enableDevtools
148
+ ? getSnapshot(internals.signalMap)
149
+ : {};
150
+
151
+ for (const [key, value] of Object.entries(internals.initialState)) {
152
+ const sig = internals.signalMap.get(key);
153
+ if (sig) sig.value = value;
154
+ }
155
+ atomicState.set({ ...internals.initialState });
156
+
157
+ logDevtools(deps, 'reset', null, prevState, { ...internals.initialState });
158
+
159
+ notifyAll(deps);
160
+ persist(deps);
161
+ updateComputed(deps);
162
+ };
163
+
164
+ export const batchUpdates = (
165
+ deps: StoreHandlerDeps,
166
+ updates: () => void
167
+ ): void => {
168
+ deps.internals.isBatching = true;
169
+ try {
170
+ updates();
171
+ } finally {
172
+ deps.internals.isBatching = false;
173
+ }
174
+ notifyAll(deps);
175
+ persist(deps);
176
+ updateComputed(deps);
177
+ };
178
+
179
+ export const updateState = (
180
+ deps: StoreHandlerDeps,
181
+ input: UpdateStateInput
182
+ ): void => {
183
+ const { internals, atomicState, config } = deps;
184
+ const prevState = config.enableDevtools
185
+ ? getSnapshot(internals.signalMap)
186
+ : {};
187
+
188
+ const draft = { ...getSnapshot(internals.signalMap) };
189
+ input.updater(draft);
190
+
191
+ internals.isBatching = true;
192
+ for (const [key, val] of Object.entries(draft)) {
193
+ const sig = internals.signalMap.get(key);
194
+ if (sig && sig.value !== val) {
195
+ sig.value = val;
196
+ atomicState.update((s) => ({ ...s, [key]: val }));
197
+ }
198
+ }
199
+ internals.isBatching = false;
200
+
201
+ logDevtools(
202
+ deps,
203
+ 'update',
204
+ null,
205
+ prevState,
206
+ getSnapshot(internals.signalMap)
207
+ );
208
+
209
+ notifyAll(deps);
210
+ persist(deps);
211
+ updateComputed(deps);
212
+ };
213
+
214
+ export { getSnapshot };
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Option } from 'effect';
3
+ import {
4
+ getItem,
5
+ setItem,
6
+ removeItem,
7
+ hasItem,
8
+ clearStorage,
9
+ getStorageKeys,
10
+ getStorageSize,
11
+ } from './persistence.js';
12
+ import type { StorageHandlerDeps } from './persistence.js';
13
+
14
+ const createMockDeps = (
15
+ initial: Record<string, string> = {}
16
+ ): StorageHandlerDeps => {
17
+ const storage = new Map<string, string>(Object.entries(initial));
18
+ return { storage };
19
+ };
20
+
21
+ describe('persistence handlers', () => {
22
+ describe('getItem', () => {
23
+ it('should return Some for existing key', () => {
24
+ const deps = createMockDeps({ key1: 'value1' });
25
+ const result = getItem(deps, { key: 'key1' });
26
+ expect(Option.isSome(result)).toBe(true);
27
+ expect(Option.getOrNull(result)).toBe('value1');
28
+ });
29
+
30
+ it('should return None for non-existent key', () => {
31
+ const deps = createMockDeps({});
32
+ const result = getItem(deps, { key: 'missing' });
33
+ expect(Option.isNone(result)).toBe(true);
34
+ });
35
+
36
+ it('should return empty string value correctly', () => {
37
+ const deps = createMockDeps({ empty: '' });
38
+ const result = getItem(deps, { key: 'empty' });
39
+ expect(Option.isSome(result)).toBe(true);
40
+ expect(Option.getOrNull(result)).toBe('');
41
+ });
42
+
43
+ it('should handle special characters in key', () => {
44
+ const deps = createMockDeps({ 'key.with.dots': 'value' });
45
+ const result = getItem(deps, { key: 'key.with.dots' });
46
+ expect(Option.getOrNull(result)).toBe('value');
47
+ });
48
+
49
+ it('should handle unicode key', () => {
50
+ const deps = createMockDeps({ 日本語: 'japanese' });
51
+ const result = getItem(deps, { key: '日本語' });
52
+ expect(Option.getOrNull(result)).toBe('japanese');
53
+ });
54
+ });
55
+
56
+ describe('setItem', () => {
57
+ it('should set value for new key', () => {
58
+ const deps = createMockDeps({});
59
+ setItem(deps, { key: 'newKey', value: 'newValue' });
60
+ expect(deps.storage.get('newKey')).toBe('newValue');
61
+ });
62
+
63
+ it('should overwrite existing value', () => {
64
+ const deps = createMockDeps({ key1: 'oldValue' });
65
+ setItem(deps, { key: 'key1', value: 'newValue' });
66
+ expect(deps.storage.get('key1')).toBe('newValue');
67
+ });
68
+
69
+ it('should handle empty string value', () => {
70
+ const deps = createMockDeps({});
71
+ setItem(deps, { key: 'key1', value: '' });
72
+ expect(deps.storage.get('key1')).toBe('');
73
+ });
74
+
75
+ it('should handle very long values', () => {
76
+ const deps = createMockDeps({});
77
+ const longValue = 'x'.repeat(10000);
78
+ setItem(deps, { key: 'long', value: longValue });
79
+ expect(deps.storage.get('long')).toBe(longValue);
80
+ });
81
+
82
+ it('should handle JSON stringified objects', () => {
83
+ const deps = createMockDeps({});
84
+ const jsonValue = JSON.stringify({ a: 1, b: [1, 2, 3] });
85
+ setItem(deps, { key: 'json', value: jsonValue });
86
+ expect(JSON.parse(deps.storage.get('json')!)).toEqual({
87
+ a: 1,
88
+ b: [1, 2, 3],
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('removeItem', () => {
94
+ it('should remove existing key and return true', () => {
95
+ const deps = createMockDeps({ key1: 'value1' });
96
+ const result = removeItem(deps, { key: 'key1' });
97
+ expect(result).toBe(true);
98
+ expect(deps.storage.has('key1')).toBe(false);
99
+ });
100
+
101
+ it('should return false for non-existent key', () => {
102
+ const deps = createMockDeps({});
103
+ const result = removeItem(deps, { key: 'missing' });
104
+ expect(result).toBe(false);
105
+ });
106
+
107
+ it('should not affect other keys', () => {
108
+ const deps = createMockDeps({ a: '1', b: '2', c: '3' });
109
+ removeItem(deps, { key: 'b' });
110
+ expect(deps.storage.get('a')).toBe('1');
111
+ expect(deps.storage.get('c')).toBe('3');
112
+ });
113
+ });
114
+
115
+ describe('hasItem', () => {
116
+ it('should return true for existing key', () => {
117
+ const deps = createMockDeps({ key1: 'value1' });
118
+ expect(hasItem(deps, { key: 'key1' })).toBe(true);
119
+ });
120
+
121
+ it('should return false for non-existent key', () => {
122
+ const deps = createMockDeps({});
123
+ expect(hasItem(deps, { key: 'missing' })).toBe(false);
124
+ });
125
+
126
+ it('should return true for key with empty value', () => {
127
+ const deps = createMockDeps({ empty: '' });
128
+ expect(hasItem(deps, { key: 'empty' })).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('clearStorage', () => {
133
+ it('should remove all items', () => {
134
+ const deps = createMockDeps({ a: '1', b: '2', c: '3' });
135
+ clearStorage(deps);
136
+ expect(deps.storage.size).toBe(0);
137
+ });
138
+
139
+ it('should handle empty storage', () => {
140
+ const deps = createMockDeps({});
141
+ expect(() => clearStorage(deps)).not.toThrow();
142
+ });
143
+ });
144
+
145
+ describe('getStorageKeys', () => {
146
+ it('should return all keys', () => {
147
+ const deps = createMockDeps({ a: '1', b: '2', c: '3' });
148
+ const keys = getStorageKeys(deps);
149
+ expect(keys).toHaveLength(3);
150
+ expect(keys).toContain('a');
151
+ expect(keys).toContain('b');
152
+ expect(keys).toContain('c');
153
+ });
154
+
155
+ it('should return empty array for empty storage', () => {
156
+ const deps = createMockDeps({});
157
+ expect(getStorageKeys(deps)).toEqual([]);
158
+ });
159
+ });
160
+
161
+ describe('getStorageSize', () => {
162
+ it('should return correct size', () => {
163
+ const deps = createMockDeps({ a: '1', b: '2', c: '3' });
164
+ expect(getStorageSize(deps)).toBe(3);
165
+ });
166
+
167
+ it('should return 0 for empty storage', () => {
168
+ const deps = createMockDeps({});
169
+ expect(getStorageSize(deps)).toBe(0);
170
+ });
171
+
172
+ it('should update after operations', () => {
173
+ const deps = createMockDeps({});
174
+ setItem(deps, { key: 'a', value: '1' });
175
+ expect(getStorageSize(deps)).toBe(1);
176
+ setItem(deps, { key: 'b', value: '2' });
177
+ expect(getStorageSize(deps)).toBe(2);
178
+ removeItem(deps, { key: 'a' });
179
+ expect(getStorageSize(deps)).toBe(1);
180
+ });
181
+ });
182
+ });
@@ -0,0 +1,82 @@
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 { Option } from 'effect';
26
+
27
+ export interface StorageHandlerDeps {
28
+ storage: Map<string, string>;
29
+ }
30
+
31
+ export interface StorageGetInput {
32
+ key: string;
33
+ }
34
+
35
+ export interface StorageSetInput {
36
+ key: string;
37
+ value: string;
38
+ }
39
+
40
+ export interface StorageRemoveInput {
41
+ key: string;
42
+ }
43
+
44
+ export const getItem = (
45
+ deps: StorageHandlerDeps,
46
+ input: StorageGetInput
47
+ ): Option.Option<string> => {
48
+ return Option.fromNullable(deps.storage.get(input.key));
49
+ };
50
+
51
+ export const setItem = (
52
+ deps: StorageHandlerDeps,
53
+ input: StorageSetInput
54
+ ): void => {
55
+ deps.storage.set(input.key, input.value);
56
+ };
57
+
58
+ export const removeItem = (
59
+ deps: StorageHandlerDeps,
60
+ input: StorageRemoveInput
61
+ ): boolean => {
62
+ return deps.storage.delete(input.key);
63
+ };
64
+
65
+ export const hasItem = (
66
+ deps: StorageHandlerDeps,
67
+ input: StorageGetInput
68
+ ): boolean => {
69
+ return deps.storage.has(input.key);
70
+ };
71
+
72
+ export const clearStorage = (deps: StorageHandlerDeps): void => {
73
+ deps.storage.clear();
74
+ };
75
+
76
+ export const getStorageKeys = (deps: StorageHandlerDeps): string[] => {
77
+ return Array.from(deps.storage.keys());
78
+ };
79
+
80
+ export const getStorageSize = (deps: StorageHandlerDeps): number => {
81
+ return deps.storage.size;
82
+ };