@zeix/cause-effect 0.17.3 → 0.18.1

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 (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. package/types/src/system.d.ts +0 -78
@@ -1,51 +1,81 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- BaseStore,
4
3
  createEffect,
4
+ createMemo,
5
+ createState,
5
6
  createStore,
7
+ isList,
6
8
  isStore,
7
- Memo,
8
- State,
9
- UNSET,
10
9
  } from '../index.ts'
11
10
 
12
- describe('store', () => {
13
- describe('creation and basic operations', () => {
14
- test('creates BaseStore with initial values', () => {
15
- const user = new BaseStore({
11
+ describe('Store', () => {
12
+ describe('createStore', () => {
13
+ test('should create a store with initial values', () => {
14
+ const user = createStore({
16
15
  name: 'Hannah',
17
16
  email: 'hannah@example.com',
18
17
  })
19
- expect(user.byKey('name').get()).toBe('Hannah')
20
- expect(user.byKey('email').get()).toBe('hannah@example.com')
18
+ expect(user.name.get()).toBe('Hannah')
19
+ expect(user.email.get()).toBe('hannah@example.com')
21
20
  })
22
21
 
23
- test('creates stores with initial values', () => {
22
+ test('should create nested stores for object properties', () => {
24
23
  const user = createStore({
25
- name: 'Hannah',
26
- email: 'hannah@example.com',
24
+ name: 'Alice',
25
+ preferences: { theme: 'light', notifications: true },
27
26
  })
28
- expect(user.name.get()).toBe('Hannah')
29
- expect(user.email.get()).toBe('hannah@example.com')
27
+ expect(isStore(user.preferences)).toBe(true)
28
+ expect(user.preferences.theme.get()).toBe('light')
29
+ expect(user.preferences.notifications.get()).toBe(true)
30
+ })
31
+
32
+ test('should create lists for array properties', () => {
33
+ const data = createStore({ tags: ['a', 'b', 'c'] })
34
+ expect(isList(data.tags)).toBe(true)
35
+ expect(data.tags.get()).toEqual(['a', 'b', 'c'])
36
+ })
37
+
38
+ test('should handle deeply nested objects', () => {
39
+ const config = createStore({
40
+ ui: { theme: { colors: { primary: '#007acc' } } },
41
+ })
42
+ expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
43
+ config.ui.theme.colors.primary.set('#ff6600')
44
+ expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
45
+ })
46
+
47
+ test('should handle empty initial value', () => {
48
+ const empty = createStore({})
49
+ expect(empty.get()).toEqual({})
30
50
  })
31
51
 
32
- test('has Symbol.toStringTag of Store', () => {
52
+ test('should have Symbol.toStringTag of "Store"', () => {
33
53
  const store = createStore({ a: 1 })
34
54
  expect(store[Symbol.toStringTag]).toBe('Store')
35
55
  })
36
56
 
37
- test('isStore identifies store instances correctly', () => {
57
+ test('should have Symbol.isConcatSpreadable set to false', () => {
38
58
  const store = createStore({ a: 1 })
39
- const state = new State(1)
40
- const computed = new Memo(() => 1)
59
+ expect(store[Symbol.isConcatSpreadable]).toBe(false)
60
+ })
61
+ })
41
62
 
63
+ describe('isStore', () => {
64
+ test('should return true for store instances', () => {
65
+ const store = createStore({ a: 1 })
42
66
  expect(isStore(store)).toBe(true)
43
- expect(isStore(state)).toBe(false)
44
- expect(isStore(computed)).toBe(false)
67
+ })
68
+
69
+ test('should return false for non-store values', () => {
70
+ expect(isStore(createState(1))).toBe(false)
71
+ expect(isStore(createMemo(() => 1))).toBe(false)
45
72
  expect(isStore({})).toBe(false)
73
+ expect(isStore(null)).toBe(false)
46
74
  })
75
+ })
47
76
 
48
- test('get() returns the complete store value', () => {
77
+ describe('get', () => {
78
+ test('should return the complete store value', () => {
49
79
  const user = createStore({
50
80
  name: 'Alice',
51
81
  email: 'alice@example.com',
@@ -55,146 +85,240 @@ describe('store', () => {
55
85
  email: 'alice@example.com',
56
86
  })
57
87
  })
88
+
89
+ test('should return updated value after property changes', () => {
90
+ const user = createStore({ name: 'Alice', age: 30 })
91
+ user.name.set('Bob')
92
+ expect(user.get()).toEqual({ name: 'Bob', age: 30 })
93
+ })
58
94
  })
59
95
 
60
- describe('proxy data access and modification', () => {
61
- test('properties can be accessed and modified via signals', () => {
62
- const user = createStore({ name: 'John', age: 30 })
63
- expect(user.name.get()).toBe('John')
64
- expect(user.age.get()).toBe(30)
96
+ describe('set', () => {
97
+ test('should replace entire store value', () => {
98
+ const user = createStore({
99
+ name: 'John',
100
+ email: 'john@example.com',
101
+ })
102
+ user.set({ name: 'Jane', email: 'jane@example.com' })
103
+ expect(user.name.get()).toBe('Jane')
104
+ expect(user.email.get()).toBe('jane@example.com')
105
+ })
65
106
 
66
- user.name.set('Alicia')
67
- user.age.set(31)
68
- expect(user.name.get()).toBe('Alicia')
69
- expect(user.age.get()).toBe(31)
107
+ test('should diff and apply granular changes', () => {
108
+ const user = createStore({ name: 'John', age: 25 })
109
+ let nameRuns = 0
110
+ let ageRuns = 0
111
+ createEffect(() => {
112
+ user.name.get()
113
+ nameRuns++
114
+ })
115
+ createEffect(() => {
116
+ user.age.get()
117
+ ageRuns++
118
+ })
119
+ expect(nameRuns).toBe(1)
120
+ expect(ageRuns).toBe(1)
121
+
122
+ // Only change age — name effect should not re-run
123
+ user.set({ name: 'John', age: 26 })
124
+ expect(nameRuns).toBe(1)
125
+ expect(ageRuns).toBe(2)
70
126
  })
71
127
 
72
- test('returns undefined for non-existent properties', () => {
73
- const user = createStore({ name: 'Alice' })
74
- // @ts-expect-error accessing non-existent property
75
- expect(user.nonexistent).toBeUndefined()
128
+ test('should not propagate when value is identical', () => {
129
+ const store = createStore({ x: 1 })
130
+ let runs = 0
131
+ createEffect(() => {
132
+ store.get()
133
+ runs++
134
+ })
135
+ expect(runs).toBe(1)
136
+
137
+ store.set({ x: 1 })
138
+ expect(runs).toBe(1)
76
139
  })
140
+ })
77
141
 
78
- test('supports string key access', () => {
79
- const items = createStore({ first: 'alpha', second: 'beta' })
80
- expect(items.first.get()).toBe('alpha')
81
- expect(items.second.get()).toBe('beta')
142
+ describe('update', () => {
143
+ test('should modify store using callback', () => {
144
+ const user = createStore({ name: 'John', age: 25 })
145
+ user.update(u => ({ ...u, age: u.age + 1 }))
146
+ expect(user.name.get()).toBe('John')
147
+ expect(user.age.get()).toBe(26)
82
148
  })
83
149
  })
84
150
 
85
- describe('add() and remove() methods', () => {
86
- test('add() method adds new properties', () => {
151
+ describe('add', () => {
152
+ test('should add a new property', () => {
87
153
  const user = createStore<{ name: string; email?: string }>({
88
154
  name: 'John',
89
155
  })
90
156
  user.add('email', 'john@example.com')
91
- expect(user.byKey('email')?.get()).toBe('john@example.com')
92
157
  expect(user.email?.get()).toBe('john@example.com')
158
+ expect(user.byKey('email')?.get()).toBe('john@example.com')
159
+ })
160
+
161
+ test('should throw DuplicateKeyError for existing key', () => {
162
+ const user = createStore({ name: 'John' })
163
+ expect(() => user.add('name', 'Jane')).toThrow()
93
164
  })
94
165
 
95
- test('remove() method removes properties', () => {
166
+ test('should be reactive', () => {
167
+ const store = createStore<{ name: string; email?: string }>({
168
+ name: 'John',
169
+ })
170
+ let lastValue: { name: string; email?: string } = { name: '' }
171
+ createEffect(() => {
172
+ lastValue = store.get()
173
+ })
174
+ expect(lastValue).toEqual({ name: 'John' })
175
+
176
+ store.add('email', 'john@example.com')
177
+ expect(lastValue).toEqual({
178
+ name: 'John',
179
+ email: 'john@example.com',
180
+ })
181
+ })
182
+ })
183
+
184
+ describe('remove', () => {
185
+ test('should remove an existing property', () => {
96
186
  const user = createStore<{ name: string; email?: string }>({
97
187
  name: 'John',
98
188
  email: 'john@example.com',
99
189
  })
100
190
  user.remove('email')
101
191
  expect(user.byKey('email')).toBeUndefined()
102
- // expect(user.byKey('name').get()).toBe('John')
103
192
  expect(user.email).toBeUndefined()
104
- // expect(user.name.get()).toBe('John')
105
193
  })
106
194
 
107
- test('add method prevents null values', () => {
108
- const user = createStore<{ name: string; email?: string }>({
109
- name: 'John',
110
- })
111
- // @ts-expect-error testing null values
112
- expect(() => user.add('email', null)).toThrow()
195
+ test('should handle non-existent key gracefully', () => {
196
+ const store = createStore({ name: 'John' })
197
+ expect(() => store.remove('nonexistent')).not.toThrow()
113
198
  })
114
199
 
115
- test('add method prevents overwriting existing properties', () => {
116
- const user = createStore({
200
+ test('should be reactive', () => {
201
+ const store = createStore({
117
202
  name: 'John',
118
203
  email: 'john@example.com',
119
204
  })
120
- expect(() => user.add('name', 'Jane')).toThrow()
121
- })
205
+ let lastValue: { name: string; email?: string } = {
206
+ name: '',
207
+ email: '',
208
+ }
209
+ let runs = 0
210
+ createEffect(() => {
211
+ lastValue = store.get()
212
+ runs++
213
+ })
214
+ expect(runs).toBe(1)
122
215
 
123
- test('remove method handles non-existent properties gracefully', () => {
124
- const user = createStore({ name: 'John' })
125
- expect(() => user.remove('nonexistent')).not.toThrow()
216
+ store.remove('email')
217
+ expect(lastValue).toEqual({ name: 'John' })
218
+ expect(runs).toBe(2)
126
219
  })
127
220
  })
128
221
 
129
- describe('nested stores', () => {
130
- test('creates nested stores for object properties', () => {
131
- const user = createStore({
132
- name: 'Alice',
133
- preferences: {
134
- theme: 'light',
135
- notifications: true,
136
- },
137
- })
222
+ describe('byKey', () => {
223
+ test('should return the signal for a property', () => {
224
+ const user = createStore({ name: 'Alice', age: 30 })
225
+ const nameSignal = user.byKey('name')
226
+ expect(nameSignal?.get()).toBe('Alice')
227
+ expect(nameSignal).toBe(user.name)
228
+ })
138
229
 
139
- expect(user.name.get()).toBe('Alice')
140
- expect(user.preferences.theme.get()).toBe('light')
141
- expect(user.preferences.notifications.get()).toBe(true)
230
+ test('should return nested store for object property', () => {
231
+ const app = createStore({ config: { version: '1.0.0' } })
232
+ const configStore = app.byKey('config')
233
+ expect(isStore(configStore)).toBe(true)
234
+ expect(configStore).toBe(app.config)
142
235
  })
143
236
 
144
- test('nested properties are reactive', () => {
145
- const user = createStore({
146
- preferences: {
147
- theme: 'light',
148
- },
149
- })
150
- let lastTheme = ''
151
- createEffect(() => {
152
- lastTheme = user.preferences.theme.get()
153
- })
237
+ test('should return undefined for non-existent key', () => {
238
+ const store = createStore({ name: 'Alice' })
239
+ // @ts-expect-error deliberate access for nonexistent key
240
+ expect(store.byKey('nonexistent')).toBeUndefined()
241
+ })
242
+ })
154
243
 
155
- expect(lastTheme).toBe('light')
156
- user.preferences.theme.set('dark')
157
- expect(lastTheme).toBe('dark')
244
+ describe('keys', () => {
245
+ test('should return an iterator of property keys', () => {
246
+ const store = createStore({ alpha: 1, beta: 2, gamma: 3 })
247
+ expect(Array.from(store.keys())).toEqual(['alpha', 'beta', 'gamma'])
158
248
  })
159
249
 
160
- test('deeply nested stores work correctly', () => {
161
- const config = createStore({
162
- ui: {
163
- theme: {
164
- colors: {
165
- primary: '#007acc',
166
- },
167
- },
168
- },
169
- })
250
+ test('should reflect additions and removals', () => {
251
+ const store = createStore<{ a: number; b?: number }>({ a: 1 })
252
+ expect(Array.from(store.keys())).toEqual(['a'])
170
253
 
171
- expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
172
- config.ui.theme.colors.primary.set('#ff6600')
173
- expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
254
+ store.add('b', 2)
255
+ expect(Array.from(store.keys())).toEqual(['a', 'b'])
256
+
257
+ store.remove('b')
258
+ expect(Array.from(store.keys())).toEqual(['a'])
174
259
  })
175
260
  })
176
261
 
177
- describe('set() and update() methods', () => {
178
- test('set() replaces entire store value', () => {
262
+ describe('Proxy Behavior', () => {
263
+ test('should access properties directly as signals', () => {
264
+ const user = createStore({ name: 'John', age: 30 })
265
+ expect(user.name.get()).toBe('John')
266
+ expect(user.age.get()).toBe(30)
267
+
268
+ user.name.set('Alicia')
269
+ expect(user.name.get()).toBe('Alicia')
270
+ })
271
+
272
+ test('should return undefined for non-existent properties', () => {
273
+ const user = createStore({ name: 'Alice' })
274
+ // @ts-expect-error accessing non-existent property
275
+ expect(user.nonexistent).toBeUndefined()
276
+ })
277
+
278
+ test('should support "in" operator', () => {
279
+ const user = createStore({ name: 'Alice' })
280
+ expect('name' in user).toBe(true)
281
+ expect('email' in user).toBe(false)
282
+ })
283
+
284
+ test('should support Object.keys()', () => {
179
285
  const user = createStore({
180
- name: 'John',
181
- email: 'john@example.com',
286
+ name: 'Alice',
287
+ email: 'alice@example.com',
182
288
  })
183
- user.set({ name: 'Jane', email: 'jane@example.com' })
184
- expect(user.name.get()).toBe('Jane')
185
- expect(user.email.get()).toBe('jane@example.com')
289
+ expect(Object.keys(user).sort()).toEqual(['email', 'name'])
186
290
  })
187
291
 
188
- test('update() modifies store using function', () => {
189
- const user = createStore({ name: 'John', age: 25 })
190
- user.update(u => ({ ...u, age: u.age + 1 }))
191
- expect(user.name.get()).toBe('John')
192
- expect(user.age.get()).toBe(26)
292
+ test('should support for...in enumeration', () => {
293
+ const user = createStore({
294
+ name: 'Alice',
295
+ email: 'alice@example.com',
296
+ })
297
+ const keys: string[] = []
298
+ for (const key in user) keys.push(key)
299
+ expect(keys.sort()).toEqual(['email', 'name'])
300
+ })
301
+
302
+ test('should support Object.getOwnPropertyDescriptor', () => {
303
+ const user = createStore({ name: 'Alice' })
304
+ expect(Object.getOwnPropertyDescriptor(user, 'name')).toEqual({
305
+ enumerable: true,
306
+ configurable: true,
307
+ writable: true,
308
+ value: user.name,
309
+ })
310
+ })
311
+
312
+ test('should return undefined descriptor for Symbol properties', () => {
313
+ const store = createStore({ a: 1 })
314
+ expect(
315
+ Object.getOwnPropertyDescriptor(store, Symbol('test')),
316
+ ).toBeUndefined()
193
317
  })
194
318
  })
195
319
 
196
- describe('iteration protocol', () => {
197
- test('supports for...of iteration', () => {
320
+ describe('Iteration', () => {
321
+ test('should support spread operator', () => {
198
322
  const user = createStore({ name: 'John', age: 25 })
199
323
  const entries = [...user]
200
324
  expect(entries).toHaveLength(2)
@@ -204,16 +328,8 @@ describe('store', () => {
204
328
  expect(entries[1][1].get()).toBe(25)
205
329
  })
206
330
 
207
- test('Symbol.isConcatSpreadable is false', () => {
208
- const user = createStore({ name: 'John', age: 25 })
209
- expect(user[Symbol.isConcatSpreadable]).toBe(false)
210
- })
211
-
212
- test('maintains property key ordering', () => {
331
+ test('should maintain property key ordering', () => {
213
332
  const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
214
- const keys = Object.keys(config)
215
- expect(keys).toEqual(['alpha', 'beta', 'gamma'])
216
-
217
333
  const entries = [...config]
218
334
  expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
219
335
  [
@@ -225,8 +341,8 @@ describe('store', () => {
225
341
  })
226
342
  })
227
343
 
228
- describe('reactivity', () => {
229
- test('store-level get() is reactive', () => {
344
+ describe('Reactivity', () => {
345
+ test('should react to property changes via get()', () => {
230
346
  const user = createStore({
231
347
  name: 'John',
232
348
  email: 'john@example.com',
@@ -235,422 +351,206 @@ describe('store', () => {
235
351
  createEffect(() => {
236
352
  lastValue = user.get()
237
353
  })
238
-
239
354
  expect(lastValue).toEqual({
240
355
  name: 'John',
241
356
  email: 'john@example.com',
242
357
  })
243
358
 
244
359
  user.name.set('Jane')
245
- user.email.set('jane@example.com')
246
-
247
360
  expect(lastValue).toEqual({
248
361
  name: 'Jane',
249
- email: 'jane@example.com',
362
+ email: 'john@example.com',
250
363
  })
251
364
  })
252
365
 
253
- test('individual signal reactivity works', () => {
366
+ test('should support granular property-level subscriptions', () => {
254
367
  const user = createStore({
255
368
  name: 'John',
256
369
  email: 'john@example.com',
257
370
  })
258
- let lastName = ''
259
- let nameEffectRuns = 0
371
+ let nameRuns = 0
260
372
  createEffect(() => {
261
- lastName = user.name.get()
262
- nameEffectRuns++
373
+ user.name.get()
374
+ nameRuns++
263
375
  })
376
+ expect(nameRuns).toBe(1)
264
377
 
265
- expect(lastName).toBe('John')
266
- expect(nameEffectRuns).toBe(1)
378
+ user.email.set('new@example.com')
379
+ expect(nameRuns).toBe(1) // name effect not triggered
267
380
 
268
381
  user.name.set('Jane')
269
- expect(lastName).toBe('Jane')
270
- expect(nameEffectRuns).toBe(2)
382
+ expect(nameRuns).toBe(2)
271
383
  })
272
384
 
273
- test('nested store changes propagate to parent', () => {
385
+ test('should propagate nested store changes to parent', () => {
274
386
  const user = createStore({
275
- preferences: {
276
- theme: 'light',
277
- },
387
+ preferences: { theme: 'light' },
278
388
  })
279
- let effectRuns = 0
389
+ let runs = 0
280
390
  createEffect(() => {
281
391
  user.get()
282
- effectRuns++
392
+ runs++
283
393
  })
394
+ expect(runs).toBe(1)
284
395
 
285
- expect(effectRuns).toBe(1)
286
396
  user.preferences.theme.set('dark')
287
- expect(effectRuns).toBe(2)
397
+ expect(runs).toBe(2)
288
398
  })
289
399
 
290
- test('updates are reactive', () => {
291
- const user = createStore({
292
- name: 'John',
293
- })
294
- let lastValue: {
295
- name: string
296
- email?: string
297
- } = { name: '' }
298
- let effectRuns = 0
400
+ test('should react to update()', () => {
401
+ const user = createStore({ name: 'John' })
402
+ let lastValue: { name: string; email?: string } = { name: '' }
403
+ let runs = 0
299
404
  createEffect(() => {
300
405
  lastValue = user.get()
301
- effectRuns++
406
+ runs++
302
407
  })
303
-
304
- expect(lastValue).toEqual({ name: 'John' })
305
- expect(effectRuns).toBe(1)
408
+ expect(runs).toBe(1)
306
409
 
307
410
  user.update(u => ({ ...u, email: 'john@example.com' }))
308
411
  expect(lastValue).toEqual({
309
412
  name: 'John',
310
413
  email: 'john@example.com',
311
414
  })
312
- expect(effectRuns).toBe(2)
415
+ expect(runs).toBe(2)
313
416
  })
314
417
 
315
- test('remove method is reactive', () => {
316
- const user = createStore({
317
- name: 'John',
318
- email: 'john@example.com',
319
- age: 30,
320
- })
321
- let lastValue: {
322
- name: string
323
- email?: string
324
- age?: number
325
- } = { name: '', email: '', age: 0 }
326
- let effectRuns = 0
327
- createEffect(() => {
328
- lastValue = user.get()
329
- effectRuns++
330
- })
331
-
332
- expect(lastValue).toEqual({
333
- name: 'John',
334
- email: 'john@example.com',
335
- age: 30,
336
- })
337
- expect(effectRuns).toBe(1)
338
-
339
- user.remove('email')
340
- expect(lastValue).toEqual({ name: 'John', age: 30 })
341
- expect(effectRuns).toBe(2)
342
- })
343
- })
344
-
345
- describe('computed integration', () => {
346
- test('works with computed signals', () => {
347
- const user = createStore({
348
- firstName: 'John',
349
- lastName: 'Doe',
350
- })
351
- const fullName = new Memo(
418
+ test('should work with createMemo', () => {
419
+ const user = createStore({ firstName: 'John', lastName: 'Doe' })
420
+ const fullName = createMemo(
352
421
  () => `${user.firstName.get()} ${user.lastName.get()}`,
353
422
  )
354
-
355
423
  expect(fullName.get()).toBe('John Doe')
424
+
356
425
  user.firstName.set('Jane')
357
426
  expect(fullName.get()).toBe('Jane Doe')
358
427
  })
359
428
 
360
- test('computed reacts to nested store changes', () => {
361
- const config = createStore({
362
- ui: {
363
- theme: 'light',
364
- },
365
- })
366
- const themeDisplay = new Memo(
367
- () => `Theme: ${config.ui.theme.get()}`,
368
- )
429
+ test('should work with createMemo on nested stores', () => {
430
+ const config = createStore({ ui: { theme: 'light' } })
431
+ const display = createMemo(() => `Theme: ${config.ui.theme.get()}`)
432
+ expect(display.get()).toBe('Theme: light')
369
433
 
370
- expect(themeDisplay.get()).toBe('Theme: light')
371
434
  config.ui.theme.set('dark')
372
- expect(themeDisplay.get()).toBe('Theme: dark')
435
+ expect(display.get()).toBe('Theme: dark')
373
436
  })
374
437
  })
375
438
 
376
- describe('proxy behavior and enumeration', () => {
377
- test('Object.keys returns property keys', () => {
378
- const user = createStore({
379
- name: 'Alice',
380
- email: 'alice@example.com',
381
- })
382
- const userKeys = Object.keys(user)
383
- expect(userKeys.sort()).toEqual(['email', 'name'])
384
- })
385
-
386
- test('property enumeration works', () => {
387
- const user = createStore({
388
- name: 'Alice',
389
- email: 'alice@example.com',
390
- })
391
- const userKeys: string[] = []
392
- for (const key in user) {
393
- userKeys.push(key)
439
+ describe('Serialization', () => {
440
+ test('should round-trip through JSON', () => {
441
+ const data = {
442
+ user: { name: 'Alice', preferences: { theme: 'dark' } },
443
+ settings: { timeout: 5000 },
394
444
  }
395
- expect(userKeys.sort()).toEqual(['email', 'name'])
396
- })
397
-
398
- test('in operator works', () => {
399
- const user = createStore({ name: 'Alice' })
400
- expect('name' in user).toBe(true)
401
- expect('email' in user).toBe(false)
402
- })
403
-
404
- test('Object.getOwnPropertyDescriptor works', () => {
405
- const user = createStore({ name: 'Alice' })
406
- const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
407
- expect(nameDescriptor).toEqual({
408
- enumerable: true,
409
- configurable: true,
410
- writable: true,
411
- value: user.name,
412
- })
445
+ const store = createStore(data)
446
+ const parsed = JSON.parse(JSON.stringify(store.get()))
447
+ expect(parsed).toEqual(data)
413
448
  })
414
449
  })
415
450
 
416
- describe('byKey() method', () => {
417
- test('works with property keys', () => {
418
- const user = createStore({
419
- name: 'Alice',
420
- email: 'alice@example.com',
421
- age: 30,
422
- })
423
-
424
- const nameSignal = user.byKey('name')
425
- const emailSignal = user.byKey('email')
426
- const ageSignal = user.byKey('age')
427
- // @ts-expect-error deliberate access for nonexistent key
428
- const nonexistentSignal = user.byKey('nonexistent')
429
-
430
- expect(nameSignal?.get()).toBe('Alice')
431
- expect(emailSignal?.get()).toBe('alice@example.com')
432
- expect(ageSignal?.get()).toBe(30)
433
- expect(nonexistentSignal).toBeUndefined()
434
-
435
- // Verify these are the same signals as property access
436
- expect(nameSignal).toBe(user.name)
437
- expect(emailSignal).toBe(user.email)
438
- expect(ageSignal).toBe(user.age)
439
- })
440
-
441
- test('works with nested stores', () => {
442
- const app = createStore({
443
- config: {
444
- version: '1.0.0',
451
+ describe('options.watched', () => {
452
+ test('should activate on first effect and clean up on last', () => {
453
+ let watchCount = 0
454
+ const store = createStore(
455
+ { users: {} as Record<string, { name: string }> },
456
+ {
457
+ watched: () => {
458
+ watchCount++
459
+ return () => {
460
+ watchCount--
461
+ }
462
+ },
445
463
  },
446
- })
447
-
448
- const configStore = app.byKey('config')
449
- expect(configStore?.get()).toEqual({ version: '1.0.0' })
450
- expect(configStore).toBe(app.config)
451
- })
452
-
453
- test('is reactive and works with computed signals', () => {
454
- const user = createStore<{
455
- name: string
456
- age: number
457
- }>({
458
- name: 'Alice',
459
- age: 30,
460
- })
461
-
462
- const nameSignal = user.byKey('name')
463
- const displayName = new Memo(() =>
464
- nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
465
464
  )
465
+ expect(watchCount).toBe(0)
466
466
 
467
- expect(displayName.get()).toBe('Hello, Alice!')
468
- nameSignal?.set('Bob')
469
- expect(displayName.get()).toBe('Hello, Bob!')
470
- })
471
- })
472
-
473
- describe('UNSET and edge cases', () => {
474
- test('handles UNSET values', () => {
475
- const store = createStore({ value: UNSET })
476
- expect(store.get()).toEqual({ value: UNSET })
477
- })
478
-
479
- test('handles primitive values', () => {
480
- const store = createStore({
481
- str: 'hello',
482
- num: 42,
483
- bool: true,
467
+ const cleanup1 = createEffect(() => {
468
+ store.get()
484
469
  })
485
- expect(store.str.get()).toBe('hello')
486
- expect(store.num.get()).toBe(42)
487
- expect(store.bool.get()).toBe(true)
488
- })
489
-
490
- test('handles empty stores correctly', () => {
491
- const empty = createStore({})
492
- expect(empty.get()).toEqual({})
493
- })
494
- })
495
-
496
- describe('JSON integration and serialization', () => {
497
- test('seamless JSON integration', () => {
498
- const jsonData = {
499
- user: { name: 'Alice', preferences: { theme: 'dark' } },
500
- settings: { timeout: 5000 },
501
- }
502
- const store = createStore(jsonData)
503
-
504
- expect(store.user.name.get()).toBe('Alice')
505
- expect(store.user.preferences.theme.get()).toBe('dark')
506
- expect(store.settings.timeout.get()).toBe(5000)
507
-
508
- const serialized = JSON.stringify(store.get())
509
- const parsed = JSON.parse(serialized)
510
- expect(parsed).toEqual(jsonData)
511
- })
512
-
513
- test('handles complex nested structures from JSON', () => {
514
- type Dashboard = {
515
- dashboard: {
516
- widgets: Array<{
517
- id: string
518
- type: string
519
- config: { color?: string; rows?: number }
520
- }>
521
- }
522
- }
523
-
524
- const complexData: Dashboard = {
525
- dashboard: {
526
- widgets: [
527
- { id: '1', type: 'chart', config: { color: 'blue' } },
528
- { id: '2', type: 'table', config: { rows: 10 } },
529
- ],
530
- },
531
- }
532
-
533
- const store = createStore(complexData)
534
- expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
535
- 'blue',
536
- )
537
- expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
538
- })
539
- })
470
+ expect(watchCount).toBe(1)
540
471
 
541
- describe('type conversion and nested stores', () => {
542
- test('nested objects become nested stores', () => {
543
- const config = createStore({
544
- database: {
545
- host: 'localhost',
546
- port: 5432,
547
- },
472
+ const cleanup2 = createEffect(() => {
473
+ store.get()
548
474
  })
475
+ expect(watchCount).toBe(1) // still 1
476
+
477
+ cleanup2()
478
+ expect(watchCount).toBe(1) // still active
549
479
 
550
- expect(isStore(config.database)).toBe(true)
551
- expect(config.database.host.get()).toBe('localhost')
552
- expect(config.database.port.get()).toBe(5432)
480
+ cleanup1()
481
+ expect(watchCount).toBe(0) // cleaned up
553
482
  })
554
- })
555
483
 
556
- describe('Watch Callbacks', () => {
557
- test('Root store watched callback triggered only by direct store access', async () => {
558
- let rootStoreCounter = 0
559
- let intervalId: Timer | undefined
484
+ test('should not activate for nested property access only', async () => {
485
+ let activated = false
560
486
  const store = createStore(
561
- {
562
- user: {
563
- name: 'John',
564
- profile: {
565
- email: 'john@example.com',
566
- },
567
- },
568
- },
487
+ { user: { name: 'John' } },
569
488
  {
570
489
  watched: () => {
571
- intervalId = setInterval(() => {
572
- rootStoreCounter++
573
- }, 10)
574
- },
575
- unwatched: () => {
576
- if (intervalId) {
577
- clearInterval(intervalId)
578
- intervalId = undefined
490
+ activated = true
491
+ return () => {
492
+ activated = false
579
493
  }
580
494
  },
581
495
  },
582
496
  )
583
497
 
584
- expect(rootStoreCounter).toBe(0)
585
- await new Promise(resolve => setTimeout(resolve, 50))
586
- expect(rootStoreCounter).toBe(0)
587
-
588
- // Access nested property directly - should NOT trigger root watched callback
589
- const nestedEffectCleanup = createEffect(() => {
498
+ const cleanup = createEffect(() => {
590
499
  store.user.name.get()
591
500
  })
501
+ await new Promise(resolve => setTimeout(resolve, 10))
502
+ expect(activated).toBe(false)
592
503
 
593
- await new Promise(resolve => setTimeout(resolve, 50))
594
- expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
595
- expect(intervalId).toBeUndefined()
596
-
597
- // Access root store directly - should trigger watched callback
598
- const rootEffectCleanup = createEffect(() => {
599
- store.get()
600
- })
601
-
602
- await new Promise(resolve => setTimeout(resolve, 50))
603
- expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
604
- expect(intervalId).toBeDefined()
605
-
606
- // Cleanup
607
- rootEffectCleanup()
608
- nestedEffectCleanup()
609
- await new Promise(resolve => setTimeout(resolve, 50))
610
- expect(intervalId).toBeUndefined()
504
+ cleanup()
611
505
  })
612
506
 
613
- test('Store property addition/removal affects store watched callback', async () => {
614
- let usersStoreCounter = 0
615
- const store = createStore(
616
- {
617
- users: {} as Record<string, { name: string }>,
618
- },
507
+ test('should activate when keys() is called in an effect', () => {
508
+ let watchCount = 0
509
+ const store = createStore<{ a: number; b?: number }>(
510
+ { a: 1 },
619
511
  {
620
512
  watched: () => {
621
- usersStoreCounter++
622
- },
623
- unwatched: () => {
624
- usersStoreCounter--
513
+ watchCount++
514
+ return () => {
515
+ watchCount--
516
+ }
625
517
  },
626
518
  },
627
519
  )
520
+ expect(watchCount).toBe(0)
628
521
 
629
- expect(usersStoreCounter).toBe(0)
630
-
631
- // Watch the entire store
632
- const usersEffect = createEffect(() => {
633
- store.get()
522
+ const cleanup = createEffect(() => {
523
+ Array.from(store.keys()).forEach(() => {})
634
524
  })
635
- expect(usersStoreCounter).toBe(1)
525
+ expect(watchCount).toBe(1)
636
526
 
637
- // Add a user - this modifies the users store content but doesn't affect watched callback
638
- store.users.add('user1', { name: 'Alice' })
639
- expect(usersStoreCounter).toBe(1) // Still 1
527
+ cleanup()
528
+ expect(watchCount).toBe(0)
529
+ })
530
+ })
640
531
 
641
- // Watch a specific user property - this doesn't trigger users store watched callback
642
- const userEffect = createEffect(() => {
643
- store.users.user1?.name.get()
644
- })
645
- expect(usersStoreCounter).toBe(1) // Still 1
532
+ describe('Input Validation', () => {
533
+ test('should throw for null initial value', () => {
534
+ // @ts-expect-error testing null
535
+ expect(() => createStore(null)).toThrow()
536
+ })
646
537
 
647
- // Cleanup user effect
648
- userEffect()
649
- expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
538
+ test('should throw for undefined initial value', () => {
539
+ // @ts-expect-error testing undefined
540
+ expect(() => createStore(undefined)).toThrow()
541
+ })
650
542
 
651
- // Cleanup users effect
652
- usersEffect()
653
- expect(usersStoreCounter).toBe(0) // Now cleaned up
543
+ test('should throw for non-object initial value', () => {
544
+ // @ts-expect-error testing primitive
545
+ expect(() => createStore('hello')).toThrow()
546
+ })
547
+
548
+ test('should throw for null value in add()', () => {
549
+ const store = createStore<{ name: string; email?: string }>({
550
+ name: 'John',
551
+ })
552
+ // @ts-expect-error testing null
553
+ expect(() => store.add('email', null)).toThrow()
654
554
  })
655
555
  })
656
556
  })