@pumped-fn/lite 1.0.0 → 1.1.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.
- package/CHANGELOG.md +41 -0
- package/README.md +568 -134
- package/dist/index.cjs +100 -14
- package/dist/index.d.cts +55 -7
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +55 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +100 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight effect system for TypeScript with managed lifecycles and minimal reactivity.
|
|
4
4
|
|
|
5
|
+
**Zero dependencies** · **<17KB bundle** · **Full TypeScript support**
|
|
6
|
+
|
|
5
7
|
## What is an Effect System?
|
|
6
8
|
|
|
7
9
|
An effect system manages **how** and **when** computations run, handling:
|
|
@@ -10,6 +12,56 @@ An effect system manages **how** and **when** computations run, handling:
|
|
|
10
12
|
- **Side effect isolation** - controlled execution boundaries
|
|
11
13
|
- **State transitions** - idle → resolving → resolved → failed
|
|
12
14
|
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @pumped-fn/lite
|
|
19
|
+
# or
|
|
20
|
+
pnpm add @pumped-fn/lite
|
|
21
|
+
# or
|
|
22
|
+
yarn add @pumped-fn/lite
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { atom, flow, createScope, tag, tags, controller } from '@pumped-fn/lite'
|
|
29
|
+
|
|
30
|
+
// 1. Define atoms (long-lived, cached dependencies)
|
|
31
|
+
const configAtom = atom({
|
|
32
|
+
factory: () => ({ apiUrl: 'https://api.example.com', timeout: 5000 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const apiClientAtom = atom({
|
|
36
|
+
deps: { config: configAtom },
|
|
37
|
+
factory: (ctx, { config }) => {
|
|
38
|
+
const client = new ApiClient(config.apiUrl, config.timeout)
|
|
39
|
+
ctx.cleanup(() => client.disconnect())
|
|
40
|
+
return client
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// 2. Define flows (short-lived request handlers)
|
|
45
|
+
const fetchUserFlow = flow({
|
|
46
|
+
deps: { api: apiClientAtom },
|
|
47
|
+
factory: async (ctx, { api }) => {
|
|
48
|
+
const userId = ctx.input as string
|
|
49
|
+
return api.getUser(userId)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// 3. Create scope and execute
|
|
54
|
+
const scope = createScope()
|
|
55
|
+
await scope.ready
|
|
56
|
+
|
|
57
|
+
const context = scope.createContext()
|
|
58
|
+
const user = await context.exec({ flow: fetchUserFlow, input: 'user-123' })
|
|
59
|
+
await context.close()
|
|
60
|
+
|
|
61
|
+
// 4. Cleanup when done
|
|
62
|
+
await scope.dispose()
|
|
63
|
+
```
|
|
64
|
+
|
|
13
65
|
## Core Concepts
|
|
14
66
|
|
|
15
67
|
```
|
|
@@ -35,60 +87,445 @@ An effect system manages **how** and **when** computations run, handling:
|
|
|
35
87
|
|---------|---------|
|
|
36
88
|
| **Scope** | Long-lived boundary that manages atom lifecycles |
|
|
37
89
|
| **Atom** | A managed effect with lifecycle (create, cache, cleanup, recreate) |
|
|
38
|
-
| **
|
|
90
|
+
| **Flow** | Template for short-lived operations with input/output |
|
|
91
|
+
| **ExecutionContext** | Short-lived context for running flows with input and tags |
|
|
39
92
|
| **Controller** | Handle for observing and controlling an atom's state |
|
|
40
93
|
| **Tag** | Contextual value passed through execution |
|
|
41
94
|
|
|
42
|
-
##
|
|
95
|
+
## Atoms
|
|
43
96
|
|
|
44
|
-
|
|
45
|
-
npm install @pumped-fn/lite
|
|
46
|
-
```
|
|
97
|
+
Atoms are long-lived dependencies that are cached within a scope.
|
|
47
98
|
|
|
48
|
-
|
|
99
|
+
### Basic Atom
|
|
49
100
|
|
|
50
101
|
```typescript
|
|
51
|
-
import { atom, flow, createScope } from '@pumped-fn/lite'
|
|
52
|
-
|
|
53
|
-
// Define effects (atoms) - long-lived, cached
|
|
54
102
|
const dbAtom = atom({
|
|
55
103
|
factory: async (ctx) => {
|
|
56
|
-
const
|
|
57
|
-
ctx.cleanup(() =>
|
|
58
|
-
return
|
|
104
|
+
const connection = await createConnection()
|
|
105
|
+
ctx.cleanup(() => connection.close())
|
|
106
|
+
return connection
|
|
59
107
|
}
|
|
60
108
|
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Atom with Dependencies
|
|
61
112
|
|
|
62
|
-
|
|
113
|
+
```typescript
|
|
114
|
+
const userRepoAtom = atom({
|
|
63
115
|
deps: { db: dbAtom },
|
|
64
116
|
factory: (ctx, { db }) => new UserRepository(db)
|
|
65
117
|
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Self-Invalidating Atom
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
const configAtom = atom({
|
|
124
|
+
factory: async (ctx) => {
|
|
125
|
+
const config = await fetchConfig()
|
|
126
|
+
|
|
127
|
+
// Re-fetch every 60 seconds
|
|
128
|
+
const interval = setInterval(() => ctx.invalidate(), 60_000)
|
|
129
|
+
ctx.cleanup(() => clearInterval(interval))
|
|
130
|
+
|
|
131
|
+
return config
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Per-Atom Private Storage
|
|
137
|
+
|
|
138
|
+
Use `ctx.data` to store data that survives invalidation:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
const prevDataTag = tag<Data>({ label: 'prevData' })
|
|
142
|
+
|
|
143
|
+
const pollingAtom = atom({
|
|
144
|
+
factory: async (ctx) => {
|
|
145
|
+
const prev = ctx.data.get(prevDataTag) // Data | undefined
|
|
146
|
+
const current = await fetchData()
|
|
147
|
+
|
|
148
|
+
if (prev && hasChanged(prev, current)) {
|
|
149
|
+
notifyChanges(prev, current)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ctx.data.set(prevDataTag, current)
|
|
153
|
+
setTimeout(() => ctx.invalidate(), 5000)
|
|
154
|
+
return current
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
With default values:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const countTag = tag<number>({ label: 'count', default: 0 })
|
|
163
|
+
|
|
164
|
+
const counterAtom = atom({
|
|
165
|
+
factory: (ctx) => {
|
|
166
|
+
const count = ctx.data.get(countTag) // number (guaranteed!)
|
|
167
|
+
ctx.data.set(countTag, count + 1)
|
|
168
|
+
return count
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Flows
|
|
174
|
+
|
|
175
|
+
Flows are templates for short-lived operations.
|
|
176
|
+
|
|
177
|
+
### Basic Flow
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const createUserFlow = flow({
|
|
181
|
+
deps: { repo: userRepoAtom },
|
|
182
|
+
factory: async (ctx, { repo }) => {
|
|
183
|
+
const input = ctx.input as CreateUserInput
|
|
184
|
+
return repo.create(input)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
```
|
|
66
188
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
189
|
+
### Flow with Parse Validation
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const createUserFlow = flow({
|
|
193
|
+
name: 'createUser',
|
|
194
|
+
parse: (raw) => {
|
|
195
|
+
const obj = raw as Record<string, unknown>
|
|
196
|
+
if (typeof obj.name !== 'string') throw new Error('name required')
|
|
197
|
+
if (typeof obj.email !== 'string') throw new Error('email required')
|
|
198
|
+
return { name: obj.name, email: obj.email }
|
|
199
|
+
},
|
|
200
|
+
deps: { repo: userRepoAtom },
|
|
70
201
|
factory: async (ctx, { repo }) => {
|
|
71
|
-
|
|
202
|
+
// ctx.input is typed as { name: string; email: string }
|
|
203
|
+
return repo.create(ctx.input)
|
|
72
204
|
}
|
|
73
205
|
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Parse runs before the factory, and `ctx.input` type is inferred from the parse return type. On validation failure, throws `ParseError` with phase `'flow-input'`.
|
|
74
209
|
|
|
75
|
-
|
|
76
|
-
const scope = await createScope()
|
|
210
|
+
### Executing Flows
|
|
77
211
|
|
|
78
|
-
|
|
79
|
-
const
|
|
212
|
+
```typescript
|
|
213
|
+
const scope = createScope()
|
|
214
|
+
await scope.ready
|
|
80
215
|
|
|
81
|
-
|
|
82
|
-
const user = await ctx.exec({ flow: getUser, input: 'user-123' })
|
|
216
|
+
const context = scope.createContext()
|
|
83
217
|
|
|
84
|
-
//
|
|
85
|
-
await
|
|
218
|
+
// Execute with input
|
|
219
|
+
const user = await context.exec({
|
|
220
|
+
flow: createUserFlow,
|
|
221
|
+
input: { name: 'Alice', email: 'alice@example.com' }
|
|
222
|
+
})
|
|
86
223
|
|
|
87
|
-
//
|
|
88
|
-
await
|
|
224
|
+
// Always close the context when done
|
|
225
|
+
await context.close()
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Flow with Tags
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
const requestIdTag = tag<string>({ label: 'requestId' })
|
|
232
|
+
|
|
233
|
+
const loggingFlow = flow({
|
|
234
|
+
deps: { requestId: tags.required(requestIdTag) },
|
|
235
|
+
factory: (ctx, { requestId }) => {
|
|
236
|
+
console.log(`[${requestId}] Processing request`)
|
|
237
|
+
return processRequest(ctx.input)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Pass tags at execution time
|
|
242
|
+
const context = scope.createContext()
|
|
243
|
+
await context.exec({
|
|
244
|
+
flow: loggingFlow,
|
|
245
|
+
input: data,
|
|
246
|
+
tags: [requestIdTag('req-abc-123')]
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Controllers
|
|
251
|
+
|
|
252
|
+
Controllers provide reactive access to atom state.
|
|
253
|
+
|
|
254
|
+
### Basic Usage
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
const ctrl = scope.controller(configAtom)
|
|
258
|
+
|
|
259
|
+
// Access state
|
|
260
|
+
console.log(ctrl.state) // 'idle' | 'resolving' | 'resolved' | 'failed'
|
|
261
|
+
|
|
262
|
+
// Get resolved value (throws if not resolved)
|
|
263
|
+
const config = ctrl.get()
|
|
264
|
+
|
|
265
|
+
// Resolve and wait
|
|
266
|
+
const config = await ctrl.resolve()
|
|
267
|
+
|
|
268
|
+
// Manual invalidation
|
|
269
|
+
ctrl.invalidate()
|
|
89
270
|
```
|
|
90
271
|
|
|
91
|
-
|
|
272
|
+
### Subscribing to Changes
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// Subscribe to specific events
|
|
276
|
+
ctrl.on('resolved', () => {
|
|
277
|
+
console.log('Config updated:', ctrl.get())
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
ctrl.on('resolving', () => {
|
|
281
|
+
console.log('Config is refreshing...')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Subscribe to all state changes
|
|
285
|
+
ctrl.on('*', () => {
|
|
286
|
+
console.log('State changed:', ctrl.state)
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Controller as Dependency
|
|
291
|
+
|
|
292
|
+
Use `controller()` to get a Controller instead of the resolved value:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
const appAtom = atom({
|
|
296
|
+
deps: { config: controller(configAtom) },
|
|
297
|
+
factory: (ctx, { config }) => {
|
|
298
|
+
// Subscribe to config changes
|
|
299
|
+
config.on('resolved', () => {
|
|
300
|
+
console.log('Config updated, reinitializing...')
|
|
301
|
+
ctx.invalidate()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
return new App(config.get())
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Fine-Grained Reactivity with select()
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
const portSelect = scope.select(
|
|
313
|
+
configAtom,
|
|
314
|
+
(config) => config.port,
|
|
315
|
+
{ eq: (a, b) => a === b } // Optional custom equality
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
portSelect.subscribe(() => {
|
|
319
|
+
console.log('Port changed:', portSelect.get())
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Tags
|
|
324
|
+
|
|
325
|
+
Tags pass contextual values through execution without explicit wiring.
|
|
326
|
+
|
|
327
|
+
### Creating Tags
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
const tenantIdTag = tag<string>({ label: 'tenantId' })
|
|
331
|
+
const userRolesTag = tag<string[]>({ label: 'userRoles', default: [] })
|
|
332
|
+
|
|
333
|
+
// With parse validation
|
|
334
|
+
const userId = tag({
|
|
335
|
+
label: 'userId',
|
|
336
|
+
parse: (raw) => {
|
|
337
|
+
if (typeof raw !== 'string') throw new Error('Must be string')
|
|
338
|
+
return raw
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
userId('abc-123') // OK
|
|
343
|
+
userId(123) // Throws ParseError
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Using Tags as Dependencies
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
// Required - throws if not found
|
|
350
|
+
const tenantAtom = atom({
|
|
351
|
+
deps: { tenantId: tags.required(tenantIdTag) },
|
|
352
|
+
factory: (ctx, { tenantId }) => loadTenantData(tenantId)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// Optional - undefined if not found
|
|
356
|
+
const optionalAtom = atom({
|
|
357
|
+
deps: { tenantId: tags.optional(tenantIdTag) },
|
|
358
|
+
factory: (ctx, { tenantId }) => {
|
|
359
|
+
if (tenantId) {
|
|
360
|
+
return loadTenantData(tenantId)
|
|
361
|
+
}
|
|
362
|
+
return loadDefaultData()
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// Collect all - returns array of all matching tags
|
|
367
|
+
const multiAtom = atom({
|
|
368
|
+
deps: { roles: tags.all(userRolesTag) },
|
|
369
|
+
factory: (ctx, { roles }) => roles.flat() // string[][]
|
|
370
|
+
})
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Passing Tags
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
// At scope creation
|
|
377
|
+
const scope = createScope({
|
|
378
|
+
tags: [tenantIdTag('tenant-123')]
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// At context creation
|
|
382
|
+
const context = scope.createContext({
|
|
383
|
+
tags: [userRolesTag(['admin', 'user'])]
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// At execution time
|
|
387
|
+
await context.exec({
|
|
388
|
+
flow: myFlow,
|
|
389
|
+
input: data,
|
|
390
|
+
tags: [requestIdTag('req-456')]
|
|
391
|
+
})
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Direct Tag Methods
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
const tags = [tenantIdTag('tenant-123'), userRolesTag(['admin'])]
|
|
398
|
+
|
|
399
|
+
// Get (throws if not found for tags without default)
|
|
400
|
+
const tenantId = tenantIdTag.get(tags)
|
|
401
|
+
|
|
402
|
+
// Find (returns undefined if not found)
|
|
403
|
+
const roles = userRolesTag.find(tags)
|
|
404
|
+
|
|
405
|
+
// Collect all matching values
|
|
406
|
+
const allRoles = userRolesTag.collect(tags)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Presets
|
|
410
|
+
|
|
411
|
+
Presets inject or redirect atom values, useful for testing.
|
|
412
|
+
|
|
413
|
+
### Value Injection
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
const mockDb = { query: jest.fn() }
|
|
417
|
+
|
|
418
|
+
const scope = createScope({
|
|
419
|
+
presets: [preset(dbAtom, mockDb)]
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// resolves to mockDb without calling factory
|
|
423
|
+
const db = await scope.resolve(dbAtom)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Atom Redirection
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
const testConfigAtom = atom({
|
|
430
|
+
factory: () => ({ apiUrl: 'http://localhost:3000' })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const scope = createScope({
|
|
434
|
+
presets: [preset(configAtom, testConfigAtom)]
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
// resolves testConfigAtom instead of configAtom
|
|
438
|
+
const config = await scope.resolve(configAtom)
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Extensions
|
|
442
|
+
|
|
443
|
+
Extensions provide cross-cutting behavior via AOP-style hooks.
|
|
444
|
+
|
|
445
|
+
### Extension Interface
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
interface Extension {
|
|
449
|
+
readonly name: string
|
|
450
|
+
init?(scope: Scope): MaybePromise<void>
|
|
451
|
+
wrapResolve?<T>(next: () => Promise<T>, atom: Atom<T>, scope: Scope): Promise<T>
|
|
452
|
+
wrapExec?<T>(next: () => Promise<T>, target: Flow | Function, ctx: ExecutionContext): Promise<T>
|
|
453
|
+
dispose?(scope: Scope): MaybePromise<void>
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Example: Logging Extension
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
const loggingExtension: Lite.Extension = {
|
|
461
|
+
name: 'logging',
|
|
462
|
+
|
|
463
|
+
init: (scope) => {
|
|
464
|
+
console.log('Scope initialized')
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
wrapResolve: async (next, atom, scope) => {
|
|
468
|
+
const start = performance.now()
|
|
469
|
+
console.log('Resolving atom...')
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await next()
|
|
473
|
+
console.log(`Resolved in ${performance.now() - start}ms`)
|
|
474
|
+
return result
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error('Resolution failed:', error)
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
wrapExec: async (next, target, ctx) => {
|
|
482
|
+
console.log('Executing:', 'flow' in target ? 'flow' : 'function')
|
|
483
|
+
return next()
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
dispose: (scope) => {
|
|
487
|
+
console.log('Scope disposed')
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const scope = createScope({ extensions: [loggingExtension] })
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### Example: Metrics Extension
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
const metricsExtension: Lite.Extension = {
|
|
498
|
+
name: 'metrics',
|
|
499
|
+
|
|
500
|
+
wrapResolve: async (next, atom, scope) => {
|
|
501
|
+
const start = performance.now()
|
|
502
|
+
try {
|
|
503
|
+
const result = await next()
|
|
504
|
+
metrics.recordResolution(atom, performance.now() - start, 'success')
|
|
505
|
+
return result
|
|
506
|
+
} catch (error) {
|
|
507
|
+
metrics.recordResolution(atom, performance.now() - start, 'error')
|
|
508
|
+
throw error
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
wrapExec: async (next, target, ctx) => {
|
|
513
|
+
const start = performance.now()
|
|
514
|
+
try {
|
|
515
|
+
const result = await next()
|
|
516
|
+
metrics.recordExecution(target, performance.now() - start, 'success')
|
|
517
|
+
return result
|
|
518
|
+
} catch (error) {
|
|
519
|
+
metrics.recordExecution(target, performance.now() - start, 'error')
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## Lifecycle
|
|
527
|
+
|
|
528
|
+
### Effect Lifecycle
|
|
92
529
|
|
|
93
530
|
```mermaid
|
|
94
531
|
stateDiagram-v2
|
|
@@ -102,7 +539,7 @@ stateDiagram-v2
|
|
|
102
539
|
failed --> idle: release()
|
|
103
540
|
```
|
|
104
541
|
|
|
105
|
-
|
|
542
|
+
### Resolution Flow
|
|
106
543
|
|
|
107
544
|
```mermaid
|
|
108
545
|
sequenceDiagram
|
|
@@ -126,9 +563,7 @@ sequenceDiagram
|
|
|
126
563
|
Atom-->>App: value
|
|
127
564
|
```
|
|
128
565
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
Atoms can be invalidated to re-run their effect:
|
|
566
|
+
### Invalidation Flow
|
|
132
567
|
|
|
133
568
|
```mermaid
|
|
134
569
|
sequenceDiagram
|
|
@@ -145,7 +580,7 @@ sequenceDiagram
|
|
|
145
580
|
Note over Queue: queueMicrotask (batched)
|
|
146
581
|
|
|
147
582
|
Queue->>Scope: flush
|
|
148
|
-
Scope->>Scope: run cleanups
|
|
583
|
+
Scope->>Scope: run cleanups (LIFO)
|
|
149
584
|
Scope->>Scope: state = resolving
|
|
150
585
|
Scope->>Factory: factory(ctx, deps)
|
|
151
586
|
Factory-->>Scope: new value
|
|
@@ -153,117 +588,115 @@ sequenceDiagram
|
|
|
153
588
|
Scope->>Scope: notify listeners
|
|
154
589
|
```
|
|
155
590
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
## Controller Pattern
|
|
159
|
-
|
|
160
|
-
Controllers provide a handle to observe and control atom state:
|
|
161
|
-
|
|
162
|
-
```typescript
|
|
163
|
-
const configAtom = atom({
|
|
164
|
-
factory: async (ctx) => {
|
|
165
|
-
const config = await fetchConfig()
|
|
166
|
-
setTimeout(() => ctx.invalidate(), 60000) // refresh every minute
|
|
167
|
-
return config
|
|
168
|
-
}
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
const appAtom = atom({
|
|
172
|
-
deps: { config: controller(configAtom) }, // controller, not direct
|
|
173
|
-
factory: (ctx, { config }) => {
|
|
174
|
-
// Subscribe to config changes (must specify event type)
|
|
175
|
-
config.on('resolved', () => {
|
|
176
|
-
console.log('config updated:', config.get())
|
|
177
|
-
ctx.invalidate() // re-run this atom too
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
return new App(config.get())
|
|
181
|
-
}
|
|
182
|
-
})
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
## Per-Atom Private Storage
|
|
186
|
-
|
|
187
|
-
The `ctx.data` Map provides private storage that survives invalidation but clears on release. Useful for internal state that shouldn't be exposed:
|
|
188
|
-
|
|
189
|
-
```typescript
|
|
190
|
-
const pollingAtom = atom({
|
|
191
|
-
factory: async (ctx) => {
|
|
192
|
-
const prev = ctx.data.get('prev') as Data | undefined
|
|
193
|
-
const current = await fetchData()
|
|
194
|
-
|
|
195
|
-
if (prev && current !== prev) {
|
|
196
|
-
console.log('Data changed!')
|
|
197
|
-
}
|
|
198
|
-
ctx.data.set('prev', current)
|
|
199
|
-
|
|
200
|
-
setTimeout(() => ctx.invalidate(), 5000)
|
|
201
|
-
return current
|
|
202
|
-
}
|
|
203
|
-
})
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
| Event | `ctx.data` Behavior |
|
|
207
|
-
|-------|---------------------|
|
|
208
|
-
| First access | Map created lazily |
|
|
209
|
-
| `invalidate()` | Map preserved |
|
|
210
|
-
| `release()` | Map cleared |
|
|
211
|
-
|
|
212
|
-
## Tags (Contextual Values)
|
|
591
|
+
## API Reference
|
|
213
592
|
|
|
214
|
-
|
|
593
|
+
### Factory Functions
|
|
215
594
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
595
|
+
| Function | Description |
|
|
596
|
+
|----------|-------------|
|
|
597
|
+
| `createScope(options?)` | Create DI container (returns Scope with `ready` promise) |
|
|
598
|
+
| `atom(config)` | Define long-lived cached dependency |
|
|
599
|
+
| `flow(config)` | Define short-lived operation template (optional `name`, `parse`) |
|
|
600
|
+
| `tag(config)` | Define contextual value (optional `parse` for validation) |
|
|
601
|
+
| `controller(atom)` | Create controller dependency helper |
|
|
602
|
+
| `preset(atom, value)` | Create value injection preset |
|
|
603
|
+
|
|
604
|
+
### Scope Methods
|
|
605
|
+
|
|
606
|
+
| Method | Description |
|
|
607
|
+
|--------|-------------|
|
|
608
|
+
| `scope.ready` | Promise that resolves when extensions are initialized |
|
|
609
|
+
| `scope.resolve(atom)` | Resolve atom and return cached value |
|
|
610
|
+
| `scope.controller(atom)` | Get Controller for atom |
|
|
611
|
+
| `scope.select(atom, selector, options?)` | Create fine-grained subscription |
|
|
612
|
+
| `scope.release(atom)` | Release atom (run cleanups, remove from cache) |
|
|
613
|
+
| `scope.dispose()` | Dispose scope (release all atoms, cleanup extensions) |
|
|
614
|
+
| `scope.createContext(options?)` | Create ExecutionContext for flows |
|
|
615
|
+
| `scope.on(event, atom, listener)` | Subscribe to atom state changes |
|
|
616
|
+
|
|
617
|
+
### Controller Methods
|
|
618
|
+
|
|
619
|
+
| Method | Description |
|
|
620
|
+
|--------|-------------|
|
|
621
|
+
| `ctrl.state` | Current state: `'idle'` \| `'resolving'` \| `'resolved'` \| `'failed'` |
|
|
622
|
+
| `ctrl.get()` | Get resolved value (throws if not resolved) |
|
|
623
|
+
| `ctrl.resolve()` | Resolve and return value |
|
|
624
|
+
| `ctrl.release()` | Release atom |
|
|
625
|
+
| `ctrl.invalidate()` | Trigger re-resolution |
|
|
626
|
+
| `ctrl.on(event, listener)` | Subscribe: `'resolved'` \| `'resolving'` \| `'*'` |
|
|
627
|
+
|
|
628
|
+
### ExecutionContext Methods
|
|
629
|
+
|
|
630
|
+
| Method | Description |
|
|
631
|
+
|--------|-------------|
|
|
632
|
+
| `ctx.input` | Current execution input |
|
|
633
|
+
| `ctx.scope` | Parent scope |
|
|
634
|
+
| `ctx.exec(options)` | Execute flow or function |
|
|
635
|
+
| `ctx.onClose(fn)` | Register cleanup for context close |
|
|
636
|
+
| `ctx.close()` | Close context and run cleanups |
|
|
637
|
+
|
|
638
|
+
### ResolveContext Methods
|
|
639
|
+
|
|
640
|
+
| Method | Description |
|
|
641
|
+
|--------|-------------|
|
|
642
|
+
| `ctx.cleanup(fn)` | Register cleanup for atom invalidation/release |
|
|
643
|
+
| `ctx.invalidate()` | Schedule self-invalidation |
|
|
644
|
+
| `ctx.scope` | Parent scope |
|
|
645
|
+
| `ctx.data` | Per-atom DataStore (survives invalidation) |
|
|
646
|
+
|
|
647
|
+
### Type Guards
|
|
227
648
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
649
|
+
| Function | Description |
|
|
650
|
+
|----------|-------------|
|
|
651
|
+
| `isAtom(value)` | Check if value is Atom |
|
|
652
|
+
| `isFlow(value)` | Check if value is Flow |
|
|
653
|
+
| `isTag(value)` | Check if value is Tag |
|
|
654
|
+
| `isTagged(value)` | Check if value is Tagged |
|
|
655
|
+
| `isPreset(value)` | Check if value is Preset |
|
|
656
|
+
| `isControllerDep(value)` | Check if value is ControllerDep |
|
|
235
657
|
|
|
236
|
-
|
|
658
|
+
### Types
|
|
237
659
|
|
|
238
|
-
|
|
660
|
+
All types are available under the `Lite` namespace:
|
|
239
661
|
|
|
240
662
|
```typescript
|
|
241
|
-
|
|
242
|
-
name: 'timing',
|
|
243
|
-
wrapResolve: async (next, atom, scope) => {
|
|
244
|
-
const start = performance.now()
|
|
245
|
-
const result = await next()
|
|
246
|
-
console.log(`resolved in ${performance.now() - start}ms`)
|
|
247
|
-
return result
|
|
248
|
-
}
|
|
249
|
-
}
|
|
663
|
+
import type { Lite } from '@pumped-fn/lite'
|
|
250
664
|
|
|
251
|
-
const
|
|
665
|
+
const myAtom: Lite.Atom<Config> = atom({ factory: () => loadConfig() })
|
|
666
|
+
const myController: Lite.Controller<Config> = scope.controller(myAtom)
|
|
667
|
+
const myTag: Lite.Tag<string> = tag({ label: 'myTag' })
|
|
252
668
|
```
|
|
253
669
|
|
|
254
|
-
##
|
|
255
|
-
|
|
256
|
-
|
|
|
257
|
-
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
261
|
-
|
|
|
262
|
-
|
|
|
263
|
-
|
|
|
264
|
-
|
|
|
265
|
-
|
|
|
266
|
-
|
|
|
670
|
+
## Comparison with @pumped-fn/core-next
|
|
671
|
+
|
|
672
|
+
| Feature | @pumped-fn/lite | @pumped-fn/core-next |
|
|
673
|
+
|---------|-----------------|----------------------|
|
|
674
|
+
| Atoms/Executors | `atom()` | `provide()`, `derive()` |
|
|
675
|
+
| Flows | `flow()` | `flow()` |
|
|
676
|
+
| Tags | `tag()`, `tags.*` | `tag()`, `tags.*` |
|
|
677
|
+
| Extensions | Simple 4-hook interface | Full lifecycle hooks |
|
|
678
|
+
| Schema validation | No | StandardSchema |
|
|
679
|
+
| Journaling | No | Yes |
|
|
680
|
+
| Multi-executor pools | No | `multi()` |
|
|
681
|
+
| Enhanced Promise | No | `Promised` class |
|
|
682
|
+
| Error classes | Simple Error | Rich hierarchy |
|
|
683
|
+
| Controller reactivity | ✅ Built-in | No |
|
|
684
|
+
| Self-invalidation | ✅ Built-in | No |
|
|
685
|
+
| Fine-grained select() | ✅ Built-in | No |
|
|
686
|
+
| Tag/Flow parse functions | ✅ Built-in | No |
|
|
687
|
+
| Bundle size | <17KB | ~75KB |
|
|
688
|
+
|
|
689
|
+
**Choose `@pumped-fn/lite` when:**
|
|
690
|
+
- Bundle size matters
|
|
691
|
+
- You need built-in reactivity (Controller pattern)
|
|
692
|
+
- You want a minimal API surface
|
|
693
|
+
- Schema validation can be done manually
|
|
694
|
+
|
|
695
|
+
**Choose `@pumped-fn/core-next` when:**
|
|
696
|
+
- You need StandardSchema validation
|
|
697
|
+
- You need multi-executor pools
|
|
698
|
+
- You need journaling/debugging features
|
|
699
|
+
- You need rich error context
|
|
267
700
|
|
|
268
701
|
## Design Principles
|
|
269
702
|
|
|
@@ -271,6 +704,7 @@ const scope = await createScope({ extensions: [timingExtension] })
|
|
|
271
704
|
2. **Zero dependencies** - No runtime dependencies
|
|
272
705
|
3. **Explicit lifecycle** - No magic, clear state transitions
|
|
273
706
|
4. **Composable** - Effects compose through deps
|
|
707
|
+
5. **Type-safe** - Full TypeScript inference
|
|
274
708
|
|
|
275
709
|
## License
|
|
276
710
|
|