@pumped-fn/lite 2.1.4 → 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 +61 -0
- package/dist/cli.cjs +249 -64
- package/dist/cli.mjs +249 -64
- package/dist/cli.mjs.map +1 -1
- package/dist/index.cjs +176 -157
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +176 -157
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
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
|
+
|
|
3
64
|
## 2.1.4
|
|
4
65
|
|
|
5
66
|
### 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: `
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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: `
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ctx.
|
|
89
|
-
|
|
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? })
|
|
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.
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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: `
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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: `
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
155
|
-
tag.find(source) → T | undefined
|
|
156
|
-
tag.collect(source) → T[]
|
|
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
|
|
159
|
-
ctx.data.setTag(tag, value)
|
|
160
|
-
ctx.data.getTag(tag)
|
|
161
|
-
ctx.data.seekTag(tag)
|
|
162
|
-
ctx.data.hasTag(tag)
|
|
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
|
|
167
|
-
tags.all(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)",
|