@zeix/cause-effect 0.17.0 → 0.17.2

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 (50) hide show
  1. package/.ai-context.md +26 -5
  2. package/.cursorrules +8 -3
  3. package/.github/copilot-instructions.md +13 -4
  4. package/CLAUDE.md +191 -262
  5. package/README.md +268 -420
  6. package/archive/collection.ts +23 -25
  7. package/archive/computed.ts +5 -4
  8. package/archive/list.ts +21 -28
  9. package/archive/memo.ts +4 -2
  10. package/archive/state.ts +2 -1
  11. package/archive/store.ts +21 -32
  12. package/archive/task.ts +6 -9
  13. package/index.dev.js +411 -220
  14. package/index.js +1 -1
  15. package/index.ts +25 -8
  16. package/package.json +1 -1
  17. package/src/classes/collection.ts +103 -77
  18. package/src/classes/composite.ts +28 -33
  19. package/src/classes/computed.ts +90 -31
  20. package/src/classes/list.ts +39 -33
  21. package/src/classes/ref.ts +96 -0
  22. package/src/classes/state.ts +41 -8
  23. package/src/classes/store.ts +47 -30
  24. package/src/diff.ts +2 -1
  25. package/src/effect.ts +19 -9
  26. package/src/errors.ts +31 -1
  27. package/src/match.ts +5 -12
  28. package/src/resolve.ts +3 -2
  29. package/src/signal.ts +0 -1
  30. package/src/system.ts +159 -43
  31. package/src/util.ts +0 -10
  32. package/test/collection.test.ts +383 -67
  33. package/test/computed.test.ts +268 -11
  34. package/test/effect.test.ts +2 -2
  35. package/test/list.test.ts +249 -21
  36. package/test/ref.test.ts +381 -0
  37. package/test/state.test.ts +13 -13
  38. package/test/store.test.ts +473 -28
  39. package/types/index.d.ts +6 -5
  40. package/types/src/classes/collection.d.ts +27 -12
  41. package/types/src/classes/composite.d.ts +4 -4
  42. package/types/src/classes/computed.d.ts +17 -0
  43. package/types/src/classes/list.d.ts +6 -6
  44. package/types/src/classes/ref.d.ts +48 -0
  45. package/types/src/classes/state.d.ts +9 -0
  46. package/types/src/classes/store.d.ts +4 -4
  47. package/types/src/effect.d.ts +1 -2
  48. package/types/src/errors.d.ts +9 -1
  49. package/types/src/system.d.ts +40 -24
  50. package/types/src/util.d.ts +1 -3
@@ -0,0 +1,381 @@
1
+ import { expect, mock, test } from 'bun:test'
2
+ import { isRef, Ref } from '../src/classes/ref'
3
+ import { createEffect } from '../src/effect'
4
+ import { HOOK_WATCH } from '../src/system'
5
+
6
+ test('Ref - basic functionality', () => {
7
+ const obj = { name: 'test', value: 42 }
8
+ const ref = new Ref(obj)
9
+
10
+ expect(ref.get()).toBe(obj)
11
+ expect(ref[Symbol.toStringTag]).toBe('Ref')
12
+ })
13
+
14
+ test('Ref - isRef type guard', () => {
15
+ const ref = new Ref({ test: true })
16
+ const notRef = { test: true }
17
+
18
+ expect(isRef(ref)).toBe(true)
19
+ expect(isRef(notRef)).toBe(false)
20
+ expect(isRef(null)).toBe(false)
21
+ expect(isRef(undefined)).toBe(false)
22
+ })
23
+
24
+ test('Ref - validation with guard function', () => {
25
+ const isConfig = (
26
+ value: unknown,
27
+ ): value is { host: string; port: number } =>
28
+ typeof value === 'object' &&
29
+ value !== null &&
30
+ 'host' in value &&
31
+ 'port' in value &&
32
+ typeof value.host === 'string' &&
33
+ typeof value.port === 'number'
34
+
35
+ const validConfig = { host: 'localhost', port: 3000 }
36
+ const invalidConfig = { host: 'localhost' } // missing port
37
+
38
+ expect(() => new Ref(validConfig, isConfig)).not.toThrow()
39
+ expect(() => new Ref(invalidConfig, isConfig)).toThrow()
40
+ })
41
+
42
+ test('Ref - reactive subscriptions', () => {
43
+ const server = { status: 'offline', connections: 0 }
44
+ const ref = new Ref(server)
45
+
46
+ let effectRunCount = 0
47
+ let lastStatus: string = ''
48
+
49
+ createEffect(() => {
50
+ const current = ref.get()
51
+ lastStatus = current.status
52
+ effectRunCount++
53
+ })
54
+
55
+ expect(effectRunCount).toBe(1)
56
+ expect(lastStatus).toBe('offline')
57
+
58
+ // Simulate external change without going through reactive system
59
+ server.status = 'online'
60
+ server.connections = 5
61
+
62
+ // Effect shouldn't re-run yet (reference hasn't changed)
63
+ expect(effectRunCount).toBe(1)
64
+
65
+ // Notify that the external object has changed
66
+ ref.notify()
67
+
68
+ expect(effectRunCount).toBe(2)
69
+ expect(lastStatus).toBe('online')
70
+ })
71
+
72
+ test('Ref - notify triggers watchers even with same reference', () => {
73
+ const fileObj = { path: '/test.txt', size: 100, modified: Date.now() }
74
+ const ref = new Ref(fileObj)
75
+
76
+ const mockCallback = mock(() => {})
77
+
78
+ createEffect(() => {
79
+ ref.get()
80
+ mockCallback()
81
+ })
82
+
83
+ expect(mockCallback).toHaveBeenCalledTimes(1)
84
+
85
+ // Simulate file modification (same object reference, different content)
86
+ fileObj.size = 200
87
+ fileObj.modified = Date.now()
88
+
89
+ // Notify about external change
90
+ ref.notify()
91
+
92
+ expect(mockCallback).toHaveBeenCalledTimes(2)
93
+
94
+ // Multiple notifies should trigger multiple times
95
+ ref.notify()
96
+ expect(mockCallback).toHaveBeenCalledTimes(3)
97
+ })
98
+
99
+ test('Ref - multiple effects with same ref', () => {
100
+ const database = { connected: false, queries: 0 }
101
+ const ref = new Ref(database)
102
+
103
+ const effect1Mock = mock(() => {})
104
+ const effect2Mock = mock((_connected: boolean) => {})
105
+
106
+ createEffect(() => {
107
+ ref.get()
108
+ effect1Mock()
109
+ })
110
+
111
+ createEffect(() => {
112
+ const db = ref.get()
113
+ effect2Mock(db.connected)
114
+ })
115
+
116
+ expect(effect1Mock).toHaveBeenCalledTimes(1)
117
+ expect(effect2Mock).toHaveBeenCalledTimes(1)
118
+ expect(effect2Mock).toHaveBeenCalledWith(false)
119
+
120
+ // Simulate database connection change
121
+ database.connected = true
122
+ database.queries = 10
123
+ ref.notify()
124
+
125
+ expect(effect1Mock).toHaveBeenCalledTimes(2)
126
+ expect(effect2Mock).toHaveBeenCalledTimes(2)
127
+ expect(effect2Mock).toHaveBeenLastCalledWith(true)
128
+ })
129
+
130
+ test('Ref - with Bun.file() scenario', () => {
131
+ // Mock a file-like object that could change externally
132
+ const fileRef = {
133
+ name: 'config.json',
134
+ size: 1024,
135
+ lastModified: Date.now(),
136
+ // Simulate file methods
137
+ exists: () => true,
138
+ text: () => Promise.resolve('{"version": "1.0"}'),
139
+ }
140
+
141
+ const ref = new Ref(fileRef)
142
+
143
+ let sizeChanges = 0
144
+ createEffect(() => {
145
+ const file = ref.get()
146
+ if (file.size > 1000) sizeChanges++
147
+ })
148
+
149
+ expect(sizeChanges).toBe(1) // Initial run
150
+
151
+ // Simulate file growing (external change)
152
+ fileRef.size = 2048
153
+ fileRef.lastModified = Date.now()
154
+ ref.notify()
155
+
156
+ expect(sizeChanges).toBe(2) // Effect re-ran and condition still met
157
+
158
+ // Simulate file shrinking
159
+ fileRef.size = 500
160
+ ref.notify()
161
+
162
+ expect(sizeChanges).toBe(2) // Effect re-ran but condition no longer met
163
+ })
164
+
165
+ test('Ref - validation errors', () => {
166
+ // @ts-expect-error deliberatly provoked error
167
+ expect(() => new Ref(null)).toThrow()
168
+ // @ts-expect-error deliberatly provoked error
169
+ expect(() => new Ref(undefined)).toThrow()
170
+ })
171
+
172
+ test('Ref - server config object scenario', () => {
173
+ const config = {
174
+ host: 'localhost',
175
+ port: 3000,
176
+ ssl: false,
177
+ maxConnections: 100,
178
+ }
179
+
180
+ const configRef = new Ref(config)
181
+ const connectionAttempts: string[] = []
182
+
183
+ createEffect(() => {
184
+ const cfg = configRef.get()
185
+ const protocol = cfg.ssl ? 'https' : 'http'
186
+ connectionAttempts.push(`${protocol}://${cfg.host}:${cfg.port}`)
187
+ })
188
+
189
+ expect(connectionAttempts).toEqual(['http://localhost:3000'])
190
+
191
+ // Simulate config reload from file/environment
192
+ config.ssl = true
193
+ config.port = 8443
194
+ configRef.notify()
195
+
196
+ expect(connectionAttempts).toEqual([
197
+ 'http://localhost:3000',
198
+ 'https://localhost:8443',
199
+ ])
200
+ })
201
+
202
+ test('Ref - handles complex nested objects', () => {
203
+ const apiResponse = {
204
+ status: 200,
205
+ data: {
206
+ users: [{ id: 1, name: 'Alice' }],
207
+ pagination: { page: 1, total: 1 },
208
+ },
209
+ headers: { 'content-type': 'application/json' },
210
+ }
211
+
212
+ const ref = new Ref(apiResponse)
213
+ let userCount = 0
214
+
215
+ createEffect(() => {
216
+ const response = ref.get()
217
+ userCount = response.data.users.length
218
+ })
219
+
220
+ expect(userCount).toBe(1)
221
+
222
+ // Simulate API response update
223
+ apiResponse.data.users.push({ id: 2, name: 'Bob' })
224
+ apiResponse.data.pagination.total = 2
225
+ ref.notify()
226
+
227
+ expect(userCount).toBe(2)
228
+ })
229
+
230
+ test('Ref - HOOK_WATCH lazy resource management', async () => {
231
+ // 1. Create Ref with current Date
232
+ const currentDate = new Date()
233
+ const ref = new Ref(currentDate)
234
+
235
+ let counter = 0
236
+ let intervalId: Timer | undefined
237
+
238
+ // 2. Add HOOK_WATCH callback that starts setInterval and returns cleanup
239
+ const cleanupHookCallback = ref.on(HOOK_WATCH, () => {
240
+ intervalId = setInterval(() => {
241
+ counter++
242
+ }, 10) // Use short interval for faster test
243
+
244
+ // Return cleanup function to clear interval
245
+ return () => {
246
+ if (intervalId) {
247
+ clearInterval(intervalId)
248
+ intervalId = undefined
249
+ }
250
+ }
251
+ })
252
+
253
+ // 3. Counter should not be running yet
254
+ expect(counter).toBe(0)
255
+
256
+ // Wait a bit to ensure counter doesn't increment
257
+ await new Promise(resolve => setTimeout(resolve, 50))
258
+ expect(counter).toBe(0)
259
+ expect(intervalId).toBeUndefined()
260
+
261
+ // 4. Effect subscribes by .get()ting the signal value
262
+ const effectCleanup = createEffect(() => {
263
+ ref.get()
264
+ })
265
+
266
+ // 5. Counter should now be running
267
+ await new Promise(resolve => setTimeout(resolve, 50))
268
+ expect(counter).toBeGreaterThan(0)
269
+ expect(intervalId).toBeDefined()
270
+
271
+ // 6. Call effect cleanup, which should stop internal watcher and unsubscribe
272
+ effectCleanup()
273
+ const counterAfterStop = counter
274
+
275
+ // 7. Ref signal should call #unwatch() and counter should stop incrementing
276
+ await new Promise(resolve => setTimeout(resolve, 50))
277
+ expect(counter).toBe(counterAfterStop) // Counter should not have incremented
278
+ expect(intervalId).toBeUndefined() // Interval should be cleared
279
+
280
+ // Clean up hook callback registration
281
+ cleanupHookCallback()
282
+ })
283
+
284
+ test('Ref - HOOK_WATCH exception handling', async () => {
285
+ const ref = new Ref({ test: 'value' })
286
+
287
+ // Mock console.error to capture error logs
288
+ const originalError = console.error
289
+ const errorSpy = mock(() => {})
290
+ console.error = errorSpy
291
+
292
+ let successfulCallbackCalled = false
293
+ let throwingCallbackCalled = false
294
+
295
+ // Add callback that throws an exception
296
+ const cleanup1 = ref.on(HOOK_WATCH, () => {
297
+ throwingCallbackCalled = true
298
+ throw new Error('Test error in HOOK_WATCH callback')
299
+ })
300
+
301
+ // Add callback that works normally
302
+ const cleanup2 = ref.on(HOOK_WATCH, () => {
303
+ successfulCallbackCalled = true
304
+ return () => {
305
+ // cleanup function
306
+ }
307
+ })
308
+
309
+ // Subscribe to trigger HOOK_WATCH callbacks
310
+ const effectCleanup = createEffect(() => {
311
+ ref.get()
312
+ })
313
+
314
+ // Both callbacks should have been called despite the exception
315
+ expect(throwingCallbackCalled).toBe(true)
316
+ expect(successfulCallbackCalled).toBe(true)
317
+
318
+ // Error should have been logged
319
+ expect(errorSpy).toHaveBeenCalledWith(
320
+ 'Error in effect callback:',
321
+ expect.any(Error),
322
+ )
323
+
324
+ // Cleanup
325
+ effectCleanup()
326
+ cleanup1()
327
+ cleanup2()
328
+ console.error = originalError
329
+ })
330
+
331
+ test('Ref - cleanup function exception handling', async () => {
332
+ const ref = new Ref({ test: 'value' })
333
+
334
+ // Mock console.error to capture error logs
335
+ const originalError = console.error
336
+ const errorSpy = mock(() => {})
337
+ console.error = errorSpy
338
+
339
+ let cleanup1Called = false
340
+ let cleanup2Called = false
341
+
342
+ // Add callbacks with cleanup functions, one throws
343
+ const hookCleanup1 = ref.on(HOOK_WATCH, () => {
344
+ return () => {
345
+ cleanup1Called = true
346
+ throw new Error('Test error in cleanup function')
347
+ }
348
+ })
349
+
350
+ const hookCleanup2 = ref.on(HOOK_WATCH, () => {
351
+ return () => {
352
+ cleanup2Called = true
353
+ }
354
+ })
355
+
356
+ // Subscribe and then unsubscribe to trigger cleanup
357
+ const effectCleanup = createEffect(() => {
358
+ ref.get()
359
+ })
360
+
361
+ // Unsubscribe to trigger cleanup functions
362
+ effectCleanup()
363
+
364
+ // Wait a bit for cleanup to complete
365
+ await new Promise(resolve => setTimeout(resolve, 10))
366
+
367
+ // Both cleanup functions should have been called despite the exception
368
+ expect(cleanup1Called).toBe(true)
369
+ expect(cleanup2Called).toBe(true)
370
+
371
+ // Error should have been logged
372
+ expect(errorSpy).toHaveBeenCalledWith(
373
+ 'Error in effect cleanup:',
374
+ expect.any(Error),
375
+ )
376
+
377
+ // Cleanup
378
+ hookCleanup1()
379
+ hookCleanup2()
380
+ console.error = originalError
381
+ })
@@ -125,12 +125,12 @@ describe('State', () => {
125
125
  expect(() => {
126
126
  // @ts-expect-error - Testing invalid input
127
127
  new State(null)
128
- }).toThrow('Nullish signal values are not allowed in state')
128
+ }).toThrow('Nullish signal values are not allowed in State')
129
129
 
130
130
  expect(() => {
131
131
  // @ts-expect-error - Testing invalid input
132
132
  new State(undefined)
133
- }).toThrow('Nullish signal values are not allowed in state')
133
+ }).toThrow('Nullish signal values are not allowed in State')
134
134
  })
135
135
 
136
136
  test('should throw NullishSignalValueError when newValue is nullish in set()', () => {
@@ -139,12 +139,12 @@ describe('State', () => {
139
139
  expect(() => {
140
140
  // @ts-expect-error - Testing invalid input
141
141
  state.set(null)
142
- }).toThrow('Nullish signal values are not allowed in state')
142
+ }).toThrow('Nullish signal values are not allowed in State')
143
143
 
144
144
  expect(() => {
145
145
  // @ts-expect-error - Testing invalid input
146
146
  state.set(undefined)
147
- }).toThrow('Nullish signal values are not allowed in state')
147
+ }).toThrow('Nullish signal values are not allowed in State')
148
148
  })
149
149
 
150
150
  test('should throw specific error types for nullish values', () => {
@@ -156,7 +156,7 @@ describe('State', () => {
156
156
  expect(error).toBeInstanceOf(TypeError)
157
157
  expect(error.name).toBe('NullishSignalValueError')
158
158
  expect(error.message).toBe(
159
- 'Nullish signal values are not allowed in state',
159
+ 'Nullish signal values are not allowed in State',
160
160
  )
161
161
  }
162
162
 
@@ -169,7 +169,7 @@ describe('State', () => {
169
169
  expect(error).toBeInstanceOf(TypeError)
170
170
  expect(error.name).toBe('NullishSignalValueError')
171
171
  expect(error.message).toBe(
172
- 'Nullish signal values are not allowed in state',
172
+ 'Nullish signal values are not allowed in State',
173
173
  )
174
174
  }
175
175
  })
@@ -213,22 +213,22 @@ describe('State', () => {
213
213
  expect(() => {
214
214
  // @ts-expect-error - Testing invalid input
215
215
  state.update(null)
216
- }).toThrow('Invalid state update callback null')
216
+ }).toThrow('Invalid State update callback null')
217
217
 
218
218
  expect(() => {
219
219
  // @ts-expect-error - Testing invalid input
220
220
  state.update(undefined)
221
- }).toThrow('Invalid state update callback undefined')
221
+ }).toThrow('Invalid State update callback undefined')
222
222
 
223
223
  expect(() => {
224
224
  // @ts-expect-error - Testing invalid input
225
225
  state.update('not a function')
226
- }).toThrow('Invalid state update callback "not a function"')
226
+ }).toThrow('Invalid State update callback "not a function"')
227
227
 
228
228
  expect(() => {
229
229
  // @ts-expect-error - Testing invalid input
230
230
  state.update(42)
231
- }).toThrow('Invalid state update callback 42')
231
+ }).toThrow('Invalid State update callback 42')
232
232
  })
233
233
 
234
234
  test('should throw specific error type for non-function updater', () => {
@@ -242,7 +242,7 @@ describe('State', () => {
242
242
  expect(error).toBeInstanceOf(TypeError)
243
243
  expect(error.name).toBe('InvalidCallbackError')
244
244
  expect(error.message).toBe(
245
- 'Invalid state update callback null',
245
+ 'Invalid State update callback null',
246
246
  )
247
247
  }
248
248
  })
@@ -266,12 +266,12 @@ describe('State', () => {
266
266
  expect(() => {
267
267
  // @ts-expect-error - Testing invalid return value
268
268
  state.update(() => null)
269
- }).toThrow('Nullish signal values are not allowed in state')
269
+ }).toThrow('Nullish signal values are not allowed in State')
270
270
 
271
271
  expect(() => {
272
272
  // @ts-expect-error - Testing invalid return value
273
273
  state.update(() => undefined)
274
- }).toThrow('Nullish signal values are not allowed in state')
274
+ }).toThrow('Nullish signal values are not allowed in State')
275
275
 
276
276
  // State should remain unchanged after error
277
277
  expect(state.get()).toBe(42)