@pumped-fn/lite 2.1.3 → 2.1.5

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # @pumped-fn/lite
2
2
 
3
+ ## 2.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - **@pumped-fn/lite** — Expand CLI corpus for LLM comprehension
8
+
9
+ - New `mental-model` category: atom/flow/resource lifetimes, scope vs context, key invariant
10
+ - New `tanstack-start` category: singleton scope, per-request execContext middleware, tag-seeding, client hydration
11
+ - `primitives`: add `resource()`, clarify ResolveContext vs ExecutionContext factory types
12
+ - `context`: split two context types with full API surfaces
13
+ - `reactivity`: disambiguate `controller()` dep marker vs `scope.controller()`, document `watch:true`
14
+ - `tags`: add 6-level resolution hierarchy (exec > flow > context > data > scope > default)
15
+
16
+ **@pumped-fn/lite-react** — Test consolidation and coverage improvements
17
+
18
+ - 50 → 37 tests (-26%) with coverage increase: 90.5% → 97.3% stmt, 81.6% → 94.3% branch
19
+ - Add useSelect non-suspense coverage tests (auto-resolve, failed, refresh error)
20
+ - Import from barrel file, exclude uninstrumentable index.ts from coverage config
21
+
22
+ **@pumped-fn/lite-hmr** — Widen vite peer dependency to `^5 || ^6 || ^7 || ^8`
23
+
24
+ **All packages** — Upgrade vitest 4.0.18 → 4.1.0, pin vite 6.x in catalog
25
+
26
+ - 10ec5a7: **@pumped-fn/lite-react** — Harden for modern React (RSC, Compiler, useSelect non-suspense)
27
+
28
+ - Add `'use client'` directive for RSC/Next.js App Router compatibility
29
+ - `useController({ resolve: true })` retries once on failed atoms before throwing to ErrorBoundary
30
+ - `useSelect` gains `{ suspense: false }` mode returning `UseSelectState<S>` with data/loading/error
31
+ - Selector errors in non-suspense `useSelect` now surface in the `error` field
32
+ - React Compiler-safe: selector/eq via plain closures, useRef caches in getSnapshot only
33
+ - `UseSelectOptions<S>` split into discriminated union for sound overload resolution
34
+ - New exports: `UseSelectSuspenseOptions`, `UseSelectManualOptions`, `UseSelectOptions`, `UseSelectState`
35
+
36
+ **@pumped-fn/lite** — `release()` now notifies listeners before cache deletion (fixes hanging promises)
37
+
38
+ - 73d426b: Significant performance improvements to scope internals — no API changes.
39
+
40
+ **Resolve path**
41
+
42
+ - Non-async `resolve()` with cached Promise for resolved atoms (+56% cache hits)
43
+ - Sync fast-path in `resolveDeps` for already-resolved atom and controller deps
44
+ - Skip extension closure chain when scope has zero extensions (+111% flow execution)
45
+
46
+ **Invalidation & reactivity**
47
+
48
+ - Optimized `doInvalidateSequential` set fast-path (+57% listener dispatch, +75% select)
49
+ - Simplified invalidation chain scheduling (lighter microtask setup)
50
+ - Eliminated redundant Map.get calls in listener subscribe/unsubscribe (+63% churn)
51
+
52
+ **Execution context**
53
+
54
+ - Non-async `close()` when no cleanups registered
55
+ - Skip `ContextDataImpl` allocation when no tags configured
56
+ - Early return in `emitStateChange` for the common no-state-listeners case
57
+
58
+ **Misc**
59
+
60
+ - Pass entry directly to notification methods (avoid cache lookups)
61
+ - Simplified `controller.get()` branching
62
+ - `for-in` over `Object.values` in release/GC to avoid array allocation
63
+
64
+ ## 2.1.4
65
+
66
+ ### Patch Changes
67
+
68
+ - 39dbe6c: Harden the `lite` type surface so runtime-invalid dependency shapes fail at compile
69
+ time. `watch: true` controller deps now only type-check in atom dependencies,
70
+ fake tag-like deps no longer satisfy the public overloads, and compile-only
71
+ fixtures lock the contract against regression.
72
+
3
73
  ## 2.1.3
4
74
 
5
75
  ### Patch Changes
package/dist/cli.cjs CHANGED
@@ -18,11 +18,48 @@ const categories = {
18
18
  title: "What is @pumped-fn/lite",
19
19
  content: extractOverview
20
20
  },
21
+ "mental-model": {
22
+ title: "Mental Model",
23
+ content: `@pumped-fn/lite is a scoped dependency graph with three primitives:
24
+
25
+ ATOM = singleton (cached per scope)
26
+ Created once. Lives as long as the scope. Think: db pool, config, auth service.
27
+ Resolved via scope.resolve(atom). Second call returns cached value.
28
+ Factory receives ResolveContext: ctx.cleanup(), ctx.invalidate(), ctx.scope, ctx.data.
29
+
30
+ FLOW = transient operation (new instance per exec)
31
+ Runs once per ctx.exec() call. Think: HTTP handler, mutation, query.
32
+ Factory receives ExecutionContext: ctx.exec(), ctx.onClose(), ctx.input, ctx.parent, ctx.data.
33
+
34
+ RESOURCE = execution-scoped singleton (shared within an exec chain)
35
+ Created fresh per root ctx.exec(). Shared across nested exec() calls via seek-up.
36
+ Think: per-request logger, transaction, trace span.
37
+ Declared as a flow dep, NOT called directly.
38
+
39
+ Scope = the container. Owns all atom caches. One per process (server) or per component tree (React).
40
+ ExecutionContext = the request boundary. Created per request/operation. Carries tags. Closes with cleanup.
41
+ Controller = opt-in reactive handle for an atom. Enables set/update/invalidate/subscribe.
42
+ Tag = ambient typed value. Propagates through scope → context → nested exec. No parameter drilling.
43
+ Preset = test/environment override. Replaces any atom or flow without touching production code.
44
+ Extension = middleware for resolve and exec. Wraps every atom resolution and flow execution.
45
+
46
+ Key invariant: atoms are resolved from scope, flows are executed from context.
47
+ scope.resolve(atom) ✓ correct
48
+ ctx.exec({ flow, input }) ✓ correct
49
+ scope.resolve(flow) ✗ wrong — flows are not cached
50
+ ctx.exec({ atom }) ✗ wrong — atoms are not executed`
51
+ },
21
52
  primitives: {
22
53
  title: "Primitives API",
23
- content: `atom({ factory, deps?, tags?, keepAlive? })
24
- Creates a managed effect. Factory receives (ctx, resolvedDeps) and returns a value.
25
- Cached per scope. Supports cleanup via ctx.onClose().
54
+ content: `There are three primitives with distinct lifetimes:
55
+ atom — SINGLETON per scope. Created once, cached, reused everywhere. Think: db pool, config, service instance.
56
+ flow — EPHEMERAL per call. New execution each time ctx.exec() is called. Think: HTTP handler, mutation, query.
57
+ resource — EPHEMERAL per execution chain. Created once per ctx.exec() tree, shared across nested execs. Think: logger, transaction, trace span.
58
+
59
+ atom({ factory, deps?, tags?, keepAlive? })
60
+ Factory receives (resolveCtx, resolvedDeps) → value.
61
+ resolveCtx has: cleanup(fn), invalidate(), scope, data.
62
+ Resolved via scope.resolve(atom). Cached — second resolve() returns same value.
26
63
 
27
64
  import { atom } from "@pumped-fn/lite"
28
65
  const dbAtom = atom({ factory: () => createDbPool() })
@@ -32,7 +69,9 @@ const categories = {
32
69
  })
33
70
 
34
71
  flow({ factory, parse?, deps?, tags? })
35
- Operation template executed per call. parse validates input, factory runs logic.
72
+ Factory receives (executionCtx, resolvedDeps) output.
73
+ executionCtx has: exec(), onClose(fn), input, parent, data, scope, name.
74
+ Executed via ctx.exec({ flow, input }). Never cached — each call runs the factory.
36
75
 
37
76
  import { flow, typed } from "@pumped-fn/lite"
38
77
  const getUser = flow({
@@ -41,21 +80,45 @@ flow({ factory, parse?, deps?, tags? })
41
80
  factory: (ctx, { db }) => db.findUser(ctx.input.id),
42
81
  })
43
82
 
83
+ resource({ factory, deps?, name? })
84
+ Like a flow factory but resolved as a DEPENDENCY of flows, not called directly.
85
+ Created fresh per execution chain. Shared via seek-up: nested ctx.exec() reuses parent's instance.
86
+ Factory receives (executionCtx, resolvedDeps) → instance.
87
+ Cleanup via ctx.onClose(fn).
88
+
89
+ import { resource } from "@pumped-fn/lite"
90
+ const txResource = resource({
91
+ deps: { db: dbAtom },
92
+ factory: (ctx, { db }) => {
93
+ const tx = db.beginTransaction()
94
+ ctx.onClose(result => result.ok ? tx.commit() : tx.rollback())
95
+ return tx
96
+ },
97
+ })
98
+ // Used as a flow dep — NOT called directly
99
+ const saveUser = flow({
100
+ deps: { tx: txResource },
101
+ factory: (ctx, { tx }) => tx.insert("users", ctx.input),
102
+ })
103
+
44
104
  tag({ label, default?, parse? })
45
- Ambient context value. Attach to atoms/flows/contexts. Retrieve via tag.get/find/collect.
105
+ Ambient context value. Propagates through scope context → exec hierarchy.
106
+ Resolution order: exec tags > context tags > scope tags (nearest wins).
46
107
 
47
108
  import { tag } from "@pumped-fn/lite"
48
109
  const tenantTag = tag<string>({ label: "tenant" })
49
110
 
50
111
  preset(target, value)
51
- Override an atom's resolved value. Used for testing and multi-tenant isolation.
112
+ Override an atom or flow's resolved value. Used for testing and multi-tenant isolation.
113
+ value can be: a literal, another atom (redirect), or a function (flow only).
52
114
 
53
115
  import { preset } from "@pumped-fn/lite"
54
116
  const mockDb = preset(dbAtom, fakeDatabaseInstance)
55
117
 
56
118
  service({ factory, deps? })
57
119
  Convenience wrapper for atom whose value is an object of methods.
58
- Each method receives (ctx, ...args) for tracing/auth integration.`
120
+ Each method MUST have (ctx: ExecutionContext, ...args) as signature.
121
+ Called via ctx.exec({ fn: svc.method, params: [args] }) for lifecycle/tracing.`
59
122
  },
60
123
  scope: {
61
124
  title: "Scope Management",
@@ -82,89 +145,131 @@ scope.dispose() → void release everything, run all c
82
145
  },
83
146
  context: {
84
147
  title: "ExecutionContext",
85
- content: `ctx = scope.createContext({ tags? })
86
- Execution boundary. Tags merge with scope tags. Cleanup runs LIFO on close.
87
-
88
- ctx.exec({ flow, input?, tags? }) → Promise<output>
89
- Execute a flow within this context. Creates a child context with merged tags.
148
+ content: `IMPORTANT: There are two context types. Don't confuse them.
149
+
150
+ ResolveContext (received by atom factories):
151
+ ctx.cleanup(fn) register cleanup (runs LIFO on release/invalidate)
152
+ ctx.invalidate() schedule re-resolution after current factory completes
153
+ ctx.scope the owning Scope
154
+ ctx.data per-atom key-value store (persists across invalidations)
155
+
156
+ ExecutionContext (received by flow factories, resource factories, and inline fns):
157
+ ctx.exec(...) execute a nested flow or function (creates child context)
158
+ ctx.onClose(fn) register cleanup (runs LIFO on close, receives CloseResult)
159
+ ctx.close(result?) close this context, run all cleanups
160
+ ctx.input parsed input (flows only)
161
+ ctx.parent parent ExecutionContext (undefined for root)
162
+ ctx.name exec name or flow name
163
+ ctx.scope the owning Scope
164
+ ctx.data per-context key-value store with tag support
165
+
166
+ ctx = scope.createContext({ tags? })
167
+ Creates a root ExecutionContext. Tags merge: exec tags > context tags > scope tags.
168
+
169
+ ctx.exec({ flow, input?, rawInput?, tags? }) → Promise<output>
170
+ Execute a flow. Creates a child context with merged tags.
171
+ If flow has parse: rawInput goes through parse first, input skips parse.
90
172
  Child context closes automatically after execution.
91
173
 
92
- ctx.exec({ fn, params?, tags? }) → Promise<result>
174
+ ctx.exec({ fn, params?, name?, tags? }) → Promise<result>
93
175
  Execute an inline function: fn(childCtx, ...params).
94
176
  Same child-context lifecycle as flow execution.
95
177
 
96
- ctx.onClose(cleanup) → void register cleanup (runs LIFO on ctx.close)
97
- ctx.close() void run all registered cleanups in LIFO order
178
+ ctx.data (both context types)
179
+ Raw: get(key) / set(key, val) / has(key) / delete(key) / clear() / seek(key)
180
+ Typed: getTag(tag) / setTag(tag, val) / hasTag(tag) / deleteTag(tag) / seekTag(tag) / getOrSetTag(tag, default?)
98
181
 
99
- ctx.data
100
- Key-value store scoped to the context:
101
- Raw: get(key) / set(key, val) / has(key) / delete(key) / clear() / seek(key)
102
- Typed: getTag(tag) / setTag(tag, val) / hasTag(tag) / deleteTag(tag) / seekTag(tag) / getOrSetTag(tag, factory)
103
-
104
- seek/seekTag walks up the context chain to find values in parent contexts.`
182
+ seek/seekTag walks up the parent chain to find values set in ancestor contexts.
183
+ This is how tags propagate: middleware sets a tag, nested flows read it via seekTag.`
105
184
  },
106
185
  reactivity: {
107
186
  title: "Reactivity (opt-in)",
108
- content: `controller(atom) → Controller
109
- Opt-in reactive handle for an atom.
110
-
111
- ctrl.get() current value (must be resolved first)
112
- ctrl.resolve() → Promise<value> (resolve if not yet)
113
- ctrl.set(value) → replace value, notify listeners
114
- ctrl.update(fn) → update value via function, notify listeners
115
- ctrl.invalidate() re-run factory, notify listeners
116
- ctrl.release() → release atom, run cleanups
117
- ctrl.on(event, listener) unsubscribe
118
- events: 'resolving' | 'resolved' | '*'
187
+ content: `Atoms are STATIC by default — resolved once, value never changes.
188
+ Reactivity is opt-in via controllers. Two ways to get a controller:
189
+
190
+ 1. scope.controller(atom) Controller
191
+ Retrieve the reactive handle for an atom. Same instance per atom per scope.
192
+ Used externally (app code, React hooks, middleware).
193
+
194
+ 2. controller(atom, opts?) ControllerDep (dep marker)
195
+ Wrap an atom dep so the factory receives a Controller instead of the resolved value.
196
+ Used inside deps: { cfg: controller(configAtom, { resolve: true }) }
197
+ This is NOT the same as scope.controller() — it's a dep declaration.
198
+
199
+ Controller API:
200
+ ctrl.state → 'idle' | 'resolving' | 'resolved' | 'failed'
201
+ ctrl.get() → current value (throws if not resolved)
202
+ ctrl.resolve() → Promise<value> (resolve if not yet)
203
+ ctrl.set(value) → replace value, notify listeners, skip factory
204
+ ctrl.update(fn) → transform value via function, notify listeners
205
+ ctrl.invalidate() → re-run factory, notify listeners
206
+ ctrl.release() → release atom, run cleanups
207
+ ctrl.on(event, listener) → unsubscribe
208
+ events: 'resolving' | 'resolved' | 'failed' | '*'
209
+
210
+ Controller as dependency (opts):
211
+ controller(atom) → dep receives Controller (idle, must manually resolve)
212
+ controller(atom, { resolve: true }) → dep receives Controller (pre-resolved before factory runs)
213
+ controller(atom, { resolve: true, watch: true }) → ALSO auto-invalidates parent when dep value changes
214
+ controller(atom, { resolve: true, watch: true, eq }) → custom equality gate (default: structural deep equal for plain objects, Object.is otherwise)
215
+
216
+ watch:true replaces the manual pattern:
217
+ ctx.cleanup(ctx.scope.on('resolved', dep, () => ctx.invalidate()))
218
+ With the declarative:
219
+ deps: { src: controller(srcAtom, { resolve: true, watch: true }) }
220
+ The watch listener is auto-cleaned on re-resolve, release, and dispose.
119
221
 
120
222
  select(atom, selector, { eq? }) → SelectHandle
121
223
  Derived state slice. Only notifies when selected value changes per eq function.
122
-
123
224
  handle.get() → current selected value
124
225
  handle.subscribe(fn) → unsubscribe
226
+ handle.dispose() → clean up internal subscription
125
227
 
126
- scope.on('resolved', atom, listener) → unsubscribe
127
- Listen to atom resolution events at scope level.
128
-
129
- Controller as dependency:
130
- import { controller } from "@pumped-fn/lite"
131
- const serverAtom = atom({
132
- deps: { cfg: controller(configAtom, { resolve: true }) },
133
- factory: (ctx, { cfg }) => {
134
- cfg.on('resolved', () => ctx.invalidate())
135
- return createServer(cfg.get())
136
- },
137
- })`
228
+ scope.on('resolving' | 'resolved' | 'failed', atom, listener) → unsubscribe
229
+ Listen to atom state transitions at scope level.`
138
230
  },
139
231
  tags: {
140
232
  title: "Tag System",
141
- content: `tag<T>({ label, default?, parse? }) → Tag<T>
142
- Define an ambient context value type.
233
+ content: `Tags are typed ambient values that propagate without parameter drilling.
234
+
235
+ tag<T>({ label, default?, parse? }) → Tag<T>
236
+ Define a tag type. The tag object is both a type definition and a factory:
237
+ const tenantTag = tag<string>({ label: "tenant" })
238
+ const tagged = tenantTag("acme") // creates Tagged<string>
239
+
240
+ Resolution hierarchy (nearest wins):
241
+ 1. exec tags: ctx.exec({ flow, tags: [tenantTag("exec")] })
242
+ 2. flow tags: flow({ tags: [tenantTag("flow")] })
243
+ 3. context tags: scope.createContext({ tags: [tenantTag("ctx")] })
244
+ 4. ctx.data: parent ctx.data.setTag(tenantTag, "middleware") ← seekTag walks up
245
+ 5. scope tags: createScope({ tags: [tenantTag("scope")] })
246
+ 6. tag default: tag({ label: "tenant", default: "default" })
143
247
 
144
- tag(value) → Tagged<T>
145
- Create a tagged value to attach to atoms, flows, or contexts.
248
+ In atom deps: tags resolve from scope tags (atoms live at scope level).
249
+ In flow deps: tags resolve from exec/context/scope hierarchy + ctx.data seek-up.
146
250
 
147
251
  Attaching tags:
148
- atom({ tags: [tenantTag("acme")] })
149
- flow({ tags: [roleTag("admin")] })
150
- scope.createContext({ tags: [userTag(currentUser)] })
151
- ctx.exec({ flow, tags: [localeTag("en")] })
252
+ atom({ tags: [tenantTag("acme")] }) metadata on atom definition
253
+ flow({ tags: [roleTag("admin")] }) applied to child context
254
+ scope.createContext({ tags: [userTag(currentUser)] }) on context creation
255
+ ctx.exec({ flow, tags: [localeTag("en")] }) on specific execution
256
+ ctx.data.setTag(tenantTag, "middleware-set") programmatic, propagates to children
152
257
 
153
258
  Reading tags:
154
- tag.get(source) → T first match or throw
155
- tag.find(source) → T | undefined first match or undefined
156
- tag.collect(source) → T[] all matches
259
+ tag.get(source) → T first match or throw
260
+ tag.find(source) → T | undefined first match or undefined
261
+ tag.collect(source) → T[] all matches
157
262
 
158
- Context data integration:
159
- ctx.data.setTag(tag, value)
160
- ctx.data.getTag(tag) T
161
- ctx.data.seekTag(tag) T (walks parent chain)
162
- ctx.data.hasTag(tag) boolean
263
+ Context data:
264
+ ctx.data.setTag(tag, value) set on current context
265
+ ctx.data.getTag(tag) read from current context only
266
+ ctx.data.seekTag(tag) walk up parent chain until found
267
+ ctx.data.hasTag(tag) check current context only
163
268
 
164
269
  Tag executor (dependency wiring):
165
- tags.required(tag) → resolves tag or throws
166
- tags.optional(tag) → resolves tag or undefined
167
- tags.all(tag) → resolves all values for tag
270
+ tags.required(tag) → T resolves tag or throws (atom deps: scope, flow deps: hierarchy)
271
+ tags.optional(tag) → T | undefined resolves or undefined
272
+ tags.all(tag) → T[] collects from all levels of hierarchy
168
273
 
169
274
  Introspection:
170
275
  tag.atoms() → Atom[] with this tag attached
@@ -273,6 +378,86 @@ Atom with cleanup:
273
378
  Atom retention / GC:
274
379
  createScope({ gc: { enabled: true, graceMs: 3000 } })
275
380
  atom({ keepAlive: true }) // never GC'd`
381
+ },
382
+ "tanstack-start": {
383
+ title: "TanStack Start Integration",
384
+ content: `Singleton scope at server entry, per-request ExecutionContext via middleware.
385
+
386
+ Server entry — one scope per process:
387
+ const scope = createScope({ extensions: [otel()], tags: [envTag(env)] })
388
+ export default createServerEntry({
389
+ async fetch(request) {
390
+ return handler.fetch(request, { context: { scope } })
391
+ },
392
+ })
393
+
394
+ Execution context middleware — per-request lifecycle:
395
+ export const execCtxMiddleware = createMiddleware()
396
+ .server(async ({ next, context: { scope } }) => {
397
+ const execContext = scope.createContext({})
398
+ try {
399
+ return await next({ context: { execContext } })
400
+ } finally {
401
+ await execContext.close()
402
+ }
403
+ })
404
+
405
+ Tag-seeding middleware — ambient data for downstream:
406
+ export const authMiddleware = createMiddleware()
407
+ .middleware([execCtxMiddleware])
408
+ .server(async ({ next, context: { execContext } }) => {
409
+ const user = await resolveCurrentUser()
410
+ execContext.data.setTag(currentUserTag, user)
411
+ return next({ context: { user } })
412
+ })
413
+
414
+ export const transactionMiddleware = createMiddleware()
415
+ .middleware([authMiddleware])
416
+ .server(async ({ next, context: { execContext } }) => {
417
+ const tx = await beginTransaction()
418
+ execContext.data.setTag(transactionTag, tx)
419
+ try {
420
+ const result = await next()
421
+ await tx.commit()
422
+ return result
423
+ } catch (e) {
424
+ await tx.rollback()
425
+ throw e
426
+ }
427
+ })
428
+
429
+ Server functions — execute flows via context:
430
+ export const listInvoices = createServerFn({ method: 'POST' })
431
+ .middleware([transactionMiddleware])
432
+ .handler(async ({ data, context: { execContext } }) => {
433
+ return execContext.exec({ flow: invoiceFlows.list, rawInput: data })
434
+ })
435
+
436
+ Client hydration — preset loader data into client scope:
437
+ const loaderData = Route.useLoaderData()
438
+ const scope = createScope({
439
+ presets: [
440
+ preset(invoicesAtom, loaderData.invoices),
441
+ preset(userAtom, loaderData.user),
442
+ ],
443
+ })
444
+ return <ScopeProvider scope={scope}><Outlet /></ScopeProvider>
445
+
446
+ Rules:
447
+ One scope per server process Atoms cache singletons (connections, services)
448
+ One execContext per request Tag isolation (user, tx, tracing)
449
+ Middleware creates+closes ctx Guarantees cleanup even on error
450
+ Tags over function params Flows read ambient tags, no signature coupling
451
+ execContext.exec({ flow }) Flows get lifecycle, tracing, cleanup
452
+ scope.resolve(atom) for deps Atoms are long-lived, cached in scope
453
+ Preset server data on client No re-fetch; atoms hydrate from loader
454
+
455
+ Don't:
456
+ createScope() in a server fn New scope per request — atoms re-resolve, connections leak
457
+ flow.factory(ctx, deps) direct Bypasses context lifecycle, tags, extensions, cleanup
458
+ User/tx as flow input Couples signatures to transport; use tags instead
459
+ scope.resolve(flow) Flows are ephemeral — exec(), don't resolve()
460
+ ScopeProvider without presets Client re-fetches everything server already loaded`
276
461
  },
277
462
  diagrams: {
278
463
  title: "Visual Diagrams (mermaid)",