@storve/core 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.
Files changed (196) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/benchmarks/run.ts +102 -0
  3. package/benchmarks/week2.md +9 -0
  4. package/benchmarks/week2.ts +64 -0
  5. package/benchmarks/week4.md +13 -0
  6. package/benchmarks/week4.ts +178 -0
  7. package/benchmarks/week5.md +15 -0
  8. package/benchmarks/week5.ts +184 -0
  9. package/coverage/coverage-summary.json +31 -0
  10. package/dist/adapters/indexedDB.cjs +2 -0
  11. package/dist/adapters/indexedDB.cjs.map +1 -0
  12. package/dist/adapters/indexedDB.mjs +2 -0
  13. package/dist/adapters/indexedDB.mjs.map +1 -0
  14. package/dist/adapters/localStorage.cjs +2 -0
  15. package/dist/adapters/localStorage.cjs.map +1 -0
  16. package/dist/adapters/localStorage.mjs +2 -0
  17. package/dist/adapters/localStorage.mjs.map +1 -0
  18. package/dist/adapters/memory.cjs +2 -0
  19. package/dist/adapters/memory.cjs.map +1 -0
  20. package/dist/adapters/memory.mjs +2 -0
  21. package/dist/adapters/memory.mjs.map +1 -0
  22. package/dist/adapters/sessionStorage.cjs +2 -0
  23. package/dist/adapters/sessionStorage.cjs.map +1 -0
  24. package/dist/adapters/sessionStorage.mjs +2 -0
  25. package/dist/adapters/sessionStorage.mjs.map +1 -0
  26. package/dist/async-entry.d.ts +7 -0
  27. package/dist/async-entry.d.ts.map +1 -0
  28. package/dist/async.cjs +2 -0
  29. package/dist/async.cjs.map +1 -0
  30. package/dist/async.d.ts +52 -0
  31. package/dist/async.d.ts.map +1 -0
  32. package/dist/async.mjs +2 -0
  33. package/dist/async.mjs.map +1 -0
  34. package/dist/batch.d.ts +12 -0
  35. package/dist/batch.d.ts.map +1 -0
  36. package/dist/compose.d.ts +7 -0
  37. package/dist/compose.d.ts.map +1 -0
  38. package/dist/computed-entry.d.ts +7 -0
  39. package/dist/computed-entry.d.ts.map +1 -0
  40. package/dist/computed.cjs +2 -0
  41. package/dist/computed.cjs.map +1 -0
  42. package/dist/computed.d.ts +56 -0
  43. package/dist/computed.d.ts.map +1 -0
  44. package/dist/computed.mjs +2 -0
  45. package/dist/computed.mjs.map +1 -0
  46. package/dist/devtools/history.d.ts +51 -0
  47. package/dist/devtools/history.d.ts.map +1 -0
  48. package/dist/devtools/index.d.ts +5 -0
  49. package/dist/devtools/index.d.ts.map +1 -0
  50. package/dist/devtools/redux-bridge.d.ts +21 -0
  51. package/dist/devtools/redux-bridge.d.ts.map +1 -0
  52. package/dist/devtools/snapshots.d.ts +32 -0
  53. package/dist/devtools/snapshots.d.ts.map +1 -0
  54. package/dist/devtools/withDevtools.d.ts +17 -0
  55. package/dist/devtools/withDevtools.d.ts.map +1 -0
  56. package/dist/devtools.cjs +2 -0
  57. package/dist/devtools.cjs.map +1 -0
  58. package/dist/devtools.mjs +2 -0
  59. package/dist/devtools.mjs.map +1 -0
  60. package/dist/extensions/noop.d.ts +2 -0
  61. package/dist/extensions/noop.d.ts.map +1 -0
  62. package/dist/index.cjs +2 -0
  63. package/dist/index.cjs.js +118 -0
  64. package/dist/index.cjs.js.map +1 -0
  65. package/dist/index.cjs.map +1 -0
  66. package/dist/index.d.ts +5 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.esm.js +116 -0
  69. package/dist/index.esm.js.map +1 -0
  70. package/dist/index.mjs +2 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/persist/adapters/indexedDB.d.ts +12 -0
  73. package/dist/persist/adapters/indexedDB.d.ts.map +1 -0
  74. package/dist/persist/adapters/localStorage.d.ts +11 -0
  75. package/dist/persist/adapters/localStorage.d.ts.map +1 -0
  76. package/dist/persist/adapters/memory.d.ts +11 -0
  77. package/dist/persist/adapters/memory.d.ts.map +1 -0
  78. package/dist/persist/adapters/sessionStorage.d.ts +11 -0
  79. package/dist/persist/adapters/sessionStorage.d.ts.map +1 -0
  80. package/dist/persist/debounce.d.ts +12 -0
  81. package/dist/persist/debounce.d.ts.map +1 -0
  82. package/dist/persist/hydrate.d.ts +15 -0
  83. package/dist/persist/hydrate.d.ts.map +1 -0
  84. package/dist/persist/index.d.ts +34 -0
  85. package/dist/persist/index.d.ts.map +1 -0
  86. package/dist/persist/serialize.d.ts +28 -0
  87. package/dist/persist/serialize.d.ts.map +1 -0
  88. package/dist/persist.cjs +2 -0
  89. package/dist/persist.cjs.map +1 -0
  90. package/dist/persist.mjs +2 -0
  91. package/dist/persist.mjs.map +1 -0
  92. package/dist/proxy.d.ts +2 -0
  93. package/dist/proxy.d.ts.map +1 -0
  94. package/dist/registry-D3X0HSbl.js +26 -0
  95. package/dist/registry-D3X0HSbl.js.map +1 -0
  96. package/dist/registry-RDjbeJdx.js +29 -0
  97. package/dist/registry-RDjbeJdx.js.map +1 -0
  98. package/dist/registry-qtr1UpFU.js +2 -0
  99. package/dist/registry-qtr1UpFU.js.map +1 -0
  100. package/dist/registry-zaKZ1P-s.js +2 -0
  101. package/dist/registry-zaKZ1P-s.js.map +1 -0
  102. package/dist/registry.d.ts +54 -0
  103. package/dist/registry.d.ts.map +1 -0
  104. package/dist/signals/createSignal.d.ts +19 -0
  105. package/dist/signals/createSignal.d.ts.map +1 -0
  106. package/dist/signals/index.d.ts +20 -0
  107. package/dist/signals/index.d.ts.map +1 -0
  108. package/dist/signals/useSignal.d.ts +11 -0
  109. package/dist/signals/useSignal.d.ts.map +1 -0
  110. package/dist/signals.cjs +2 -0
  111. package/dist/signals.cjs.map +1 -0
  112. package/dist/signals.mjs +2 -0
  113. package/dist/signals.mjs.map +1 -0
  114. package/dist/stats.html +4949 -0
  115. package/dist/store.d.ts +12 -0
  116. package/dist/store.d.ts.map +1 -0
  117. package/dist/sync/channel.d.ts +7 -0
  118. package/dist/sync/channel.d.ts.map +1 -0
  119. package/dist/sync/index.d.ts +3 -0
  120. package/dist/sync/index.d.ts.map +1 -0
  121. package/dist/sync/protocol.d.ts +22 -0
  122. package/dist/sync/protocol.d.ts.map +1 -0
  123. package/dist/sync/withSync.d.ts +17 -0
  124. package/dist/sync/withSync.d.ts.map +1 -0
  125. package/dist/sync.cjs +2 -0
  126. package/dist/sync.cjs.map +1 -0
  127. package/dist/sync.mjs +2 -0
  128. package/dist/sync.mjs.map +1 -0
  129. package/dist/types.d.ts +134 -0
  130. package/dist/types.d.ts.map +1 -0
  131. package/package.json +91 -0
  132. package/rollup.config.mjs +44 -0
  133. package/src/async-entry.ts +6 -0
  134. package/src/async.ts +240 -0
  135. package/src/batch.ts +33 -0
  136. package/src/compose.ts +50 -0
  137. package/src/computed-entry.ts +6 -0
  138. package/src/computed.ts +187 -0
  139. package/src/devtools/history.ts +103 -0
  140. package/src/devtools/index.ts +5 -0
  141. package/src/devtools/redux-bridge.ts +70 -0
  142. package/src/devtools/snapshots.ts +54 -0
  143. package/src/devtools/withDevtools.ts +196 -0
  144. package/src/extensions/noop.ts +12 -0
  145. package/src/index.ts +4 -0
  146. package/src/persist/adapters/indexedDB.ts +114 -0
  147. package/src/persist/adapters/localStorage.ts +28 -0
  148. package/src/persist/adapters/memory.ts +26 -0
  149. package/src/persist/adapters/sessionStorage.ts +28 -0
  150. package/src/persist/debounce.ts +28 -0
  151. package/src/persist/hydrate.ts +60 -0
  152. package/src/persist/index.ts +141 -0
  153. package/src/persist/serialize.ts +60 -0
  154. package/src/proxy.ts +87 -0
  155. package/src/registry.ts +67 -0
  156. package/src/signals/createSignal.ts +81 -0
  157. package/src/signals/index.ts +20 -0
  158. package/src/signals/useSignal.ts +18 -0
  159. package/src/store.ts +250 -0
  160. package/src/sync/channel.ts +15 -0
  161. package/src/sync/index.ts +3 -0
  162. package/src/sync/protocol.ts +18 -0
  163. package/src/sync/withSync.ts +147 -0
  164. package/src/types.ts +159 -0
  165. package/tests/async.test.ts +1100 -0
  166. package/tests/batch.test.ts +41 -0
  167. package/tests/compose.test.ts +209 -0
  168. package/tests/computed.test.ts +867 -0
  169. package/tests/devtools.test.ts +1039 -0
  170. package/tests/integration/persist.integration.test.ts +258 -0
  171. package/tests/integration/signals.integration.test.ts +309 -0
  172. package/tests/integration.test.ts +278 -0
  173. package/tests/persist/adapters/indexedDB.adapter.test.ts +185 -0
  174. package/tests/persist/adapters/localStorage.adapter.test.ts +105 -0
  175. package/tests/persist/adapters/memory.adapter.test.ts +112 -0
  176. package/tests/persist/adapters/sessionStorage.adapter.test.ts +128 -0
  177. package/tests/persist/debounce.test.ts +121 -0
  178. package/tests/persist/hydrate.test.ts +120 -0
  179. package/tests/persist/migrate.test.ts +208 -0
  180. package/tests/persist/persist.test.ts +357 -0
  181. package/tests/persist/serialize.test.ts +128 -0
  182. package/tests/proxy.test.ts +473 -0
  183. package/tests/registry.test.ts +67 -0
  184. package/tests/signals/derived.test.ts +244 -0
  185. package/tests/signals/inference.test.ts +108 -0
  186. package/tests/signals/signal.test.ts +348 -0
  187. package/tests/signals/useSignal.test.tsx +275 -0
  188. package/tests/store.test.ts +482 -0
  189. package/tests/stress.test.ts +268 -0
  190. package/tests/sync.test.ts +576 -0
  191. package/tests/types.test.ts +32 -0
  192. package/tests/v0.3.test.ts +813 -0
  193. package/tree-shake-test.js +1 -0
  194. package/tsconfig.json +15 -0
  195. package/vitest.config.ts +22 -0
  196. package/vitest_play.ts +7 -0
@@ -0,0 +1,258 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+ import { createStore } from '../../src/store'
4
+ import { compose } from '../../src/compose'
5
+ import { withPersist } from '../../src/persist/index'
6
+ import { memoryAdapter } from '../../src/persist/adapters/memory'
7
+ import { localStorageAdapter } from '../../src/persist/adapters/localStorage'
8
+
9
+ describe('Persist Integration', () => {
10
+ beforeEach(() => {
11
+ vi.useFakeTimers({ now: Date.now() })
12
+ })
13
+
14
+ afterEach(() => {
15
+ vi.useRealTimers()
16
+ vi.restoreAllMocks()
17
+ vi.unstubAllGlobals()
18
+ if (typeof localStorage !== 'undefined') localStorage.clear()
19
+ })
20
+
21
+ describe('compose + withPersist', () => {
22
+ it('compose(createStore({count:0}), withPersist({key:\'a\', adapter: memoryAdapter(), debounce:0})) works correctly', async () => {
23
+ const adapter = memoryAdapter()
24
+ const store = compose(
25
+ createStore({ count: 0 }),
26
+ withPersist({ key: 'a', adapter, debounce: 0 })
27
+ )
28
+ expect(store.hydrated).toBeInstanceOf(Promise)
29
+ await store.hydrated
30
+ expect(store.getState()).toEqual({ count: 0 })
31
+ })
32
+
33
+ it('setState on composed store writes to adapter', async () => {
34
+ const adapter = memoryAdapter()
35
+ const store = compose(
36
+ createStore({ count: 0 }),
37
+ withPersist({ key: 'a', adapter, debounce: 0 })
38
+ )
39
+ await store.hydrated
40
+ store.setState({ count: 1 })
41
+
42
+ const raw = await adapter.getItem('a')
43
+ expect(raw).toContain('"count":1')
44
+ })
45
+
46
+ it('hydration restores state on a new composed store using the same adapter instance', async () => {
47
+ const adapter = memoryAdapter()
48
+ const store1 = compose(
49
+ createStore({ count: 0 }),
50
+ withPersist({ key: 'a', adapter, debounce: 0 })
51
+ )
52
+ await store1.hydrated
53
+ store1.setState({ count: 99 })
54
+
55
+ const store2 = compose(
56
+ createStore({ count: 0 }),
57
+ withPersist({ key: 'a', adapter, debounce: 0 })
58
+ )
59
+ await store2.hydrated
60
+ expect(store2.getState()).toEqual({ count: 99 })
61
+ })
62
+ })
63
+
64
+ describe('Full round-trip', () => {
65
+ it('create store → setState → create new store with same adapter → await hydrated → getState matches', async () => {
66
+ const adapter = memoryAdapter()
67
+ const storeA = withPersist(createStore({ val: 'first' }), { key: 'a', adapter, debounce: 0 })
68
+ await storeA.hydrated
69
+ storeA.setState({ val: 'updated' })
70
+
71
+ const storeB = withPersist(createStore({ val: 'default' }), { key: 'a', adapter, debounce: 0 })
72
+ await storeB.hydrated
73
+
74
+ expect(storeB.getState()).toEqual({ val: 'updated' })
75
+ })
76
+ })
77
+
78
+ describe('Multiple stores', () => {
79
+ it('two stores persisting to same adapter with different keys do not interfere', async () => {
80
+ const adapter = memoryAdapter()
81
+ const storeX = withPersist(createStore({ x: 0 }), { key: 'X', adapter, debounce: 0 })
82
+ const storeY = withPersist(createStore({ y: 0 }), { key: 'Y', adapter, debounce: 0 })
83
+
84
+ await storeX.hydrated
85
+ await storeY.hydrated
86
+
87
+ storeX.setState({ x: 1 })
88
+ storeY.setState({ y: 2 })
89
+
90
+ const rawX = await adapter.getItem('X')
91
+ const rawY = await adapter.getItem('Y')
92
+
93
+ expect(rawX).toContain('"x":1')
94
+ expect(rawY).toContain('"y":2')
95
+ expect(rawX).not.toContain('"y":2')
96
+ expect(rawY).not.toContain('"x":1')
97
+ })
98
+
99
+ it('updating store A does not affect store B\'s persisted data', async () => {
100
+ vi.useRealTimers()
101
+ const adapter = memoryAdapter()
102
+ const storeA = withPersist(createStore({ a: 1 }), { key: 'A', adapter, debounce: 0 })
103
+ const storeB = withPersist(createStore({ b: 0 }), { key: 'B', adapter, debounce: 0 })
104
+
105
+ await storeA.hydrated
106
+ await storeB.hydrated
107
+
108
+ storeB.setState({ b: 1 })
109
+ await Promise.resolve()
110
+ await Promise.resolve()
111
+
112
+ storeA.setState({ a: 99 })
113
+ await Promise.resolve()
114
+ await Promise.resolve()
115
+
116
+ const storeBRehydrated = withPersist(createStore({ b: 0 }), { key: 'B', adapter, debounce: 0 })
117
+ await storeBRehydrated.hydrated
118
+ expect(storeBRehydrated.getState()).toEqual({ b: 1 }) // unchanged
119
+ })
120
+
121
+ it('stores are fully isolated with different adapters', async () => {
122
+ const storeA = withPersist(createStore({ a: 1 }), { key: 'key', adapter: memoryAdapter(), debounce: 0 })
123
+ const storeB = withPersist(createStore({ b: 2 }), { key: 'key', adapter: memoryAdapter(), debounce: 0 })
124
+
125
+ await storeA.hydrated
126
+ await storeB.hydrated
127
+
128
+ storeA.setState({ a: 99 })
129
+ storeB.setState({ b: 88 })
130
+
131
+ expect(storeA.getState()).toEqual({ a: 99 })
132
+ expect(storeB.getState()).toEqual({ b: 88 })
133
+ })
134
+ })
135
+
136
+ describe('Normal API behaviour', () => {
137
+ it('subscriber is notified on setState', async () => {
138
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter: memoryAdapter(), debounce: 0 })
139
+ await store.hydrated
140
+
141
+ const sub = vi.fn()
142
+ store.subscribe(sub)
143
+
144
+ store.setState({ count: 5 })
145
+ expect(sub).toHaveBeenCalled()
146
+ })
147
+
148
+ it('subscriber receives correct new state', async () => {
149
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter: memoryAdapter(), debounce: 0 })
150
+ await store.hydrated
151
+
152
+ const sub = vi.fn()
153
+ store.subscribe(sub)
154
+
155
+ store.setState({ count: 10 })
156
+ expect(sub).toHaveBeenCalledWith({ count: 10 })
157
+ })
158
+ })
159
+
160
+ describe('pick isolation', () => {
161
+ it('setting a non-picked key does not write anything to adapter', async () => {
162
+ const adapter = memoryAdapter()
163
+ const store = withPersist(createStore({ a: 1, secret: 'x' }), { key: 'a', adapter, pick: ['a'], debounce: 0 })
164
+ await store.hydrated
165
+
166
+ const spySet = vi.spyOn(adapter, 'setItem')
167
+ store.setState({ secret: 'y' })
168
+ expect(spySet).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('setting a picked key writes only picked keys', async () => {
172
+ const adapter = memoryAdapter()
173
+ const store = withPersist(createStore({ a: 1, secret: 'x' }), { key: 'a', adapter, pick: ['a'], debounce: 0 })
174
+ await store.hydrated
175
+
176
+ store.setState({ a: 2, secret: 'y' })
177
+ const raw = await adapter.getItem('a')
178
+ const parsed = JSON.parse(raw!)
179
+ expect(parsed.a).toBe(2)
180
+ expect(parsed.secret).toBeUndefined()
181
+ })
182
+ })
183
+
184
+ describe('Debounce integration', () => {
185
+ it('rapid setStates result in single adapter write (use vi.useFakeTimers)', async () => {
186
+ const adapter = memoryAdapter()
187
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter, debounce: 100 })
188
+ await store.hydrated
189
+
190
+ const spySet = vi.spyOn(adapter, 'setItem')
191
+
192
+ store.setState({ count: 1 })
193
+ vi.advanceTimersByTime(50)
194
+ store.setState({ count: 2 })
195
+ vi.advanceTimersByTime(50)
196
+ store.setState({ count: 3 })
197
+ vi.advanceTimersByTime(100)
198
+
199
+ expect(spySet).toHaveBeenCalledTimes(1)
200
+ })
201
+
202
+ it('adapter write contains the latest state, not an intermediate one', async () => {
203
+ const adapter = memoryAdapter()
204
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter, debounce: 100 })
205
+ await store.hydrated
206
+
207
+ store.setState({ count: 1 })
208
+ store.setState({ count: 2 })
209
+ store.setState({ count: 3 })
210
+ vi.advanceTimersByTime(100)
211
+
212
+ const raw = await adapter.getItem('a')
213
+ expect(raw).toContain('"count":3')
214
+ })
215
+ })
216
+
217
+ describe('Hydration timing', () => {
218
+ it('store.getState() before hydration contains default state', () => {
219
+ const adapter = memoryAdapter()
220
+ // artificially mock getItem to be slow
221
+ vi.spyOn(adapter, 'getItem').mockReturnValue(new Promise(resolve => setTimeout(() => resolve('{"count":42,"__version":1}'), 100)))
222
+
223
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter })
224
+ expect(store.getState()).toEqual({ count: 0 })
225
+ })
226
+
227
+ it('store.getState() after awaiting store.hydrated contains persisted state', async () => {
228
+ const adapter = memoryAdapter()
229
+ vi.spyOn(adapter, 'getItem').mockReturnValue(new Promise(resolve => setTimeout(() => resolve('{"count":42,"__version":1}'), 100)))
230
+
231
+ const store = withPersist(createStore({ count: 0 }), { key: 'a', adapter })
232
+ vi.advanceTimersByTime(100)
233
+ await store.hydrated
234
+ expect(store.getState()).toEqual({ count: 42 })
235
+ })
236
+ })
237
+
238
+ describe('localStorage adapter integration', () => {
239
+ it('withPersist + localStorageAdapter writes to window.localStorage correctly', async () => {
240
+ const adapter = localStorageAdapter()
241
+ const store = withPersist(createStore({ num: 0 }), { key: 'storage-key', adapter, debounce: 0 })
242
+ await store.hydrated
243
+
244
+ store.setState({ num: 99 })
245
+ const raw = localStorage.getItem('storage-key')
246
+ expect(raw).toContain('"num":99')
247
+ })
248
+
249
+ it('SSR scenario: localStorageAdapter with window undefined — withPersist completes without error, store uses defaults', async () => {
250
+ vi.stubGlobal('window', undefined)
251
+ const adapter = localStorageAdapter()
252
+
253
+ const store = withPersist(createStore({ num: 10 }), { key: 'ssr-key', adapter, debounce: 0 })
254
+ await expect(store.hydrated).resolves.toBeUndefined()
255
+ expect(store.getState()).toEqual({ num: 10 })
256
+ })
257
+ })
258
+ })
@@ -0,0 +1,309 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createStore } from '../../src/store';
3
+ import type { Store } from '../../src/types';
4
+ import { signal } from '../../src/signals/createSignal';
5
+ import { withPersist } from '../../src/persist/index';
6
+ import { memoryAdapter } from '../../src/persist/adapters/memory';
7
+ import { compose } from '../../src/compose';
8
+
9
+ describe('signals integration', () => {
10
+ interface State {
11
+ count: number;
12
+ name: string;
13
+ secret: string;
14
+ }
15
+
16
+ describe('signal + plain store', () => {
17
+ it('signal.get() returns correct initial value', () => {
18
+ const store = createStore<State>({ count: 0, name: 'alice', secret: 'shh' });
19
+ const sig = signal(store, 'count');
20
+ expect(sig.get()).toBe(0);
21
+ });
22
+
23
+ it('signal.set(value) updates store and signal.get() reflects new value immediately', () => {
24
+ const store = createStore<State>({ count: 0, name: 'alice', secret: 'shh' });
25
+ const sig = signal(store, 'count');
26
+ sig.set(10);
27
+ expect(store.getState().count).toBe(10);
28
+ expect(sig.get()).toBe(10);
29
+ });
30
+
31
+ it('signal.set(fn) updater form works correctly', () => {
32
+ const store = createStore<State>({ count: 5, name: 'alice', secret: 'shh' });
33
+ const sig = signal(store, 'count');
34
+ sig.set((prev) => prev + 5);
35
+ expect(store.getState().count).toBe(10);
36
+ });
37
+
38
+ it('signal.subscribe() fires when store.setState changes the key', () => {
39
+ const store = createStore<State>({ count: 0, name: 'alice', secret: 'shh' });
40
+ const sig = signal(store, 'count');
41
+ const listener = vi.fn();
42
+ sig.subscribe(listener);
43
+ store.setState({ count: 1 });
44
+ expect(listener).toHaveBeenCalledWith(1);
45
+ });
46
+
47
+ it('signal.subscribe() does NOT fire when store.setState changes unrelated key', () => {
48
+ const store = createStore<State>({ count: 0, name: 'alice', secret: 'shh' });
49
+ const sig = signal(store, 'count');
50
+ const listener = vi.fn();
51
+ sig.subscribe(listener);
52
+ store.setState({ name: 'bob' });
53
+ expect(listener).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('two signals on same store are fully independent', () => {
57
+ const store = createStore<State>({ count: 0, name: 'alice', secret: 'shh' });
58
+ const sigA = signal(store, 'count');
59
+ const sigB = signal(store, 'name');
60
+ const listenerB = vi.fn();
61
+ sigB.subscribe(listenerB);
62
+
63
+ sigA.set(1);
64
+ expect(listenerB).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
68
+ describe('signal + withPersist', () => {
69
+ it('signal.set(5) causes the store key to be persisted to adapter', async () => {
70
+ const adapter = memoryAdapter();
71
+ const store = withPersist(
72
+ createStore<State>({ count: 0, name: 'alice', secret: 'shh' }),
73
+ { key: 'test-store', adapter, debounce: 0 }
74
+ );
75
+ const sig = signal(store, 'count');
76
+
77
+ sig.set(42);
78
+ await Promise.resolve(); // wait for microtasks/persist
79
+
80
+ const saved = await adapter.getItem('test-store');
81
+ expect(JSON.parse(saved!).count).toBe(42);
82
+ });
83
+
84
+ it('after rehydration on new store: signal.get() reflects the persisted value', async () => {
85
+ const adapter = memoryAdapter();
86
+ const initialState = { count: 0, name: 'alice', secret: 'shh' };
87
+
88
+ // Store A sets value
89
+ const storeA = withPersist(createStore<State>(initialState), { key: 'x', adapter, debounce: 0 });
90
+ const sigA = signal(storeA, 'count');
91
+ sigA.set(42);
92
+ await Promise.resolve();
93
+
94
+ // Store B rehydrates
95
+ const storeB = withPersist(createStore<State>(initialState), { key: 'x', adapter, debounce: 0 });
96
+ const sigB = signal(storeB, 'count');
97
+ await storeB.hydrated;
98
+
99
+ expect(sigB.get()).toBe(42);
100
+ });
101
+
102
+ it('signal.subscribe() fires after hydration completes with the persisted value', async () => {
103
+ const adapter = memoryAdapter();
104
+ const initialState = { count: 0, name: 'alice', secret: 'shh' };
105
+
106
+ // Persist value 99
107
+ await adapter.setItem('y', JSON.stringify({ count: 99, __version: 1 }));
108
+
109
+ const store = withPersist(createStore<State>(initialState), { key: 'y', adapter, debounce: 0 });
110
+ const sig = signal(store, 'count');
111
+ const listener = vi.fn();
112
+ sig.subscribe(listener);
113
+
114
+ await store.hydrated;
115
+ expect(listener).toHaveBeenCalledWith(99);
116
+ expect(sig.get()).toBe(99);
117
+ });
118
+ });
119
+
120
+ describe('signal + compose', () => {
121
+ it('signal works correctly on a store created with compose', () => {
122
+ const adapter = memoryAdapter();
123
+ const store = compose(
124
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
125
+ (s) => withPersist(s, { key: 'composed', adapter, debounce: 0 })
126
+ ) as Store<State>;
127
+
128
+ const sig = signal(store, 'count');
129
+ sig.set(123);
130
+ expect(store.getState().count).toBe(123);
131
+ });
132
+
133
+ it('signal.set() on composed store writes through all enhancers', async () => {
134
+ const adapter = memoryAdapter();
135
+ const store = compose(
136
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
137
+ (s) => withPersist(s as Store<State>, { key: 'composed-write', adapter, debounce: 0 })
138
+ ) as Store<State>;
139
+
140
+ const sig = signal(store, 'count');
141
+ sig.set(500);
142
+ await Promise.resolve();
143
+
144
+ const saved = await adapter.getItem('composed-write');
145
+ expect(JSON.parse(saved!).count).toBe(500);
146
+ });
147
+
148
+ it('signal.get() reads through all enhancers correctly', async () => {
149
+ const adapter = memoryAdapter();
150
+ await adapter.setItem('composed-read', JSON.stringify({ count: 777, __version: 1 }));
151
+
152
+ const store = compose(
153
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
154
+ (s) => withPersist(s as Store<State>, { key: 'composed-read', adapter, debounce: 0 })
155
+ ) as Store<State> & { hydrated: Promise<void> };
156
+
157
+ const sig = signal(store, 'count');
158
+ await store.hydrated;
159
+ expect(sig.get()).toBe(777);
160
+ });
161
+ });
162
+
163
+ describe('derived signal + withPersist', () => {
164
+ it('derived signal reflects persisted base value after hydration', async () => {
165
+ const adapter = memoryAdapter();
166
+ await adapter.setItem('derived-persist', JSON.stringify({ count: 10, __version: 1 }));
167
+
168
+ const store = withPersist(
169
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
170
+ { key: 'derived-persist', adapter, debounce: 0 }
171
+ );
172
+ const derivedSig = signal(store, 'count', (v) => v * 2);
173
+
174
+ await store.hydrated;
175
+ expect(derivedSig.get()).toBe(20);
176
+ });
177
+
178
+ it('derived signal subscriber fires after hydration with correct transformed value', async () => {
179
+ const adapter = memoryAdapter();
180
+ await adapter.setItem('derived-sub', JSON.stringify({ count: 5, __version: 1 }));
181
+
182
+ const store = withPersist(
183
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
184
+ { key: 'derived-sub', adapter, debounce: 0 }
185
+ );
186
+ const derivedSig = signal(store, 'count', (v) => v + 100);
187
+ const listener = vi.fn();
188
+ derivedSig.subscribe(listener);
189
+
190
+ await store.hydrated;
191
+ expect(listener).toHaveBeenCalledWith(105);
192
+ });
193
+
194
+ it('derived signal set() still throws even on persisted store', () => {
195
+ const adapter = memoryAdapter();
196
+ const store = withPersist(
197
+ createStore<State>({ count: 0, name: 'a', secret: 's' }),
198
+ { key: 'p', adapter, debounce: 0 }
199
+ );
200
+ const derivedSig = signal(store, 'count', (v) => v);
201
+ expect(() => (derivedSig as unknown as { set: (v: number) => void }).set(1)).toThrow('Storve: cannot call set() on a derived signal. Derived signals are read-only.');
202
+ });
203
+ });
204
+
205
+ describe('pick filter interaction', () => {
206
+ it('signal.set() on a NON-picked key does NOT write to adapter', async () => {
207
+ const adapter = memoryAdapter();
208
+ const store = withPersist(
209
+ createStore<State>({ count: 1, secret: 'x' } as State),
210
+ { key: 'pick-test', adapter, pick: ['count'], debounce: 0 }
211
+ );
212
+ await (store as Store<State> & { hydrated: Promise<void> }).hydrated;
213
+
214
+ // First write a picked key so adapter has data
215
+ const countSig = signal(store, 'count');
216
+ countSig.set(5);
217
+ await Promise.resolve();
218
+
219
+ // Now spy on setItem and set a non-picked key
220
+ const spySet = vi.spyOn(adapter, 'setItem');
221
+ const secretSig = signal(store, 'secret');
222
+ secretSig.set('y');
223
+ await Promise.resolve();
224
+
225
+ // setItem should NOT have been called for the non-picked key change
226
+ expect(spySet).not.toHaveBeenCalled();
227
+
228
+ // Adapter should still only have count, not secret
229
+ const saved = await adapter.getItem('pick-test');
230
+ expect(JSON.parse(saved!).secret).toBeUndefined();
231
+ expect(JSON.parse(saved!).count).toBe(5);
232
+ });
233
+
234
+ it('signal.set() on a PICKED key DOES write to adapter', async () => {
235
+ const adapter = memoryAdapter();
236
+ const store = withPersist(
237
+ createStore<State>({ count: 0, name: 'alice', secret: 'shh' }),
238
+ { key: 'pick-test', adapter, debounce: 0, pick: ['count'] }
239
+ );
240
+ const sig = signal(store, 'count');
241
+
242
+ sig.set(42);
243
+ await Promise.resolve();
244
+
245
+ const saved = await adapter.getItem('pick-test');
246
+ expect(JSON.parse(saved!).count).toBe(42);
247
+ });
248
+ });
249
+
250
+ describe('Multiple signals, shared adapter', () => {
251
+ it('signals on storeA (key \'A\') and storeB (key \'B\') with same adapter — fully isolated', async () => {
252
+ const adapter = memoryAdapter();
253
+ const storeA = withPersist(createStore<State>({ count: 0 } as State), { key: 'A', adapter, debounce: 0 });
254
+ const storeB = withPersist(createStore<State>({ count: 0 } as State), { key: 'B', adapter, debounce: 0 });
255
+
256
+ const sigA = signal(storeA, 'count');
257
+ const sigB = signal(storeB, 'count');
258
+
259
+ sigA.set(1);
260
+ await Promise.resolve();
261
+
262
+ expect(sigB.get()).toBe(0);
263
+ const savedA = await adapter.getItem('A');
264
+ const savedB = await adapter.getItem('B');
265
+ expect(JSON.parse(savedA!).count).toBe(1);
266
+ expect(savedB).toBeNull();
267
+ });
268
+ });
269
+
270
+ describe('Memory management', () => {
271
+ it('unsubscribing from signal prevents callbacks even after store changes', () => {
272
+ const store = createStore<State>({ count: 0, name: 'a', secret: 's' });
273
+ const sig = signal(store, 'count');
274
+ const listener = vi.fn();
275
+ const unsub = sig.subscribe(listener);
276
+
277
+ unsub();
278
+ store.setState({ count: 1 });
279
+ expect(listener).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it('unsubscribing from sigA does not affect sigB on same store', () => {
283
+ const store = createStore<State>({ count: 0, name: 'a', secret: 's' });
284
+ const sigA = signal(store, 'count');
285
+ const sigB = signal(store, 'count');
286
+ const lA = vi.fn();
287
+ const lB = vi.fn();
288
+ const unsubA = sigA.subscribe(lA);
289
+ sigB.subscribe(lB);
290
+
291
+ unsubA();
292
+ store.setState({ count: 1 });
293
+ expect(lA).not.toHaveBeenCalled();
294
+ expect(lB).toHaveBeenCalled();
295
+ });
296
+
297
+ it('after store is garbage collected (no references), signal.subscribe callback does not throw', () => {
298
+ // This is mostly a conceptual test, hard to force GC in JS.
299
+ // But we ensure no dangling strong references that would cause crashes.
300
+ let store: Store<State> | null = createStore<State>({ count: 0, name: 'a', secret: 's' });
301
+ const sig = signal(store, 'count');
302
+ sig.subscribe(() => {});
303
+ store = null;
304
+ // No references to store remain except through signal's closure
305
+ // We're checking for no obvious leaks/dangling pointer issues.
306
+ expect(true).toBe(true);
307
+ });
308
+ });
309
+ });