@zeix/cause-effect 0.16.1 → 0.17.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 (66) hide show
  1. package/.ai-context.md +85 -21
  2. package/.cursorrules +11 -5
  3. package/.github/copilot-instructions.md +64 -13
  4. package/CLAUDE.md +143 -163
  5. package/LICENSE +1 -1
  6. package/README.md +248 -333
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +21 -21
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +139 -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 +938 -509
  17. package/index.js +1 -1
  18. package/index.ts +50 -23
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +282 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +305 -0
  24. package/src/classes/ref.ts +68 -0
  25. package/src/classes/state.ts +98 -0
  26. package/src/classes/store.ts +210 -0
  27. package/src/diff.ts +26 -53
  28. package/src/effect.ts +9 -9
  29. package/src/errors.ts +71 -25
  30. package/src/match.ts +5 -12
  31. package/src/resolve.ts +3 -2
  32. package/src/signal.ts +58 -41
  33. package/src/system.ts +79 -42
  34. package/src/util.ts +16 -34
  35. package/test/batch.test.ts +15 -17
  36. package/test/benchmark.test.ts +4 -4
  37. package/test/collection.test.ts +853 -0
  38. package/test/computed.test.ts +138 -130
  39. package/test/diff.test.ts +2 -2
  40. package/test/effect.test.ts +36 -35
  41. package/test/list.test.ts +754 -0
  42. package/test/match.test.ts +25 -25
  43. package/test/ref.test.ts +227 -0
  44. package/test/resolve.test.ts +17 -19
  45. package/test/signal.test.ts +70 -119
  46. package/test/state.test.ts +44 -44
  47. package/test/store.test.ts +253 -929
  48. package/types/index.d.ts +12 -9
  49. package/types/src/classes/collection.d.ts +46 -0
  50. package/types/src/classes/composite.d.ts +15 -0
  51. package/types/src/classes/computed.d.ts +97 -0
  52. package/types/src/classes/list.d.ts +41 -0
  53. package/types/src/classes/ref.d.ts +39 -0
  54. package/types/src/classes/state.d.ts +52 -0
  55. package/types/src/classes/store.d.ts +51 -0
  56. package/types/src/diff.d.ts +8 -12
  57. package/types/src/errors.d.ts +17 -11
  58. package/types/src/signal.d.ts +27 -14
  59. package/types/src/system.d.ts +41 -20
  60. package/types/src/util.d.ts +6 -4
  61. package/src/store.ts +0 -474
  62. package/types/src/collection.d.ts +0 -26
  63. package/types/src/computed.d.ts +0 -33
  64. package/types/src/scheduler.d.ts +0 -55
  65. package/types/src/state.d.ts +0 -24
  66. package/types/src/store.d.ts +0 -65
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { createComputed, createState, match, resolve, UNSET } from '..'
2
+ import { Memo, match, resolve, State, Task, UNSET } from '../index.ts'
3
3
 
4
4
  /* === Tests === */
5
5
 
6
6
  describe('Match Function', () => {
7
7
  test('should call ok handler for successful resolution', () => {
8
- const a = createState(10)
9
- const b = createState('hello')
8
+ const a = new State(10)
9
+ const b = new State('hello')
10
10
  let okCalled = false
11
11
  let okValues: { a: number; b: string } | null = null
12
12
 
@@ -32,8 +32,8 @@ describe('Match Function', () => {
32
32
  })
33
33
 
34
34
  test('should call nil handler for pending signals', () => {
35
- const a = createState(10)
36
- const b = createState(UNSET)
35
+ const a = new State(10)
36
+ const b = new State(UNSET)
37
37
  let nilCalled = false
38
38
 
39
39
  match(resolve({ a, b }), {
@@ -52,8 +52,8 @@ describe('Match Function', () => {
52
52
  })
53
53
 
54
54
  test('should call error handler for error signals', () => {
55
- const a = createState(10)
56
- const b = createComputed(() => {
55
+ const a = new State(10)
56
+ const b = new Memo(() => {
57
57
  throw new Error('Test error')
58
58
  })
59
59
  let errCalled = false
@@ -78,7 +78,7 @@ describe('Match Function', () => {
78
78
  })
79
79
 
80
80
  test('should handle missing optional handlers gracefully', () => {
81
- const a = createState(10)
81
+ const a = new State(10)
82
82
  const result = resolve({ a })
83
83
 
84
84
  // Should not throw even with only required ok handler (err and nil are optional)
@@ -92,7 +92,7 @@ describe('Match Function', () => {
92
92
  })
93
93
 
94
94
  test('should return void always', () => {
95
- const a = createState(42)
95
+ const a = new State(42)
96
96
 
97
97
  const returnValue = match(resolve({ a }), {
98
98
  ok: () => {
@@ -105,7 +105,7 @@ describe('Match Function', () => {
105
105
  })
106
106
 
107
107
  test('should handle handler errors by calling error handler', () => {
108
- const a = createState(10)
108
+ const a = new State(10)
109
109
  let handlerErrorCalled = false
110
110
  let handlerError: Error | null = null
111
111
 
@@ -125,7 +125,7 @@ describe('Match Function', () => {
125
125
  })
126
126
 
127
127
  test('should rethrow handler errors if no error handler available', () => {
128
- const a = createState(10)
128
+ const a = new State(10)
129
129
 
130
130
  expect(() => {
131
131
  match(resolve({ a }), {
@@ -137,7 +137,7 @@ describe('Match Function', () => {
137
137
  })
138
138
 
139
139
  test('should combine existing errors with handler errors', () => {
140
- const a = createComputed(() => {
140
+ const a = new Memo(() => {
141
141
  throw new Error('Signal error')
142
142
  })
143
143
  let allErrors: readonly Error[] | null = null
@@ -167,9 +167,9 @@ describe('Match Function', () => {
167
167
  })
168
168
 
169
169
  test('should work with complex type inference', () => {
170
- const user = createState({ id: 1, name: 'Alice' })
171
- const posts = createState([{ id: 1, title: 'Hello' }])
172
- const settings = createState({ theme: 'dark' })
170
+ const user = new State({ id: 1, name: 'Alice' })
171
+ const posts = new State([{ id: 1, title: 'Hello' }])
172
+ const settings = new State({ theme: 'dark' })
173
173
 
174
174
  let typeTestPassed = false
175
175
 
@@ -194,8 +194,8 @@ describe('Match Function', () => {
194
194
  })
195
195
 
196
196
  test('should handle side effects only pattern', () => {
197
- const count = createState(5)
198
- const name = createState('test')
197
+ const count = new State(5)
198
+ const name = new State('test')
199
199
  let sideEffectExecuted = false
200
200
  let capturedData = ''
201
201
 
@@ -214,10 +214,10 @@ describe('Match Function', () => {
214
214
  })
215
215
 
216
216
  test('should handle multiple error types correctly', () => {
217
- const error1 = createComputed(() => {
217
+ const error1 = new Memo(() => {
218
218
  throw new Error('First error')
219
219
  })
220
- const error2 = createComputed(() => {
220
+ const error2 = new Memo(() => {
221
221
  throw new Error('Second error')
222
222
  })
223
223
  let errorMessages: string[] = []
@@ -240,7 +240,7 @@ describe('Match Function', () => {
240
240
  const wait = (ms: number) =>
241
241
  new Promise(resolve => setTimeout(resolve, ms))
242
242
 
243
- const asyncSignal = createComputed(async () => {
243
+ const asyncSignal = new Task(async () => {
244
244
  await wait(10)
245
245
  return 'async result'
246
246
  })
@@ -280,7 +280,7 @@ describe('Match Function', () => {
280
280
  })
281
281
 
282
282
  test('should maintain referential transparency', () => {
283
- const a = createState(42)
283
+ const a = new State(42)
284
284
  const result = resolve({ a })
285
285
  let callCount = 0
286
286
 
@@ -303,7 +303,7 @@ describe('Match Function', () => {
303
303
 
304
304
  describe('Match Function Integration', () => {
305
305
  test('should work seamlessly with resolve', () => {
306
- const data = createState({ id: 1, value: 'test' })
306
+ const data = new State({ id: 1, value: 'test' })
307
307
  let processed = false
308
308
  let processedValue = ''
309
309
 
@@ -322,12 +322,12 @@ describe('Match Function Integration', () => {
322
322
  const wait = (ms: number) =>
323
323
  new Promise(resolve => setTimeout(resolve, ms))
324
324
 
325
- const syncData = createState('available')
326
- const asyncData = createComputed(async () => {
325
+ const syncData = new State('available')
326
+ const asyncData = new Task(async () => {
327
327
  await wait(10)
328
328
  return 'loaded'
329
329
  })
330
- const errorData = createComputed(() => {
330
+ const errorData = new Memo(() => {
331
331
  throw new Error('Failed to load')
332
332
  })
333
333
 
@@ -0,0 +1,227 @@
1
+ import { expect, mock, test } from 'bun:test'
2
+ import { isRef, Ref } from '../src/classes/ref'
3
+ import { createEffect } from '../src/effect'
4
+
5
+ test('Ref - basic functionality', () => {
6
+ const obj = { name: 'test', value: 42 }
7
+ const ref = new Ref(obj)
8
+
9
+ expect(ref.get()).toBe(obj)
10
+ expect(ref[Symbol.toStringTag]).toBe('Ref')
11
+ })
12
+
13
+ test('Ref - isRef type guard', () => {
14
+ const ref = new Ref({ test: true })
15
+ const notRef = { test: true }
16
+
17
+ expect(isRef(ref)).toBe(true)
18
+ expect(isRef(notRef)).toBe(false)
19
+ expect(isRef(null)).toBe(false)
20
+ expect(isRef(undefined)).toBe(false)
21
+ })
22
+
23
+ test('Ref - validation with guard function', () => {
24
+ const isConfig = (
25
+ value: unknown,
26
+ ): value is { host: string; port: number } =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ 'host' in value &&
30
+ 'port' in value &&
31
+ typeof value.host === 'string' &&
32
+ typeof value.port === 'number'
33
+
34
+ const validConfig = { host: 'localhost', port: 3000 }
35
+ const invalidConfig = { host: 'localhost' } // missing port
36
+
37
+ expect(() => new Ref(validConfig, isConfig)).not.toThrow()
38
+ expect(() => new Ref(invalidConfig, isConfig)).toThrow()
39
+ })
40
+
41
+ test('Ref - reactive subscriptions', () => {
42
+ const server = { status: 'offline', connections: 0 }
43
+ const ref = new Ref(server)
44
+
45
+ let effectRunCount = 0
46
+ let lastStatus: string = ''
47
+
48
+ createEffect(() => {
49
+ const current = ref.get()
50
+ lastStatus = current.status
51
+ effectRunCount++
52
+ })
53
+
54
+ expect(effectRunCount).toBe(1)
55
+ expect(lastStatus).toBe('offline')
56
+
57
+ // Simulate external change without going through reactive system
58
+ server.status = 'online'
59
+ server.connections = 5
60
+
61
+ // Effect shouldn't re-run yet (reference hasn't changed)
62
+ expect(effectRunCount).toBe(1)
63
+
64
+ // Notify that the external object has changed
65
+ ref.notify()
66
+
67
+ expect(effectRunCount).toBe(2)
68
+ expect(lastStatus).toBe('online')
69
+ })
70
+
71
+ test('Ref - notify triggers watchers even with same reference', () => {
72
+ const fileObj = { path: '/test.txt', size: 100, modified: Date.now() }
73
+ const ref = new Ref(fileObj)
74
+
75
+ const mockCallback = mock(() => {})
76
+
77
+ createEffect(() => {
78
+ ref.get()
79
+ mockCallback()
80
+ })
81
+
82
+ expect(mockCallback).toHaveBeenCalledTimes(1)
83
+
84
+ // Simulate file modification (same object reference, different content)
85
+ fileObj.size = 200
86
+ fileObj.modified = Date.now()
87
+
88
+ // Notify about external change
89
+ ref.notify()
90
+
91
+ expect(mockCallback).toHaveBeenCalledTimes(2)
92
+
93
+ // Multiple notifies should trigger multiple times
94
+ ref.notify()
95
+ expect(mockCallback).toHaveBeenCalledTimes(3)
96
+ })
97
+
98
+ test('Ref - multiple effects with same ref', () => {
99
+ const database = { connected: false, queries: 0 }
100
+ const ref = new Ref(database)
101
+
102
+ const effect1Mock = mock(() => {})
103
+ const effect2Mock = mock((_connected: boolean) => {})
104
+
105
+ createEffect(() => {
106
+ ref.get()
107
+ effect1Mock()
108
+ })
109
+
110
+ createEffect(() => {
111
+ const db = ref.get()
112
+ effect2Mock(db.connected)
113
+ })
114
+
115
+ expect(effect1Mock).toHaveBeenCalledTimes(1)
116
+ expect(effect2Mock).toHaveBeenCalledTimes(1)
117
+ expect(effect2Mock).toHaveBeenCalledWith(false)
118
+
119
+ // Simulate database connection change
120
+ database.connected = true
121
+ database.queries = 10
122
+ ref.notify()
123
+
124
+ expect(effect1Mock).toHaveBeenCalledTimes(2)
125
+ expect(effect2Mock).toHaveBeenCalledTimes(2)
126
+ expect(effect2Mock).toHaveBeenLastCalledWith(true)
127
+ })
128
+
129
+ test('Ref - with Bun.file() scenario', () => {
130
+ // Mock a file-like object that could change externally
131
+ const fileRef = {
132
+ name: 'config.json',
133
+ size: 1024,
134
+ lastModified: Date.now(),
135
+ // Simulate file methods
136
+ exists: () => true,
137
+ text: () => Promise.resolve('{"version": "1.0"}'),
138
+ }
139
+
140
+ const ref = new Ref(fileRef)
141
+
142
+ let sizeChanges = 0
143
+ createEffect(() => {
144
+ const file = ref.get()
145
+ if (file.size > 1000) sizeChanges++
146
+ })
147
+
148
+ expect(sizeChanges).toBe(1) // Initial run
149
+
150
+ // Simulate file growing (external change)
151
+ fileRef.size = 2048
152
+ fileRef.lastModified = Date.now()
153
+ ref.notify()
154
+
155
+ expect(sizeChanges).toBe(2) // Effect re-ran and condition still met
156
+
157
+ // Simulate file shrinking
158
+ fileRef.size = 500
159
+ ref.notify()
160
+
161
+ expect(sizeChanges).toBe(2) // Effect re-ran but condition no longer met
162
+ })
163
+
164
+ test('Ref - validation errors', () => {
165
+ // @ts-expect-error deliberatly provoked error
166
+ expect(() => new Ref(null)).toThrow()
167
+ // @ts-expect-error deliberatly provoked error
168
+ expect(() => new Ref(undefined)).toThrow()
169
+ })
170
+
171
+ test('Ref - server config object scenario', () => {
172
+ const config = {
173
+ host: 'localhost',
174
+ port: 3000,
175
+ ssl: false,
176
+ maxConnections: 100,
177
+ }
178
+
179
+ const configRef = new Ref(config)
180
+ const connectionAttempts: string[] = []
181
+
182
+ createEffect(() => {
183
+ const cfg = configRef.get()
184
+ const protocol = cfg.ssl ? 'https' : 'http'
185
+ connectionAttempts.push(`${protocol}://${cfg.host}:${cfg.port}`)
186
+ })
187
+
188
+ expect(connectionAttempts).toEqual(['http://localhost:3000'])
189
+
190
+ // Simulate config reload from file/environment
191
+ config.ssl = true
192
+ config.port = 8443
193
+ configRef.notify()
194
+
195
+ expect(connectionAttempts).toEqual([
196
+ 'http://localhost:3000',
197
+ 'https://localhost:8443',
198
+ ])
199
+ })
200
+
201
+ test('Ref - handles complex nested objects', () => {
202
+ const apiResponse = {
203
+ status: 200,
204
+ data: {
205
+ users: [{ id: 1, name: 'Alice' }],
206
+ pagination: { page: 1, total: 1 },
207
+ },
208
+ headers: { 'content-type': 'application/json' },
209
+ }
210
+
211
+ const ref = new Ref(apiResponse)
212
+ let userCount = 0
213
+
214
+ createEffect(() => {
215
+ const response = ref.get()
216
+ userCount = response.data.users.length
217
+ })
218
+
219
+ expect(userCount).toBe(1)
220
+
221
+ // Simulate API response update
222
+ apiResponse.data.users.push({ id: 2, name: 'Bob' })
223
+ apiResponse.data.pagination.total = 2
224
+ ref.notify()
225
+
226
+ expect(userCount).toBe(2)
227
+ })
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { createComputed, createState, resolve, UNSET } from '..'
2
+ import { Memo, resolve, State, Task, UNSET } from '../index.ts'
3
3
 
4
4
  /* === Tests === */
5
5
 
6
6
  describe('Resolve Function', () => {
7
7
  test('should return discriminated union for successful resolution', () => {
8
- const a = createState(10)
9
- const b = createState('hello')
8
+ const a = new State(10)
9
+ const b = new State('hello')
10
10
 
11
11
  const result = resolve({ a, b })
12
12
 
@@ -20,8 +20,8 @@ describe('Resolve Function', () => {
20
20
  })
21
21
 
22
22
  test('should return discriminated union for pending signals', () => {
23
- const a = createState(10)
24
- const b = createState(UNSET)
23
+ const a = new State(10)
24
+ const b = new State(UNSET)
25
25
 
26
26
  const result = resolve({ a, b })
27
27
 
@@ -32,8 +32,8 @@ describe('Resolve Function', () => {
32
32
  })
33
33
 
34
34
  test('should return discriminated union for error signals', () => {
35
- const a = createState(10)
36
- const b = createComputed(() => {
35
+ const a = new State(10)
36
+ const b = new Memo(() => {
37
37
  throw new Error('Test error')
38
38
  })
39
39
 
@@ -49,11 +49,11 @@ describe('Resolve Function', () => {
49
49
  })
50
50
 
51
51
  test('should handle mixed error and valid signals', () => {
52
- const valid = createState('valid')
53
- const error1 = createComputed(() => {
52
+ const valid = new State('valid')
53
+ const error1 = new Memo(() => {
54
54
  throw new Error('Error 1')
55
55
  })
56
- const error2 = createComputed(() => {
56
+ const error2 = new Memo(() => {
57
57
  throw new Error('Error 2')
58
58
  })
59
59
 
@@ -69,8 +69,8 @@ describe('Resolve Function', () => {
69
69
  })
70
70
 
71
71
  test('should prioritize pending over errors', () => {
72
- const pending = createState(UNSET)
73
- const error = createComputed(() => {
72
+ const pending = new State(UNSET)
73
+ const error = new Memo(() => {
74
74
  throw new Error('Test error')
75
75
  })
76
76
 
@@ -91,8 +91,8 @@ describe('Resolve Function', () => {
91
91
  })
92
92
 
93
93
  test('should handle complex nested object signals', () => {
94
- const user = createState({ name: 'Alice', age: 25 })
95
- const settings = createState({ theme: 'dark', lang: 'en' })
94
+ const user = new State({ name: 'Alice', age: 25 })
95
+ const settings = new State({ theme: 'dark', lang: 'en' })
96
96
 
97
97
  const result = resolve({ user, settings })
98
98
 
@@ -109,7 +109,7 @@ describe('Resolve Function', () => {
109
109
  const wait = (ms: number) =>
110
110
  new Promise(resolve => setTimeout(resolve, ms))
111
111
 
112
- const asyncSignal = createComputed(async () => {
112
+ const asyncSignal = new Task(async () => {
113
113
  await wait(10)
114
114
  return 'async result'
115
115
  })
@@ -124,16 +124,14 @@ describe('Resolve Function', () => {
124
124
 
125
125
  result = resolve({ asyncSignal })
126
126
  expect(result.ok).toBe(true)
127
- if (result.ok) {
128
- expect(result.values.asyncSignal).toBe('async result')
129
- }
127
+ if (result.ok) expect(result.values.asyncSignal).toBe('async result')
130
128
  })
131
129
 
132
130
  test('should handle async computed signals that error', async () => {
133
131
  const wait = (ms: number) =>
134
132
  new Promise(resolve => setTimeout(resolve, ms))
135
133
 
136
- const asyncError = createComputed(async () => {
134
+ const asyncError = new Task(async () => {
137
135
  await wait(10)
138
136
  throw new Error('Async error')
139
137
  })