@silvery/tea 0.3.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.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * AsyncIterable stream helpers for event-driven TUI architecture.
3
+ *
4
+ * These are pure functions over AsyncIterables - no EventEmitters, no callbacks.
5
+ * All helpers properly handle cleanup via return() on early break.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const keys = term.keys()
10
+ * const resizes = term.resizes()
11
+ *
12
+ * // Merge multiple sources
13
+ * const events = merge(
14
+ * map(keys, k => ({ type: 'key', ...k })),
15
+ * map(resizes, r => ({ type: 'resize', ...r }))
16
+ * )
17
+ *
18
+ * // Consume until ctrl+c
19
+ * for await (const event of events) {
20
+ * if (event.type === 'key' && event.key === 'ctrl+c') break
21
+ * }
22
+ * ```
23
+ */
24
+
25
+ /**
26
+ * Merge multiple AsyncIterables into one.
27
+ *
28
+ * Values are emitted in arrival order (first-come). When all sources complete,
29
+ * the merged iterable completes. If any source throws, the error propagates
30
+ * and remaining sources are cleaned up.
31
+ *
32
+ * IMPORTANT: Each call to merge() creates a fresh iterable. Don't share
33
+ * the same merged iterable between multiple consumers.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const merged = merge(keys, resizes, ticks)
38
+ * for await (const event of merged) {
39
+ * // Process events from any source
40
+ * }
41
+ * ```
42
+ */
43
+ export async function* merge<T>(...sources: AsyncIterable<T>[]): AsyncGenerator<T, void, undefined> {
44
+ if (sources.length === 0) return
45
+
46
+ // Track active iterators and their pending promises
47
+ const iterators = sources.map((source) => source[Symbol.asyncIterator]())
48
+ const pending = new Map<number, Promise<{ index: number; result: IteratorResult<T, unknown> }>>()
49
+
50
+ async function nextWithIndex(idx: number): Promise<{ index: number; result: IteratorResult<T, unknown> }> {
51
+ const iterator = iterators[idx]
52
+ if (!iterator) throw new Error(`No iterator at index ${idx}`)
53
+ const result = await iterator.next()
54
+ return { index: idx, result }
55
+ }
56
+
57
+ // Start all iterators
58
+ for (let i = 0; i < iterators.length; i++) {
59
+ pending.set(i, nextWithIndex(i))
60
+ }
61
+
62
+ try {
63
+ while (pending.size > 0) {
64
+ // Race all pending promises
65
+ const { index, result } = await Promise.race(pending.values())
66
+
67
+ if (result.done) {
68
+ // This source is exhausted, remove it
69
+ pending.delete(index)
70
+ } else {
71
+ // Yield the value and request next from this source
72
+ yield result.value
73
+ pending.set(index, nextWithIndex(index))
74
+ }
75
+ }
76
+ } finally {
77
+ // Clean up all iterators on early exit or error
78
+ await Promise.all(iterators.map((it) => (it.return ? it.return() : Promise.resolve())))
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Transform each value from an AsyncIterable.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const keyEvents = map(keys, k => ({ type: 'key' as const, key: k }))
88
+ * ```
89
+ */
90
+ export async function* map<T, U>(source: AsyncIterable<T>, fn: (value: T) => U): AsyncGenerator<U, void, undefined> {
91
+ const iterator = source[Symbol.asyncIterator]()
92
+ try {
93
+ // Use the iterator directly to avoid double-iteration
94
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
95
+ yield fn(value)
96
+ }
97
+ } finally {
98
+ if (iterator.return) {
99
+ await iterator.return()
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Filter values from an AsyncIterable.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * const letters = filter(keys, k => k.key.length === 1)
110
+ * ```
111
+ */
112
+ export async function* filter<T>(
113
+ source: AsyncIterable<T>,
114
+ predicate: (value: T) => boolean,
115
+ ): AsyncGenerator<T, void, undefined> {
116
+ const iterator = source[Symbol.asyncIterator]()
117
+ try {
118
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
119
+ if (predicate(value)) {
120
+ yield value
121
+ }
122
+ }
123
+ } finally {
124
+ if (iterator.return) {
125
+ await iterator.return()
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Filter and transform in one pass (type narrowing).
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const keyEvents = filterMap(events, e =>
136
+ * e.type === 'key' ? e : undefined
137
+ * )
138
+ * ```
139
+ */
140
+ export async function* filterMap<T, U>(
141
+ source: AsyncIterable<T>,
142
+ fn: (value: T) => U | undefined,
143
+ ): AsyncGenerator<U, void, undefined> {
144
+ const iterator = source[Symbol.asyncIterator]()
145
+ try {
146
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
147
+ const mapped = fn(value)
148
+ if (mapped !== undefined) {
149
+ yield mapped
150
+ }
151
+ }
152
+ } finally {
153
+ if (iterator.return) {
154
+ await iterator.return()
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Take values until an AbortSignal fires.
161
+ *
162
+ * When the signal aborts, the iterator completes gracefully (no error thrown).
163
+ * The source iterator is properly cleaned up.
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * const controller = new AbortController()
168
+ * const events = takeUntil(allEvents, controller.signal)
169
+ *
170
+ * // Later: controller.abort() will end the iteration
171
+ * ```
172
+ */
173
+ export async function* takeUntil<T>(source: AsyncIterable<T>, signal: AbortSignal): AsyncGenerator<T, void, undefined> {
174
+ if (signal.aborted) return
175
+
176
+ const iterator = source[Symbol.asyncIterator]()
177
+
178
+ // Create a promise that resolves when signal aborts
179
+ let abortResolve: () => void
180
+ const abortPromise = new Promise<void>((resolve) => {
181
+ abortResolve = resolve
182
+ })
183
+ const onAbort = () => abortResolve()
184
+ signal.addEventListener("abort", onAbort, { once: true })
185
+
186
+ try {
187
+ while (!signal.aborted) {
188
+ // Race between next value and abort
189
+ const result = await Promise.race([
190
+ iterator.next(),
191
+ abortPromise.then(() => ({ done: true, value: undefined }) as const),
192
+ ])
193
+
194
+ if (result.done) break
195
+ yield result.value as T
196
+ }
197
+ } finally {
198
+ signal.removeEventListener("abort", onAbort)
199
+ if (iterator.return) {
200
+ await iterator.return()
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Take the first n values from an AsyncIterable.
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * const firstThree = take(events, 3)
211
+ * ```
212
+ */
213
+ export async function* take<T>(source: AsyncIterable<T>, count: number): AsyncGenerator<T, void, undefined> {
214
+ if (count <= 0) return
215
+
216
+ const iterator = source[Symbol.asyncIterator]()
217
+ let taken = 0
218
+
219
+ try {
220
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
221
+ yield value
222
+ taken++
223
+ if (taken >= count) break
224
+ }
225
+ } finally {
226
+ if (iterator.return) {
227
+ await iterator.return()
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Create an AsyncIterable from an array (useful for testing).
234
+ *
235
+ * @example
236
+ * ```typescript
237
+ * const events = fromArray([
238
+ * { type: 'key', key: 'j' },
239
+ * { type: 'key', key: 'k' },
240
+ * ])
241
+ * ```
242
+ */
243
+ export async function* fromArray<T>(items: T[]): AsyncGenerator<T, void, undefined> {
244
+ for (const item of items) {
245
+ yield item
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Create an AsyncIterable that yields after a delay (useful for testing).
251
+ *
252
+ * @example
253
+ * ```typescript
254
+ * const delayed = fromArrayWithDelay([1, 2, 3], 100) // 100ms between each
255
+ * ```
256
+ */
257
+ export async function* fromArrayWithDelay<T>(items: T[], delayMs: number): AsyncGenerator<T, void, undefined> {
258
+ for (const item of items) {
259
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
260
+ yield item
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Throttle high-frequency sources.
266
+ *
267
+ * Emits the first value immediately, then ignores values for the specified
268
+ * duration. After the duration, the next value is emitted and the cycle repeats.
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * const throttled = throttle(mouseMoves, 16) // ~60fps
273
+ * ```
274
+ */
275
+ export async function* throttle<T>(source: AsyncIterable<T>, ms: number): AsyncGenerator<T, void, undefined> {
276
+ const iterator = source[Symbol.asyncIterator]()
277
+ let lastEmit = 0
278
+
279
+ try {
280
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
281
+ const now = Date.now()
282
+ if (now - lastEmit >= ms) {
283
+ lastEmit = now
284
+ yield value
285
+ }
286
+ }
287
+ } finally {
288
+ if (iterator.return) {
289
+ await iterator.return()
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Debounce values - only emit after source is quiet for specified duration.
296
+ *
297
+ * NOTE: With pull-based AsyncIterables, true debouncing is complex. This
298
+ * implementation collects all values and only yields the final value after
299
+ * the source completes and quiet period passes. For real-time debouncing,
300
+ * consider using a push-based pattern or EventEmitter.
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * const debounced = debounce(searchInput, 300) // Yields last value after source ends + delay
305
+ * ```
306
+ */
307
+ export async function* debounce<T>(source: AsyncIterable<T>, ms: number): AsyncGenerator<T, void, undefined> {
308
+ const iterator = source[Symbol.asyncIterator]()
309
+ let last: { value: T } | undefined
310
+
311
+ try {
312
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
313
+ last = { value }
314
+ }
315
+
316
+ if (last) {
317
+ await new Promise((resolve) => setTimeout(resolve, ms))
318
+ yield last.value
319
+ }
320
+ } finally {
321
+ if (iterator.return) {
322
+ await iterator.return()
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Collect values into batches of specified size.
329
+ *
330
+ * @throws {Error} If size is not positive
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * const batched = batch(events, 10) // Emit arrays of 10 events
335
+ * ```
336
+ */
337
+ export function batch<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> {
338
+ if (size <= 0) throw new Error("Batch size must be positive")
339
+ return batchImpl(source, size)
340
+ }
341
+
342
+ async function* batchImpl<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> {
343
+ const iterator = source[Symbol.asyncIterator]()
344
+ let buffer: T[] = []
345
+
346
+ try {
347
+ for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
348
+ buffer.push(value)
349
+ if (buffer.length >= size) {
350
+ yield buffer
351
+ buffer = []
352
+ }
353
+ }
354
+ // Emit remaining items
355
+ if (buffer.length > 0) {
356
+ yield buffer
357
+ }
358
+ } finally {
359
+ if (iterator.return) {
360
+ await iterator.return()
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Concatenate multiple AsyncIterables in sequence.
367
+ *
368
+ * @example
369
+ * ```typescript
370
+ * const all = concat(header, body, footer)
371
+ * ```
372
+ */
373
+ export async function* concat<T>(...sources: AsyncIterable<T>[]): AsyncGenerator<T, void, undefined> {
374
+ for (const source of sources) {
375
+ yield* source
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Zip multiple AsyncIterables together.
381
+ * Completes when the shortest source completes.
382
+ *
383
+ * @example
384
+ * ```typescript
385
+ * const pairs = zip(keys, timestamps) // [key, timestamp][]
386
+ * ```
387
+ */
388
+ export async function* zip<T extends unknown[]>(
389
+ ...sources: { [K in keyof T]: AsyncIterable<T[K]> }
390
+ ): AsyncGenerator<T, void, undefined> {
391
+ const iterators = sources.map((source) => source[Symbol.asyncIterator]())
392
+
393
+ try {
394
+ while (true) {
395
+ const results = await Promise.all(iterators.map((it) => it.next()))
396
+
397
+ // If any source is done, we're done
398
+ if (results.some((r) => r.done)) break
399
+
400
+ yield results.map((r) => r.value) as T
401
+ }
402
+ } finally {
403
+ await Promise.all(iterators.map((it) => (it.return ? it.return() : Promise.resolve())))
404
+ }
405
+ }
@@ -0,0 +1,208 @@
1
+ # silvery/tea
2
+
3
+ Zustand middleware for TEA (The Elm Architecture) effects-as-data pattern.
4
+
5
+ A ~30-line middleware that lets Zustand reducers optionally return `[state, effects]`
6
+ alongside plain state. Gradual adoption: start with pure state updates (Level 3),
7
+ add effects-as-data when you need side effects (Level 4).
8
+
9
+ ## Install
10
+
11
+ ```ts
12
+ import { tea, collect } from "@silvery/tea"
13
+ import type { TeaResult, TeaReducer, EffectRunners, TeaSlice, EffectLike } from "@silvery/tea"
14
+ ```
15
+
16
+ Not a standalone package. Exported as a sub-path from Silvery.
17
+
18
+ ## Quick Start
19
+
20
+ Level 3 — ops as data. The reducer takes state and an operation, returns new state.
21
+ `tea()` wraps it as a Zustand state creator with `dispatch`.
22
+
23
+ ```ts
24
+ import { createStore } from "zustand"
25
+ import { tea } from "@silvery/tea"
26
+
27
+ interface State {
28
+ count: number
29
+ }
30
+
31
+ type Op = { type: "increment" } | { type: "decrement" } | { type: "reset" }
32
+
33
+ function reducer(state: State, op: Op): State {
34
+ switch (op.type) {
35
+ case "increment":
36
+ return { ...state, count: state.count + 1 }
37
+ case "decrement":
38
+ return { ...state, count: state.count - 1 }
39
+ case "reset":
40
+ return { ...state, count: 0 }
41
+ }
42
+ }
43
+
44
+ const store = createStore(tea({ count: 0 }, reducer))
45
+
46
+ store.getState().dispatch({ type: "increment" })
47
+ store.getState().count // 1
48
+ ```
49
+
50
+ No effects, no runners, no ceremony. Plain `(state, op) => state`.
51
+
52
+ ## Effects
53
+
54
+ Level 4 — effects as data. Same middleware, same reducer signature. When an operation
55
+ needs a side effect, return `[state, effects]` instead of plain state. Mix freely
56
+ on a per-case basis.
57
+
58
+ ```ts
59
+ import { createStore } from "zustand"
60
+ import { tea, type TeaResult, type EffectRunners } from "@silvery/tea"
61
+
62
+ // Effects are plain objects with a `type` discriminant
63
+ const log = (msg: string) => ({ type: "log" as const, msg })
64
+ const save = (url: string, body: unknown) => ({ type: "save" as const, url, body })
65
+
66
+ type MyEffect = ReturnType<typeof log> | ReturnType<typeof save>
67
+
68
+ interface State {
69
+ count: number
70
+ }
71
+
72
+ type Op = { type: "increment" } | { type: "save" }
73
+
74
+ function reducer(state: State, op: Op): TeaResult<State, MyEffect> {
75
+ switch (op.type) {
76
+ case "increment":
77
+ return { ...state, count: state.count + 1 } // Level 3: plain state
78
+ case "save":
79
+ return [state, [save("/api/count", state), log("saved")]] // Level 4: [state, effects]
80
+ }
81
+ }
82
+
83
+ // Effect runners: swappable for production, test, replay
84
+ const runners: EffectRunners<MyEffect, Op> = {
85
+ log: (effect) => console.log(effect.msg),
86
+ save: async (effect, dispatch) => {
87
+ await fetch(effect.url, { method: "POST", body: JSON.stringify(effect.body) })
88
+ // Round-trip: dispatch back into the reducer (Elm's Cmd Msg pattern)
89
+ dispatch({ type: "increment" })
90
+ },
91
+ }
92
+
93
+ const store = createStore(tea({ count: 0 }, reducer, { runners }))
94
+
95
+ store.getState().dispatch({ type: "save" })
96
+ // -> state unchanged, effects executed: POST /api/count + console.log("saved")
97
+ ```
98
+
99
+ ### Detection mechanism
100
+
101
+ `Array.isArray` distinguishes plain state (object) from `[state, effects]` (array).
102
+ Safe because Zustand state is always an object — never an array.
103
+
104
+ ### Effect execution
105
+
106
+ Effects run synchronously after state update, in order. Each effect is routed to a
107
+ runner by its `type` field. Runners receive a `dispatch` callback for round-trip
108
+ communication. Unmatched effects (no runner for that type) are silently dropped.
109
+
110
+ ## API Reference
111
+
112
+ ### `tea(initialState, reducer, options?)`
113
+
114
+ Zustand `StateCreator` middleware. Returns a store shape of `S & { dispatch }`.
115
+
116
+ | Parameter | Type | Description |
117
+ | -------------- | ---------------------- | -------------------------------------------------------- |
118
+ | `initialState` | `S extends object` | Initial domain state |
119
+ | `reducer` | `TeaReducer<S, Op, E>` | Pure reducer: `(state, op) => state \| [state, effects]` |
120
+ | `options` | `TeaOptions<E, Op>` | Optional `{ runners }` for effect execution |
121
+
122
+ Returns: `StateCreator<TeaSlice<S, Op>>`
123
+
124
+ ### `collect(result)`
125
+
126
+ Test helper. Normalizes a reducer result to `[state, effects]` regardless of what
127
+ the reducer returned.
128
+
129
+ | Parameter | Type | Description |
130
+ | --------- | ----------------- | -------------------------------- |
131
+ | `result` | `TeaResult<S, E>` | Return value from a reducer call |
132
+
133
+ Returns: `[S, E[]]` — always a tuple. Plain state becomes `[state, []]`.
134
+
135
+ ### Types
136
+
137
+ ```ts
138
+ // An effect must have a `type` discriminant
139
+ type EffectLike = { type: string }
140
+
141
+ // Reducer return: plain state (no effects) or [state, effects]
142
+ type TeaResult<S, E extends EffectLike = EffectLike> = S | readonly [S, E[]]
143
+
144
+ // A reducer function
145
+ type TeaReducer<S, Op, E extends EffectLike = EffectLike> = (state: S, op: Op) => TeaResult<S, E>
146
+
147
+ // Runners keyed by effect type. Each receives the effect + dispatch for round-trips.
148
+ type EffectRunners<E extends EffectLike, Op = unknown> = {
149
+ [K in E["type"]]?: (effect: Extract<E, { type: K }>, dispatch: (op: Op) => void) => void | Promise<void>
150
+ }
151
+
152
+ // Options for tea()
153
+ interface TeaOptions<E extends EffectLike, Op> {
154
+ runners?: EffectRunners<E, Op>
155
+ }
156
+
157
+ // The store shape: domain state + dispatch
158
+ type TeaSlice<S, Op> = S & { dispatch: (op: Op) => void }
159
+ ```
160
+
161
+ ## Testing
162
+
163
+ Reducers are pure functions. Test them directly without a store. `collect()` normalizes
164
+ the return value so assertions work uniformly whether the reducer returned plain state
165
+ or a tuple.
166
+
167
+ ```ts
168
+ import { collect } from "@silvery/tea"
169
+
170
+ const initial: State = { count: 0 }
171
+
172
+ // Level 3 case: plain state
173
+ const [state1, effects1] = collect(reducer(initial, { type: "increment" }))
174
+ expect(state1.count).toBe(1)
175
+ expect(effects1).toEqual([])
176
+
177
+ // Level 4 case: state + effects
178
+ const [state2, effects2] = collect(reducer(initial, { type: "save" }))
179
+ expect(state2).toEqual(initial)
180
+ expect(effects2).toContainEqual(save("/api/count", initial))
181
+ expect(effects2).toContainEqual(log("saved"))
182
+ ```
183
+
184
+ Effect runners are tested separately — inject mock dispatch, assert on calls:
185
+
186
+ ```ts
187
+ const dispatched: Op[] = []
188
+ const mockDispatch = (op: Op) => dispatched.push(op)
189
+
190
+ runners.save!(save("/api/count", { count: 5 }), mockDispatch)
191
+ // assert: dispatched contains expected round-trip ops
192
+ ```
193
+
194
+ ## Prior Art
195
+
196
+ | System | Approach | Difference |
197
+ | ------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------- |
198
+ | [redux-loop](https://github.com/redux-loop/redux-loop) | Redux middleware, `loop(state, effects)` | Store enhancer, more API surface. tea() is ~30 lines. |
199
+ | [Hyperapp v2](https://github.com/jorgebucaran/hyperapp) | `[state, effects]` tuples from actions | Full framework. tea() is just a Zustand middleware. |
200
+ | [Elm](https://guide.elm-lang.org/effects/) | `Cmd Msg` — effects return messages to update | The original. tea() adapts this to JS/Zustand. |
201
+
202
+ The key insight shared by all: effects are **data**, not imperative calls. The reducer
203
+ declares _what_ should happen; runners decide _how_. This makes reducers pure, testable,
204
+ and replayable.
205
+
206
+ ## See Also
207
+
208
+ - [docs/guide/state-management.md](../../docs/guide/state-management.md) — full state management guide covering createApp, createSlice, selectors, and effects middleware