@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
@@ -0,0 +1,853 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ createEffect,
4
+ createStore,
5
+ DerivedCollection,
6
+ isCollection,
7
+ List,
8
+ UNSET,
9
+ } from '../index.ts'
10
+
11
+ describe('collection', () => {
12
+ describe('creation and basic operations', () => {
13
+ test('creates collection with initial values from list', () => {
14
+ const numbers = new List([1, 2, 3])
15
+ const doubled = new DerivedCollection(
16
+ numbers,
17
+ (value: number) => value * 2,
18
+ )
19
+
20
+ expect(doubled.length).toBe(3)
21
+ expect(doubled.at(0)?.get()).toBe(2)
22
+ expect(doubled.at(1)?.get()).toBe(4)
23
+ expect(doubled.at(2)?.get()).toBe(6)
24
+ })
25
+
26
+ test('creates collection from function source', () => {
27
+ const doubled = new DerivedCollection(
28
+ () => new List([10, 20, 30]),
29
+ (value: number) => value * 2,
30
+ )
31
+
32
+ expect(doubled.length).toBe(3)
33
+ expect(doubled.at(0)?.get()).toBe(20)
34
+ expect(doubled.at(1)?.get()).toBe(40)
35
+ expect(doubled.at(2)?.get()).toBe(60)
36
+ })
37
+
38
+ test('has Symbol.toStringTag of Collection', () => {
39
+ const list = new List([1, 2, 3])
40
+ const collection = new DerivedCollection(list, (x: number) => x)
41
+ expect(Object.prototype.toString.call(collection)).toBe(
42
+ '[object Collection]',
43
+ )
44
+ })
45
+
46
+ test('isCollection identifies collection instances correctly', () => {
47
+ const store = createStore({ a: 1 })
48
+ const list = new List([1, 2, 3])
49
+ const collection = new DerivedCollection(list, (x: number) => x)
50
+
51
+ expect(isCollection(collection)).toBe(true)
52
+ expect(isCollection(list)).toBe(false)
53
+ expect(isCollection(store)).toBe(false)
54
+ expect(isCollection({})).toBe(false)
55
+ expect(isCollection(null)).toBe(false)
56
+ })
57
+
58
+ test('get() returns the complete collection value', () => {
59
+ const numbers = new List([1, 2, 3])
60
+ const doubled = new DerivedCollection(
61
+ numbers,
62
+ (value: number) => value * 2,
63
+ )
64
+
65
+ const result = doubled.get()
66
+ expect(result).toEqual([2, 4, 6])
67
+ expect(Array.isArray(result)).toBe(true)
68
+ })
69
+ })
70
+
71
+ describe('length property and sizing', () => {
72
+ test('length property works for collections', () => {
73
+ const numbers = new List([1, 2, 3, 4, 5])
74
+ const collection = new DerivedCollection(
75
+ numbers,
76
+ (x: number) => x * 2,
77
+ )
78
+ expect(collection.length).toBe(5)
79
+ })
80
+
81
+ test('length is reactive and updates with changes', () => {
82
+ const items = new List([1, 2])
83
+ const collection = new DerivedCollection(
84
+ items,
85
+ (x: number) => x * 2,
86
+ )
87
+
88
+ expect(collection.length).toBe(2)
89
+ items.add(3)
90
+ expect(collection.length).toBe(3)
91
+ })
92
+ })
93
+
94
+ describe('index-based access', () => {
95
+ test('properties can be accessed via computed signals', () => {
96
+ const items = new List([10, 20, 30])
97
+ const doubled = new DerivedCollection(items, (x: number) => x * 2)
98
+
99
+ expect(doubled.at(0)?.get()).toBe(20)
100
+ expect(doubled.at(1)?.get()).toBe(40)
101
+ expect(doubled.at(2)?.get()).toBe(60)
102
+ })
103
+
104
+ test('returns undefined for non-existent properties', () => {
105
+ const items = new List([1, 2])
106
+ const collection = new DerivedCollection(items, (x: number) => x)
107
+ expect(collection[5]).toBeUndefined()
108
+ })
109
+
110
+ test('supports numeric key access', () => {
111
+ const numbers = new List([1, 2, 3])
112
+ const collection = new DerivedCollection(
113
+ numbers,
114
+ (x: number) => x * 2,
115
+ )
116
+ expect(collection.at(1)?.get()).toBe(4)
117
+ })
118
+ })
119
+
120
+ describe('key-based access methods', () => {
121
+ test('byKey() returns computed signal for existing keys', () => {
122
+ const numbers = new List([1, 2, 3])
123
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
124
+
125
+ const key0 = numbers.keyAt(0)
126
+ const key1 = numbers.keyAt(1)
127
+
128
+ expect(key0).toBeDefined()
129
+ expect(key1).toBeDefined()
130
+ // biome-ignore lint/style/noNonNullAssertion: test
131
+ expect(doubled.byKey(key0!)).toBeDefined()
132
+ // biome-ignore lint/style/noNonNullAssertion: test
133
+ expect(doubled.byKey(key1!)).toBeDefined()
134
+ // biome-ignore lint/style/noNonNullAssertion: test
135
+ expect(doubled.byKey(key0!)?.get()).toBe(2)
136
+ // biome-ignore lint/style/noNonNullAssertion: test
137
+ expect(doubled.byKey(key1!)?.get()).toBe(4)
138
+ })
139
+
140
+ test('keyAt() and indexOfKey() work correctly', () => {
141
+ const numbers = new List([5, 10, 15])
142
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
143
+
144
+ const key0 = doubled.keyAt(0)
145
+ const key1 = doubled.keyAt(1)
146
+
147
+ expect(key0).toBeDefined()
148
+ expect(key1).toBeDefined()
149
+ // biome-ignore lint/style/noNonNullAssertion: test
150
+ expect(doubled.indexOfKey(key0!)).toBe(0)
151
+ // biome-ignore lint/style/noNonNullAssertion: test
152
+ expect(doubled.indexOfKey(key1!)).toBe(1)
153
+ })
154
+ })
155
+
156
+ describe('reactivity', () => {
157
+ test('collection-level get() is reactive', () => {
158
+ const numbers = new List([1, 2, 3])
159
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
160
+
161
+ let lastArray: number[] = []
162
+ createEffect(() => {
163
+ lastArray = doubled.get()
164
+ })
165
+
166
+ expect(lastArray).toEqual([2, 4, 6])
167
+ numbers.add(4)
168
+ expect(lastArray).toEqual([2, 4, 6, 8])
169
+ })
170
+
171
+ test('individual signal reactivity works', () => {
172
+ const items = new List([{ count: 1 }, { count: 2 }])
173
+ const doubled = new DerivedCollection(
174
+ items,
175
+ (item: { count: number }) => ({ count: item.count * 2 }),
176
+ )
177
+
178
+ let lastItem: { count: number } | undefined
179
+ let itemEffectRuns = 0
180
+ createEffect(() => {
181
+ lastItem = doubled.at(0)?.get()
182
+ itemEffectRuns++
183
+ })
184
+
185
+ expect(lastItem).toEqual({ count: 2 })
186
+ expect(itemEffectRuns).toBe(1)
187
+
188
+ items.at(0)?.set({ count: 5 })
189
+ expect(lastItem).toEqual({ count: 10 })
190
+ // Effect runs twice: once initially, once for change
191
+ expect(itemEffectRuns).toEqual(2)
192
+ })
193
+
194
+ test('updates are reactive', () => {
195
+ const numbers = new List([1, 2, 3])
196
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
197
+
198
+ let lastArray: number[] = []
199
+ let arrayEffectRuns = 0
200
+ createEffect(() => {
201
+ lastArray = doubled.get()
202
+ arrayEffectRuns++
203
+ })
204
+
205
+ expect(lastArray).toEqual([2, 4, 6])
206
+ expect(arrayEffectRuns).toBe(1)
207
+
208
+ numbers.at(1)?.set(10)
209
+ expect(lastArray).toEqual([2, 20, 6])
210
+ expect(arrayEffectRuns).toBe(2)
211
+ })
212
+ })
213
+
214
+ describe('iteration and spreading', () => {
215
+ test('supports for...of iteration', () => {
216
+ const numbers = new List([1, 2, 3])
217
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
218
+ const signals = [...doubled]
219
+
220
+ expect(signals).toHaveLength(3)
221
+ expect(signals[0].get()).toBe(2)
222
+ expect(signals[1].get()).toBe(4)
223
+ expect(signals[2].get()).toBe(6)
224
+ })
225
+
226
+ test('Symbol.isConcatSpreadable is true', () => {
227
+ const numbers = new List([1, 2, 3])
228
+ const collection = new DerivedCollection(numbers, (x: number) => x)
229
+ expect(collection[Symbol.isConcatSpreadable]).toBe(true)
230
+ })
231
+ })
232
+
233
+ describe('change notifications', () => {
234
+ test('emits add notifications', () => {
235
+ const numbers = new List([1, 2])
236
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
237
+
238
+ let arrayAddNotification: readonly string[] = []
239
+ doubled.on('add', keys => {
240
+ arrayAddNotification = keys
241
+ })
242
+
243
+ numbers.add(3)
244
+ expect(arrayAddNotification).toHaveLength(1)
245
+ // biome-ignore lint/style/noNonNullAssertion: test
246
+ expect(doubled.byKey(arrayAddNotification[0]!)?.get()).toBe(6)
247
+ })
248
+
249
+ test('emits remove notifications when items are removed', () => {
250
+ const items = new List([1, 2, 3])
251
+ const doubled = new DerivedCollection(items, (x: number) => x * 2)
252
+
253
+ let arrayRemoveNotification: readonly string[] = []
254
+ doubled.on('remove', keys => {
255
+ arrayRemoveNotification = keys
256
+ })
257
+
258
+ items.remove(1)
259
+ expect(arrayRemoveNotification).toHaveLength(1)
260
+ })
261
+
262
+ test('emits sort notifications when source is sorted', () => {
263
+ const numbers = new List([3, 1, 2])
264
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
265
+
266
+ let sortNotification: readonly string[] = []
267
+ doubled.on('sort', newOrder => {
268
+ sortNotification = newOrder
269
+ })
270
+
271
+ numbers.sort((a, b) => a - b)
272
+ expect(sortNotification).toHaveLength(3)
273
+ expect(doubled.get()).toEqual([2, 4, 6])
274
+ })
275
+ })
276
+
277
+ describe('edge cases', () => {
278
+ test('handles empty collections correctly', () => {
279
+ const empty = new List<number>([])
280
+ const collection = new DerivedCollection(
281
+ empty,
282
+ (x: number) => x * 2,
283
+ )
284
+ expect(collection.length).toBe(0)
285
+ expect(collection.get()).toEqual([])
286
+ })
287
+
288
+ test('handles UNSET values', () => {
289
+ const list = new List([1, 2, 3])
290
+ const processed = new DerivedCollection(list, (x: number) =>
291
+ x > 2 ? x : UNSET,
292
+ )
293
+
294
+ // UNSET values should be filtered out
295
+ expect(processed.get()).toEqual([3])
296
+ })
297
+
298
+ test('handles primitive values', () => {
299
+ const list = new List(['hello', 'world'])
300
+ const lengths = new DerivedCollection(list, (str: string) => ({
301
+ length: str.length,
302
+ }))
303
+
304
+ expect(lengths.at(0)?.get()).toEqual({ length: 5 })
305
+ expect(lengths.at(1)?.get()).toEqual({ length: 5 })
306
+ })
307
+ })
308
+
309
+ describe('deriveCollection() method', () => {
310
+ describe('synchronous transformations', () => {
311
+ test('transforms collection values with sync callback', () => {
312
+ const numbers = new List([1, 2, 3])
313
+ const doubled = new DerivedCollection(
314
+ numbers,
315
+ (x: number) => x * 2,
316
+ )
317
+ const quadrupled = doubled.deriveCollection(
318
+ (x: number) => x * 2,
319
+ )
320
+
321
+ expect(quadrupled.length).toBe(3)
322
+ expect(quadrupled.at(0)?.get()).toBe(4)
323
+ expect(quadrupled.at(1)?.get()).toBe(8)
324
+ expect(quadrupled.at(2)?.get()).toBe(12)
325
+ })
326
+
327
+ test('transforms object values with sync callback', () => {
328
+ const users = new List([
329
+ { name: 'Alice', age: 25 },
330
+ { name: 'Bob', age: 30 },
331
+ ])
332
+ const basicInfo = new DerivedCollection(
333
+ users,
334
+ (user: { name: string; age: number }) => ({
335
+ displayName: user.name.toUpperCase(),
336
+ isAdult: user.age >= 18,
337
+ }),
338
+ )
339
+ const detailedInfo = basicInfo.deriveCollection(
340
+ (info: { displayName: string; isAdult: boolean }) => ({
341
+ ...info,
342
+ category: info.isAdult ? 'adult' : 'minor',
343
+ }),
344
+ )
345
+
346
+ expect(detailedInfo.at(0)?.get()).toEqual({
347
+ displayName: 'ALICE',
348
+ isAdult: true,
349
+ category: 'adult',
350
+ })
351
+ expect(detailedInfo.at(1)?.get()).toEqual({
352
+ displayName: 'BOB',
353
+ isAdult: true,
354
+ category: 'adult',
355
+ })
356
+ })
357
+
358
+ test('transforms string values to different types', () => {
359
+ const words = new List(['hello', 'world', 'test'])
360
+ const wordInfo = new DerivedCollection(
361
+ words,
362
+ (word: string) => ({
363
+ word,
364
+ length: word.length,
365
+ }),
366
+ )
367
+ const analysis = wordInfo.deriveCollection(
368
+ (info: { word: string; length: number }) => ({
369
+ ...info,
370
+ isLong: info.length > 4,
371
+ }),
372
+ )
373
+
374
+ expect(analysis.at(0)?.get().word).toBe('hello')
375
+ expect(analysis.at(0)?.get().length).toBe(5)
376
+ expect(analysis.at(0)?.get().isLong).toBe(true)
377
+ expect(analysis.at(1)?.get().word).toBe('world')
378
+ expect(analysis.at(1)?.get().length).toBe(5)
379
+ expect(analysis.at(1)?.get().isLong).toBe(true)
380
+ expect(analysis.at(2)?.get().word).toBe('test')
381
+ expect(analysis.at(2)?.get().length).toBe(4)
382
+ expect(analysis.at(2)?.get().isLong).toBe(false)
383
+ })
384
+
385
+ test('derived collection reactivity with sync transformations', () => {
386
+ const numbers = new List([1, 2, 3])
387
+ const doubled = new DerivedCollection(
388
+ numbers,
389
+ (x: number) => x * 2,
390
+ )
391
+ const quadrupled = doubled.deriveCollection(
392
+ (x: number) => x * 2,
393
+ )
394
+
395
+ let collectionValue: number[] = []
396
+ let effectRuns = 0
397
+ createEffect(() => {
398
+ collectionValue = quadrupled.get()
399
+ effectRuns++
400
+ })
401
+
402
+ expect(collectionValue).toEqual([4, 8, 12])
403
+ expect(effectRuns).toBe(1)
404
+
405
+ numbers.add(4)
406
+ expect(collectionValue).toEqual([4, 8, 12, 16])
407
+ expect(effectRuns).toBe(2)
408
+
409
+ numbers.at(1)?.set(5)
410
+ expect(collectionValue).toEqual([4, 20, 12, 16])
411
+ expect(effectRuns).toBe(3)
412
+ })
413
+
414
+ test('derived collection responds to source removal', () => {
415
+ const numbers = new List([1, 2, 3, 4])
416
+ const doubled = new DerivedCollection(
417
+ numbers,
418
+ (x: number) => x * 2,
419
+ )
420
+ const quadrupled = doubled.deriveCollection(
421
+ (x: number) => x * 2,
422
+ )
423
+
424
+ expect(quadrupled.get()).toEqual([4, 8, 12, 16])
425
+
426
+ numbers.remove(1)
427
+ expect(quadrupled.get()).toEqual([4, 12, 16])
428
+ })
429
+ })
430
+
431
+ describe('asynchronous transformations', () => {
432
+ test('transforms values with async callback', async () => {
433
+ const numbers = new List([1, 2, 3])
434
+ const doubled = new DerivedCollection(
435
+ numbers,
436
+ (x: number) => x * 2,
437
+ )
438
+
439
+ const asyncQuadrupled = doubled.deriveCollection(
440
+ async (x: number, abort: AbortSignal) => {
441
+ await new Promise(resolve => setTimeout(resolve, 10))
442
+ if (abort.aborted) throw new Error('Aborted')
443
+ return x * 2
444
+ },
445
+ )
446
+
447
+ const initialLength = asyncQuadrupled.length
448
+ expect(initialLength).toBe(3)
449
+
450
+ // Initially, async computations return UNSET
451
+ expect(asyncQuadrupled.at(0)).toBeDefined()
452
+ expect(asyncQuadrupled.at(1)).toBeDefined()
453
+ expect(asyncQuadrupled.at(2)).toBeDefined()
454
+
455
+ // Use effects to test async reactivity
456
+ const results: number[] = []
457
+ let effectRuns = 0
458
+
459
+ createEffect(() => {
460
+ const values = asyncQuadrupled.get()
461
+ results.push(...values)
462
+ effectRuns++
463
+ })
464
+
465
+ // Wait for async computations to complete
466
+ await new Promise(resolve => setTimeout(resolve, 50))
467
+
468
+ // Should have received the computed values
469
+ expect(results.slice(-3)).toEqual([4, 8, 12])
470
+ expect(effectRuns).toBeGreaterThanOrEqual(1)
471
+ })
472
+
473
+ test('async derived collection with object transformation', async () => {
474
+ const users = new List([
475
+ { id: 1, name: 'Alice' },
476
+ { id: 2, name: 'Bob' },
477
+ ])
478
+ const basicInfo = new DerivedCollection(
479
+ users,
480
+ (user: { id: number; name: string }) => ({
481
+ userId: user.id,
482
+ displayName: user.name.toUpperCase(),
483
+ }),
484
+ )
485
+
486
+ const enrichedUsers = basicInfo.deriveCollection(
487
+ async (
488
+ info: { userId: number; displayName: string },
489
+ abort: AbortSignal,
490
+ ) => {
491
+ // Simulate async enrichment
492
+ await new Promise(resolve => setTimeout(resolve, 10))
493
+ if (abort.aborted) throw new Error('Aborted')
494
+
495
+ return {
496
+ ...info,
497
+ slug: info.displayName
498
+ .toLowerCase()
499
+ .replace(/\s+/g, '-'),
500
+ timestamp: Date.now(),
501
+ }
502
+ },
503
+ )
504
+
505
+ // Use effect to test async behavior
506
+ let enrichedResults: Array<{
507
+ userId: number
508
+ displayName: string
509
+ slug: string
510
+ timestamp: number
511
+ }> = []
512
+
513
+ createEffect(() => {
514
+ enrichedResults = enrichedUsers.get()
515
+ })
516
+
517
+ // Wait for async computations to complete
518
+ await new Promise(resolve => setTimeout(resolve, 50))
519
+
520
+ expect(enrichedResults).toHaveLength(2)
521
+
522
+ const [result1, result2] = enrichedResults
523
+
524
+ expect(result1?.userId).toBe(1)
525
+ expect(result1?.displayName).toBe('ALICE')
526
+ expect(result1?.slug).toBe('alice')
527
+ expect(typeof result1?.timestamp).toBe('number')
528
+
529
+ expect(result2?.userId).toBe(2)
530
+ expect(result2?.displayName).toBe('BOB')
531
+ expect(result2?.slug).toBe('bob')
532
+ expect(typeof result2?.timestamp).toBe('number')
533
+ })
534
+
535
+ test('async derived collection reactivity', async () => {
536
+ const numbers = new List([1, 2, 3])
537
+ const doubled = new DerivedCollection(
538
+ numbers,
539
+ (x: number) => x * 2,
540
+ )
541
+ const asyncQuadrupled = doubled.deriveCollection(
542
+ async (x: number, abort: AbortSignal) => {
543
+ await new Promise(resolve => setTimeout(resolve, 10))
544
+ if (abort.aborted) throw new Error('Aborted')
545
+ return x * 2
546
+ },
547
+ )
548
+
549
+ const effectValues: number[][] = []
550
+ createEffect(() => {
551
+ // Access all values to trigger reactive behavior
552
+ const currentValue = asyncQuadrupled.get()
553
+ effectValues.push(currentValue)
554
+ })
555
+
556
+ // Wait for initial effect
557
+ await new Promise(resolve => setTimeout(resolve, 50))
558
+
559
+ // Initial empty array (async values not resolved yet)
560
+ expect(effectValues[0]).toEqual([])
561
+
562
+ // Trigger individual computations
563
+ asyncQuadrupled.at(0)?.get()
564
+ asyncQuadrupled.at(1)?.get()
565
+ asyncQuadrupled.at(2)?.get()
566
+
567
+ // Wait for effects to process
568
+ await new Promise(resolve => setTimeout(resolve, 50))
569
+
570
+ // Should have the computed values now
571
+ const lastValue = effectValues[effectValues.length - 1]
572
+ expect(lastValue).toEqual([4, 8, 12])
573
+ })
574
+
575
+ test('handles AbortSignal cancellation', async () => {
576
+ const numbers = new List([1, 2, 3])
577
+ const doubled = new DerivedCollection(
578
+ numbers,
579
+ (x: number) => x * 2,
580
+ )
581
+ let abortCalled = false
582
+
583
+ const slowCollection = doubled.deriveCollection(
584
+ async (x: number, abort: AbortSignal) => {
585
+ abort.addEventListener('abort', () => {
586
+ abortCalled = true
587
+ })
588
+
589
+ // Long delay to allow cancellation
590
+ const timeout = new Promise(resolve =>
591
+ setTimeout(resolve, 100),
592
+ )
593
+ await timeout
594
+
595
+ if (abort.aborted) throw new Error('Aborted')
596
+ return x * 2
597
+ },
598
+ )
599
+
600
+ // Start computation
601
+ const _awaited = slowCollection.at(0)?.get()
602
+
603
+ // Change source to trigger cancellation
604
+ numbers.at(0)?.set(10)
605
+
606
+ // Wait for potential abort
607
+ await new Promise(resolve => setTimeout(resolve, 50))
608
+
609
+ expect(abortCalled).toBe(true)
610
+ })
611
+ })
612
+
613
+ describe('derived collection chaining', () => {
614
+ test('chains multiple sync derivations', () => {
615
+ const numbers = new List([1, 2, 3])
616
+ const doubled = new DerivedCollection(
617
+ numbers,
618
+ (x: number) => x * 2,
619
+ )
620
+ const quadrupled = doubled.deriveCollection(
621
+ (x: number) => x * 2,
622
+ )
623
+ const octupled = quadrupled.deriveCollection(
624
+ (x: number) => x * 2,
625
+ )
626
+
627
+ expect(octupled.at(0)?.get()).toBe(8)
628
+ expect(octupled.at(1)?.get()).toBe(16)
629
+ expect(octupled.at(2)?.get()).toBe(24)
630
+ })
631
+
632
+ test('chains sync and async derivations', async () => {
633
+ const numbers = new List([1, 2, 3])
634
+ const doubled = new DerivedCollection(
635
+ numbers,
636
+ (x: number) => x * 2,
637
+ )
638
+ const quadrupled = doubled.deriveCollection(
639
+ (x: number) => x * 2,
640
+ )
641
+
642
+ const asyncOctupled = quadrupled.deriveCollection(
643
+ async (x: number, abort: AbortSignal) => {
644
+ await new Promise(resolve => setTimeout(resolve, 10))
645
+ if (abort.aborted) throw new Error('Aborted')
646
+ return x * 2
647
+ },
648
+ )
649
+
650
+ // Use effect to test chained async behavior
651
+ let chainedResults: number[] = []
652
+
653
+ createEffect(() => {
654
+ chainedResults = asyncOctupled.get()
655
+ })
656
+
657
+ // Wait for async computations to complete
658
+ await new Promise(resolve => setTimeout(resolve, 50))
659
+
660
+ expect(chainedResults).toEqual([8, 16, 24])
661
+ })
662
+ })
663
+
664
+ describe('derived collection access methods', () => {
665
+ test('provides index-based access to computed signals', () => {
666
+ const numbers = new List([1, 2, 3])
667
+ const doubled = new DerivedCollection(
668
+ numbers,
669
+ (x: number) => x * 2,
670
+ )
671
+ const quadrupled = doubled.deriveCollection(
672
+ (x: number) => x * 2,
673
+ )
674
+
675
+ expect(quadrupled.at(0)?.get()).toBe(4)
676
+ expect(quadrupled.at(1)?.get()).toBe(8)
677
+ expect(quadrupled.at(2)?.get()).toBe(12)
678
+ expect(quadrupled.at(10)).toBeUndefined()
679
+ })
680
+
681
+ test('supports key-based access', () => {
682
+ const numbers = new List([1, 2, 3])
683
+ const doubled = new DerivedCollection(
684
+ numbers,
685
+ (x: number) => x * 2,
686
+ )
687
+ const quadrupled = doubled.deriveCollection(
688
+ (x: number) => x * 2,
689
+ )
690
+
691
+ const key0 = quadrupled.keyAt(0)
692
+ const key1 = quadrupled.keyAt(1)
693
+
694
+ expect(key0).toBeDefined()
695
+ expect(key1).toBeDefined()
696
+ // biome-ignore lint/style/noNonNullAssertion: test
697
+ expect(quadrupled.byKey(key0!)).toBeDefined()
698
+ // biome-ignore lint/style/noNonNullAssertion: test
699
+ expect(quadrupled.byKey(key1!)).toBeDefined()
700
+ // biome-ignore lint/style/noNonNullAssertion: test
701
+ expect(quadrupled.byKey(key0!)?.get()).toBe(4)
702
+ // biome-ignore lint/style/noNonNullAssertion: test
703
+ expect(quadrupled.byKey(key1!)?.get()).toBe(8)
704
+ })
705
+
706
+ test('supports iteration', () => {
707
+ const numbers = new List([1, 2, 3])
708
+ const doubled = new DerivedCollection(
709
+ numbers,
710
+ (x: number) => x * 2,
711
+ )
712
+ const quadrupled = doubled.deriveCollection(
713
+ (x: number) => x * 2,
714
+ )
715
+
716
+ const signals = [...quadrupled]
717
+ expect(signals).toHaveLength(3)
718
+ expect(signals[0].get()).toBe(4)
719
+ expect(signals[1].get()).toBe(8)
720
+ expect(signals[2].get()).toBe(12)
721
+ })
722
+ })
723
+
724
+ describe('derived collection event handling', () => {
725
+ test('emits add events when source adds items', () => {
726
+ const numbers = new List([1, 2])
727
+ const doubled = new DerivedCollection(
728
+ numbers,
729
+ (x: number) => x * 2,
730
+ )
731
+ const quadrupled = doubled.deriveCollection(
732
+ (x: number) => x * 2,
733
+ )
734
+
735
+ let addedKeys: readonly string[] = []
736
+ quadrupled.on('add', keys => {
737
+ addedKeys = keys
738
+ })
739
+
740
+ numbers.add(3)
741
+ expect(addedKeys).toHaveLength(1)
742
+ // biome-ignore lint/style/noNonNullAssertion: test
743
+ expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
744
+ })
745
+
746
+ test('emits remove events when source removes items', () => {
747
+ const numbers = new List([1, 2, 3])
748
+ const doubled = new DerivedCollection(
749
+ numbers,
750
+ (x: number) => x * 2,
751
+ )
752
+ const quadrupled = doubled.deriveCollection(
753
+ (x: number) => x * 2,
754
+ )
755
+
756
+ let removedKeys: readonly string[] = []
757
+ quadrupled.on('remove', keys => {
758
+ removedKeys = keys
759
+ })
760
+
761
+ numbers.remove(1)
762
+ expect(removedKeys).toHaveLength(1)
763
+ })
764
+
765
+ test('emits sort events when source is sorted', () => {
766
+ const numbers = new List([3, 1, 2])
767
+ const doubled = new DerivedCollection(
768
+ numbers,
769
+ (x: number) => x * 2,
770
+ )
771
+ const quadrupled = doubled.deriveCollection(
772
+ (x: number) => x * 2,
773
+ )
774
+
775
+ let sortedKeys: readonly string[] = []
776
+ quadrupled.on('sort', newOrder => {
777
+ sortedKeys = newOrder
778
+ })
779
+
780
+ numbers.sort((a, b) => a - b)
781
+ expect(sortedKeys).toHaveLength(3)
782
+ expect(quadrupled.get()).toEqual([4, 8, 12])
783
+ })
784
+ })
785
+
786
+ describe('edge cases', () => {
787
+ test('handles empty collection derivation', () => {
788
+ const empty = new List<number>([])
789
+ const emptyCollection = new DerivedCollection(
790
+ empty,
791
+ (x: number) => x * 2,
792
+ )
793
+ const derived = emptyCollection.deriveCollection(
794
+ (x: number) => x * 2,
795
+ )
796
+
797
+ expect(derived.length).toBe(0)
798
+ expect(derived.get()).toEqual([])
799
+ })
800
+
801
+ test('handles UNSET values in transformation', () => {
802
+ const list = new List([1, 2, 3])
803
+ const filtered = new DerivedCollection(list, (x: number) =>
804
+ x > 1 ? { value: x } : UNSET,
805
+ )
806
+ const doubled = filtered.deriveCollection(
807
+ (x: { value: number }) => ({ value: x.value * 2 }),
808
+ )
809
+
810
+ expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
811
+ })
812
+
813
+ test('handles complex object transformations', () => {
814
+ const items = new List([
815
+ { id: 1, data: { value: 10, active: true } },
816
+ { id: 2, data: { value: 20, active: false } },
817
+ ])
818
+
819
+ const processed = new DerivedCollection(
820
+ items,
821
+ (item: {
822
+ id: number
823
+ data: { value: number; active: boolean }
824
+ }) => ({
825
+ itemId: item.id,
826
+ processedValue: item.data.value * 2,
827
+ status: item.data.active ? 'active' : 'inactive',
828
+ }),
829
+ )
830
+
831
+ const enhanced = processed.deriveCollection(
832
+ (item: {
833
+ itemId: number
834
+ processedValue: number
835
+ status: string
836
+ }) => ({
837
+ ...item,
838
+ category: item.processedValue > 15 ? 'high' : 'low',
839
+ }),
840
+ )
841
+
842
+ expect(enhanced.at(0)?.get().itemId).toBe(1)
843
+ expect(enhanced.at(0)?.get().processedValue).toBe(20)
844
+ expect(enhanced.at(0)?.get().status).toBe('active')
845
+ expect(enhanced.at(0)?.get().category).toBe('high')
846
+ expect(enhanced.at(1)?.get().itemId).toBe(2)
847
+ expect(enhanced.at(1)?.get().processedValue).toBe(40)
848
+ expect(enhanced.at(1)?.get().status).toBe('inactive')
849
+ expect(enhanced.at(1)?.get().category).toBe('high')
850
+ })
851
+ })
852
+ })
853
+ })