@zeix/cause-effect 0.17.3 → 0.18.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 (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. package/types/src/system.d.ts +0 -78
@@ -0,0 +1,378 @@
1
+ import { DuplicateKeyError, validateSignalValue } from '../errors'
2
+ import {
3
+ activeSink,
4
+ batch,
5
+ batchDepth,
6
+ type Cleanup,
7
+ FLAG_CLEAN,
8
+ FLAG_DIRTY,
9
+ flush,
10
+ link,
11
+ type MemoNode,
12
+ propagate,
13
+ refresh,
14
+ type SinkNode,
15
+ TYPE_STORE,
16
+ untrack,
17
+ } from '../graph'
18
+ import { isObjectOfType, isRecord } from '../util'
19
+ import {
20
+ createList,
21
+ type DiffResult,
22
+ isEqual,
23
+ type List,
24
+ type UnknownRecord,
25
+ } from './list'
26
+ import { createState, type State } from './state'
27
+
28
+ /* === Types === */
29
+
30
+ type StoreOptions = {
31
+ watched?: () => Cleanup
32
+ }
33
+
34
+ type BaseStore<T extends UnknownRecord> = {
35
+ readonly [Symbol.toStringTag]: 'Store'
36
+ readonly [Symbol.isConcatSpreadable]: false
37
+ [Symbol.iterator](): IterableIterator<
38
+ [
39
+ string,
40
+ State<T[keyof T] & {}> | Store<UnknownRecord> | List<unknown & {}>,
41
+ ]
42
+ >
43
+ keys(): IterableIterator<string>
44
+ byKey<K extends keyof T & string>(
45
+ key: K,
46
+ ): T[K] extends readonly (infer U extends {})[]
47
+ ? List<U>
48
+ : T[K] extends UnknownRecord
49
+ ? Store<T[K]>
50
+ : T[K] extends unknown & {}
51
+ ? State<T[K] & {}>
52
+ : State<T[K] & {}> | undefined
53
+ get(): T
54
+ set(next: T): void
55
+ update(fn: (prev: T) => T): void
56
+ add<K extends keyof T & string>(key: K, value: T[K]): K
57
+ remove(key: string): void
58
+ }
59
+
60
+ type Store<T extends UnknownRecord> = BaseStore<T> & {
61
+ [K in keyof T]: T[K] extends readonly (infer U extends {})[]
62
+ ? List<U>
63
+ : T[K] extends UnknownRecord
64
+ ? Store<T[K]>
65
+ : T[K] extends unknown & {}
66
+ ? State<T[K] & {}>
67
+ : State<T[K] & {}> | undefined
68
+ }
69
+
70
+ /* === Functions === */
71
+
72
+ /** Diff two records and return granular changes */
73
+ function diffRecords<T extends UnknownRecord>(prev: T, next: T): DiffResult {
74
+ // Guard against non-objects that can't be diffed properly with Object.keys and 'in' operator
75
+ const prevValid = isRecord(prev) || Array.isArray(prev)
76
+ const nextValid = isRecord(next) || Array.isArray(next)
77
+ if (!prevValid || !nextValid) {
78
+ // For non-objects or non-plain objects, treat as complete change if different
79
+ const changed = !Object.is(prev, next)
80
+ return {
81
+ changed,
82
+ add: changed && nextValid ? next : {},
83
+ change: {},
84
+ remove: changed && prevValid ? prev : {},
85
+ }
86
+ }
87
+
88
+ const visited = new WeakSet()
89
+
90
+ const add = {} as UnknownRecord
91
+ const change = {} as UnknownRecord
92
+ const remove = {} as UnknownRecord
93
+ let changed = false
94
+
95
+ const prevKeys = Object.keys(prev)
96
+ const nextKeys = Object.keys(next)
97
+
98
+ // Pass 1: iterate new keys — find additions and changes
99
+ for (const key of nextKeys) {
100
+ if (key in prev) {
101
+ if (!isEqual(prev[key], next[key], visited)) {
102
+ change[key] = next[key]
103
+ changed = true
104
+ }
105
+ } else {
106
+ add[key] = next[key]
107
+ changed = true
108
+ }
109
+ }
110
+
111
+ // Pass 2: iterate old keys — find removals
112
+ for (const key of prevKeys) {
113
+ if (!(key in next)) {
114
+ remove[key] = undefined
115
+ changed = true
116
+ }
117
+ }
118
+
119
+ return { add, change, remove, changed }
120
+ }
121
+
122
+ /**
123
+ * Creates a reactive store with deeply nested reactive properties.
124
+ * Each property becomes its own signal (State for primitives, nested Store for objects, List for arrays).
125
+ * Properties are accessible directly via proxy.
126
+ *
127
+ * @since 0.15.0
128
+ * @param value - Initial object value of the store
129
+ * @param options - Optional configuration for watch lifecycle
130
+ * @returns A Store with reactive properties
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const user = createStore({ name: 'Alice', age: 30 });
135
+ * user.name.set('Bob'); // Only name subscribers react
136
+ * console.log(user.get()); // { name: 'Bob', age: 30 }
137
+ * ```
138
+ */
139
+ function createStore<T extends UnknownRecord>(
140
+ value: T,
141
+ options?: StoreOptions,
142
+ ): Store<T> {
143
+ validateSignalValue(TYPE_STORE, value, isRecord)
144
+
145
+ const signals = new Map<
146
+ string,
147
+ State<unknown & {}> | Store<UnknownRecord> | List<unknown & {}>
148
+ >()
149
+
150
+ // --- Internal helpers ---
151
+
152
+ const addSignal = (key: string, val: unknown): void => {
153
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
154
+ if (Array.isArray(val)) signals.set(key, createList(val))
155
+ else if (isRecord(val)) signals.set(key, createStore(val))
156
+ else signals.set(key, createState(val as unknown & {}))
157
+ }
158
+
159
+ // Build current value from child signals
160
+ const buildValue = (): T => {
161
+ const record = {} as UnknownRecord
162
+ signals.forEach((signal, key) => {
163
+ record[key] = signal.get()
164
+ })
165
+ return record as T
166
+ }
167
+
168
+ // Structural tracking node — not a general-purpose Memo.
169
+ // On first get(): refresh() establishes edges from child signals.
170
+ // On subsequent get(): untrack(buildValue) rebuilds without re-linking.
171
+ // Mutation methods (add/remove/set) null out sources to force re-establishment.
172
+ const node: MemoNode<T> = {
173
+ fn: buildValue,
174
+ value,
175
+ flags: FLAG_DIRTY,
176
+ sources: null,
177
+ sourcesTail: null,
178
+ sinks: null,
179
+ sinksTail: null,
180
+ equals: isEqual,
181
+ error: undefined,
182
+ }
183
+
184
+ const applyChanges = (changes: DiffResult): boolean => {
185
+ let structural = false
186
+
187
+ // Additions
188
+ for (const key in changes.add) {
189
+ addSignal(key, changes.add[key])
190
+ structural = true
191
+ }
192
+
193
+ // Changes
194
+ if (Object.keys(changes.change).length) {
195
+ batch(() => {
196
+ for (const key in changes.change) {
197
+ const val = changes.change[key]
198
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val)
199
+ const signal = signals.get(key)
200
+ if (signal) {
201
+ // Type changed (e.g. primitive → object or vice versa): replace signal
202
+ if (isRecord(val) !== isStore(signal)) {
203
+ addSignal(key, val)
204
+ structural = true
205
+ } else signal.set(val as never)
206
+ }
207
+ }
208
+ })
209
+ }
210
+
211
+ // Removals
212
+ for (const key in changes.remove) {
213
+ signals.delete(key)
214
+ structural = true
215
+ }
216
+
217
+ if (structural) {
218
+ node.sources = null
219
+ node.sourcesTail = null
220
+ }
221
+
222
+ return changes.changed
223
+ }
224
+
225
+ const watched = options?.watched
226
+ const subscribe = watched
227
+ ? () => {
228
+ if (activeSink) {
229
+ if (!node.sinks) node.stop = watched()
230
+ link(node, activeSink)
231
+ }
232
+ }
233
+ : () => {
234
+ if (activeSink) link(node, activeSink)
235
+ }
236
+
237
+ // --- Initialize ---
238
+ for (const key of Object.keys(value)) addSignal(key, value[key])
239
+
240
+ // --- Store object ---
241
+ const store: BaseStore<T> = {
242
+ [Symbol.toStringTag]: TYPE_STORE,
243
+ [Symbol.isConcatSpreadable]: false as const,
244
+
245
+ *[Symbol.iterator]() {
246
+ for (const key of Array.from(signals.keys())) {
247
+ const signal = signals.get(key)
248
+ if (signal)
249
+ yield [key, signal] as [
250
+ string,
251
+ (
252
+ | State<T[keyof T] & {}>
253
+ | Store<UnknownRecord>
254
+ | List<unknown & {}>
255
+ ),
256
+ ]
257
+ }
258
+ },
259
+
260
+ keys() {
261
+ subscribe()
262
+ return signals.keys()
263
+ },
264
+
265
+ byKey<K extends keyof T & string>(key: K) {
266
+ return signals.get(key) as T[K] extends readonly (infer U extends
267
+ {})[]
268
+ ? List<U>
269
+ : T[K] extends UnknownRecord
270
+ ? Store<T[K]>
271
+ : T[K] extends unknown & {}
272
+ ? State<T[K] & {}>
273
+ : State<T[K] & {}> | undefined
274
+ },
275
+
276
+ get() {
277
+ subscribe()
278
+ if (node.sources) {
279
+ // Fast path: edges already established, rebuild value directly
280
+ // from child signals using untrack to avoid creating spurious
281
+ // edges to the current effect/memo consumer
282
+ if (node.flags) {
283
+ node.value = untrack(buildValue)
284
+ node.flags = FLAG_CLEAN
285
+ }
286
+ } else {
287
+ // First access: use refresh() to establish child → store edges
288
+ refresh(node as unknown as SinkNode)
289
+ if (node.error) throw node.error
290
+ }
291
+ return node.value
292
+ },
293
+
294
+ set(next: T) {
295
+ // Use cached value if clean, recompute if dirty
296
+ const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
297
+
298
+ const changes = diffRecords(prev, next)
299
+ if (applyChanges(changes)) {
300
+ node.flags |= FLAG_DIRTY
301
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
302
+ if (batchDepth === 0) flush()
303
+ }
304
+ },
305
+
306
+ update(fn: (prev: T) => T) {
307
+ store.set(fn(store.get()))
308
+ },
309
+
310
+ add<K extends keyof T & string>(key: K, value: T[K]) {
311
+ if (signals.has(key))
312
+ throw new DuplicateKeyError(TYPE_STORE, key, value)
313
+ addSignal(key, value)
314
+ node.sources = null
315
+ node.sourcesTail = null
316
+ node.flags |= FLAG_DIRTY
317
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
318
+ if (batchDepth === 0) flush()
319
+ return key
320
+ },
321
+
322
+ remove(key: string) {
323
+ const ok = signals.delete(key)
324
+ if (ok) {
325
+ node.sources = null
326
+ node.sourcesTail = null
327
+ node.flags |= FLAG_DIRTY
328
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
329
+ if (batchDepth === 0) flush()
330
+ }
331
+ },
332
+ }
333
+
334
+ // --- Proxy ---
335
+ return new Proxy(store, {
336
+ get(target, prop) {
337
+ if (prop in target) return Reflect.get(target, prop)
338
+ if (typeof prop !== 'symbol')
339
+ return target.byKey(prop as keyof T & string)
340
+ },
341
+ has(target, prop) {
342
+ if (prop in target) return true
343
+ return target.byKey(String(prop) as keyof T & string) !== undefined
344
+ },
345
+ ownKeys(target) {
346
+ return Array.from(target.keys())
347
+ },
348
+ getOwnPropertyDescriptor(target, prop) {
349
+ if (prop in target)
350
+ return Reflect.getOwnPropertyDescriptor(target, prop)
351
+ if (typeof prop === 'symbol') return undefined
352
+ const signal = target.byKey(String(prop) as keyof T & string)
353
+ return signal
354
+ ? {
355
+ enumerable: true,
356
+ configurable: true,
357
+ writable: true,
358
+ value: signal,
359
+ }
360
+ : undefined
361
+ },
362
+ }) as Store<T>
363
+ }
364
+
365
+ /**
366
+ * Checks if a value is a Store signal.
367
+ *
368
+ * @since 0.15.0
369
+ * @param value - The value to check
370
+ * @returns True if the value is a Store
371
+ */
372
+ function isStore<T extends UnknownRecord>(value: unknown): value is Store<T> {
373
+ return isObjectOfType(value, TYPE_STORE)
374
+ }
375
+
376
+ /* === Exports === */
377
+
378
+ export { createStore, isStore, type Store, type StoreOptions, TYPE_STORE }
@@ -0,0 +1,174 @@
1
+ import {
2
+ validateCallback,
3
+ validateReadValue,
4
+ validateSignalValue,
5
+ } from '../errors'
6
+ import {
7
+ activeSink,
8
+ batchDepth,
9
+ type ComputedOptions,
10
+ DEFAULT_EQUALITY,
11
+ FLAG_DIRTY,
12
+ flush,
13
+ link,
14
+ propagate,
15
+ refresh,
16
+ type SinkNode,
17
+ type TaskCallback,
18
+ type TaskNode,
19
+ TYPE_TASK,
20
+ } from '../graph'
21
+ import { isAsyncFunction, isObjectOfType } from '../util'
22
+
23
+ /* === Types === */
24
+
25
+ /**
26
+ * An asynchronous reactive computation (colorless async).
27
+ * Automatically tracks dependencies and re-executes when they change.
28
+ * Provides abort semantics and pending state tracking.
29
+ *
30
+ * @template T - The type of value resolved by the task
31
+ */
32
+ type Task<T extends {}> = {
33
+ readonly [Symbol.toStringTag]: 'Task'
34
+
35
+ /**
36
+ * Gets the current value of the task.
37
+ * Returns the last resolved value, even while a new computation is pending.
38
+ * When called inside another reactive context, creates a dependency.
39
+ * @returns The current value
40
+ * @throws UnsetSignalValueError If the task value is still unset when read.
41
+ */
42
+ get(): T
43
+
44
+ /**
45
+ * Checks if the task is currently executing.
46
+ * @returns True if a computation is in progress
47
+ */
48
+ isPending(): boolean
49
+
50
+ /**
51
+ * Aborts the current computation if one is running.
52
+ * The task's AbortSignal will be triggered.
53
+ */
54
+ abort(): void
55
+ }
56
+
57
+ /* === Exported Functions === */
58
+
59
+ /**
60
+ * Creates an asynchronous reactive computation (colorless async).
61
+ * The computation automatically tracks dependencies and re-executes when they change.
62
+ * Provides abort semantics - in-flight computations are aborted when dependencies change.
63
+ *
64
+ * @since 0.18.0
65
+ * @template T - The type of value resolved by the task
66
+ * @param fn - The async computation function that receives the previous value and an AbortSignal
67
+ * @param options - Optional configuration for the task
68
+ * @param options.value - Optional initial value for reducer patterns
69
+ * @param options.equals - Optional equality function. Defaults to strict equality (`===`)
70
+ * @param options.guard - Optional type guard to validate values
71
+ * @param options.watched - Optional callback invoked when the task is first watched by an effect.
72
+ * Receives an `invalidate` function to mark the task dirty and trigger re-execution.
73
+ * Must return a cleanup function called when no effects are watching.
74
+ * @returns A Task object with get(), isPending(), and abort() methods
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const userId = createState(1);
79
+ * const user = createTask(async (prev, signal) => {
80
+ * const response = await fetch(`/api/users/${userId.get()}`, { signal });
81
+ * return response.json();
82
+ * });
83
+ *
84
+ * // When userId changes, the previous fetch is aborted
85
+ * userId.set(2);
86
+ * ```
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // Check pending state
91
+ * if (user.isPending()) {
92
+ * console.log('Loading...');
93
+ * }
94
+ * ```
95
+ */
96
+ function createTask<T extends {}>(
97
+ fn: (prev: T, signal: AbortSignal) => Promise<T>,
98
+ options: ComputedOptions<T> & { value: T },
99
+ ): Task<T>
100
+ function createTask<T extends {}>(
101
+ fn: TaskCallback<T>,
102
+ options?: ComputedOptions<T>,
103
+ ): Task<T>
104
+ function createTask<T extends {}>(
105
+ fn: TaskCallback<T>,
106
+ options?: ComputedOptions<T>,
107
+ ): Task<T> {
108
+ validateCallback(TYPE_TASK, fn, isAsyncFunction)
109
+ if (options?.value !== undefined)
110
+ validateSignalValue(TYPE_TASK, options.value, options?.guard)
111
+
112
+ const node: TaskNode<T> = {
113
+ fn,
114
+ value: options?.value as T,
115
+ sources: null,
116
+ sourcesTail: null,
117
+ sinks: null,
118
+ sinksTail: null,
119
+ flags: FLAG_DIRTY,
120
+ equals: options?.equals ?? DEFAULT_EQUALITY,
121
+ controller: undefined,
122
+ error: undefined,
123
+ stop: undefined,
124
+ }
125
+
126
+ const watched = options?.watched
127
+ const subscribe = watched
128
+ ? () => {
129
+ if (activeSink) {
130
+ if (!node.sinks)
131
+ node.stop = watched(() => {
132
+ node.flags |= FLAG_DIRTY
133
+ for (let e = node.sinks; e; e = e.nextSink)
134
+ propagate(e.sink)
135
+ if (batchDepth === 0) flush()
136
+ })
137
+ link(node, activeSink)
138
+ }
139
+ }
140
+ : () => {
141
+ if (activeSink) link(node, activeSink)
142
+ }
143
+
144
+ return {
145
+ [Symbol.toStringTag]: TYPE_TASK,
146
+ get(): T {
147
+ subscribe()
148
+ refresh(node as unknown as SinkNode)
149
+ if (node.error) throw node.error
150
+ validateReadValue(TYPE_TASK, node.value)
151
+ return node.value
152
+ },
153
+ isPending(): boolean {
154
+ return !!node.controller
155
+ },
156
+ abort(): void {
157
+ node.controller?.abort()
158
+ node.controller = undefined
159
+ },
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Checks if a value is a Task signal.
165
+ *
166
+ * @since 0.18.0
167
+ * @param value - The value to check
168
+ * @returns True if the value is a Task
169
+ */
170
+ function isTask<T extends {} = unknown & {}>(value: unknown): value is Task<T> {
171
+ return isObjectOfType(value, TYPE_TASK)
172
+ }
173
+
174
+ export { createTask, isTask, type Task }