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