@pumped-fn/lite 1.3.0 → 1.4.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 +45 -0
- package/README.md +149 -576
- package/dist/index.cjs +47 -6
- package/dist/index.d.cts +54 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +54 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +47 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,655 +4,228 @@ A lightweight effect system for TypeScript with managed lifecycles and minimal r
|
|
|
4
4
|
|
|
5
5
|
**Zero dependencies** · **<17KB bundle** · **Full TypeScript support**
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
An effect system manages **how** and **when** computations run, handling:
|
|
10
|
-
- **Resource lifecycle** - acquire, use, release
|
|
11
|
-
- **Computation ordering** - what depends on what
|
|
12
|
-
- **Side effect isolation** - controlled execution boundaries
|
|
13
|
-
- **State transitions** - idle → resolving → resolved → failed
|
|
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
|
-
|
|
65
|
-
## Core Concepts
|
|
7
|
+
## How It Works
|
|
66
8
|
|
|
67
9
|
```mermaid
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
A1 --> EC
|
|
73
|
-
A3 --> EC
|
|
74
|
-
EC["ExecutionContext<br/>(short-lived operation with input, tags, cleanup)"]
|
|
75
|
-
end
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
| Concept | Purpose |
|
|
79
|
-
|---------|---------|
|
|
80
|
-
| **Scope** | Long-lived boundary that manages atom lifecycles |
|
|
81
|
-
| **Atom** | A managed effect with lifecycle (create, cache, cleanup, recreate) |
|
|
82
|
-
| **Flow** | Template for short-lived operations with input/output |
|
|
83
|
-
| **ExecutionContext** | Short-lived context for running flows with input and tags |
|
|
84
|
-
| **Controller** | Handle for observing and controlling an atom's state |
|
|
85
|
-
| **Tag** | Contextual value passed through execution |
|
|
86
|
-
|
|
87
|
-
## Atoms
|
|
88
|
-
|
|
89
|
-
Atoms are long-lived dependencies that are cached within a scope.
|
|
10
|
+
sequenceDiagram
|
|
11
|
+
participant User
|
|
12
|
+
participant Scope
|
|
13
|
+
participant Atom
|
|
90
14
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
15
|
+
User->>Scope: createScope(options?)
|
|
16
|
+
Scope-->>User: scope
|
|
17
|
+
User->>Scope: await scope.ready
|
|
18
|
+
|
|
19
|
+
User->>Scope: scope.resolve(atom)
|
|
20
|
+
alt preset exists
|
|
21
|
+
Scope-->>User: preset value (factory skipped)
|
|
22
|
+
else no preset
|
|
23
|
+
Scope->>Atom: factory(ctx, deps)
|
|
24
|
+
Atom-->>Scope: value (cached)
|
|
25
|
+
Scope-->>User: value
|
|
98
26
|
end
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### Basic Atom
|
|
102
27
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
factory: async (ctx) => {
|
|
106
|
-
const connection = await createConnection()
|
|
107
|
-
ctx.cleanup(() => connection.close())
|
|
108
|
-
return connection
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
### Atom with Dependencies
|
|
114
|
-
|
|
115
|
-
```typescript
|
|
116
|
-
const userRepoAtom = atom({
|
|
117
|
-
deps: { db: dbAtom },
|
|
118
|
-
factory: (ctx, { db }) => new UserRepository(db)
|
|
119
|
-
})
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### Self-Invalidating Atom
|
|
123
|
-
|
|
124
|
-
```typescript
|
|
125
|
-
const configAtom = atom({
|
|
126
|
-
factory: async (ctx) => {
|
|
127
|
-
const config = await fetchConfig()
|
|
128
|
-
|
|
129
|
-
// Re-fetch every 60 seconds
|
|
130
|
-
const interval = setInterval(() => ctx.invalidate(), 60_000)
|
|
131
|
-
ctx.cleanup(() => clearInterval(interval))
|
|
132
|
-
|
|
133
|
-
return config
|
|
134
|
-
}
|
|
135
|
-
})
|
|
28
|
+
User->>Scope: scope.dispose()
|
|
29
|
+
Scope->>Atom: run cleanups, release all
|
|
136
30
|
```
|
|
137
31
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Use `ctx.data` to store data that survives invalidation:
|
|
32
|
+
## Invalidation & Data Retention
|
|
141
33
|
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const current = await fetchData()
|
|
149
|
-
|
|
150
|
-
if (prev && hasChanged(prev, current)) {
|
|
151
|
-
notifyChanges(prev, current)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
ctx.data.set(prevDataTag, current)
|
|
155
|
-
setTimeout(() => ctx.invalidate(), 5000)
|
|
156
|
-
return current
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
With default values:
|
|
34
|
+
```mermaid
|
|
35
|
+
sequenceDiagram
|
|
36
|
+
participant User
|
|
37
|
+
participant Controller
|
|
38
|
+
participant Atom
|
|
39
|
+
participant DataStore as ctx.data
|
|
162
40
|
|
|
163
|
-
|
|
164
|
-
const countTag = tag<number>({ label: 'count', default: 0 })
|
|
41
|
+
Note over DataStore: persists across invalidations
|
|
165
42
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
43
|
+
User->>Controller: ctrl.invalidate()
|
|
44
|
+
Controller->>Atom: run cleanups (LIFO)
|
|
45
|
+
Note over DataStore: retained
|
|
46
|
+
Controller->>Atom: state = resolving
|
|
47
|
+
Controller->>Atom: factory(ctx, deps)
|
|
48
|
+
Note right of Atom: ctx.data still has previous values
|
|
49
|
+
Atom-->>Controller: new value
|
|
50
|
+
Controller->>Atom: state = resolved
|
|
51
|
+
Controller-->>User: listeners notified
|
|
173
52
|
```
|
|
174
53
|
|
|
175
|
-
##
|
|
176
|
-
|
|
177
|
-
Flows are templates for short-lived operations.
|
|
54
|
+
## Flow Execution
|
|
178
55
|
|
|
179
56
|
```mermaid
|
|
180
57
|
sequenceDiagram
|
|
181
|
-
participant
|
|
58
|
+
participant User
|
|
59
|
+
participant Scope
|
|
182
60
|
participant Context as ExecutionContext
|
|
183
61
|
participant Flow
|
|
184
|
-
participant Atoms
|
|
185
|
-
|
|
186
|
-
Client->>Context: exec({ flow, input })
|
|
187
|
-
Context->>Atoms: resolve dependencies
|
|
188
|
-
Atoms-->>Context: deps
|
|
189
|
-
Context->>Flow: factory(ctx, deps)
|
|
190
|
-
Flow-->>Context: result
|
|
191
|
-
Context-->>Client: result
|
|
192
|
-
Client->>Context: close()
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
### Basic Flow
|
|
196
|
-
|
|
197
|
-
```typescript
|
|
198
|
-
const createUserFlow = flow({
|
|
199
|
-
deps: { repo: userRepoAtom },
|
|
200
|
-
factory: async (ctx, { repo }) => {
|
|
201
|
-
const input = ctx.input as CreateUserInput
|
|
202
|
-
return repo.create(input)
|
|
203
|
-
}
|
|
204
|
-
})
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Flow with Parse Validation
|
|
208
|
-
|
|
209
|
-
```typescript
|
|
210
|
-
const createUserFlow = flow({
|
|
211
|
-
name: 'createUser',
|
|
212
|
-
parse: (raw) => {
|
|
213
|
-
const obj = raw as Record<string, unknown>
|
|
214
|
-
if (typeof obj.name !== 'string') throw new Error('name required')
|
|
215
|
-
if (typeof obj.email !== 'string') throw new Error('email required')
|
|
216
|
-
return { name: obj.name, email: obj.email }
|
|
217
|
-
},
|
|
218
|
-
deps: { repo: userRepoAtom },
|
|
219
|
-
factory: async (ctx, { repo }) => {
|
|
220
|
-
// ctx.input is typed as { name: string; email: string }
|
|
221
|
-
return repo.create(ctx.input)
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
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'`.
|
|
227
|
-
|
|
228
|
-
### Executing Flows
|
|
229
62
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const user = await context.exec({
|
|
233
|
-
flow: createUserFlow,
|
|
234
|
-
input: { name: 'Alice', email: 'alice@example.com' }
|
|
235
|
-
})
|
|
236
|
-
await context.close()
|
|
237
|
-
```
|
|
63
|
+
User->>Scope: scope.createContext(options?)
|
|
64
|
+
Scope-->>User: context
|
|
238
65
|
|
|
239
|
-
|
|
66
|
+
User->>Context: ctx.exec({ flow, input, tags? })
|
|
67
|
+
Context->>Flow: parse(input)
|
|
68
|
+
Context->>Context: resolve flow deps
|
|
69
|
+
Context->>Flow: factory(ctx, deps)
|
|
70
|
+
Flow-->>Context: output
|
|
71
|
+
Context-->>User: output
|
|
240
72
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const loggingFlow = flow({
|
|
245
|
-
deps: { requestId: tags.required(requestIdTag) },
|
|
246
|
-
factory: (ctx, { requestId }) => {
|
|
247
|
-
console.log(`[${requestId}] Processing request`)
|
|
248
|
-
return processRequest(ctx.input)
|
|
249
|
-
}
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// Pass tags at execution time
|
|
253
|
-
const context = scope.createContext()
|
|
254
|
-
await context.exec({
|
|
255
|
-
flow: loggingFlow,
|
|
256
|
-
input: data,
|
|
257
|
-
tags: [requestIdTag('req-abc-123')]
|
|
258
|
-
})
|
|
73
|
+
User->>Context: ctx.close()
|
|
74
|
+
Context->>Context: run onClose cleanups (LIFO)
|
|
259
75
|
```
|
|
260
76
|
|
|
261
|
-
##
|
|
262
|
-
|
|
263
|
-
Controllers provide reactive access to atom state.
|
|
77
|
+
## Tag Inheritance
|
|
264
78
|
|
|
265
79
|
```mermaid
|
|
266
|
-
flowchart
|
|
267
|
-
subgraph
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
80
|
+
flowchart TD
|
|
81
|
+
subgraph Scope["Scope: tags=[tenantId('t1')]"]
|
|
82
|
+
subgraph Context["Context: tags=[requestId('r1')]"]
|
|
83
|
+
subgraph Flow["Flow deps:"]
|
|
84
|
+
T1["tags.required(tenantId) → 't1'"]
|
|
85
|
+
T2["tags.required(requestId) → 'r1'"]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
271
88
|
end
|
|
272
89
|
|
|
273
|
-
|
|
274
|
-
Controller -->|on 'resolved'| App
|
|
275
|
-
Controller -->|on 'resolving'| App
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
### Basic Usage
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
const ctrl = scope.controller(configAtom)
|
|
282
|
-
|
|
283
|
-
ctrl.state // 'idle' | 'resolving' | 'resolved' | 'failed'
|
|
284
|
-
ctrl.get() // value (throws if not resolved)
|
|
285
|
-
await ctrl.resolve() // resolve and wait
|
|
286
|
-
ctrl.invalidate() // trigger re-resolution
|
|
90
|
+
Note["Inner inherits from outer. Override by passing same tag at inner level."]
|
|
287
91
|
```
|
|
288
92
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
```typescript
|
|
292
|
-
ctrl.on('resolved', () => console.log('Updated:', ctrl.get()))
|
|
293
|
-
ctrl.on('resolving', () => console.log('Refreshing...'))
|
|
294
|
-
ctrl.on('*', () => console.log('State:', ctrl.state))
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### Controller as Dependency
|
|
298
|
-
|
|
299
|
-
Use `controller()` to receive a Controller instead of the resolved value:
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
302
|
-
const appAtom = atom({
|
|
303
|
-
deps: { config: controller(configAtom) },
|
|
304
|
-
factory: (ctx, { config }) => {
|
|
305
|
-
config.on('resolved', () => ctx.invalidate())
|
|
306
|
-
return new App(config.get())
|
|
307
|
-
}
|
|
308
|
-
})
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
### Fine-Grained Reactivity
|
|
312
|
-
|
|
313
|
-
Use `select()` to subscribe only when a derived value changes:
|
|
314
|
-
|
|
315
|
-
```typescript
|
|
316
|
-
const portSelect = scope.select(configAtom, (c) => c.port)
|
|
317
|
-
portSelect.subscribe(() => console.log('Port changed:', portSelect.get()))
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
## Tags
|
|
321
|
-
|
|
322
|
-
Tags pass contextual values through execution without explicit wiring.
|
|
93
|
+
## Controller Reactivity
|
|
323
94
|
|
|
324
95
|
```mermaid
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
subgraph Usage
|
|
331
|
-
R["tags.required(tag)"] --> |"throws if missing"| V1["string"]
|
|
332
|
-
O["tags.optional(tag)"] --> |"undefined if missing"| V2["string | undefined"]
|
|
333
|
-
A["tags.all(tag)"] --> |"collects all"| V3["string[]"]
|
|
334
|
-
end
|
|
335
|
-
```
|
|
96
|
+
sequenceDiagram
|
|
97
|
+
participant User
|
|
98
|
+
participant Controller
|
|
99
|
+
participant Atom
|
|
336
100
|
|
|
337
|
-
|
|
101
|
+
User->>Controller: scope.controller(atom)
|
|
102
|
+
User->>Controller: ctrl.on('resolved', listener)
|
|
103
|
+
Controller-->>User: unsubscribe fn
|
|
338
104
|
|
|
339
|
-
|
|
340
|
-
const tenantIdTag = tag<string>({ label: 'tenantId' })
|
|
341
|
-
const userRolesTag = tag<string[]>({ label: 'userRoles', default: [] })
|
|
342
|
-
|
|
343
|
-
// With parse validation
|
|
344
|
-
const userId = tag({
|
|
345
|
-
label: 'userId',
|
|
346
|
-
parse: (raw) => {
|
|
347
|
-
if (typeof raw !== 'string') throw new Error('Must be string')
|
|
348
|
-
return raw
|
|
349
|
-
}
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
userId('abc-123') // OK
|
|
353
|
-
userId(123) // Throws ParseError
|
|
354
|
-
```
|
|
105
|
+
Note over Controller: atom invalidated elsewhere
|
|
355
106
|
|
|
356
|
-
|
|
107
|
+
Controller->>Atom: state = resolving
|
|
108
|
+
Controller-->>User: 'resolving' listeners fire
|
|
109
|
+
Atom-->>Controller: new value
|
|
110
|
+
Controller->>Atom: state = resolved
|
|
111
|
+
Controller-->>User: 'resolved' listeners fire
|
|
357
112
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const tenantAtom = atom({
|
|
361
|
-
deps: { tenantId: tags.required(tenantIdTag) },
|
|
362
|
-
factory: (ctx, { tenantId }) => loadTenantData(tenantId)
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
// Optional - undefined if not found
|
|
366
|
-
const optionalAtom = atom({
|
|
367
|
-
deps: { tenantId: tags.optional(tenantIdTag) },
|
|
368
|
-
factory: (ctx, { tenantId }) => {
|
|
369
|
-
if (tenantId) {
|
|
370
|
-
return loadTenantData(tenantId)
|
|
371
|
-
}
|
|
372
|
-
return loadDefaultData()
|
|
373
|
-
}
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
// Collect all - returns array of all matching tags
|
|
377
|
-
const multiAtom = atom({
|
|
378
|
-
deps: { roles: tags.all(userRolesTag) },
|
|
379
|
-
factory: (ctx, { roles }) => roles.flat() // string[][]
|
|
380
|
-
})
|
|
113
|
+
User->>Controller: ctrl.get()
|
|
114
|
+
Controller-->>User: current value
|
|
381
115
|
```
|
|
382
116
|
|
|
383
|
-
|
|
117
|
+
## Primitives
|
|
384
118
|
|
|
385
|
-
|
|
119
|
+
### Scope
|
|
386
120
|
|
|
387
|
-
|
|
388
|
-
flowchart LR
|
|
389
|
-
subgraph Scope["createScope()"]
|
|
390
|
-
ST["tenantId: 'tenant-123'"]
|
|
391
|
-
subgraph Context["createContext()"]
|
|
392
|
-
CT["userRoles: ['admin']"]
|
|
393
|
-
subgraph Exec["exec()"]
|
|
394
|
-
ET["requestId: 'req-456'"]
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
end
|
|
121
|
+
Entry point. Manages atom lifecycles, caching, and cleanup orchestration.
|
|
398
122
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
123
|
+
- `createScope(options?)` — create with optional extensions, presets, tags
|
|
124
|
+
- `scope.ready` — wait for extension initialization
|
|
125
|
+
- `scope.resolve(atom)` — resolve and cache
|
|
126
|
+
- `scope.controller(atom)` — get reactive handle
|
|
127
|
+
- `scope.release(atom)` — run cleanups, remove from cache
|
|
128
|
+
- `scope.dispose()` — release all, cleanup extensions
|
|
129
|
+
- `scope.createContext(options?)` — create execution context for flows
|
|
130
|
+
- `scope.select(atom, selector)` — fine-grained reactivity
|
|
131
|
+
- `scope.flush()` — wait for pending invalidations
|
|
402
132
|
|
|
403
|
-
|
|
133
|
+
### Atom
|
|
404
134
|
|
|
405
|
-
|
|
135
|
+
Long-lived cached dependency with lifecycle.
|
|
406
136
|
|
|
407
|
-
|
|
408
|
-
|
|
137
|
+
- Dependencies on other atoms via `deps`
|
|
138
|
+
- `ctx.cleanup(fn)` — runs on invalidate and release (LIFO order)
|
|
139
|
+
- `ctx.invalidate()` — schedule re-resolution
|
|
140
|
+
- `ctx.data` — storage that survives invalidation (cleared on release)
|
|
141
|
+
- `ctx.data.getOrSet(tag, defaultValue)` — initialize and retrieve in one call
|
|
409
142
|
|
|
410
|
-
|
|
411
|
-
const tenantId = tenantIdTag.get(tags)
|
|
143
|
+
### Flow
|
|
412
144
|
|
|
413
|
-
|
|
414
|
-
const roles = userRolesTag.find(tags)
|
|
145
|
+
Short-lived operation with input/output.
|
|
415
146
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
147
|
+
- `parse` — validate/transform input before factory (throws `ParseError` on failure)
|
|
148
|
+
- `typed<T>()` — type marker without runtime parsing
|
|
149
|
+
- Dependencies on atoms via `deps`
|
|
150
|
+
- `ctx.input` — typed input access
|
|
151
|
+
- `ctx.onClose(fn)` — cleanup when context closes
|
|
419
152
|
|
|
420
|
-
|
|
153
|
+
### Tag
|
|
421
154
|
|
|
422
|
-
|
|
155
|
+
Contextual value passed through execution without explicit wiring.
|
|
423
156
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
end
|
|
157
|
+
- `tag({ label, default?, parse? })` — define with optional default and validation
|
|
158
|
+
- `tags.required(tag)` — dependency that throws if missing
|
|
159
|
+
- `tags.optional(tag)` — dependency that returns undefined if missing
|
|
160
|
+
- `tags.all(tag)` — collects all values from inheritance chain
|
|
161
|
+
- Tags inherit: Scope → Context → exec call
|
|
430
162
|
|
|
431
|
-
|
|
432
|
-
A2[resolve dbAtom] --> P[preset check]
|
|
433
|
-
P -->|value| V2[mockDb]
|
|
434
|
-
P -->|atom| F2[testAtom.factory]
|
|
435
|
-
end
|
|
436
|
-
```
|
|
163
|
+
### Controller
|
|
437
164
|
|
|
438
|
-
|
|
439
|
-
```typescript
|
|
440
|
-
const scope = createScope({
|
|
441
|
-
presets: [preset(dbAtom, mockDb)]
|
|
442
|
-
})
|
|
443
|
-
```
|
|
165
|
+
Reactive handle for observing and controlling atom state.
|
|
444
166
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
167
|
+
- `ctrl.state` — sync access: `'idle' | 'resolving' | 'resolved' | 'failed'`
|
|
168
|
+
- `ctrl.get()` — sync value access (throws if not resolved, returns stale during resolving)
|
|
169
|
+
- `ctrl.resolve()` — async resolution
|
|
170
|
+
- `ctrl.invalidate()` — trigger re-resolution (runs factory)
|
|
171
|
+
- `ctrl.set(value)` — replace value directly (skips factory)
|
|
172
|
+
- `ctrl.update(fn)` — transform value: `fn(currentValue) → newValue` (skips factory)
|
|
173
|
+
- `ctrl.on(event, listener)` — subscribe to `'resolved' | 'resolving' | '*'`
|
|
174
|
+
- Use `controller(atom)` in deps for reactive dependency (unresolved, you control timing)
|
|
451
175
|
|
|
452
|
-
|
|
176
|
+
### Preset
|
|
453
177
|
|
|
454
|
-
|
|
178
|
+
Value injection for testing. Bypasses factory entirely.
|
|
455
179
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
participant Ext1 as Extension 1
|
|
460
|
-
participant Ext2 as Extension 2
|
|
461
|
-
participant Core as Core Logic
|
|
462
|
-
|
|
463
|
-
App->>Ext1: resolve(atom)
|
|
464
|
-
Ext1->>Ext1: before logic
|
|
465
|
-
Ext1->>Ext2: next()
|
|
466
|
-
Ext2->>Ext2: before logic
|
|
467
|
-
Ext2->>Core: next()
|
|
468
|
-
Core-->>Ext2: value
|
|
469
|
-
Ext2->>Ext2: after logic
|
|
470
|
-
Ext2-->>Ext1: value
|
|
471
|
-
Ext1->>Ext1: after logic
|
|
472
|
-
Ext1-->>App: value
|
|
473
|
-
```
|
|
180
|
+
- `preset(atom, value)` — inject direct value
|
|
181
|
+
- `preset(atom, otherAtom)` — redirect to another atom's factory
|
|
182
|
+
- Pass via `createScope({ presets: [...] })`
|
|
474
183
|
|
|
475
|
-
### Extension
|
|
184
|
+
### Extension
|
|
476
185
|
|
|
477
|
-
|
|
478
|
-
interface Extension {
|
|
479
|
-
readonly name: string
|
|
480
|
-
init?(scope: Scope): MaybePromise<void>
|
|
481
|
-
wrapResolve?<T>(next: () => Promise<T>, atom: Atom<T>, scope: Scope): Promise<T>
|
|
482
|
-
wrapExec?<T>(next: () => Promise<T>, target: Flow | Function, ctx: ExecutionContext): Promise<T>
|
|
483
|
-
dispose?(scope: Scope): MaybePromise<void>
|
|
484
|
-
}
|
|
485
|
-
```
|
|
186
|
+
AOP-style middleware for cross-cutting concerns.
|
|
486
187
|
|
|
487
|
-
|
|
188
|
+
- `init(scope)` — setup when scope created
|
|
189
|
+
- `wrapResolve(next, atom, scope)` — intercept atom resolution
|
|
190
|
+
- `wrapExec(next, target, ctx)` — intercept flow execution
|
|
191
|
+
- `dispose(scope)` — cleanup when scope disposed
|
|
192
|
+
- Pass via `createScope({ extensions: [...] })`
|
|
488
193
|
|
|
489
|
-
|
|
490
|
-
const timingExtension: Lite.Extension = {
|
|
491
|
-
name: 'timing',
|
|
492
|
-
|
|
493
|
-
wrapResolve: async (next, atom, scope) => {
|
|
494
|
-
const start = performance.now()
|
|
495
|
-
const result = await next()
|
|
496
|
-
console.log(`Resolved in ${performance.now() - start}ms`)
|
|
497
|
-
return result
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const scope = createScope({ extensions: [timingExtension] })
|
|
502
|
-
```
|
|
194
|
+
## Full API
|
|
503
195
|
|
|
504
|
-
|
|
196
|
+
See [`dist/index.d.mts`](./dist/index.d.mts) for complete type definitions.
|
|
505
197
|
|
|
506
|
-
|
|
198
|
+
All types available under the `Lite` namespace:
|
|
507
199
|
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
[*] --> idle
|
|
511
|
-
idle --> resolving: resolve()
|
|
512
|
-
resolving --> resolved: success
|
|
513
|
-
resolving --> failed: error
|
|
514
|
-
resolved --> resolving: invalidate()
|
|
515
|
-
failed --> resolving: invalidate()
|
|
516
|
-
resolved --> idle: release()
|
|
517
|
-
failed --> idle: release()
|
|
200
|
+
```typescript
|
|
201
|
+
import type { Lite } from '@pumped-fn/lite'
|
|
518
202
|
```
|
|
519
203
|
|
|
520
|
-
|
|
204
|
+
## Edge Cases
|
|
521
205
|
|
|
522
|
-
|
|
523
|
-
sequenceDiagram
|
|
524
|
-
participant App
|
|
525
|
-
participant Scope
|
|
526
|
-
participant Atom
|
|
527
|
-
participant Factory
|
|
528
|
-
|
|
529
|
-
App->>Scope: resolve(atom)
|
|
530
|
-
Scope->>Atom: check state
|
|
531
|
-
|
|
532
|
-
alt idle
|
|
533
|
-
Scope->>Atom: state = resolving
|
|
534
|
-
Scope->>Factory: factory(ctx, deps)
|
|
535
|
-
Factory-->>Scope: value
|
|
536
|
-
Scope->>Atom: state = resolved
|
|
537
|
-
else resolved
|
|
538
|
-
Atom-->>App: cached value
|
|
539
|
-
end
|
|
206
|
+
### Controller.set() / update()
|
|
540
207
|
|
|
541
|
-
|
|
542
|
-
|
|
208
|
+
| State | Behavior |
|
|
209
|
+
|-------|----------|
|
|
210
|
+
| `idle` | Throws "Atom not resolved" |
|
|
211
|
+
| `resolving` | Queues, applies after resolution completes |
|
|
212
|
+
| `resolved` | Queues normally |
|
|
213
|
+
| `failed` | Throws the stored error |
|
|
543
214
|
|
|
544
|
-
|
|
215
|
+
Both run cleanups before applying the new value.
|
|
545
216
|
|
|
546
|
-
|
|
547
|
-
sequenceDiagram
|
|
548
|
-
participant App
|
|
549
|
-
participant Controller
|
|
550
|
-
participant Scope
|
|
551
|
-
participant Queue
|
|
552
|
-
participant Factory
|
|
553
|
-
|
|
554
|
-
App->>Controller: invalidate()
|
|
555
|
-
Controller->>Scope: queue invalidation
|
|
556
|
-
Scope->>Queue: add atom
|
|
557
|
-
|
|
558
|
-
Note over Queue: queueMicrotask (batched)
|
|
559
|
-
|
|
560
|
-
Queue->>Scope: flush
|
|
561
|
-
Scope->>Scope: run cleanups (LIFO)
|
|
562
|
-
Scope->>Scope: state = resolving
|
|
563
|
-
Scope->>Factory: factory(ctx, deps)
|
|
564
|
-
Factory-->>Scope: new value
|
|
565
|
-
Scope->>Scope: state = resolved
|
|
566
|
-
Scope->>Scope: notify listeners
|
|
567
|
-
```
|
|
217
|
+
### DataStore.get()
|
|
568
218
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
### Factory Functions
|
|
572
|
-
|
|
573
|
-
| Function | Description |
|
|
574
|
-
|----------|-------------|
|
|
575
|
-
| `createScope(options?)` | Create DI container (returns Scope with `ready` promise) |
|
|
576
|
-
| `atom(config)` | Define long-lived cached dependency |
|
|
577
|
-
| `flow(config)` | Define short-lived operation template (optional `name`, `parse`) |
|
|
578
|
-
| `tag(config)` | Define contextual value (optional `parse` for validation) |
|
|
579
|
-
| `controller(atom)` | Create controller dependency helper |
|
|
580
|
-
| `preset(atom, value)` | Create value injection preset |
|
|
581
|
-
|
|
582
|
-
### Scope Methods
|
|
583
|
-
|
|
584
|
-
| Method | Description |
|
|
585
|
-
|--------|-------------|
|
|
586
|
-
| `scope.ready` | Promise that resolves when extensions are initialized |
|
|
587
|
-
| `scope.resolve(atom)` | Resolve atom and return cached value |
|
|
588
|
-
| `scope.controller(atom)` | Get Controller for atom |
|
|
589
|
-
| `scope.select(atom, selector, options?)` | Create fine-grained subscription |
|
|
590
|
-
| `scope.release(atom)` | Release atom (run cleanups, remove from cache) |
|
|
591
|
-
| `scope.dispose()` | Dispose scope (release all atoms, cleanup extensions) |
|
|
592
|
-
| `scope.createContext(options?)` | Create ExecutionContext for flows |
|
|
593
|
-
| `scope.on(event, atom, listener)` | Subscribe to atom state changes |
|
|
594
|
-
|
|
595
|
-
### Controller Methods
|
|
596
|
-
|
|
597
|
-
| Method | Description |
|
|
598
|
-
|--------|-------------|
|
|
599
|
-
| `ctrl.state` | Current state: `'idle'` \| `'resolving'` \| `'resolved'` \| `'failed'` |
|
|
600
|
-
| `ctrl.get()` | Get resolved value (throws if not resolved) |
|
|
601
|
-
| `ctrl.resolve()` | Resolve and return value |
|
|
602
|
-
| `ctrl.release()` | Release atom |
|
|
603
|
-
| `ctrl.invalidate()` | Trigger re-resolution |
|
|
604
|
-
| `ctrl.on(event, listener)` | Subscribe: `'resolved'` \| `'resolving'` \| `'*'` |
|
|
605
|
-
|
|
606
|
-
### ExecutionContext Methods
|
|
607
|
-
|
|
608
|
-
| Method | Description |
|
|
609
|
-
|--------|-------------|
|
|
610
|
-
| `ctx.input` | Current execution input |
|
|
611
|
-
| `ctx.scope` | Parent scope |
|
|
612
|
-
| `ctx.exec(options)` | Execute flow or function |
|
|
613
|
-
| `ctx.onClose(fn)` | Register cleanup for context close |
|
|
614
|
-
| `ctx.close()` | Close context and run cleanups |
|
|
615
|
-
|
|
616
|
-
### ResolveContext Methods
|
|
617
|
-
|
|
618
|
-
| Method | Description |
|
|
619
|
-
|--------|-------------|
|
|
620
|
-
| `ctx.cleanup(fn)` | Register cleanup for atom invalidation/release |
|
|
621
|
-
| `ctx.invalidate()` | Schedule self-invalidation |
|
|
622
|
-
| `ctx.scope` | Parent scope |
|
|
623
|
-
| `ctx.data` | Per-atom DataStore (survives invalidation) |
|
|
624
|
-
|
|
625
|
-
### Type Guards
|
|
626
|
-
|
|
627
|
-
| Function | Description |
|
|
628
|
-
|----------|-------------|
|
|
629
|
-
| `isAtom(value)` | Check if value is Atom |
|
|
630
|
-
| `isFlow(value)` | Check if value is Flow |
|
|
631
|
-
| `isTag(value)` | Check if value is Tag |
|
|
632
|
-
| `isTagged(value)` | Check if value is Tagged |
|
|
633
|
-
| `isPreset(value)` | Check if value is Preset |
|
|
634
|
-
| `isControllerDep(value)` | Check if value is ControllerDep |
|
|
635
|
-
|
|
636
|
-
### Types
|
|
637
|
-
|
|
638
|
-
All types are available under the `Lite` namespace:
|
|
219
|
+
`ctx.data.get(tag)` always returns `T | undefined` (Map-like semantics). Use `getOrSet(tag)` when you need the tag's default value.
|
|
639
220
|
|
|
640
221
|
```typescript
|
|
641
|
-
|
|
222
|
+
const countTag = tag<number>({ label: 'count', default: 0 })
|
|
642
223
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
224
|
+
ctx.data.get(countTag) // undefined (not stored)
|
|
225
|
+
ctx.data.getOrSet(countTag) // 0 (uses default, now stored)
|
|
226
|
+
ctx.data.get(countTag) // 0 (now stored)
|
|
646
227
|
```
|
|
647
228
|
|
|
648
|
-
## Design Principles
|
|
649
|
-
|
|
650
|
-
1. **Minimal API** - Every export is expensive to learn
|
|
651
|
-
2. **Zero dependencies** - No runtime dependencies
|
|
652
|
-
3. **Explicit lifecycle** - No magic, clear state transitions
|
|
653
|
-
4. **Composable** - Effects compose through deps
|
|
654
|
-
5. **Type-safe** - Full TypeScript inference
|
|
655
|
-
|
|
656
229
|
## License
|
|
657
230
|
|
|
658
231
|
MIT
|