@zeix/cause-effect 0.18.0 → 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.
- package/.ai-context.md +14 -3
- package/.github/copilot-instructions.md +15 -5
- package/ARCHITECTURE.md +15 -13
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +9 -7
- package/README.md +23 -5
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +276 -222
- package/index.js +1 -1
- package/index.ts +4 -2
- package/package.json +2 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/graph.ts +13 -2
- package/src/nodes/collection.ts +166 -128
- package/src/nodes/list.ts +105 -104
- package/src/nodes/memo.ts +31 -3
- package/src/nodes/sensor.ts +27 -17
- package/src/nodes/state.ts +2 -2
- package/src/nodes/store.ts +55 -60
- package/src/nodes/task.ts +31 -3
- package/test/collection.test.ts +40 -51
- package/test/memo.test.ts +194 -0
- package/test/task.test.ts +134 -0
- package/types/index.d.ts +5 -5
- package/types/src/graph.d.ts +12 -2
- package/types/src/nodes/collection.d.ts +12 -7
- package/types/src/nodes/list.d.ts +12 -11
- package/types/src/nodes/memo.d.ts +6 -0
- package/types/src/nodes/sensor.d.ts +15 -9
- package/types/src/nodes/store.d.ts +4 -4
- package/types/src/nodes/task.d.ts +6 -0
- package/COLLECTION_REFACTORING.md +0 -161
package/src/nodes/collection.ts
CHANGED
|
@@ -15,12 +15,13 @@ import {
|
|
|
15
15
|
refresh,
|
|
16
16
|
type Signal,
|
|
17
17
|
type SinkNode,
|
|
18
|
+
SKIP_EQUALITY,
|
|
18
19
|
TYPE_COLLECTION,
|
|
19
20
|
untrack,
|
|
20
21
|
} from '../graph'
|
|
21
|
-
import { isAsyncFunction,
|
|
22
|
+
import { isAsyncFunction, isObjectOfType, isSyncFunction } from '../util'
|
|
22
23
|
import {
|
|
23
|
-
|
|
24
|
+
getKeyGenerator,
|
|
24
25
|
isList,
|
|
25
26
|
type KeyConfig,
|
|
26
27
|
keysEqual,
|
|
@@ -57,14 +58,20 @@ type Collection<T extends {}> = {
|
|
|
57
58
|
readonly length: number
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
type CollectionChanges<T> = {
|
|
62
|
+
add?: T[]
|
|
63
|
+
change?: T[]
|
|
64
|
+
remove?: T[]
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
type CollectionOptions<T extends {}> = {
|
|
61
68
|
value?: T[]
|
|
62
69
|
keyConfig?: KeyConfig<T>
|
|
63
|
-
createItem?: (
|
|
70
|
+
createItem?: (value: T) => Signal<T>
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
type CollectionCallback = (
|
|
67
|
-
|
|
73
|
+
type CollectionCallback<T extends {}> = (
|
|
74
|
+
apply: (changes: CollectionChanges<T>) => void,
|
|
68
75
|
) => Cleanup
|
|
69
76
|
|
|
70
77
|
/* === Functions === */
|
|
@@ -99,6 +106,7 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
99
106
|
|
|
100
107
|
const isAsync = isAsyncFunction(callback)
|
|
101
108
|
const signals = new Map<string, Memo<T>>()
|
|
109
|
+
let keys: string[] = []
|
|
102
110
|
|
|
103
111
|
const addSignal = (key: string): void => {
|
|
104
112
|
const signal = isAsync
|
|
@@ -121,68 +129,100 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
121
129
|
signals.set(key, signal as Memo<T>)
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
// Sync
|
|
132
|
+
// Sync signals map with source keys, reading source.keys()
|
|
125
133
|
// to establish a graph edge from source → this node.
|
|
126
|
-
// Intentionally side-effectful: mutates the private signals map
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
// Intentionally side-effectful: mutates the private signals map and keys
|
|
135
|
+
// array. The side effects are idempotent and scoped to private state.
|
|
136
|
+
function syncKeys(): void {
|
|
137
|
+
const nextKeys = Array.from(source.keys())
|
|
138
|
+
|
|
139
|
+
if (!keysEqual(keys, nextKeys)) {
|
|
140
|
+
const a = new Set(keys)
|
|
141
|
+
const b = new Set(nextKeys)
|
|
142
|
+
|
|
143
|
+
for (const key of keys) if (!b.has(key)) signals.delete(key)
|
|
144
|
+
for (const key of nextKeys) if (!a.has(key)) addSignal(key)
|
|
145
|
+
keys = nextKeys
|
|
146
|
+
|
|
147
|
+
// Force re-establishment of edges on next refresh() so new
|
|
148
|
+
// child signals are properly linked to this node
|
|
149
|
+
node.sources = null
|
|
150
|
+
node.sourcesTail = null
|
|
140
151
|
}
|
|
152
|
+
}
|
|
141
153
|
|
|
142
|
-
|
|
154
|
+
// Build current value from child signals; syncKeys runs first to
|
|
155
|
+
// ensure the signals map is up to date and — during refresh() —
|
|
156
|
+
// to establish the graph edge from source → this node.
|
|
157
|
+
function buildValue(): T[] {
|
|
158
|
+
syncKeys()
|
|
159
|
+
const result: T[] = []
|
|
160
|
+
for (const key of keys) {
|
|
161
|
+
try {
|
|
162
|
+
const v = signals.get(key)?.get()
|
|
163
|
+
if (v != null) result.push(v)
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// Skip pending async items; rethrow real errors
|
|
166
|
+
if (!(e instanceof UnsetSignalValueError)) throw e
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result
|
|
143
170
|
}
|
|
144
171
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
172
|
+
// Shallow reference equality for value arrays — prevents unnecessary
|
|
173
|
+
// downstream propagation when re-evaluation produces the same item references
|
|
174
|
+
const valuesEqual = (a: T[], b: T[]): boolean => {
|
|
175
|
+
if (a.length !== b.length) return false
|
|
176
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Structural tracking node — mirrors the List/Store/createCollection pattern.
|
|
181
|
+
// fn (buildValue) syncs keys then reads child signals to produce T[].
|
|
182
|
+
// Keys are tracked separately in a local variable.
|
|
183
|
+
const node: MemoNode<T[]> = {
|
|
184
|
+
fn: buildValue,
|
|
150
185
|
value: [],
|
|
151
186
|
flags: FLAG_DIRTY,
|
|
152
187
|
sources: null,
|
|
153
188
|
sourcesTail: null,
|
|
154
189
|
sinks: null,
|
|
155
190
|
sinksTail: null,
|
|
156
|
-
equals:
|
|
191
|
+
equals: valuesEqual,
|
|
157
192
|
error: undefined,
|
|
158
193
|
}
|
|
159
194
|
|
|
160
|
-
|
|
161
|
-
function ensureSynced(): string[] {
|
|
195
|
+
function ensureFresh(): void {
|
|
162
196
|
if (node.sources) {
|
|
163
197
|
if (node.flags) {
|
|
164
|
-
node.value = untrack(
|
|
198
|
+
node.value = untrack(buildValue)
|
|
165
199
|
node.flags = FLAG_CLEAN
|
|
200
|
+
// syncKeys may have nulled sources if keys changed —
|
|
201
|
+
// re-run with refresh() to establish edges to new child signals
|
|
202
|
+
if (!node.sources) {
|
|
203
|
+
node.flags = FLAG_DIRTY
|
|
204
|
+
refresh(node as unknown as SinkNode)
|
|
205
|
+
if (node.error) throw node.error
|
|
206
|
+
}
|
|
166
207
|
}
|
|
167
208
|
} else {
|
|
168
209
|
refresh(node as unknown as SinkNode)
|
|
169
210
|
if (node.error) throw node.error
|
|
170
211
|
}
|
|
171
|
-
return node.value
|
|
172
212
|
}
|
|
173
213
|
|
|
174
214
|
// Initialize signals for current source keys
|
|
175
215
|
const initialKeys = Array.from(source.keys())
|
|
176
216
|
for (const key of initialKeys) addSignal(key)
|
|
177
|
-
|
|
178
|
-
// Keep FLAG_DIRTY so the first refresh() establishes
|
|
217
|
+
keys = initialKeys
|
|
218
|
+
// Keep FLAG_DIRTY so the first refresh() establishes edges
|
|
179
219
|
|
|
180
220
|
const collection: Collection<T> = {
|
|
181
221
|
[Symbol.toStringTag]: TYPE_COLLECTION,
|
|
182
222
|
[Symbol.isConcatSpreadable]: true as const,
|
|
183
223
|
|
|
184
224
|
*[Symbol.iterator]() {
|
|
185
|
-
for (const key of
|
|
225
|
+
for (const key of keys) {
|
|
186
226
|
const signal = signals.get(key)
|
|
187
227
|
if (signal) yield signal
|
|
188
228
|
}
|
|
@@ -190,32 +230,24 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
190
230
|
|
|
191
231
|
get length() {
|
|
192
232
|
if (activeSink) link(node, activeSink)
|
|
193
|
-
|
|
233
|
+
ensureFresh()
|
|
234
|
+
return keys.length
|
|
194
235
|
},
|
|
195
236
|
|
|
196
237
|
keys() {
|
|
197
238
|
if (activeSink) link(node, activeSink)
|
|
198
|
-
|
|
239
|
+
ensureFresh()
|
|
240
|
+
return keys.values()
|
|
199
241
|
},
|
|
200
242
|
|
|
201
243
|
get() {
|
|
202
244
|
if (activeSink) link(node, activeSink)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
for (const key of currentKeys) {
|
|
206
|
-
try {
|
|
207
|
-
const v = signals.get(key)?.get()
|
|
208
|
-
if (v != null) result.push(v)
|
|
209
|
-
} catch (e) {
|
|
210
|
-
// Skip pending async items; rethrow real errors
|
|
211
|
-
if (!(e instanceof UnsetSignalValueError)) throw e
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return result
|
|
245
|
+
ensureFresh()
|
|
246
|
+
return node.value
|
|
215
247
|
},
|
|
216
248
|
|
|
217
249
|
at(index: number) {
|
|
218
|
-
return signals.get(
|
|
250
|
+
return signals.get(keys[index])
|
|
219
251
|
},
|
|
220
252
|
|
|
221
253
|
byKey(key: string) {
|
|
@@ -223,11 +255,11 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
223
255
|
},
|
|
224
256
|
|
|
225
257
|
keyAt(index: number) {
|
|
226
|
-
return
|
|
258
|
+
return keys[index]
|
|
227
259
|
},
|
|
228
260
|
|
|
229
261
|
indexOfKey(key: string) {
|
|
230
|
-
return
|
|
262
|
+
return keys.indexOf(key)
|
|
231
263
|
},
|
|
232
264
|
|
|
233
265
|
deriveCollection<R extends {}>(
|
|
@@ -247,37 +279,32 @@ function deriveCollection<T extends {}, U extends {}>(
|
|
|
247
279
|
|
|
248
280
|
/**
|
|
249
281
|
* Creates an externally-driven Collection with a watched lifecycle.
|
|
250
|
-
* Items are managed
|
|
282
|
+
* Items are managed via the `applyChanges(changes)` helper passed to the watched callback.
|
|
251
283
|
* The collection activates when first accessed by an effect and deactivates when no longer watched.
|
|
252
284
|
*
|
|
253
285
|
* @since 0.18.0
|
|
254
|
-
* @param
|
|
286
|
+
* @param watched - Callback invoked when the collection starts being watched, receives applyChanges helper
|
|
255
287
|
* @param options - Optional configuration including initial value, key generation, and item signal creation
|
|
256
288
|
* @returns A read-only Collection signal
|
|
257
289
|
*/
|
|
258
290
|
function createCollection<T extends {}>(
|
|
259
|
-
|
|
291
|
+
watched: CollectionCallback<T>,
|
|
260
292
|
options?: CollectionOptions<T>,
|
|
261
293
|
): Collection<T> {
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
validateCallback(TYPE_COLLECTION, start)
|
|
294
|
+
const value = options?.value ?? []
|
|
295
|
+
if (value.length) validateSignalValue(TYPE_COLLECTION, value, Array.isArray)
|
|
296
|
+
validateCallback(TYPE_COLLECTION, watched, isSyncFunction)
|
|
266
297
|
|
|
267
298
|
const signals = new Map<string, Signal<T>>()
|
|
268
299
|
const keys: string[] = []
|
|
300
|
+
const itemToKey = new Map<T, string>()
|
|
269
301
|
|
|
270
|
-
|
|
271
|
-
const keyConfig = options?.keyConfig
|
|
272
|
-
const generateKey: (item: T) => string =
|
|
273
|
-
typeof keyConfig === 'string'
|
|
274
|
-
? () => `${keyConfig}${keyCounter++}`
|
|
275
|
-
: isFunction<string>(keyConfig)
|
|
276
|
-
? (item: T) => keyConfig(item)
|
|
277
|
-
: () => String(keyCounter++)
|
|
302
|
+
const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
|
|
278
303
|
|
|
279
|
-
const
|
|
280
|
-
|
|
304
|
+
const resolveKey = (item: T): string | undefined =>
|
|
305
|
+
itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined)
|
|
306
|
+
|
|
307
|
+
const itemFactory = options?.createItem ?? createState
|
|
281
308
|
|
|
282
309
|
// Build current value from child signals
|
|
283
310
|
function buildValue(): T[] {
|
|
@@ -296,68 +323,87 @@ function createCollection<T extends {}>(
|
|
|
296
323
|
|
|
297
324
|
const node: MemoNode<T[]> = {
|
|
298
325
|
fn: buildValue,
|
|
299
|
-
value
|
|
326
|
+
value,
|
|
300
327
|
flags: FLAG_DIRTY,
|
|
301
328
|
sources: null,
|
|
302
329
|
sourcesTail: null,
|
|
303
330
|
sinks: null,
|
|
304
331
|
sinksTail: null,
|
|
305
|
-
equals:
|
|
332
|
+
equals: SKIP_EQUALITY, // Always rebuild — structural changes are managed externally
|
|
306
333
|
error: undefined,
|
|
307
334
|
}
|
|
308
335
|
|
|
309
|
-
/** Apply external changes to the collection */
|
|
310
|
-
function applyChanges(changes: DiffResult): void {
|
|
311
|
-
if (!changes.changed) return
|
|
312
|
-
let structural = false
|
|
313
|
-
|
|
314
|
-
batch(() => {
|
|
315
|
-
// Additions
|
|
316
|
-
for (const key in changes.add) {
|
|
317
|
-
const value = changes.add[key] as T
|
|
318
|
-
signals.set(key, itemFactory(key, value))
|
|
319
|
-
if (!keys.includes(key)) keys.push(key)
|
|
320
|
-
structural = true
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Changes — only for State signals
|
|
324
|
-
for (const key in changes.change) {
|
|
325
|
-
const signal = signals.get(key)
|
|
326
|
-
if (signal && isState(signal)) {
|
|
327
|
-
signal.set(changes.change[key] as T)
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Removals
|
|
332
|
-
for (const key in changes.remove) {
|
|
333
|
-
signals.delete(key)
|
|
334
|
-
const index = keys.indexOf(key)
|
|
335
|
-
if (index !== -1) keys.splice(index, 1)
|
|
336
|
-
structural = true
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (structural) {
|
|
340
|
-
node.sources = null
|
|
341
|
-
node.sourcesTail = null
|
|
342
|
-
}
|
|
343
|
-
// Reset CHECK/RUNNING before propagate; mark DIRTY so next get() rebuilds
|
|
344
|
-
node.flags = FLAG_CLEAN
|
|
345
|
-
propagate(node as unknown as SinkNode)
|
|
346
|
-
node.flags |= FLAG_DIRTY
|
|
347
|
-
})
|
|
348
|
-
}
|
|
349
|
-
|
|
350
336
|
// Initialize signals for initial value
|
|
351
|
-
for (const item of
|
|
337
|
+
for (const item of value) {
|
|
352
338
|
const key = generateKey(item)
|
|
353
|
-
signals.set(key, itemFactory(
|
|
339
|
+
signals.set(key, itemFactory(item))
|
|
340
|
+
itemToKey.set(item, key)
|
|
354
341
|
keys.push(key)
|
|
355
342
|
}
|
|
356
|
-
node.value =
|
|
343
|
+
node.value = value
|
|
357
344
|
node.flags = FLAG_DIRTY // First refresh() will establish child edges
|
|
358
345
|
|
|
359
|
-
function
|
|
360
|
-
if (
|
|
346
|
+
function subscribe(): void {
|
|
347
|
+
if (activeSink) {
|
|
348
|
+
if (!node.sinks)
|
|
349
|
+
node.stop = watched((changes: CollectionChanges<T>): void => {
|
|
350
|
+
const { add, change, remove } = changes
|
|
351
|
+
if (!add?.length && !change?.length && !remove?.length)
|
|
352
|
+
return
|
|
353
|
+
let structural = false
|
|
354
|
+
|
|
355
|
+
batch(() => {
|
|
356
|
+
// Additions
|
|
357
|
+
if (add) {
|
|
358
|
+
for (const item of add) {
|
|
359
|
+
const key = generateKey(item)
|
|
360
|
+
signals.set(key, itemFactory(item))
|
|
361
|
+
itemToKey.set(item, key)
|
|
362
|
+
if (!keys.includes(key)) keys.push(key)
|
|
363
|
+
structural = true
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Changes — only for State signals
|
|
368
|
+
if (change) {
|
|
369
|
+
for (const item of change) {
|
|
370
|
+
const key = resolveKey(item)
|
|
371
|
+
if (!key) continue
|
|
372
|
+
const signal = signals.get(key)
|
|
373
|
+
if (signal && isState(signal)) {
|
|
374
|
+
// Update reverse map: remove old reference, add new
|
|
375
|
+
itemToKey.delete(signal.get())
|
|
376
|
+
signal.set(item)
|
|
377
|
+
itemToKey.set(item, key)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Removals
|
|
383
|
+
if (remove) {
|
|
384
|
+
for (const item of remove) {
|
|
385
|
+
const key = resolveKey(item)
|
|
386
|
+
if (!key) continue
|
|
387
|
+
itemToKey.delete(item)
|
|
388
|
+
signals.delete(key)
|
|
389
|
+
const index = keys.indexOf(key)
|
|
390
|
+
if (index !== -1) keys.splice(index, 1)
|
|
391
|
+
structural = true
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (structural) {
|
|
396
|
+
node.sources = null
|
|
397
|
+
node.sourcesTail = null
|
|
398
|
+
}
|
|
399
|
+
// Mark DIRTY so next get() rebuilds; propagate to sinks
|
|
400
|
+
node.flags = FLAG_DIRTY
|
|
401
|
+
for (let e = node.sinks; e; e = e.nextSink)
|
|
402
|
+
propagate(e.sink)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
link(node, activeSink)
|
|
406
|
+
}
|
|
361
407
|
}
|
|
362
408
|
|
|
363
409
|
const collection: Collection<T> = {
|
|
@@ -372,26 +418,17 @@ function createCollection<T extends {}>(
|
|
|
372
418
|
},
|
|
373
419
|
|
|
374
420
|
get length() {
|
|
375
|
-
|
|
376
|
-
startWatching()
|
|
377
|
-
link(node, activeSink)
|
|
378
|
-
}
|
|
421
|
+
subscribe()
|
|
379
422
|
return keys.length
|
|
380
423
|
},
|
|
381
424
|
|
|
382
425
|
keys() {
|
|
383
|
-
|
|
384
|
-
startWatching()
|
|
385
|
-
link(node, activeSink)
|
|
386
|
-
}
|
|
426
|
+
subscribe()
|
|
387
427
|
return keys.values()
|
|
388
428
|
},
|
|
389
429
|
|
|
390
430
|
get() {
|
|
391
|
-
|
|
392
|
-
startWatching()
|
|
393
|
-
link(node, activeSink)
|
|
394
|
-
}
|
|
431
|
+
subscribe()
|
|
395
432
|
if (node.sources) {
|
|
396
433
|
if (node.flags) {
|
|
397
434
|
node.value = untrack(buildValue)
|
|
@@ -468,6 +505,7 @@ export {
|
|
|
468
505
|
isCollectionSource,
|
|
469
506
|
type Collection,
|
|
470
507
|
type CollectionCallback,
|
|
508
|
+
type CollectionChanges,
|
|
471
509
|
type CollectionOptions,
|
|
472
510
|
type CollectionSource,
|
|
473
511
|
type DeriveCollectionCallback,
|