@zeix/cause-effect 0.17.1 → 0.17.3

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 (57) hide show
  1. package/.ai-context.md +13 -0
  2. package/.github/copilot-instructions.md +4 -0
  3. package/.zed/settings.json +3 -0
  4. package/CLAUDE.md +41 -7
  5. package/README.md +48 -25
  6. package/archive/benchmark.ts +0 -5
  7. package/archive/collection.ts +6 -65
  8. package/archive/composite.ts +85 -0
  9. package/archive/computed.ts +18 -20
  10. package/archive/list.ts +7 -75
  11. package/archive/memo.ts +15 -15
  12. package/archive/state.ts +2 -1
  13. package/archive/store.ts +8 -78
  14. package/archive/task.ts +20 -25
  15. package/index.dev.js +508 -526
  16. package/index.js +1 -1
  17. package/index.ts +9 -11
  18. package/package.json +6 -6
  19. package/src/classes/collection.ts +70 -107
  20. package/src/classes/computed.ts +165 -149
  21. package/src/classes/list.ts +145 -107
  22. package/src/classes/ref.ts +19 -17
  23. package/src/classes/state.ts +21 -17
  24. package/src/classes/store.ts +125 -73
  25. package/src/diff.ts +2 -1
  26. package/src/effect.ts +17 -10
  27. package/src/errors.ts +14 -1
  28. package/src/resolve.ts +1 -1
  29. package/src/signal.ts +3 -2
  30. package/src/system.ts +159 -61
  31. package/src/util.ts +0 -6
  32. package/test/batch.test.ts +4 -11
  33. package/test/benchmark.test.ts +4 -2
  34. package/test/collection.test.ts +106 -107
  35. package/test/computed.test.ts +351 -112
  36. package/test/effect.test.ts +2 -2
  37. package/test/list.test.ts +62 -102
  38. package/test/ref.test.ts +128 -2
  39. package/test/state.test.ts +16 -22
  40. package/test/store.test.ts +101 -108
  41. package/test/util/dependency-graph.ts +2 -2
  42. package/tsconfig.build.json +11 -0
  43. package/tsconfig.json +5 -7
  44. package/types/index.d.ts +3 -3
  45. package/types/src/classes/collection.d.ts +9 -10
  46. package/types/src/classes/computed.d.ts +17 -20
  47. package/types/src/classes/list.d.ts +8 -6
  48. package/types/src/classes/ref.d.ts +8 -12
  49. package/types/src/classes/state.d.ts +5 -8
  50. package/types/src/classes/store.d.ts +14 -13
  51. package/types/src/effect.d.ts +1 -2
  52. package/types/src/errors.d.ts +2 -1
  53. package/types/src/signal.d.ts +3 -2
  54. package/types/src/system.d.ts +47 -34
  55. package/types/src/util.d.ts +1 -2
  56. package/src/classes/composite.ts +0 -176
  57. package/types/src/classes/composite.d.ts +0 -15
@@ -1,18 +1,20 @@
1
- import { diff, isEqual, type UnknownArray } from '../diff'
2
- import { DuplicateKeyError, validateSignalValue } from '../errors'
1
+ import { type DiffResult, diff, isEqual, type UnknownArray } from '../diff'
3
2
  import {
4
- type Cleanup,
5
- emitNotification,
6
- type Listener,
7
- type Listeners,
8
- type Notifications,
9
- notifyWatchers,
10
- subscribeActiveWatcher,
11
- type Watcher,
3
+ DuplicateKeyError,
4
+ guardMutableSignal,
5
+ validateSignalValue,
6
+ } from '../errors'
7
+ import {
8
+ batch,
9
+ notifyOf,
10
+ registerWatchCallbacks,
11
+ type SignalOptions,
12
+ subscribeTo,
13
+ UNSET,
14
+ unsubscribeAllFrom,
12
15
  } from '../system'
13
- import { isFunction, isNumber, isObjectOfType, isString, UNSET } from '../util'
16
+ import { isFunction, isNumber, isObjectOfType, isString } from '../util'
14
17
  import { type CollectionCallback, DerivedCollection } from './collection'
15
- import { Composite } from './composite'
16
18
  import { State } from './state'
17
19
 
18
20
  /* === Types === */
@@ -22,6 +24,9 @@ type ArrayToRecord<T extends UnknownArray> = {
22
24
  }
23
25
 
24
26
  type KeyConfig<T> = string | ((item: T) => string)
27
+ type ListOptions<T extends {}> = SignalOptions<T> & {
28
+ keyConfig?: KeyConfig<T>
29
+ }
25
30
 
26
31
  /* === Constants === */
27
32
 
@@ -30,32 +35,39 @@ const TYPE_LIST = 'List' as const
30
35
  /* === Class === */
31
36
 
32
37
  class List<T extends {}> {
33
- #composite: Composite<Record<string, T>, State<T>>
34
- #watchers = new Set<Watcher>()
35
- #listeners: Pick<Listeners, 'sort'> = {
36
- sort: new Set<Listener<'sort'>>(),
37
- }
38
- #order: string[] = []
38
+ #signals = new Map<string, State<T>>()
39
+ #keys: string[] = []
39
40
  #generateKey: (item: T) => string
41
+ #validate: (key: string, value: unknown) => value is T
40
42
 
41
- constructor(initialValue: T[], keyConfig?: KeyConfig<T>) {
42
- validateSignalValue('list', initialValue, Array.isArray)
43
+ constructor(initialValue: T[], options?: ListOptions<T>) {
44
+ validateSignalValue(TYPE_LIST, initialValue, Array.isArray)
43
45
 
44
46
  let keyCounter = 0
47
+ const keyConfig = options?.keyConfig
45
48
  this.#generateKey = isString(keyConfig)
46
49
  ? () => `${keyConfig}${keyCounter++}`
47
50
  : isFunction<string>(keyConfig)
48
51
  ? (item: T) => keyConfig(item)
49
52
  : () => String(keyCounter++)
50
53
 
51
- this.#composite = new Composite<ArrayToRecord<T[]>, State<T>>(
52
- this.#toRecord(initialValue),
53
- (key: string, value: unknown): value is T => {
54
- validateSignalValue(`list for key "${key}"`, value)
55
- return true
56
- },
57
- value => new State(value),
58
- )
54
+ this.#validate = (key: string, value: unknown): value is T => {
55
+ validateSignalValue(
56
+ `${TYPE_LIST} item for key "${key}"`,
57
+ value,
58
+ options?.guard,
59
+ )
60
+ return true
61
+ }
62
+
63
+ this.#change({
64
+ add: this.#toRecord(initialValue),
65
+ change: {},
66
+ remove: {},
67
+ changed: true,
68
+ })
69
+ if (options?.watched)
70
+ registerWatchCallbacks(this, options.watched, options.unwatched)
59
71
  }
60
72
 
61
73
  // Convert array to record with stable keys
@@ -66,19 +78,66 @@ class List<T extends {}> {
66
78
  const value = array[i]
67
79
  if (value === undefined) continue // Skip sparse array positions
68
80
 
69
- let key = this.#order[i]
81
+ let key = this.#keys[i]
70
82
  if (!key) {
71
83
  key = this.#generateKey(value)
72
- this.#order[i] = key
84
+ this.#keys[i] = key
73
85
  }
74
86
  record[key] = value
75
87
  }
76
88
  return record
77
89
  }
78
90
 
91
+ #add(key: string, value: T) {
92
+ if (!this.#validate(key, value)) return false
93
+
94
+ this.#signals.set(key, new State(value))
95
+ return true
96
+ }
97
+
98
+ #change(changes: DiffResult) {
99
+ // Additions
100
+ if (Object.keys(changes.add).length) {
101
+ for (const key in changes.add) this.#add(key, changes.add[key] as T)
102
+ }
103
+
104
+ // Changes
105
+ if (Object.keys(changes.change).length) {
106
+ batch(() => {
107
+ for (const key in changes.change) {
108
+ const value = changes.change[key]
109
+ if (!this.#validate(key as keyof T & string, value))
110
+ continue
111
+
112
+ const signal = this.#signals.get(key)
113
+ if (
114
+ guardMutableSignal(
115
+ `${TYPE_LIST} item "${key}"`,
116
+ value,
117
+ signal,
118
+ )
119
+ )
120
+ signal.set(value)
121
+ }
122
+ })
123
+ }
124
+
125
+ // Removals
126
+ if (Object.keys(changes.remove).length) {
127
+ for (const key in changes.remove) {
128
+ this.#signals.delete(key)
129
+ const index = this.#keys.indexOf(key)
130
+ if (index !== -1) this.#keys.splice(index, 1)
131
+ }
132
+ this.#keys = this.#keys.filter(() => true)
133
+ }
134
+
135
+ return changes.changed
136
+ }
137
+
79
138
  get #value(): T[] {
80
- return this.#order
81
- .map(key => this.#composite.signals.get(key)?.get())
139
+ return this.#keys
140
+ .map(key => this.#signals.get(key)?.get())
82
141
  .filter(v => v !== undefined) as T[]
83
142
  }
84
143
 
@@ -92,43 +151,35 @@ class List<T extends {}> {
92
151
  }
93
152
 
94
153
  *[Symbol.iterator](): IterableIterator<State<T>> {
95
- for (const key of this.#order) {
96
- const signal = this.#composite.signals.get(key)
154
+ for (const key of this.#keys) {
155
+ const signal = this.#signals.get(key)
97
156
  if (signal) yield signal as State<T>
98
157
  }
99
158
  }
100
159
 
101
160
  get length(): number {
102
- subscribeActiveWatcher(this.#watchers)
103
- return this.#order.length
161
+ subscribeTo(this)
162
+ return this.#keys.length
104
163
  }
105
164
 
106
165
  get(): T[] {
107
- subscribeActiveWatcher(this.#watchers)
166
+ subscribeTo(this)
108
167
  return this.#value
109
168
  }
110
169
 
111
170
  set(newValue: T[]): void {
112
171
  if (UNSET === newValue) {
113
- this.#composite.clear()
114
- notifyWatchers(this.#watchers)
115
- this.#watchers.clear()
172
+ this.#signals.clear()
173
+ notifyOf(this)
174
+ unsubscribeAllFrom(this)
116
175
  return
117
176
  }
118
177
 
119
- const oldValue = this.#value
120
- const changes = diff(this.#toRecord(oldValue), this.#toRecord(newValue))
121
- const removedKeys = Object.keys(changes.remove)
122
-
123
- const changed = this.#composite.change(changes)
124
- if (changed) {
125
- for (const key of removedKeys) {
126
- const index = this.#order.indexOf(key)
127
- if (index !== -1) this.#order.splice(index, 1)
128
- }
129
- this.#order = this.#order.filter(() => true)
130
- notifyWatchers(this.#watchers)
131
- }
178
+ const changes = diff(
179
+ this.#toRecord(this.#value),
180
+ this.#toRecord(newValue),
181
+ )
182
+ if (this.#change(changes)) notifyOf(this)
132
183
  }
133
184
 
134
185
  update(fn: (oldValue: T[]) => T[]): void {
@@ -136,58 +187,53 @@ class List<T extends {}> {
136
187
  }
137
188
 
138
189
  at(index: number): State<T> | undefined {
139
- return this.#composite.signals.get(this.#order[index])
190
+ return this.#signals.get(this.#keys[index])
140
191
  }
141
192
 
142
193
  keys(): IterableIterator<string> {
143
- return this.#order.values()
194
+ subscribeTo(this)
195
+ return this.#keys.values()
144
196
  }
145
197
 
146
198
  byKey(key: string): State<T> | undefined {
147
- return this.#composite.signals.get(key)
199
+ return this.#signals.get(key)
148
200
  }
149
201
 
150
202
  keyAt(index: number): string | undefined {
151
- return this.#order[index]
203
+ return this.#keys[index]
152
204
  }
153
205
 
154
206
  indexOfKey(key: string): number {
155
- return this.#order.indexOf(key)
207
+ return this.#keys.indexOf(key)
156
208
  }
157
209
 
158
210
  add(value: T): string {
159
211
  const key = this.#generateKey(value)
160
- if (this.#composite.signals.has(key))
212
+ if (this.#signals.has(key))
161
213
  throw new DuplicateKeyError('store', key, value)
162
214
 
163
- if (!this.#order.includes(key)) this.#order.push(key)
164
- const ok = this.#composite.add(key, value)
165
- if (ok) notifyWatchers(this.#watchers)
215
+ if (!this.#keys.includes(key)) this.#keys.push(key)
216
+ const ok = this.#add(key, value)
217
+ if (ok) notifyOf(this)
166
218
  return key
167
219
  }
168
220
 
169
221
  remove(keyOrIndex: string | number): void {
170
- const key = isNumber(keyOrIndex) ? this.#order[keyOrIndex] : keyOrIndex
171
- const ok = this.#composite.remove(key)
222
+ const key = isNumber(keyOrIndex) ? this.#keys[keyOrIndex] : keyOrIndex
223
+ const ok = this.#signals.delete(key)
172
224
  if (ok) {
173
225
  const index = isNumber(keyOrIndex)
174
226
  ? keyOrIndex
175
- : this.#order.indexOf(key)
176
- if (index >= 0) this.#order.splice(index, 1)
177
- this.#order = this.#order.filter(() => true)
178
- notifyWatchers(this.#watchers)
227
+ : this.#keys.indexOf(key)
228
+ if (index >= 0) this.#keys.splice(index, 1)
229
+ this.#keys = this.#keys.filter(() => true)
230
+ notifyOf(this)
179
231
  }
180
232
  }
181
233
 
182
234
  sort(compareFn?: (a: T, b: T) => number): void {
183
- const entries = this.#order
184
- .map(
185
- key =>
186
- [key, this.#composite.signals.get(key)?.get()] as [
187
- string,
188
- T,
189
- ],
190
- )
235
+ const entries = this.#keys
236
+ .map(key => [key, this.#signals.get(key)?.get()] as [string, T])
191
237
  .sort(
192
238
  isFunction(compareFn)
193
239
  ? (a, b) => compareFn(a[1], b[1])
@@ -195,15 +241,14 @@ class List<T extends {}> {
195
241
  )
196
242
  const newOrder = entries.map(([key]) => key)
197
243
 
198
- if (!isEqual(this.#order, newOrder)) {
199
- this.#order = newOrder
200
- notifyWatchers(this.#watchers)
201
- emitNotification(this.#listeners.sort, this.#order)
244
+ if (!isEqual(this.#keys, newOrder)) {
245
+ this.#keys = newOrder
246
+ notifyOf(this)
202
247
  }
203
248
  }
204
249
 
205
250
  splice(start: number, deleteCount?: number, ...items: T[]): T[] {
206
- const length = this.#order.length
251
+ const length = this.#keys.length
207
252
  const actualStart =
208
253
  start < 0 ? Math.max(0, length + start) : Math.min(start, length)
209
254
  const actualDeleteCount = Math.max(
@@ -220,15 +265,15 @@ class List<T extends {}> {
220
265
  // Collect items to delete and their keys
221
266
  for (let i = 0; i < actualDeleteCount; i++) {
222
267
  const index = actualStart + i
223
- const key = this.#order[index]
268
+ const key = this.#keys[index]
224
269
  if (key) {
225
- const signal = this.#composite.signals.get(key)
270
+ const signal = this.#signals.get(key)
226
271
  if (signal) remove[key] = signal.get() as T
227
272
  }
228
273
  }
229
274
 
230
275
  // Build new order: items before splice point
231
- const newOrder = this.#order.slice(0, actualStart)
276
+ const newOrder = this.#keys.slice(0, actualStart)
232
277
 
233
278
  // Add new items
234
279
  for (const item of items) {
@@ -238,53 +283,39 @@ class List<T extends {}> {
238
283
  }
239
284
 
240
285
  // Add items after splice point
241
- newOrder.push(...this.#order.slice(actualStart + actualDeleteCount))
286
+ newOrder.push(...this.#keys.slice(actualStart + actualDeleteCount))
242
287
 
243
288
  const changed = !!(
244
289
  Object.keys(add).length || Object.keys(remove).length
245
290
  )
246
291
 
247
292
  if (changed) {
248
- this.#composite.change({
293
+ this.#change({
249
294
  add,
250
295
  change: {} as Record<string, T>,
251
296
  remove,
252
297
  changed,
253
298
  })
254
- this.#order = newOrder.filter(() => true) // Update order array
255
- notifyWatchers(this.#watchers)
299
+ this.#keys = newOrder.filter(() => true) // Update order array
300
+ notifyOf(this)
256
301
  }
257
302
 
258
303
  return Object.values(remove)
259
304
  }
260
305
 
261
- on<K extends keyof Notifications>(type: K, listener: Listener<K>): Cleanup {
262
- if (type === 'sort') {
263
- this.#listeners.sort.add(listener as Listener<'sort'>)
264
- return () => {
265
- this.#listeners.sort.delete(listener as Listener<'sort'>)
266
- }
267
- }
268
-
269
- // For other types, delegate to the composite
270
- return this.#composite.on(
271
- type,
272
- listener as Listener<
273
- keyof Pick<Notifications, 'add' | 'remove' | 'change'>
274
- >,
275
- )
276
- }
277
-
278
306
  deriveCollection<R extends {}>(
279
307
  callback: (sourceValue: T) => R,
308
+ options?: SignalOptions<R[]>,
280
309
  ): DerivedCollection<R, T>
281
310
  deriveCollection<R extends {}>(
282
311
  callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
312
+ options?: SignalOptions<R[]>,
283
313
  ): DerivedCollection<R, T>
284
314
  deriveCollection<R extends {}>(
285
315
  callback: CollectionCallback<R, T>,
316
+ options?: SignalOptions<R[]>,
286
317
  ): DerivedCollection<R, T> {
287
- return new DerivedCollection(this, callback)
318
+ return new DerivedCollection(this, callback, options)
288
319
  }
289
320
  }
290
321
 
@@ -302,4 +333,11 @@ const isList = <T extends {}>(value: unknown): value is List<T> =>
302
333
 
303
334
  /* === Exports === */
304
335
 
305
- export { isList, List, TYPE_LIST, type ArrayToRecord, type KeyConfig }
336
+ export {
337
+ isList,
338
+ List,
339
+ TYPE_LIST,
340
+ type ArrayToRecord,
341
+ type KeyConfig,
342
+ type ListOptions,
343
+ }
@@ -1,5 +1,10 @@
1
- import { type Guard, validateSignalValue } from '../errors'
2
- import { notifyWatchers, subscribeActiveWatcher, type Watcher } from '../system'
1
+ import { validateSignalValue } from '../errors'
2
+ import {
3
+ notifyOf,
4
+ registerWatchCallbacks,
5
+ type SignalOptions,
6
+ subscribeTo,
7
+ } from '../system'
3
8
  import { isObjectOfType } from '../util'
4
9
 
5
10
  /* === Constants === */
@@ -12,23 +17,20 @@ const TYPE_REF = 'Ref'
12
17
  * Create a new ref signal.
13
18
  *
14
19
  * @since 0.17.1
20
+ * @param {T} value - Reference to external object
21
+ * @param {Guard<T>} guard - Optional guard function to validate the value
22
+ * @throws {NullishSignalValueError} - If the value is null or undefined
23
+ * @throws {InvalidSignalValueError} - If the value is invalid
15
24
  */
16
25
  class Ref<T extends {}> {
17
- #watchers = new Set<Watcher>()
18
26
  #value: T
19
27
 
20
- /**
21
- * Create a new ref signal.
22
- *
23
- * @param {T} value - Reference to external object
24
- * @param {Guard<T>} guard - Optional guard function to validate the value
25
- * @throws {NullishSignalValueError} - If the value is null or undefined
26
- * @throws {InvalidSignalValueError} - If the value is invalid
27
- */
28
- constructor(value: T, guard?: Guard<T>) {
29
- validateSignalValue('ref', value, guard)
28
+ constructor(value: T, options?: SignalOptions<T>) {
29
+ validateSignalValue(TYPE_REF, value, options?.guard)
30
30
 
31
31
  this.#value = value
32
+ if (options?.watched)
33
+ registerWatchCallbacks(this, options.watched, options.unwatched)
32
34
  }
33
35
 
34
36
  get [Symbol.toStringTag](): string {
@@ -41,15 +43,15 @@ class Ref<T extends {}> {
41
43
  * @returns {T} - Object reference
42
44
  */
43
45
  get(): T {
44
- subscribeActiveWatcher(this.#watchers)
46
+ subscribeTo(this)
45
47
  return this.#value
46
48
  }
47
49
 
48
50
  /**
49
- * Notify watchers of relevant changes in the external reference
51
+ * Notify watchers of relevant changes in the external reference.
50
52
  */
51
53
  notify(): void {
52
- notifyWatchers(this.#watchers)
54
+ notifyOf(this)
53
55
  }
54
56
  }
55
57
 
@@ -60,7 +62,7 @@ class Ref<T extends {}> {
60
62
  *
61
63
  * @since 0.17.1
62
64
  * @param {unknown} value - Value to check
63
- * @returns {boolean} - True if the value is a Ref instance, false otherwise
65
+ * @returns {boolean} - Whether the value is a Ref instance
64
66
  */
65
67
  const isRef = /*#__PURE__*/ <T extends {}>(value: unknown): value is Ref<T> =>
66
68
  isObjectOfType(value, TYPE_REF)
@@ -1,7 +1,14 @@
1
1
  import { isEqual } from '../diff'
2
2
  import { validateCallback, validateSignalValue } from '../errors'
3
- import { notifyWatchers, subscribeActiveWatcher, type Watcher } from '../system'
4
- import { isObjectOfType, UNSET } from '../util'
3
+ import {
4
+ type SignalOptions,
5
+ registerWatchCallbacks,
6
+ notifyOf,
7
+ subscribeTo,
8
+ UNSET,
9
+ unsubscribeAllFrom,
10
+ } from '../system'
11
+ import { isObjectOfType } from '../util'
5
12
 
6
13
  /* === Constants === */
7
14
 
@@ -13,22 +20,19 @@ const TYPE_STATE = 'State' as const
13
20
  * Create a new state signal.
14
21
  *
15
22
  * @since 0.17.0
23
+ * @param {T} initialValue - Initial value of the state
24
+ * @throws {NullishSignalValueError} - If the initial value is null or undefined
25
+ * @throws {InvalidSignalValueError} - If the initial value is invalid
16
26
  */
17
27
  class State<T extends {}> {
18
- #watchers = new Set<Watcher>()
19
28
  #value: T
20
29
 
21
- /**
22
- * Create a new state signal.
23
- *
24
- * @param {T} initialValue - Initial value of the state
25
- * @throws {NullishSignalValueError} - If the initial value is null or undefined
26
- * @throws {InvalidSignalValueError} - If the initial value is invalid
27
- */
28
- constructor(initialValue: T) {
29
- validateSignalValue('state', initialValue)
30
+ constructor(initialValue: T, options?: SignalOptions<T>) {
31
+ validateSignalValue(TYPE_STATE, initialValue, options?.guard)
30
32
 
31
33
  this.#value = initialValue
34
+ if (options?.watched)
35
+ registerWatchCallbacks(this, options.watched, options.unwatched)
32
36
  }
33
37
 
34
38
  get [Symbol.toStringTag](): string {
@@ -41,7 +45,7 @@ class State<T extends {}> {
41
45
  * @returns {T} - Current value of the state
42
46
  */
43
47
  get(): T {
44
- subscribeActiveWatcher(this.#watchers)
48
+ subscribeTo(this)
45
49
  return this.#value
46
50
  }
47
51
 
@@ -54,14 +58,14 @@ class State<T extends {}> {
54
58
  * @throws {InvalidSignalValueError} - If the initial value is invalid
55
59
  */
56
60
  set(newValue: T): void {
57
- validateSignalValue('state', newValue)
61
+ validateSignalValue(TYPE_STATE, newValue)
58
62
 
59
63
  if (isEqual(this.#value, newValue)) return
60
64
  this.#value = newValue
61
- notifyWatchers(this.#watchers)
65
+ notifyOf(this)
62
66
 
63
67
  // Setting to UNSET clears the watchers so the signal can be garbage collected
64
- if (UNSET === this.#value) this.#watchers.clear()
68
+ if (UNSET === this.#value) unsubscribeAllFrom(this)
65
69
  }
66
70
 
67
71
  /**
@@ -74,7 +78,7 @@ class State<T extends {}> {
74
78
  * @throws {InvalidSignalValueError} - If the initial value is invalid
75
79
  */
76
80
  update(updater: (oldValue: T) => T): void {
77
- validateCallback('state update', updater)
81
+ validateCallback(`${TYPE_STATE} update`, updater)
78
82
 
79
83
  this.set(updater(this.#value))
80
84
  }