@zeix/cause-effect 0.16.1 → 0.17.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 (61) hide show
  1. package/.ai-context.md +71 -21
  2. package/.cursorrules +3 -2
  3. package/.github/copilot-instructions.md +59 -13
  4. package/CLAUDE.md +170 -24
  5. package/LICENSE +1 -1
  6. package/README.md +156 -52
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +19 -19
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +138 -0
  12. package/{src → archive}/state.ts +13 -11
  13. package/archive/store.ts +368 -0
  14. package/archive/task.ts +194 -0
  15. package/eslint.config.js +1 -0
  16. package/index.dev.js +899 -503
  17. package/index.js +1 -1
  18. package/index.ts +41 -22
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +272 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +304 -0
  24. package/src/classes/state.ts +98 -0
  25. package/src/classes/store.ts +210 -0
  26. package/src/diff.ts +26 -53
  27. package/src/effect.ts +9 -9
  28. package/src/errors.ts +50 -25
  29. package/src/signal.ts +58 -41
  30. package/src/system.ts +79 -42
  31. package/src/util.ts +16 -30
  32. package/test/batch.test.ts +15 -17
  33. package/test/benchmark.test.ts +4 -4
  34. package/test/collection.test.ts +796 -0
  35. package/test/computed.test.ts +138 -130
  36. package/test/diff.test.ts +2 -2
  37. package/test/effect.test.ts +36 -35
  38. package/test/list.test.ts +754 -0
  39. package/test/match.test.ts +25 -25
  40. package/test/resolve.test.ts +17 -19
  41. package/test/signal.test.ts +70 -119
  42. package/test/state.test.ts +44 -44
  43. package/test/store.test.ts +253 -929
  44. package/types/index.d.ts +10 -8
  45. package/types/src/classes/collection.d.ts +32 -0
  46. package/types/src/classes/composite.d.ts +15 -0
  47. package/types/src/classes/computed.d.ts +97 -0
  48. package/types/src/classes/list.d.ts +41 -0
  49. package/types/src/classes/state.d.ts +52 -0
  50. package/types/src/classes/store.d.ts +51 -0
  51. package/types/src/diff.d.ts +8 -12
  52. package/types/src/errors.d.ts +12 -11
  53. package/types/src/signal.d.ts +27 -14
  54. package/types/src/system.d.ts +41 -20
  55. package/types/src/util.d.ts +6 -3
  56. package/src/store.ts +0 -474
  57. package/types/src/collection.d.ts +0 -26
  58. package/types/src/computed.d.ts +0 -33
  59. package/types/src/scheduler.d.ts +0 -55
  60. package/types/src/state.d.ts +0 -24
  61. package/types/src/store.d.ts +0 -65
@@ -1,262 +1,147 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- createComputed,
3
+ BaseStore,
4
4
  createEffect,
5
- createState,
6
5
  createStore,
7
6
  isStore,
8
- type State,
7
+ Memo,
8
+ State,
9
9
  UNSET,
10
- } from '..'
10
+ } from '../index.ts'
11
11
 
12
12
  describe('store', () => {
13
13
  describe('creation and basic operations', () => {
14
+ test('creates BaseStore with initial values', () => {
15
+ const user = new BaseStore({
16
+ name: 'Hannah',
17
+ email: 'hannah@example.com',
18
+ })
19
+ expect(user.byKey('name').get()).toBe('Hannah')
20
+ expect(user.byKey('email').get()).toBe('hannah@example.com')
21
+ })
22
+
14
23
  test('creates stores with initial values', () => {
15
- // Record store
16
24
  const user = createStore({
17
25
  name: 'Hannah',
18
26
  email: 'hannah@example.com',
19
27
  })
20
28
  expect(user.name.get()).toBe('Hannah')
21
29
  expect(user.email.get()).toBe('hannah@example.com')
22
-
23
- // Array store
24
- const numbers = createStore([1, 2, 3])
25
- expect(numbers[0].get()).toBe(1)
26
- expect(numbers[1].get()).toBe(2)
27
- expect(numbers[2].get()).toBe(3)
28
30
  })
29
31
 
30
32
  test('has Symbol.toStringTag of Store', () => {
31
- const recordStore = createStore({ a: 1 })
32
- const arrayStore = createStore([1, 2])
33
-
34
- expect(recordStore[Symbol.toStringTag]).toBe('Store')
35
- expect(arrayStore[Symbol.toStringTag]).toBe('Store')
33
+ const store = createStore({ a: 1 })
34
+ expect(store[Symbol.toStringTag]).toBe('Store')
36
35
  })
37
36
 
38
37
  test('isStore identifies store instances correctly', () => {
39
- const recordStore = createStore({ a: 1 })
40
- const arrayStore = createStore([1])
41
- const state = createState(1)
42
- const computed = createComputed(() => 1)
38
+ const store = createStore({ a: 1 })
39
+ const state = new State(1)
40
+ const computed = new Memo(() => 1)
43
41
 
44
- expect(isStore(recordStore)).toBe(true)
45
- expect(isStore(arrayStore)).toBe(true)
42
+ expect(isStore(store)).toBe(true)
46
43
  expect(isStore(state)).toBe(false)
47
44
  expect(isStore(computed)).toBe(false)
48
45
  expect(isStore({})).toBe(false)
49
- expect(isStore(null)).toBe(false)
50
46
  })
51
47
 
52
48
  test('get() returns the complete store value', () => {
53
- // Record store
54
49
  const user = createStore({
55
- name: 'Hannah',
56
- email: 'hannah@example.com',
50
+ name: 'Alice',
51
+ email: 'alice@example.com',
57
52
  })
58
53
  expect(user.get()).toEqual({
59
- name: 'Hannah',
60
- email: 'hannah@example.com',
61
- })
62
-
63
- // Array store
64
- const numbers = createStore([1, 2, 3])
65
- expect(numbers.get()).toEqual([1, 2, 3])
66
-
67
- // Nested structures
68
- const participants = createStore([
69
- { name: 'Alice', tags: ['admin'] },
70
- { name: 'Bob', tags: ['user'] },
71
- ])
72
- expect(participants[0].name.get()).toBe('Alice')
73
- expect(participants[0].tags.get()).toEqual(['admin'])
74
- expect(participants[1].name.get()).toBe('Bob')
75
- expect(participants[1].tags.get()).toEqual(['user'])
76
- })
77
- })
78
-
79
- describe('length property and sizing', () => {
80
- test('length property works for both store types', () => {
81
- // Record store
82
- const user = createStore({ name: 'John', age: 25 })
83
- expect(user.length).toBe(2)
84
- expect(typeof user.length).toBe('number')
85
-
86
- // Array store
87
- const numbers = createStore([1, 2, 3])
88
- expect(numbers.length).toBe(3)
89
- expect(typeof numbers.length).toBe('number')
90
- })
91
-
92
- test('length is reactive and updates with changes', () => {
93
- // Record store
94
- const user = createStore<{ name: string; age?: number }>({
95
- name: 'John',
54
+ name: 'Alice',
55
+ email: 'alice@example.com',
96
56
  })
97
- expect(user.length).toBe(1)
98
- user.add('age', 25)
99
- expect(user.length).toBe(2)
100
- user.remove('age')
101
- expect(user.length).toBe(1)
102
-
103
- // Array store
104
- const items = createStore([1, 2])
105
- expect(items.length).toBe(2)
106
- items.add(3)
107
- expect(items.length).toBe(3)
108
- items.remove(1)
109
- expect(items.length).toBe(2)
110
57
  })
111
58
  })
112
59
 
113
60
  describe('proxy data access and modification', () => {
114
61
  test('properties can be accessed and modified via signals', () => {
115
- // Record store
116
- const user = createStore({ name: 'Alice', age: 30 })
117
- expect(user.name.get()).toBe('Alice')
62
+ const user = createStore({ name: 'John', age: 30 })
63
+ expect(user.name.get()).toBe('John')
118
64
  expect(user.age.get()).toBe(30)
65
+
119
66
  user.name.set('Alicia')
120
67
  user.age.set(31)
121
68
  expect(user.name.get()).toBe('Alicia')
122
69
  expect(user.age.get()).toBe(31)
123
-
124
- // Array store
125
- const items = createStore(['a', 'b'])
126
- expect(items[0].get()).toBe('a')
127
- expect(items[1].get()).toBe('b')
128
- items[0].set('alpha')
129
- items[1].set('beta')
130
- expect(items[0].get()).toBe('alpha')
131
- expect(items[1].get()).toBe('beta')
132
70
  })
133
71
 
134
72
  test('returns undefined for non-existent properties', () => {
135
- // Record store
136
73
  const user = createStore({ name: 'Alice' })
137
74
  // @ts-expect-error accessing non-existent property
138
75
  expect(user.nonexistent).toBeUndefined()
139
-
140
- // Array store
141
- const items = createStore(['a'])
142
- expect(items[5]).toBeUndefined()
143
76
  })
144
77
 
145
- test('supports numeric key access for both store types', () => {
146
- // Record store with numeric keys
147
- const items = createStore({ 0: 'zero', 1: 'one' })
148
- expect(items[0].get()).toBe('zero')
149
- expect(items[1].get()).toBe('one')
150
-
151
- // Array store with numeric keys
152
- const numbers = createStore([10, 20])
153
- expect(numbers[0].get()).toBe(10)
154
- expect(numbers[1].get()).toBe(20)
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')
155
82
  })
156
83
  })
157
84
 
158
85
  describe('add() and remove() methods', () => {
159
- test('add() method behavior differs between store types', () => {
160
- // Record store - requires key parameter
86
+ test('add() method adds new properties', () => {
161
87
  const user = createStore<{ name: string; email?: string }>({
162
88
  name: 'John',
163
89
  })
164
90
  user.add('email', 'john@example.com')
91
+ expect(user.byKey('email')?.get()).toBe('john@example.com')
165
92
  expect(user.email?.get()).toBe('john@example.com')
166
- expect(user.length).toBe(2)
167
-
168
- // Array store - single parameter adds to end
169
- const fruits = createStore(['apple', 'banana'])
170
- fruits.add('cherry')
171
- expect(fruits[2].get()).toBe('cherry')
172
- expect(fruits.length).toBe(3)
173
- expect(fruits.get()).toEqual(['apple', 'banana', 'cherry'])
174
93
  })
175
94
 
176
- test('remove() method behavior differs between store types', () => {
177
- // Record store - removes by key
178
- const user = createStore({
95
+ test('remove() method removes properties', () => {
96
+ const user = createStore<{ name: string; email?: string }>({
179
97
  name: 'John',
180
98
  email: 'john@example.com',
181
99
  })
182
100
  user.remove('email')
101
+ expect(user.byKey('email')).toBeUndefined()
102
+ // expect(user.byKey('name').get()).toBe('John')
183
103
  expect(user.email).toBeUndefined()
184
- expect(user.name.get()).toBe('John')
185
- expect(user.length).toBe(1)
186
-
187
- // Array store - removes by index
188
- const items = createStore(['a', 'b', 'c'])
189
- items.remove(1) // Remove 'b'
190
- expect(items.get()).toEqual(['a', 'c'])
191
- expect(items.length).toBe(2)
104
+ // expect(user.name.get()).toBe('John')
192
105
  })
193
106
 
194
- test('add method prevents null values for both store types', () => {
195
- // Record store
107
+ test('add method prevents null values', () => {
196
108
  const user = createStore<{ name: string; email?: string }>({
197
109
  name: 'John',
198
110
  })
199
111
  // @ts-expect-error testing null values
200
112
  expect(() => user.add('email', null)).toThrow()
201
-
202
- // Array store
203
- const items = createStore([1])
204
- // @ts-expect-error testing null values
205
- expect(() => items.add(null)).toThrow()
206
113
  })
207
114
 
208
- test('add method prevents overwriting existing properties in record stores', () => {
209
- const user = createStore<{ name: string; email?: string }>({
115
+ test('add method prevents overwriting existing properties', () => {
116
+ const user = createStore({
210
117
  name: 'John',
211
118
  email: 'john@example.com',
212
119
  })
213
- const originalSize = user.length
214
120
  expect(() => user.add('name', 'Jane')).toThrow()
215
- expect(user.length).toBe(originalSize)
216
- expect(user.name.get()).toBe('John')
217
121
  })
218
122
 
219
123
  test('remove method handles non-existent properties gracefully', () => {
220
- // Record store
221
- const user = createStore<{ name: string }>({ name: 'John' })
222
- const originalSize = user.length
223
- // @ts-expect-error deliberate removal of non-existent property
224
- user.remove('nonexistent')
225
- expect(user.length).toBe(originalSize)
226
-
227
- // Array store - out of bounds throws
228
- const items = createStore([1, 2])
229
- expect(() => items.remove(5)).toThrow()
230
- expect(() => items.remove(-5)).toThrow()
124
+ const user = createStore({ name: 'John' })
125
+ expect(() => user.remove('nonexistent')).not.toThrow()
231
126
  })
232
127
  })
233
128
 
234
129
  describe('nested stores', () => {
235
- test('creates nested stores for object properties in both store types', () => {
236
- // Record store
130
+ test('creates nested stores for object properties', () => {
237
131
  const user = createStore({
238
132
  name: 'Alice',
239
133
  preferences: {
240
- theme: 'dark',
134
+ theme: 'light',
241
135
  notifications: true,
242
136
  },
243
137
  })
244
- expect(isStore(user.preferences)).toBe(true)
245
- expect(user.preferences.theme.get()).toBe('dark')
246
- expect(user.preferences.notifications.get()).toBe(true)
247
138
 
248
- // Array store with nested objects
249
- const users = createStore([
250
- { name: 'Alice', active: true },
251
- { name: 'Bob', active: false },
252
- ])
253
- expect(isStore(users[0])).toBe(true)
254
- expect(users[0].name.get()).toBe('Alice')
255
- expect(users[1].active.get()).toBe(false)
139
+ expect(user.name.get()).toBe('Alice')
140
+ expect(user.preferences.theme.get()).toBe('light')
141
+ expect(user.preferences.notifications.get()).toBe(true)
256
142
  })
257
143
 
258
144
  test('nested properties are reactive', () => {
259
- // Record store
260
145
  const user = createStore({
261
146
  preferences: {
262
147
  theme: 'light',
@@ -266,19 +151,10 @@ describe('store', () => {
266
151
  createEffect(() => {
267
152
  lastTheme = user.preferences.theme.get()
268
153
  })
154
+
269
155
  expect(lastTheme).toBe('light')
270
156
  user.preferences.theme.set('dark')
271
157
  expect(lastTheme).toBe('dark')
272
-
273
- // Array store
274
- const configs = createStore([{ mode: 'development' }])
275
- let lastMode = ''
276
- createEffect(() => {
277
- lastMode = configs[0].mode.get()
278
- })
279
- expect(lastMode).toBe('development')
280
- configs[0].mode.set('production')
281
- expect(lastMode).toBe('production')
282
158
  })
283
159
 
284
160
  test('deeply nested stores work correctly', () => {
@@ -286,20 +162,20 @@ describe('store', () => {
286
162
  ui: {
287
163
  theme: {
288
164
  colors: {
289
- primary: '#blue',
165
+ primary: '#007acc',
290
166
  },
291
167
  },
292
168
  },
293
169
  })
294
- expect(config.ui.theme.colors.primary.get()).toBe('#blue')
295
- config.ui.theme.colors.primary.set('#red')
296
- expect(config.ui.theme.colors.primary.get()).toBe('#red')
170
+
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')
297
174
  })
298
175
  })
299
176
 
300
177
  describe('set() and update() methods', () => {
301
- test('set() replaces entire store value for both store types', () => {
302
- // Record store
178
+ test('set() replaces entire store value', () => {
303
179
  const user = createStore({
304
180
  name: 'John',
305
181
  email: 'john@example.com',
@@ -307,31 +183,18 @@ describe('store', () => {
307
183
  user.set({ name: 'Jane', email: 'jane@example.com' })
308
184
  expect(user.name.get()).toBe('Jane')
309
185
  expect(user.email.get()).toBe('jane@example.com')
310
-
311
- // Array store
312
- const numbers = createStore([1, 2, 3])
313
- numbers.set([4, 5])
314
- expect(numbers.get()).toEqual([4, 5])
315
- expect(numbers.length).toBe(2)
316
186
  })
317
187
 
318
- test('update() modifies store using function for both store types', () => {
319
- // Record store
188
+ test('update() modifies store using function', () => {
320
189
  const user = createStore({ name: 'John', age: 25 })
321
190
  user.update(u => ({ ...u, age: u.age + 1 }))
322
191
  expect(user.name.get()).toBe('John')
323
192
  expect(user.age.get()).toBe(26)
324
-
325
- // Array store
326
- const numbers = createStore([1, 2, 3])
327
- numbers.update(arr => arr.map(n => n * 2))
328
- expect(numbers.get()).toEqual([2, 4, 6])
329
193
  })
330
194
  })
331
195
 
332
196
  describe('iteration protocol', () => {
333
- test('supports for...of iteration with different behaviors', () => {
334
- // Record store - yields [key, signal] pairs
197
+ test('supports for...of iteration', () => {
335
198
  const user = createStore({ name: 'John', age: 25 })
336
199
  const entries = [...user]
337
200
  expect(entries).toHaveLength(2)
@@ -339,193 +202,119 @@ describe('store', () => {
339
202
  expect(entries[0][1].get()).toBe('John')
340
203
  expect(entries[1][0]).toBe('age')
341
204
  expect(entries[1][1].get()).toBe(25)
342
-
343
- // Array store - yields signals only
344
- const numbers = createStore([10, 20, 30])
345
- const signals = [...numbers]
346
- expect(signals).toHaveLength(3)
347
- expect(signals[0].get()).toBe(10)
348
- expect(signals[1].get()).toBe(20)
349
- expect(signals[2].get()).toBe(30)
350
205
  })
351
206
 
352
- test('Symbol.isConcatSpreadable behavior differs between store types', () => {
353
- // Array store - spreadable
354
- const numbers = createStore([1, 2, 3])
355
- expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
356
-
357
- // Record store - not spreadable
207
+ test('Symbol.isConcatSpreadable is false', () => {
358
208
  const user = createStore({ name: 'John', age: 25 })
359
209
  expect(user[Symbol.isConcatSpreadable]).toBe(false)
360
210
  })
361
211
 
362
- test('array stores maintain numeric key ordering', () => {
363
- const items = createStore(['first', 'second', 'third'])
364
- const keys = Object.keys(items).filter(
365
- k => !Number.isNaN(Number(k)),
212
+ test('maintains property key ordering', () => {
213
+ const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
214
+ const keys = Object.keys(config)
215
+ expect(keys).toEqual(['alpha', 'beta', 'gamma'])
216
+
217
+ const entries = [...config]
218
+ expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
219
+ [
220
+ ['alpha', 1],
221
+ ['beta', 2],
222
+ ['gamma', 3],
223
+ ],
366
224
  )
367
- expect(keys).toEqual(['0', '1', '2'])
368
-
369
- const signals = [...items]
370
- expect(signals.map(s => s.get())).toEqual([
371
- 'first',
372
- 'second',
373
- 'third',
374
- ])
375
225
  })
376
226
  })
377
227
 
378
228
  describe('change tracking and notifications', () => {
379
- test('emits add notifications for both store types', () => {
380
- // Record store - initial creation
381
- let addNotification: Record<string, unknown>
382
- const user = createStore({ name: 'John' })
383
- user.on('add', change => {
384
- addNotification = change
385
- })
386
-
387
- // Wait for initial add event
388
- setTimeout(() => {
389
- expect(addNotification.name).toBe('John')
390
- }, 0)
391
-
392
- // Record store - new property
393
- const userWithEmail = createStore<{ name: string; email?: string }>(
394
- { name: 'John' },
395
- )
396
- let newAddNotification: Record<string, unknown> = {}
397
- userWithEmail.on('add', change => {
398
- newAddNotification = change
229
+ test('emits add notifications', () => {
230
+ let addNotification: readonly string[] = []
231
+ const user = createStore<{ name: string; email?: string }>({
232
+ name: 'John',
399
233
  })
400
- userWithEmail.add('email', 'john@example.com')
401
- expect(newAddNotification.email).toBe('john@example.com')
402
-
403
- // Array store
404
- const numbers = createStore([1, 2])
405
- let arrayAddNotification = {}
406
- numbers.on('add', change => {
407
- arrayAddNotification = change
234
+ user.on('add', add => {
235
+ addNotification = add
408
236
  })
409
- numbers.add(3)
410
- expect(arrayAddNotification[2]).toBe(3)
237
+ user.add('email', 'john@example.com')
238
+ expect(addNotification).toContain('email')
411
239
  })
412
240
 
413
241
  test('emits change notifications when properties are modified', () => {
414
- // Record store
415
242
  const user = createStore({ name: 'John' })
416
- let changeNotification: Record<string, unknown> = {}
243
+ let changeNotification: readonly string[] = []
417
244
  user.on('change', change => {
418
245
  changeNotification = change
419
246
  })
420
247
  user.name.set('Jane')
421
- expect(changeNotification.name).toBe('Jane')
422
-
423
- // Array store
424
- const items = createStore(['a', 'b'])
425
- let arrayChangeNotification = {}
426
- items.on('change', change => {
427
- arrayChangeNotification = change
428
- })
429
- items[0].set('alpha')
430
- expect(arrayChangeNotification[0]).toBe('alpha')
248
+ expect(changeNotification).toContain('name')
431
249
  })
432
250
 
433
251
  test('emits change notifications for nested property changes', () => {
434
- // Record store
435
252
  const user = createStore({
436
- name: 'John',
437
253
  preferences: {
438
254
  theme: 'light',
439
- notifications: true,
440
255
  },
441
256
  })
442
- let changeNotification: Record<string, unknown> = {}
257
+ let changeNotification: readonly string[] = []
443
258
  user.on('change', change => {
444
259
  changeNotification = change
445
260
  })
446
261
  user.preferences.theme.set('dark')
447
- expect(changeNotification.preferences).toEqual({
448
- theme: 'dark',
449
- notifications: true,
450
- })
451
-
452
- // Array store with nested objects
453
- const users = createStore([{ name: 'Alice', role: 'admin' }])
454
- let arrayChangeNotification: Record<number, unknown> = []
455
- users.on('change', change => {
456
- arrayChangeNotification = change
457
- })
458
- users[0].name.set('Alicia')
459
- expect(arrayChangeNotification[0]).toEqual({
460
- name: 'Alicia',
461
- role: 'admin',
462
- })
262
+ expect(changeNotification).toContain('preferences')
463
263
  })
464
264
 
465
265
  test('emits remove notifications when properties are removed', () => {
466
- // Record store
467
266
  const user = createStore({
468
267
  name: 'John',
469
268
  email: 'john@example.com',
470
269
  })
471
- let removeNotification: Record<string, unknown> = {}
472
- user.on('remove', change => {
473
- removeNotification = change
270
+ let removeNotification: readonly string[] = []
271
+ user.on('remove', remove => {
272
+ removeNotification = remove
474
273
  })
475
274
  user.remove('email')
476
- expect(removeNotification.email).toBe(UNSET)
477
-
478
- // Array store
479
- const items = createStore(['a', 'b', 'c'])
480
- let arrayRemoveNotification: Record<number, unknown> = []
481
- items.on('remove', change => {
482
- arrayRemoveNotification = change
483
- })
484
- items.remove(1)
485
- expect(arrayRemoveNotification[2]).toBe(UNSET) // Last item gets removed in compaction
275
+ expect(removeNotification).toContain('email')
486
276
  })
487
277
 
488
278
  test('set() correctly handles mixed changes, additions, and removals', () => {
489
279
  const user = createStore<{
490
280
  name: string
491
281
  email?: string
492
- preferences?: {
493
- theme: string
494
- notifications?: boolean
495
- }
282
+ preferences: { theme?: string }
496
283
  age?: number
497
284
  }>({
498
- name: 'Hannah',
499
- email: 'hannah@example.com',
285
+ name: 'John',
286
+ email: 'john@example.com',
500
287
  preferences: {
501
- theme: 'light', // will change
288
+ theme: 'light',
502
289
  },
503
290
  })
504
291
 
505
- let changeNotification: Record<string, unknown> | undefined
506
- let addNotification: Record<string, unknown> | undefined
507
- let removeNotification: Record<string, unknown> | undefined
292
+ let changeNotification: readonly string[] = []
293
+ let addNotification: readonly string[] = []
294
+ let removeNotification: readonly string[] = []
295
+
508
296
  user.on('change', change => {
509
297
  changeNotification = change
510
298
  })
511
- user.on('add', change => {
512
- addNotification = change
299
+ user.on('add', add => {
300
+ addNotification = add
513
301
  })
514
- user.on('remove', change => {
515
- removeNotification = change
302
+ user.on('remove', remove => {
303
+ removeNotification = remove
516
304
  })
517
305
 
518
306
  user.set({
519
- name: 'Jane', // changed
307
+ name: 'Jane',
520
308
  preferences: {
521
- theme: 'dark', // changed
309
+ theme: 'dark',
522
310
  },
523
- age: 30, // added
524
- } as { name: string; preferences: { theme: string }; age: number })
311
+ age: 30,
312
+ })
525
313
 
526
- expect(changeNotification?.preferences).toEqual({ theme: 'dark' })
527
- expect(addNotification?.age).toBe(30)
528
- expect(removeNotification?.email).toBe(UNSET)
314
+ expect(changeNotification).toContain('name')
315
+ expect(changeNotification).toContain('preferences')
316
+ expect(addNotification).toContain('age')
317
+ expect(removeNotification).toContain('email')
529
318
  })
530
319
 
531
320
  test('notification listeners can be removed', () => {
@@ -537,15 +326,15 @@ describe('store', () => {
537
326
  const off = user.on('change', listener)
538
327
  user.name.set('Jane')
539
328
  expect(notificationCount).toBe(1)
329
+
540
330
  off()
541
- user.name.set('Jack')
542
- expect(notificationCount).toBe(1) // Should not increment
331
+ user.name.set('Bob')
332
+ expect(notificationCount).toBe(1)
543
333
  })
544
334
  })
545
335
 
546
336
  describe('reactivity', () => {
547
- test('store-level get() is reactive for both store types', () => {
548
- // Record store
337
+ test('store-level get() is reactive', () => {
549
338
  const user = createStore({
550
339
  name: 'John',
551
340
  email: 'john@example.com',
@@ -554,27 +343,22 @@ describe('store', () => {
554
343
  createEffect(() => {
555
344
  lastValue = user.get()
556
345
  })
346
+
557
347
  expect(lastValue).toEqual({
558
348
  name: 'John',
559
349
  email: 'john@example.com',
560
350
  })
351
+
561
352
  user.name.set('Jane')
562
- expect(lastValue.name).toBe('Jane')
563
- expect(lastValue.email).toBe('john@example.com')
353
+ user.email.set('jane@example.com')
564
354
 
565
- // Array store
566
- const numbers = createStore([1, 2, 3])
567
- let lastArray: number[] = []
568
- createEffect(() => {
569
- lastArray = numbers.get()
355
+ expect(lastValue).toEqual({
356
+ name: 'Jane',
357
+ email: 'jane@example.com',
570
358
  })
571
- expect(lastArray).toEqual([1, 2, 3])
572
- numbers[0].set(10)
573
- expect(lastArray).toEqual([10, 2, 3])
574
359
  })
575
360
 
576
- test('individual signal reactivity works for both store types', () => {
577
- // Record store
361
+ test('individual signal reactivity works', () => {
578
362
  const user = createStore({
579
363
  name: 'John',
580
364
  email: 'john@example.com',
@@ -582,34 +366,16 @@ describe('store', () => {
582
366
  let lastName = ''
583
367
  let nameEffectRuns = 0
584
368
  createEffect(() => {
585
- nameEffectRuns++
586
369
  lastName = user.name.get()
370
+ nameEffectRuns++
587
371
  })
372
+
588
373
  expect(lastName).toBe('John')
589
374
  expect(nameEffectRuns).toBe(1)
375
+
590
376
  user.name.set('Jane')
591
377
  expect(lastName).toBe('Jane')
592
378
  expect(nameEffectRuns).toBe(2)
593
- // Changing email should not trigger name effect
594
- user.email.set('jane@example.com')
595
- expect(nameEffectRuns).toBe(2)
596
-
597
- // Array store
598
- const items = createStore(['a', 'b'])
599
- let lastItem = ''
600
- let itemEffectRuns = 0
601
- createEffect(() => {
602
- itemEffectRuns++
603
- lastItem = items[0].get()
604
- })
605
- expect(lastItem).toBe('a')
606
- expect(itemEffectRuns).toBe(1)
607
- items[0].set('alpha')
608
- expect(lastItem).toBe('alpha')
609
- expect(itemEffectRuns).toBe(2)
610
- // Changing other item should not trigger effect
611
- items[1].set('beta')
612
- expect(itemEffectRuns).toBe(2)
613
379
  })
614
380
 
615
381
  test('nested store changes propagate to parent', () => {
@@ -620,101 +386,83 @@ describe('store', () => {
620
386
  })
621
387
  let effectRuns = 0
622
388
  createEffect(() => {
389
+ user.get()
623
390
  effectRuns++
624
- user.get() // Subscribe to entire store
625
391
  })
392
+
626
393
  expect(effectRuns).toBe(1)
627
394
  user.preferences.theme.set('dark')
628
395
  expect(effectRuns).toBe(2)
629
396
  })
630
397
 
631
- test('updates are reactive for both store types', () => {
632
- // Record store
633
- const user = createStore<{ name: string; email?: string }>({
398
+ test('updates are reactive', () => {
399
+ const user = createStore({
634
400
  name: 'John',
635
401
  })
636
- let lastValue: Record<string, unknown> = {}
402
+ let lastValue: {
403
+ name: string
404
+ email?: string
405
+ } = { name: '' }
637
406
  let effectRuns = 0
638
407
  createEffect(() => {
639
- effectRuns++
640
408
  lastValue = user.get()
409
+ effectRuns++
641
410
  })
411
+
412
+ expect(lastValue).toEqual({ name: 'John' })
642
413
  expect(effectRuns).toBe(1)
643
- user.update(u => ({ ...u, email: 'john@example.com' }))
644
- expect(effectRuns).toBe(2)
645
- expect(lastValue.name).toBe('John')
646
- expect(lastValue.email).toBe('john@example.com')
647
414
 
648
- // Array store
649
- const numbers = createStore([1, 2])
650
- let lastArray: number[] = []
651
- let arrayEffectRuns = 0
652
- createEffect(() => {
653
- arrayEffectRuns++
654
- lastArray = numbers.get()
415
+ user.update(u => ({ ...u, email: 'john@example.com' }))
416
+ expect(lastValue).toEqual({
417
+ name: 'John',
418
+ email: 'john@example.com',
655
419
  })
656
- expect(arrayEffectRuns).toBe(1)
657
- numbers.update(arr => [...arr, 3])
658
- expect(arrayEffectRuns).toBe(2)
659
- expect(lastArray).toEqual([1, 2, 3])
420
+ expect(effectRuns).toBe(2)
660
421
  })
661
422
 
662
- test('remove method is reactive for both store types', () => {
663
- // Record store
664
- const user = createStore<{
665
- name: string
666
- email?: string
667
- }>({
423
+ test('remove method is reactive', () => {
424
+ const user = createStore({
668
425
  name: 'John',
669
426
  email: 'john@example.com',
427
+ age: 30,
670
428
  })
671
- let lastValue: Record<string, unknown> = {}
429
+ let lastValue: {
430
+ name: string
431
+ email?: string
432
+ age?: number
433
+ } = { name: '', email: '', age: 0 }
672
434
  let effectRuns = 0
673
435
  createEffect(() => {
674
- effectRuns++
675
436
  lastValue = user.get()
437
+ effectRuns++
438
+ })
439
+
440
+ expect(lastValue).toEqual({
441
+ name: 'John',
442
+ email: 'john@example.com',
443
+ age: 30,
676
444
  })
677
445
  expect(effectRuns).toBe(1)
446
+
678
447
  user.remove('email')
448
+ expect(lastValue).toEqual({ name: 'John', age: 30 })
679
449
  expect(effectRuns).toBe(2)
680
- expect(lastValue.name).toBe('John')
681
- expect(lastValue.email).toBeUndefined()
682
-
683
- // Array store
684
- const items = createStore(['a', 'b', 'c'])
685
- let lastArray: string[] = []
686
- let arrayEffectRuns = 0
687
- createEffect(() => {
688
- arrayEffectRuns++
689
- lastArray = items.get()
690
- })
691
- expect(arrayEffectRuns).toBe(1)
692
- items.remove(1)
693
- // Array removal causes multiple reactivity updates due to compaction
694
- expect(arrayEffectRuns).toBeGreaterThanOrEqual(2)
695
- expect(lastArray).toEqual(['a', 'c'])
696
450
  })
697
451
  })
698
452
 
699
453
  describe('computed integration', () => {
700
- test('works with computed signals for both store types', () => {
701
- // Record store
702
- const user = createStore({ firstName: 'John', lastName: 'Doe' })
703
- const fullName = createComputed(() => {
704
- return `${user.firstName.get()} ${user.lastName.get()}`
454
+ test('works with computed signals', () => {
455
+ const user = createStore({
456
+ firstName: 'John',
457
+ lastName: 'Doe',
705
458
  })
459
+ const fullName = new Memo(
460
+ () => `${user.firstName.get()} ${user.lastName.get()}`,
461
+ )
462
+
706
463
  expect(fullName.get()).toBe('John Doe')
707
464
  user.firstName.set('Jane')
708
465
  expect(fullName.get()).toBe('Jane Doe')
709
-
710
- // Array store
711
- const numbers = createStore([1, 2, 3])
712
- const sum = createComputed(() => {
713
- return numbers.get().reduce((acc, n) => acc + n, 0)
714
- })
715
- expect(sum.get()).toBe(6)
716
- numbers[0].set(10)
717
- expect(sum.get()).toBe(15)
718
466
  })
719
467
 
720
468
  test('computed reacts to nested store changes', () => {
@@ -723,226 +471,46 @@ describe('store', () => {
723
471
  theme: 'light',
724
472
  },
725
473
  })
726
- const themeDisplay = createComputed(() => {
727
- return `Theme: ${config.ui.theme.get()}`
728
- })
474
+ const themeDisplay = new Memo(
475
+ () => `Theme: ${config.ui.theme.get()}`,
476
+ )
477
+
729
478
  expect(themeDisplay.get()).toBe('Theme: light')
730
479
  config.ui.theme.set('dark')
731
480
  expect(themeDisplay.get()).toBe('Theme: dark')
732
481
  })
733
-
734
- test('computed with array stores handles additions and removals', () => {
735
- const numbers = createStore([1, 2, 3])
736
- const sum = createComputed(() => {
737
- const array = numbers.get()
738
- return array.reduce((acc, n) => acc + n, 0)
739
- })
740
-
741
- expect(sum.get()).toBe(6)
742
-
743
- // Add a number
744
- numbers.add(4)
745
- expect(sum.get()).toBe(10)
746
-
747
- // Remove a number
748
- numbers.remove(0)
749
- const finalArray = numbers.get()
750
- expect(finalArray).toEqual([2, 3, 4])
751
- expect(sum.get()).toBe(9)
752
- })
753
-
754
- test('computed sum using store iteration with length tracking', () => {
755
- const numbers = createStore([1, 2, 3])
756
-
757
- const sum = createComputed(() => {
758
- // Access length to ensure reactivity
759
- const _length = numbers.length
760
- let total = 0
761
- for (const signal of numbers) {
762
- total += signal.get()
763
- }
764
- return total
765
- })
766
-
767
- expect(sum.get()).toBe(6)
768
-
769
- // Add item
770
- numbers.add(4)
771
- expect(sum.get()).toBe(10)
772
-
773
- // Remove item
774
- numbers.remove(1)
775
- expect(sum.get()).toBe(8) // 1 + 3 + 4 (middle item removed)
776
- })
777
- })
778
-
779
- describe('sort() method', () => {
780
- test('sorts array stores with different compare functions', () => {
781
- // Numeric sort
782
- const numbers = createStore([3, 1, 4, 1, 5])
783
- const _oldSignals = [
784
- numbers[0],
785
- numbers[1],
786
- numbers[2],
787
- numbers[3],
788
- numbers[4],
789
- ]
790
-
791
- numbers.sort((a, b) => a - b)
792
- expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
793
-
794
- // Verify signals moved correctly
795
- expect(numbers[0]).toBe(_oldSignals[1]) // First '1'
796
- expect(numbers[1]).toBe(_oldSignals[3]) // Second '1'
797
- expect(numbers[2]).toBe(_oldSignals[0]) // '3'
798
-
799
- // String sort
800
- const names = createStore(['Charlie', 'Alice', 'Bob'])
801
- names.sort()
802
- expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
803
- })
804
-
805
- test('sorts record stores by value', () => {
806
- const users = createStore({
807
- user1: { name: 'Charlie', age: 25 },
808
- user2: { name: 'Alice', age: 30 },
809
- user3: { name: 'Bob', age: 35 },
810
- })
811
-
812
- const _oldSignals = {
813
- user1: users.user1,
814
- user2: users.user2,
815
- user3: users.user3,
816
- }
817
-
818
- users.sort((a, b) => a.name.localeCompare(b.name))
819
-
820
- // After sorting by name: Alice, Bob, Charlie
821
- // The keys should be reordered based on the sort
822
- const sortedEntries = [...users]
823
- expect(sortedEntries[0][1].name.get()).toBe('Alice')
824
- expect(sortedEntries[1][1].name.get()).toBe('Bob')
825
- expect(sortedEntries[2][1].name.get()).toBe('Charlie')
826
- })
827
-
828
- test('emits sort notification with new order', () => {
829
- const numbers = createStore([3, 1, 2])
830
- let sortNotification: string[] = []
831
- numbers.on('sort', change => {
832
- sortNotification = change
833
- })
834
-
835
- numbers.sort((a, b) => a - b)
836
- expect(sortNotification).toEqual(['1', '2', '0']) // Original indices in new order
837
- })
838
-
839
- test('sort is reactive - watchers are notified', () => {
840
- const numbers = createStore([3, 1, 2])
841
- let effectCount = 0
842
- let lastValue: number[] = []
843
-
844
- createEffect(() => {
845
- effectCount++
846
- lastValue = numbers.get()
847
- })
848
-
849
- expect(effectCount).toBe(1)
850
- expect(lastValue).toEqual([3, 1, 2])
851
-
852
- numbers.sort((a, b) => a - b)
853
- expect(effectCount).toBe(2)
854
- expect(lastValue).toEqual([1, 2, 3])
855
- })
856
-
857
- test('nested signals remain reactive after sorting', () => {
858
- const items = createStore([
859
- { name: 'Charlie', score: 85 },
860
- { name: 'Alice', score: 95 },
861
- { name: 'Bob', score: 75 },
862
- ])
863
-
864
- items.sort((a, b) => a.score - b.score)
865
-
866
- // Verify order: Bob(75), Charlie(85), Alice(95)
867
- expect(items[0].name.get()).toBe('Bob')
868
- expect(items[1].name.get()).toBe('Charlie')
869
- expect(items[2].name.get()).toBe('Alice')
870
-
871
- // Verify signals are still reactive
872
- items[0].score.set(100)
873
- expect(items[0].score.get()).toBe(100)
874
- })
875
-
876
- test('default sort handles numbers as strings like Array.prototype.sort()', () => {
877
- const numbers = createStore([10, 2, 1])
878
- numbers.sort()
879
- expect(numbers.get()).toEqual([1, 10, 2]) // String comparison: "1" < "10" < "2"
880
- })
881
-
882
- test('multiple sorts work correctly', () => {
883
- const numbers = createStore([3, 1, 2])
884
- numbers.sort((a, b) => a - b) // [1, 2, 3]
885
- numbers.sort((a, b) => b - a) // [3, 2, 1]
886
- expect(numbers.get()).toEqual([3, 2, 1])
887
- })
888
482
  })
889
483
 
890
484
  describe('proxy behavior and enumeration', () => {
891
- test('Object.keys returns property keys for both store types', () => {
892
- // Record store
485
+ test('Object.keys returns property keys', () => {
893
486
  const user = createStore({
894
- name: 'John',
895
- email: 'john@example.com',
487
+ name: 'Alice',
488
+ email: 'alice@example.com',
896
489
  })
897
490
  const userKeys = Object.keys(user)
898
491
  expect(userKeys.sort()).toEqual(['email', 'name'])
899
-
900
- // Array store
901
- const numbers = createStore([1, 2, 3])
902
- const numberKeys = Object.keys(numbers).filter(
903
- k => !Number.isNaN(Number(k)),
904
- )
905
- expect(numberKeys).toEqual(['0', '1', '2'])
906
492
  })
907
493
 
908
- test('property enumeration works for both store types', () => {
909
- // Record store
494
+ test('property enumeration works', () => {
910
495
  const user = createStore({
911
- name: 'John',
912
- email: 'john@example.com',
496
+ name: 'Alice',
497
+ email: 'alice@example.com',
913
498
  })
914
499
  const userKeys: string[] = []
915
500
  for (const key in user) {
916
501
  userKeys.push(key)
917
502
  }
918
503
  expect(userKeys.sort()).toEqual(['email', 'name'])
919
-
920
- // Array store
921
- const numbers = createStore([10, 20])
922
- const numberKeys: string[] = []
923
- for (const key in numbers) {
924
- if (!Number.isNaN(Number(key))) numberKeys.push(key)
925
- }
926
- expect(numberKeys).toEqual(['0', '1'])
927
504
  })
928
505
 
929
- test('in operator works for both store types', () => {
930
- // Record store
931
- const user = createStore({ name: 'John' })
506
+ test('in operator works', () => {
507
+ const user = createStore({ name: 'Alice' })
932
508
  expect('name' in user).toBe(true)
933
509
  expect('email' in user).toBe(false)
934
- expect('length' in user).toBe(true)
935
-
936
- // Array store
937
- const numbers = createStore([1, 2])
938
- expect(0 in numbers).toBe(true)
939
- expect(2 in numbers).toBe(false)
940
- expect('length' in numbers).toBe(true)
941
510
  })
942
511
 
943
- test('Object.getOwnPropertyDescriptor works for both store types', () => {
944
- // Record store
945
- const user = createStore({ name: 'John' })
512
+ test('Object.getOwnPropertyDescriptor works', () => {
513
+ const user = createStore({ name: 'Alice' })
946
514
  const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
947
515
  expect(nameDescriptor).toEqual({
948
516
  enumerable: true,
@@ -950,195 +518,135 @@ describe('store', () => {
950
518
  writable: true,
951
519
  value: user.name,
952
520
  })
521
+ })
522
+ })
953
523
 
954
- const lengthDescriptor = Object.getOwnPropertyDescriptor(
955
- user,
956
- 'length',
957
- )
958
- expect(lengthDescriptor?.enumerable).toBe(false)
959
- expect(lengthDescriptor?.configurable).toBe(false)
960
-
961
- // Array store
962
- const numbers = createStore([1, 2])
963
- const indexDescriptor = Object.getOwnPropertyDescriptor(
964
- numbers,
965
- '0',
966
- )
967
- expect(indexDescriptor).toEqual({
968
- enumerable: true,
969
- configurable: true,
970
- writable: true,
971
- value: numbers[0],
524
+ describe('byKey() method', () => {
525
+ test('works with property keys', () => {
526
+ const user = createStore({
527
+ name: 'Alice',
528
+ email: 'alice@example.com',
529
+ age: 30,
972
530
  })
973
531
 
974
- const arrayLengthDescriptor = Object.getOwnPropertyDescriptor(
975
- numbers,
976
- 'length',
977
- )
978
- expect(arrayLengthDescriptor?.enumerable).toBe(false)
979
- expect(arrayLengthDescriptor?.configurable).toBe(false)
532
+ const nameSignal = user.byKey('name')
533
+ const emailSignal = user.byKey('email')
534
+ const ageSignal = user.byKey('age')
535
+ // @ts-expect-error deliberate access for nonexistent key
536
+ const nonexistentSignal = user.byKey('nonexistent')
537
+
538
+ expect(nameSignal?.get()).toBe('Alice')
539
+ expect(emailSignal?.get()).toBe('alice@example.com')
540
+ expect(ageSignal?.get()).toBe(30)
541
+ expect(nonexistentSignal).toBeUndefined()
542
+
543
+ // Verify these are the same signals as property access
544
+ expect(nameSignal).toBe(user.name)
545
+ expect(emailSignal).toBe(user.email)
546
+ expect(ageSignal).toBe(user.age)
980
547
  })
981
- })
982
548
 
983
- describe('spread operator behavior', () => {
984
- test('spreading stores works differently for each type', () => {
985
- // Record store - spreads individual signals
986
- const user = createStore({ name: 'John', age: 25 })
987
- const userSpread = { ...user }
988
- expect('name' in userSpread).toBe(true)
989
- expect('age' in userSpread).toBe(true)
990
- expect(typeof userSpread.name?.get).toBe('function')
991
- expect(userSpread.name?.get()).toBe('John')
992
-
993
- // Array store - spreads signals (not [key, value] pairs)
994
- const numbers = createStore([1, 2, 3])
995
- const numberSpread = [...numbers]
996
- expect(numberSpread).toHaveLength(3)
997
- expect(typeof numberSpread[0].get).toBe('function')
998
- expect(numberSpread[0].get()).toBe(1)
549
+ test('works with nested stores', () => {
550
+ const app = createStore({
551
+ config: {
552
+ version: '1.0.0',
553
+ },
554
+ })
555
+
556
+ const configStore = app.byKey('config')
557
+ expect(configStore?.get()).toEqual({ version: '1.0.0' })
558
+ expect(configStore).toBe(app.config)
999
559
  })
1000
560
 
1001
- test('concat works correctly with array stores', () => {
1002
- const numbers = createStore([2, 3])
1003
- const prefix = [createState(1)]
1004
- const suffix = [createState(4)]
561
+ test('is reactive and works with computed signals', () => {
562
+ const user = createStore<{
563
+ name: string
564
+ age: number
565
+ }>({
566
+ name: 'Alice',
567
+ age: 30,
568
+ })
1005
569
 
1006
- const combined = prefix.concat(
1007
- numbers as unknown as ConcatArray<State<number>>,
1008
- suffix,
570
+ const nameSignal = user.byKey('name')
571
+ const displayName = new Memo(() =>
572
+ nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
1009
573
  )
1010
574
 
1011
- expect(combined).toHaveLength(4)
1012
- expect(combined[0].get()).toBe(1)
1013
- expect(combined[1].get()).toBe(2) // from store
1014
- expect(combined[2].get()).toBe(3) // from store
1015
- expect(combined[3].get()).toBe(4)
575
+ expect(displayName.get()).toBe('Hello, Alice!')
576
+ nameSignal?.set('Bob')
577
+ expect(displayName.get()).toBe('Hello, Bob!')
1016
578
  })
1017
579
  })
1018
580
 
1019
581
  describe('UNSET and edge cases', () => {
1020
- test('handles UNSET values for both store types', () => {
1021
- // Record store
1022
- const recordData = createStore({ value: UNSET as string })
1023
- expect(recordData.value.get()).toBe(UNSET)
1024
- recordData.value.set('some string')
1025
- expect(recordData.value.get()).toBe('some string')
1026
-
1027
- // Array store
1028
- const arrayData = createStore([UNSET as string])
1029
- expect(arrayData[0].get()).toBe(UNSET)
1030
- arrayData[0].set('some value')
1031
- expect(arrayData[0].get()).toBe('some value')
582
+ test('handles UNSET values', () => {
583
+ const store = createStore({ value: UNSET })
584
+ expect(store.get()).toEqual({ value: UNSET })
1032
585
  })
1033
586
 
1034
- test('handles primitive values in both store types', () => {
1035
- // Record store
1036
- const recordData = createStore({
587
+ test('handles primitive values', () => {
588
+ const store = createStore({
1037
589
  str: 'hello',
1038
590
  num: 42,
1039
591
  bool: true,
1040
592
  })
1041
- expect(recordData.str.get()).toBe('hello')
1042
- expect(recordData.num.get()).toBe(42)
1043
- expect(recordData.bool.get()).toBe(true)
1044
-
1045
- // Array store
1046
- const arrayData = createStore(['hello', 42, true])
1047
- expect(arrayData[0].get()).toBe('hello')
1048
- expect(arrayData[1].get()).toBe(42)
1049
- expect(arrayData[2].get()).toBe(true)
593
+ expect(store.str.get()).toBe('hello')
594
+ expect(store.num.get()).toBe(42)
595
+ expect(store.bool.get()).toBe(true)
1050
596
  })
1051
597
 
1052
598
  test('handles empty stores correctly', () => {
1053
- // Empty record store
1054
- const emptyRecord = createStore({})
1055
- expect(emptyRecord.length).toBe(0)
1056
- expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1057
- expect([...emptyRecord]).toEqual([])
1058
-
1059
- // Empty array store
1060
- const emptyArray = createStore([])
1061
- expect(emptyArray.length).toBe(0)
1062
- expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1063
- expect([...emptyArray]).toEqual([])
599
+ const empty = createStore({})
600
+ expect(empty.get()).toEqual({})
1064
601
  })
1065
602
  })
1066
603
 
1067
604
  describe('JSON integration and serialization', () => {
1068
- test('seamless JSON integration for both store types', () => {
1069
- // Record store from JSON
605
+ test('seamless JSON integration', () => {
1070
606
  const jsonData = {
1071
- user: { name: 'John', preferences: { theme: 'dark' } },
607
+ user: { name: 'Alice', preferences: { theme: 'dark' } },
1072
608
  settings: { timeout: 5000 },
1073
609
  }
1074
- const recordStore = createStore(jsonData)
1075
- expect(recordStore.user.name.get()).toBe('John')
1076
- expect(recordStore.user.preferences.theme.get()).toBe('dark')
1077
-
1078
- // Modify and serialize back
1079
- recordStore.user.name.set('Jane')
1080
- recordStore.settings.timeout.set(10000)
1081
- const serialized = JSON.stringify(recordStore.get())
610
+ const store = createStore(jsonData)
611
+
612
+ expect(store.user.name.get()).toBe('Alice')
613
+ expect(store.user.preferences.theme.get()).toBe('dark')
614
+ expect(store.settings.timeout.get()).toBe(5000)
615
+
616
+ const serialized = JSON.stringify(store.get())
1082
617
  const parsed = JSON.parse(serialized)
1083
- expect(parsed.user.name).toBe('Jane')
1084
- expect(parsed.settings.timeout).toBe(10000)
1085
-
1086
- // Array store from JSON
1087
- const arrayData = [
1088
- { id: 1, name: 'Item 1' },
1089
- { id: 2, name: 'Item 2' },
1090
- ]
1091
- const arrayStore = createStore(arrayData)
1092
- expect(arrayStore[0].name.get()).toBe('Item 1')
1093
-
1094
- // Modify and serialize
1095
- arrayStore[0].name.set('Updated Item')
1096
- const arraySerialized = JSON.stringify(arrayStore.get())
1097
- const arrayParsed = JSON.parse(arraySerialized)
1098
- expect(arrayParsed[0].name).toBe('Updated Item')
618
+ expect(parsed).toEqual(jsonData)
1099
619
  })
1100
620
 
1101
621
  test('handles complex nested structures from JSON', () => {
1102
622
  type Dashboard = {
1103
623
  dashboard: {
1104
624
  widgets: Array<{
1105
- id: number
625
+ id: string
1106
626
  type: string
1107
- config: {
1108
- color?: string
1109
- rows?: number
1110
- }
627
+ config: { color?: string; rows?: number }
1111
628
  }>
1112
629
  }
1113
630
  }
1114
- const complexData = {
631
+
632
+ const complexData: Dashboard = {
1115
633
  dashboard: {
1116
634
  widgets: [
1117
- { id: 1, type: 'chart', config: { color: 'blue' } },
1118
- { id: 2, type: 'table', config: { rows: 10 } },
635
+ { id: '1', type: 'chart', config: { color: 'blue' } },
636
+ { id: '2', type: 'table', config: { rows: 10 } },
1119
637
  ],
1120
638
  },
1121
639
  }
1122
640
 
1123
- const store = createStore<Dashboard>(complexData)
1124
- expect(store.dashboard.widgets[0].type.get()).toBe('chart')
1125
- expect(store.dashboard.widgets[1].config.rows?.get()).toBe(10)
1126
-
1127
- // Update nested array element
1128
- store.dashboard.widgets[0].config.color?.set('red')
1129
- expect(store.get().dashboard.widgets[0].config.color).toBe('red')
641
+ const store = createStore(complexData)
642
+ expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
643
+ 'blue',
644
+ )
645
+ expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
1130
646
  })
1131
647
  })
1132
648
 
1133
649
  describe('type conversion and nested stores', () => {
1134
- test('arrays are converted to stores when nested', () => {
1135
- const data = createStore({ items: [1, 2, 3] })
1136
- expect(isStore(data.items)).toBe(true)
1137
- expect(data.items[0].get()).toBe(1)
1138
- expect(data.items[1].get()).toBe(2)
1139
- expect(data.items[2].get()).toBe(3)
1140
- })
1141
-
1142
650
  test('nested objects become nested stores', () => {
1143
651
  const config = createStore({
1144
652
  database: {
@@ -1146,194 +654,10 @@ describe('store', () => {
1146
654
  port: 5432,
1147
655
  },
1148
656
  })
657
+
1149
658
  expect(isStore(config.database)).toBe(true)
1150
659
  expect(config.database.host.get()).toBe('localhost')
1151
660
  expect(config.database.port.get()).toBe(5432)
1152
661
  })
1153
-
1154
- test('array store with nested objects has correct type inference', () => {
1155
- const users = createStore([
1156
- { name: 'Alice', active: true },
1157
- { name: 'Bob', active: false },
1158
- ])
1159
-
1160
- // Object array elements should be Store<T>
1161
- expect(isStore(users[0])).toBe(true)
1162
- expect(isStore(users[1])).toBe(true)
1163
-
1164
- // Should be able to access nested properties
1165
- expect(users[0].name.get()).toBe('Alice')
1166
- expect(users[0].active.get()).toBe(true)
1167
- expect(users[1].name.get()).toBe('Bob')
1168
- expect(users[1].active.get()).toBe(false)
1169
-
1170
- // Should be able to modify nested properties
1171
- users[0].name.set('Alicia')
1172
- users[0].active.set(false)
1173
- expect(users[0].name.get()).toBe('Alicia')
1174
- expect(users[0].active.get()).toBe(false)
1175
- })
1176
- })
1177
-
1178
- describe('advanced array behaviors', () => {
1179
- test('array compaction with remove operations', () => {
1180
- const numbers = createStore([10, 20, 30, 40, 50])
1181
-
1182
- // Create computed to test both iteration and get() approaches
1183
- const sumWithGet = createComputed(() => {
1184
- const array = numbers.get()
1185
- return array.reduce((acc, num) => acc + num, 0)
1186
- })
1187
-
1188
- expect(sumWithGet.get()).toBe(150) // 10+20+30+40+50
1189
-
1190
- // Remove middle element - should compact the array
1191
- numbers.remove(2) // Remove 30
1192
- expect(numbers.length).toBe(4)
1193
- expect(numbers.get()).toEqual([10, 20, 40, 50])
1194
- expect(sumWithGet.get()).toBe(120) // 10+20+40+50
1195
-
1196
- // Remove first element
1197
- numbers.remove(0) // Remove 10
1198
- expect(numbers.length).toBe(3)
1199
- expect(numbers.get()).toEqual([20, 40, 50])
1200
- expect(sumWithGet.get()).toBe(110) // 20+40+50
1201
- })
1202
-
1203
- test('sparse array replacement works correctly', () => {
1204
- const numbers = createStore([10, 20, 30])
1205
-
1206
- // Remove middle element to create sparse structure internally
1207
- numbers.remove(1) // Remove 20, now [10, 30] with internal keys ["0", "2"]
1208
-
1209
- expect(numbers.get()).toEqual([10, 30])
1210
- expect(numbers.length).toBe(2)
1211
-
1212
- // Set new array of same length - should work correctly
1213
- numbers.set([100, 200])
1214
- expect(numbers.get()).toEqual([100, 200])
1215
- expect(numbers.length).toBe(2)
1216
- expect(numbers[0].get()).toBe(100)
1217
- expect(numbers[1].get()).toBe(200)
1218
- })
1219
- })
1220
-
1221
- describe('polymorphic behavior determined at creation', () => {
1222
- test('store type is determined at creation time and maintained', () => {
1223
- // Array store stays array-like
1224
- const arrayStore = createStore([1, 2])
1225
- expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1226
- expect(arrayStore.length).toBe(2)
1227
-
1228
- // Even after modifications, stays array-like
1229
- arrayStore.add(3)
1230
- expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1231
- expect(arrayStore.length).toBe(3)
1232
-
1233
- // Record store stays record-like
1234
- const recordStore = createStore<{
1235
- a: number
1236
- b: number
1237
- c?: number
1238
- }>({ a: 1, b: 2 })
1239
- expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
1240
- expect(recordStore.length).toBe(2)
1241
-
1242
- // Even after modifications, stays record-like
1243
- recordStore.add('c', 3)
1244
- expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
1245
- expect(recordStore.length).toBe(3)
1246
- })
1247
-
1248
- test('empty stores maintain their type characteristics', () => {
1249
- const emptyArray = createStore<string[]>([])
1250
- const emptyRecord = createStore<{ key?: string }>({})
1251
-
1252
- // Empty array behaves like array
1253
- expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1254
- expect(emptyArray.length).toBe(0)
1255
-
1256
- // Empty record behaves like record
1257
- expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1258
- expect(emptyRecord.length).toBe(0)
1259
-
1260
- // After adding items, they maintain their characteristics
1261
- emptyArray.add('first')
1262
- emptyRecord.add('key', 'value')
1263
-
1264
- expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1265
- expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1266
- })
1267
- })
1268
-
1269
- describe('cross-component communication pattern', () => {
1270
- test('event bus with UNSET initialization - type-safe pattern', () => {
1271
- type EventBusSchema = {
1272
- userLogin: { userId: number; timestamp: number }
1273
- userLogout: { userId: number }
1274
- userUpdate: { userId: number; profile: { name: string } }
1275
- }
1276
-
1277
- const eventBus = createStore<EventBusSchema>({
1278
- userLogin: UNSET,
1279
- userLogout: UNSET,
1280
- userUpdate: UNSET,
1281
- })
1282
-
1283
- const on = (
1284
- event: keyof EventBusSchema,
1285
- callback: (data: EventBusSchema[keyof EventBusSchema]) => void,
1286
- ) =>
1287
- createEffect(() => {
1288
- const data = eventBus[event].get()
1289
- if (data !== UNSET) callback(data)
1290
- })
1291
-
1292
- let receivedLogin: unknown = null
1293
- let receivedLogout: unknown = null
1294
- let receivedUpdate: unknown = null
1295
-
1296
- on('userLogin', data => {
1297
- receivedLogin = data
1298
- })
1299
- on('userLogout', data => {
1300
- receivedLogout = data
1301
- })
1302
- on('userUpdate', data => {
1303
- receivedUpdate = data
1304
- })
1305
-
1306
- // Initially nothing received
1307
- expect(receivedLogin).toBe(null)
1308
- expect(receivedLogout).toBe(null)
1309
- expect(receivedUpdate).toBe(null)
1310
-
1311
- // Emit events
1312
- const loginData: EventBusSchema['userLogin'] = {
1313
- userId: 123,
1314
- timestamp: Date.now(),
1315
- }
1316
- eventBus.userLogin.set(loginData)
1317
-
1318
- expect(receivedLogin).toEqual(loginData)
1319
- expect(receivedLogout).toBe(null)
1320
- expect(receivedUpdate).toBe(null)
1321
-
1322
- const logoutData: EventBusSchema['userLogout'] = { userId: 123 }
1323
- eventBus.userLogout.set(logoutData)
1324
-
1325
- expect(receivedLogout).toEqual(logoutData)
1326
- expect(receivedLogin).toEqual(loginData) // unchanged
1327
-
1328
- const updateData: EventBusSchema['userUpdate'] = {
1329
- userId: 456,
1330
- profile: { name: 'Alice' },
1331
- }
1332
- eventBus.userUpdate.set(updateData)
1333
-
1334
- expect(receivedUpdate).toEqual(updateData)
1335
- expect(receivedLogin).toEqual(loginData) // unchanged
1336
- expect(receivedLogout).toEqual(logoutData) // unchanged
1337
- })
1338
662
  })
1339
663
  })