@zeix/cause-effect 0.17.2 → 0.18.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 (94) hide show
  1. package/.ai-context.md +163 -226
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/.zed/settings.json +3 -0
  5. package/ARCHITECTURE.md +274 -0
  6. package/CLAUDE.md +197 -202
  7. package/COLLECTION_REFACTORING.md +161 -0
  8. package/GUIDE.md +298 -0
  9. package/README.md +241 -220
  10. package/REQUIREMENTS.md +100 -0
  11. package/bench/reactivity.bench.ts +577 -0
  12. package/index.dev.js +1326 -1174
  13. package/index.js +1 -1
  14. package/index.ts +58 -85
  15. package/package.json +9 -6
  16. package/src/errors.ts +118 -70
  17. package/src/graph.ts +601 -0
  18. package/src/nodes/collection.ts +474 -0
  19. package/src/nodes/effect.ts +149 -0
  20. package/src/nodes/list.ts +588 -0
  21. package/src/nodes/memo.ts +120 -0
  22. package/src/nodes/sensor.ts +139 -0
  23. package/src/nodes/state.ts +135 -0
  24. package/src/nodes/store.ts +383 -0
  25. package/src/nodes/task.ts +146 -0
  26. package/src/signal.ts +112 -64
  27. package/src/util.ts +26 -57
  28. package/test/batch.test.ts +96 -69
  29. package/test/benchmark.test.ts +473 -485
  30. package/test/collection.test.ts +455 -955
  31. package/test/effect.test.ts +293 -696
  32. package/test/list.test.ts +332 -857
  33. package/test/memo.test.ts +380 -0
  34. package/test/regression.test.ts +156 -0
  35. package/test/scope.test.ts +191 -0
  36. package/test/sensor.test.ts +454 -0
  37. package/test/signal.test.ts +220 -213
  38. package/test/state.test.ts +217 -271
  39. package/test/store.test.ts +346 -898
  40. package/test/task.test.ts +395 -0
  41. package/test/untrack.test.ts +167 -0
  42. package/test/util/dependency-graph.ts +2 -2
  43. package/tsconfig.build.json +11 -0
  44. package/tsconfig.json +5 -7
  45. package/types/index.d.ts +13 -15
  46. package/types/src/errors.d.ts +73 -19
  47. package/types/src/graph.d.ts +208 -0
  48. package/types/src/nodes/collection.d.ts +64 -0
  49. package/types/src/nodes/effect.d.ts +48 -0
  50. package/types/src/nodes/list.d.ts +65 -0
  51. package/types/src/nodes/memo.d.ts +57 -0
  52. package/types/src/nodes/sensor.d.ts +75 -0
  53. package/types/src/nodes/state.d.ts +78 -0
  54. package/types/src/nodes/store.d.ts +51 -0
  55. package/types/src/nodes/task.d.ts +73 -0
  56. package/types/src/signal.d.ts +43 -28
  57. package/types/src/util.d.ts +9 -16
  58. package/archive/benchmark.ts +0 -688
  59. package/archive/collection.ts +0 -310
  60. package/archive/computed.ts +0 -198
  61. package/archive/list.ts +0 -544
  62. package/archive/memo.ts +0 -140
  63. package/archive/state.ts +0 -90
  64. package/archive/store.ts +0 -357
  65. package/archive/task.ts +0 -191
  66. package/src/classes/collection.ts +0 -298
  67. package/src/classes/composite.ts +0 -171
  68. package/src/classes/computed.ts +0 -392
  69. package/src/classes/list.ts +0 -310
  70. package/src/classes/ref.ts +0 -96
  71. package/src/classes/state.ts +0 -131
  72. package/src/classes/store.ts +0 -227
  73. package/src/diff.ts +0 -138
  74. package/src/effect.ts +0 -96
  75. package/src/match.ts +0 -45
  76. package/src/resolve.ts +0 -49
  77. package/src/system.ts +0 -275
  78. package/test/computed.test.ts +0 -1126
  79. package/test/diff.test.ts +0 -955
  80. package/test/match.test.ts +0 -388
  81. package/test/ref.test.ts +0 -381
  82. package/test/resolve.test.ts +0 -154
  83. package/types/src/classes/collection.d.ts +0 -47
  84. package/types/src/classes/composite.d.ts +0 -15
  85. package/types/src/classes/computed.d.ts +0 -114
  86. package/types/src/classes/list.d.ts +0 -41
  87. package/types/src/classes/ref.d.ts +0 -48
  88. package/types/src/classes/state.d.ts +0 -61
  89. package/types/src/classes/store.d.ts +0 -51
  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 -81
@@ -1,274 +1,281 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- type Computed,
3
+ createComputed,
4
+ createList,
5
+ createMemo,
6
+ createMutableSignal,
7
+ createScope,
4
8
  createSignal,
9
+ createState,
10
+ createStore,
11
+ createTask,
12
+ InvalidSignalValueError,
5
13
  isComputed,
6
14
  isList,
15
+ isMemo,
16
+ isMutableSignal,
17
+ isSignal,
7
18
  isState,
8
19
  isStore,
20
+ isTask,
9
21
  type List,
22
+ type Memo,
10
23
  type Signal,
11
- State,
24
+ type State,
12
25
  type Store,
13
- type UnknownRecord,
26
+ type Task,
14
27
  } from '../index.ts'
15
28
 
16
29
  /* === Tests === */
17
30
 
18
- describe('createSignal', () => {
19
- describe('type inference and runtime behavior', () => {
20
- test('converts array to List<T>', () => {
21
- const result = createSignal([
22
- { id: 1, name: 'Alice' },
23
- { id: 2, name: 'Bob' },
24
- ])
25
-
26
- // Runtime behavior
27
- expect(isList(result)).toBe(true)
28
- expect(result.at(0)?.get()).toEqual({ id: 1, name: 'Alice' })
29
- expect(result.at(1)?.get()).toEqual({ id: 2, name: 'Bob' })
30
-
31
- // Type inference test - now correctly returns List<{ id: number; name: string }>
32
- const typedResult: List<{ id: number; name: string }> = result
33
- expect(typedResult).toBeDefined()
34
- })
31
+ describe('createComputed', () => {
32
+ test('creates a Memo from a sync callback', () => {
33
+ const count = createState(2)
34
+ const doubled = createComputed(() => count.get() * 2)
35
+ expect(isMemo(doubled)).toBe(true)
36
+ expect(doubled.get()).toBe(4)
37
+
38
+ const typedResult: Memo<number> = doubled
39
+ expect(typedResult).toBeDefined()
40
+ })
35
41
 
36
- test('converts empty array to ArrayStore<never[]>', () => {
37
- const result = createSignal([])
42
+ test('creates a Task from an async callback', () => {
43
+ const cleanup = createScope(() => {
44
+ const result = createComputed(async () => 'hello')
45
+ expect(isTask(result)).toBe(true)
38
46
 
39
- // Runtime behavior
40
- expect(isList(result)).toBe(true)
41
- expect(result.length).toBe(0)
42
- expect(Object.keys(result).length).toBe(0)
47
+ const typedResult: Task<string> = result
48
+ expect(typedResult).toBeDefined()
43
49
  })
50
+ cleanup()
51
+ })
52
+ })
44
53
 
45
- test('converts record to Store<T>', () => {
46
- const record = { name: 'Alice', age: 30 }
47
- const result = createSignal(record)
54
+ describe('createSignal', () => {
55
+ test('converts a primitive to State', () => {
56
+ const result = createSignal(42)
57
+ expect(isState(result)).toBe(true)
58
+ expect(result.get()).toBe(42)
48
59
 
49
- // Runtime behavior
50
- expect(isStore(result)).toBe(true)
51
- expect(result.name.get()).toBe('Alice')
52
- expect(result.age.get()).toBe(30)
60
+ const typedResult: State<number> = result
61
+ expect(typedResult).toBeDefined()
62
+ })
53
63
 
54
- // Type inference test - should be Store<{name: string, age: number}>
55
- const typedResult: Store<{ name: string; age: number }> = result
56
- expect(typedResult).toBeDefined()
57
- })
64
+ test('converts a non-plain object to State', () => {
65
+ const date = new Date('2024-01-01')
66
+ const result = createSignal(date)
67
+ expect(isState(result)).toBe(true)
68
+ expect(result.get()).toBe(date)
58
69
 
59
- test('converts function to Computed<T>', () => {
60
- const fn = () => Math.random()
61
- const result = createSignal(fn)
70
+ const typedResult: State<Date> = result
71
+ expect(typedResult).toBeDefined()
72
+ })
62
73
 
63
- // Runtime behavior - functions are correctly converted to Computed
64
- expect(isComputed(result)).toBe(true)
65
- expect(typeof result.get()).toBe('number')
74
+ test('converts a record to Store', () => {
75
+ const result = createSignal({ name: 'Alice', age: 30 })
76
+ expect(isStore(result)).toBe(true)
77
+ expect(result.name.get()).toBe('Alice')
78
+ expect(result.age.get()).toBe(30)
66
79
 
67
- // Type inference test - should be Computed<number>
68
- const typedResult: Computed<number> = result
69
- expect(typedResult).toBeDefined()
70
- })
80
+ const typedResult: Store<{ name: string; age: number }> = result
81
+ expect(typedResult).toBeDefined()
82
+ })
71
83
 
72
- test('converts primitive to State<T>', () => {
73
- const num = 42
74
- const result = createSignal(num)
84
+ test('converts an array to List', () => {
85
+ const result = createSignal([
86
+ { id: 1, name: 'Alice' },
87
+ { id: 2, name: 'Bob' },
88
+ ])
89
+ expect(isList(result)).toBe(true)
90
+ expect(result.at(0)?.get()).toEqual({ id: 1, name: 'Alice' })
91
+ expect(result.at(1)?.get()).toEqual({ id: 2, name: 'Bob' })
92
+
93
+ const typedResult: List<{ id: number; name: string }> = result
94
+ expect(typedResult).toBeDefined()
95
+ })
75
96
 
76
- // Runtime behavior - primitives are correctly converted to State
77
- expect(isState(result)).toBe(true)
78
- expect(result.get()).toBe(42)
97
+ test('converts an empty array to List', () => {
98
+ const result = createSignal([])
99
+ expect(isList(result)).toBe(true)
100
+ expect(result.length).toBe(0)
101
+ })
79
102
 
80
- // Type inference test - should be State<number>
81
- const typedResult: State<number> = result
82
- expect(typedResult).toBeDefined()
83
- })
103
+ test('converts a sync function to Memo', () => {
104
+ const result = createSignal(() => Math.random())
105
+ expect(isMemo(result)).toBe(true)
106
+ expect(typeof result.get()).toBe('number')
84
107
 
85
- test('converts object to State<T>', () => {
86
- const obj = new Date('2024-01-01')
87
- const result = createSignal(obj)
108
+ const typedResult: Memo<number> = result
109
+ expect(typedResult).toBeDefined()
110
+ })
88
111
 
89
- // Runtime behavior - objects are correctly converted to State
90
- expect(isState(result)).toBe(true)
91
- expect(result.get()).toBe(obj)
112
+ test('converts an async function to Task', () => {
113
+ const cleanup = createScope(() => {
114
+ const result = createSignal(async () => 'hello')
115
+ expect(isTask(result)).toBe(true)
92
116
 
93
- // Type inference test - should be State<Date>
94
- const typedResult: State<Date> = result
117
+ const typedResult: Task<string> = result
95
118
  expect(typedResult).toBeDefined()
96
119
  })
120
+ cleanup()
97
121
  })
98
122
 
99
- describe('edge cases', () => {
100
- test('handles nested arrays', () => {
101
- const result = createSignal([
102
- [1, 2],
103
- [3, 4],
104
- ])
105
-
106
- expect(isList(result)).toBe(true)
107
- // With the fixed behavior, nested arrays should be recovered as arrays
108
- const firstElement = result.at(0)?.get()
109
- const secondElement = result.at(1)?.get()
110
-
111
- // The expected behavior - nested arrays are recovered as arrays
112
- expect(firstElement).toEqual([1, 2])
113
- expect(secondElement).toEqual([3, 4])
114
- })
123
+ test('passes through an existing signal without wrapping', () => {
124
+ const state = createState(42)
125
+ expect(createSignal(state)).toBe(state)
115
126
 
116
- test('handles arrays with mixed types', () => {
117
- const mixedArr = [1, 'hello', { key: 'value' }]
118
- const result = createSignal(mixedArr)
127
+ const memo = createMemo(() => 'hello')
128
+ expect(createSignal(memo)).toBe(memo)
119
129
 
120
- expect(isList(result)).toBe(true)
121
- expect(result.at(0)?.get()).toBe(1)
122
- expect(result.at(1)?.get()).toBe('hello')
123
- expect(result.at(2)?.get()).toEqual({ key: 'value' })
124
- })
130
+ const store = createStore({ a: 1 })
131
+ expect(createSignal(store)).toBe(store)
132
+
133
+ const list = createList([1, 2, 3])
134
+ expect(createSignal(list)).toBe(list)
125
135
  })
126
- })
127
136
 
128
- describe('Signal compatibility', () => {
129
- test('all results implement Signal<T> interface', () => {
130
- const arraySignal = createSignal([1, 2, 3])
131
- const recordSignal = createSignal({ a: 1, b: 2 })
132
- const primitiveSignal = createSignal(42)
133
- const functionSignal = createSignal(() => 'hello')
134
- const stateSignal = createSignal(new State(true))
135
-
136
- // All should have get() method
137
- expect(typeof arraySignal.get).toBe('function')
138
- expect(typeof recordSignal.get).toBe('function')
139
- expect(typeof primitiveSignal.get).toBe('function')
140
- expect(typeof functionSignal.get).toBe('function')
141
- expect(typeof stateSignal.get).toBe('function')
142
-
143
- // All should be assignable to Signal<T>
144
- const signals: Signal<unknown & {}>[] = [
145
- arraySignal,
146
- recordSignal,
147
- primitiveSignal,
148
- functionSignal,
149
- stateSignal,
150
- ]
151
- expect(signals.length).toBe(5)
137
+ test('throws InvalidSignalValueError for null', () => {
138
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
139
+ expect(() => createSignal(null as any)).toThrow(InvalidSignalValueError)
140
+ })
141
+
142
+ test('throws InvalidSignalValueError for undefined', () => {
143
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
144
+ expect(() => createSignal(undefined as any)).toThrow(
145
+ InvalidSignalValueError,
146
+ )
152
147
  })
153
148
  })
154
149
 
155
- describe('Type precision tests', () => {
156
- test('array type should infer element type correctly', () => {
157
- // Test that arrays infer the correct element type
158
- const stringArray = ['a', 'b', 'c']
159
- const stringArraySignal = createSignal(stringArray)
150
+ describe('createMutableSignal', () => {
151
+ test('converts a primitive to State', () => {
152
+ const result = createMutableSignal(42)
153
+ expect(isState(result)).toBe(true)
154
+ expect(result.get()).toBe(42)
155
+ })
156
+
157
+ test('converts a record to Store', () => {
158
+ const result = createMutableSignal({ name: 'Alice' })
159
+ expect(isStore(result)).toBe(true)
160
+ })
161
+
162
+ test('converts an array to List', () => {
163
+ const result = createMutableSignal([1, 2, 3])
164
+ expect(isList(result)).toBe(true)
165
+ })
160
166
 
161
- // Should be List<string>
162
- expect(stringArraySignal.at(0)?.get()).toBe('a')
167
+ test('passes through an existing mutable signal without wrapping', () => {
168
+ const state = createState(42)
169
+ expect(createMutableSignal(state)).toBe(state)
163
170
 
164
- const numberArray = [1, 2, 3]
165
- const numberArraySignal = createSignal(numberArray)
171
+ const store = createStore({ a: 1 })
172
+ expect(createMutableSignal(store)).toBe(store)
166
173
 
167
- // Should be List<number>
168
- expect(typeof numberArraySignal.at(0)?.get()).toBe('number')
174
+ const list = createList([1, 2, 3])
175
+ expect(createMutableSignal(list)).toBe(list)
169
176
  })
170
177
 
171
- test('complex object arrays maintain precise typing', () => {
172
- interface User {
173
- id: number
174
- name: string
175
- email: string
176
- }
177
-
178
- const users: User[] = [
179
- { id: 1, name: 'Alice', email: 'alice@example.com' },
180
- { id: 2, name: 'Bob', email: 'bob@example.com' },
181
- ]
182
-
183
- const usersSignal = createSignal(users)
184
-
185
- // Should maintain User type for each element
186
- const firstUser = usersSignal.at(0)?.get()
187
- expect(firstUser?.id).toBe(1)
188
- expect(firstUser?.name).toBe('Alice')
189
- expect(firstUser?.email).toBe('alice@example.com')
178
+ test('throws InvalidSignalValueError for null', () => {
179
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
180
+ expect(() => createMutableSignal(null as any)).toThrow(
181
+ InvalidSignalValueError,
182
+ )
190
183
  })
191
184
 
192
- describe('Type inference issues', () => {
193
- test('demonstrates current type inference problem', () => {
194
- const result = createSignal([{ id: 1 }, { id: 2 }])
185
+ test('throws InvalidSignalValueError for a function', () => {
186
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
187
+ expect(() => createMutableSignal((() => 42) as any)).toThrow(
188
+ InvalidSignalValueError,
189
+ )
190
+ })
195
191
 
196
- // Let's verify the actual behavior
197
- expect(isList(result)).toBe(true)
198
- expect(result.at(0)?.get()).toEqual({ id: 1 })
199
- expect(result.at(1)?.get()).toEqual({ id: 2 })
192
+ test('throws InvalidSignalValueError for a read-only signal', () => {
193
+ const memo = createMemo(() => 42)
194
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
195
+ expect(() => createMutableSignal(memo as any)).toThrow(
196
+ InvalidSignalValueError,
197
+ )
198
+ })
199
+ })
200
200
 
201
- // Type assertion test - this should now work with correct typing
202
- const typedResult: List<{ id: number }> = result
203
- expect(typedResult).toBeDefined()
201
+ describe('isComputed', () => {
202
+ test('returns true for Memo', () => {
203
+ expect(isComputed(createMemo(() => 42))).toBe(true)
204
+ })
204
205
 
205
- // Simulate external library usage where P[K] represents element type
206
- interface ExternalLibraryConstraint<P extends UnknownRecord> {
207
- process<K extends keyof P>(signal: Signal<P[K] & {}>): void
208
- }
206
+ test('returns true for Task', () => {
207
+ const cleanup = createScope(() => {
208
+ expect(isComputed(createTask(async () => 42))).toBe(true)
209
+ })
210
+ cleanup()
211
+ })
209
212
 
210
- // This should work if types are correct
211
- const processor: ExternalLibraryConstraint<
212
- Record<string, { id: number }>
213
- > = {
214
- process: <K extends keyof Record<string, { id: number }>>(
215
- signal: Signal<Record<string, { id: number }>[K] & {}>,
216
- ) => {
217
- // Process the signal
218
- const value = signal.get()
219
- expect(value).toHaveProperty('id')
220
- },
221
- }
213
+ test('returns false for State', () => {
214
+ expect(isComputed(createState(42))).toBe(false)
215
+ })
216
+
217
+ test('returns false for non-signals', () => {
218
+ expect(isComputed(42)).toBe(false)
219
+ expect(isComputed('hello')).toBe(false)
220
+ expect(isComputed(null)).toBe(false)
221
+ })
222
+ })
222
223
 
223
- // This call should work without type errors
224
- const item = result.at(0)
225
- if (item) processor.process(item)
224
+ describe('isSignal', () => {
225
+ test('returns true for all signal types', () => {
226
+ const cleanup = createScope(() => {
227
+ expect(isSignal(createState(42))).toBe(true)
228
+ expect(isSignal(createMemo(() => 42))).toBe(true)
229
+ expect(isSignal(createTask(async () => 42))).toBe(true)
230
+ expect(isSignal(createStore({ a: 1 }))).toBe(true)
231
+ expect(isSignal(createList([1, 2, 3]))).toBe(true)
226
232
  })
233
+ cleanup()
234
+ })
227
235
 
228
- test('verifies fixed type inference for external library compatibility', () => {
229
- const items = [
230
- { id: 1, name: 'Alice' },
231
- { id: 2, name: 'Bob' },
232
- ]
233
- const signal = createSignal(items)
234
- const firstItemSignal = signal.at(0)
235
- const secondItemSignal = signal.at(1)
236
-
237
- // Runtime behavior works correctly
238
- expect(isList(signal)).toBe(true)
239
- expect(firstItemSignal?.get()).toEqual({ id: 1, name: 'Alice' })
240
- expect(secondItemSignal?.get()).toEqual({ id: 2, name: 'Bob' })
241
-
242
- // Type inference should now work correctly:
243
- const properlyTyped: List<{ id: number; name: string }> = signal
244
- expect(properlyTyped).toBeDefined()
245
-
246
- // These should work without type errors in external libraries
247
- // that expect Signal<P[K]> where P[K] is the individual element type
248
- interface ExternalAPI<P extends Record<string, object>> {
249
- process<K extends keyof P>(
250
- key: K,
251
- signal: Signal<P[K] & object>,
252
- ): P[K]
253
- }
236
+ test('returns false for non-signals', () => {
237
+ expect(isSignal(42)).toBe(false)
238
+ expect(isSignal('hello')).toBe(false)
239
+ expect(isSignal({ get: () => 42 })).toBe(false)
240
+ expect(isSignal(null)).toBe(false)
241
+ expect(isSignal(undefined)).toBe(false)
242
+ })
243
+ })
254
244
 
255
- const api: ExternalAPI<
256
- Record<string, { id: number; name: string }>
257
- > = {
258
- process: (_key, signal) => signal.get(),
259
- }
245
+ describe('isMutableSignal', () => {
246
+ test('returns true for State, Store, and List', () => {
247
+ expect(isMutableSignal(createState(42))).toBe(true)
248
+ expect(isMutableSignal(createStore({ a: 1 }))).toBe(true)
249
+ expect(isMutableSignal(createList([1, 2, 3]))).toBe(true)
250
+ })
260
251
 
261
- // These calls should work with proper typing now
262
- const result1 = firstItemSignal && api.process('0', firstItemSignal)
263
- const result2 =
264
- secondItemSignal && api.process('1', secondItemSignal)
252
+ test('returns false for read-only signals', () => {
253
+ const cleanup = createScope(() => {
254
+ expect(isMutableSignal(createMemo(() => 42))).toBe(false)
255
+ expect(isMutableSignal(createTask(async () => 42))).toBe(false)
256
+ })
257
+ cleanup()
258
+ })
265
259
 
266
- expect(result1).toEqual({ id: 1, name: 'Alice' })
267
- expect(result2).toEqual({ id: 2, name: 'Bob' })
260
+ test('returns false for non-signals', () => {
261
+ expect(isMutableSignal(42)).toBe(false)
262
+ expect(isMutableSignal(null)).toBe(false)
263
+ })
264
+ })
268
265
 
269
- // Verify the types are precise
270
- expect(typeof result1?.id).toBe('number')
271
- expect(typeof result1?.name).toBe('string')
266
+ describe('Signal compatibility', () => {
267
+ test('all signal factory results implement Signal<T>', () => {
268
+ const cleanup = createScope(() => {
269
+ const signals: Signal<unknown & {}>[] = [
270
+ createSignal(42),
271
+ createSignal({ a: 1 }),
272
+ createSignal([1, 2, 3]),
273
+ createSignal(() => 'hello'),
274
+ ]
275
+ for (const signal of signals) {
276
+ expect(typeof signal.get).toBe('function')
277
+ }
272
278
  })
279
+ cleanup()
273
280
  })
274
281
  })