Effect 服务审查
按下面的约定审查改动过的 TypeScript。当一个拉取请求创建、移动、重构或消费一个 Effect 服务,或者增加服务器行为时,这些约定适用。作者在 docs/internals/effect-services.md 里读到同样的规则;让两处保持同步。只审查 PR 改动的行;同一文件里早于这些约定的旧代码不是发现。不要要求全仓库清理。
导入与模块命名空间
- 以命名空间从子路径导入 Effect 模块:
import * as Effect from "effect/Effect"、import * as Layer from "effect/Layer"。标出从裸的"effect"包做的具名导入。 - 在服务边界,把本地服务模块作为命名空间导入,并使用它的公开形状:
WorkspacePaths.WorkspacePaths、WorkspacePaths.make、WorkspacePaths.layer。标出抹掉命名空间的别名,例如import { layer as workspacePathsLayer }。 - 具名导入对整个包仍然正确,例如
@t3tools/contracts,以及对只用于纯辅助函数、错误、模式、配置值或类型的模块也正确。不要要求import type * as Contracts。 - 当桶文件暴露整个服务模块时,优先使用
export * as TokenStore from "./tokenStore.ts",而不是逐个重命名的make和layer导出。
放置位置
- 标出传输处理器里新增或扩大的功能逻辑:
apps/server/src/ws.ts里的 WebSocket RPC 处理器、HTTP 路由处理器,或 MCP 工具处理器。处理器解码输入,调用一个服务方法,并把服务的错误映射成传输的错误。处理器内部的文件系统、Git、进程或持久化工作、文件夹命名、多步命令分发、重试或回滚属于服务方法;请要求把它移到该领域已有的服务,或者在没有服务拥有该领域时移到一个新服务。已有的内联处理器是遗留代码;只标出改动的行。 - 标出新增的、做服务器能力的带效应工作(文件系统、Git、进程、已持久化状态)并从处理器调用的导出自由函数,当它应该是服务方法时。纯工作(格式化、命名、解析)的普通函数没有问题。
服务定义
- 每个服务一个规范模块,顺序是:导入、错误和模式声明、接口内联的
Context.Service标签、make,然后是layer。 - 在
Context.Service里内联定义接口。不要添加独立的FooShape接口;把推断类型称为Foo["Service"]。 - 当模块拥有构造时,定义一个真正的
make,并且只有另一个模块导入它时才导出;knip 会让未使用的导出使 CI 失败,所以不要要求导出没有东西使用的东西。不要只为了强迫Layer.effect而写make = Effect.succeed(...);使用Layer.succeed、Layer.scoped,或与之匹配的构造器。 - 在以其实现命名的模块里使用普通的
make和layer(BunPtyAdapter.ts)。当一个抽象端口模块持有多个实现时,保留实现专有的名字(RelayClient.ts里的makeCloudflaredRelayClient、layerCloudflared)。infra/relay/src/db.ts可以保留它的内联Layer.succeed(RelayDb, db)。 - 服务移动时,删除旧文件并更新每个消费者,包括编排、MCP、测试和集成工具。不要留下兼容性再导出垫片。
Effect 函数与跟踪
- 可复用函数使用具名的
Effect.fn("Service.operation"),而不是包着Effect.gen的函数。内联程序、回调和层构造可以保留Effect.gen。 - 具名的
Effect.fn创建一个 span;不具名的形式不会。服务操作和 I/O 默认使用具名 span。Effect.fnUntraced是有意用于按块/按事件的工作、紧循环,或重复已有跟踪边界的辅助函数;新的使用需要附近有理由。 - 保留已有的 span 名称和选项,避免添加重复的 span。组合子放在生成器函数体之后;变换在效应之后收到原来的函数参数。
- 保留泛型签名、惰性、资源作用域和中断行为。显式的生成器返回类型是
Effect.fn.Return<A, E, R>。
依赖获取与运行时边界
- 生产服务的构造用
yield* Foo.Foo从环境获取它的 Effect 依赖,make/layer类型暴露这些需求。当某个值是服务依赖时,标出把Foo["Service"](或一组返回 Effect 的方法组成的对象)作为参数的工厂。测试里显式传递服务实例没有问题;传递纯配置、不可变的领域值或有意的回调策略不是服务注入。 - 不要把依赖藏在模块全局变量、对单例服务的闭包,或实现会调用运行时支持的或命令式 API 的
Layer.succeed里。 ManagedRuntime.make、runPromise和runPromiseExit属于应用或框架边界:React、原生回调、CLI、HTTP 适配器。在领域服务、仓库、持久化和服务构造器里标出它们。一个具名的命令式适配器可以把 Effect 服务桥接到 Promise API,但不得成为另一个 Effect 服务的依赖。- 不要为了把同一个拥有的资源交给多个消费者而创建按功能划分的托管运行时或 Atom 运行时。在应用拥有的层里组合该资源一次,并把上下文提供给集成运行时。
- 当获取可能失败、调用方需要回退行为时,把失败保持为 Effect 中的类型(服务操作里的错误,或显式的可选服务层),而不是通过命令式运行时绕过该层。
错误
- 用
Schema.TaggedError和结构化属性定义服务失败:操作或阶段、资源路径或实体标识符、规范化的类别或状态。只从这些属性派生message。绝不从cause、cause.message或字符串化的缺陷派生它,也不要添加一个复制cause.message的detail字段。 - 包装真实失败时,把紧邻的底层错误保留为
cause,这样链和堆栈还能留下。如果每次构造都包装一个失败,就把cause设为必需。没有底层失败而创建的纯校验或领域错误不需要 cause。 - 让属性和日志注解安全且有界:不要原始线路载荷、命令参数或输出、签名 URL、凭据、查询字符串或任意缺陷文本。只把精确值保留为
cause;在有用的地方暴露规范化类别、长度、计数,以及安全的 URL 协议或主机名。已记录的例外是DeviceBootError.diagnostics:设备枢纽的启动命令和失败文本有意暴露给已授权的设备用户和智能体,上限 2,000 个字符,并包含在错误消息里。不要把这个例外用到其他服务错误上。 - 在翻译边界,当一个已经结构化的领域错误属于目标错误通道时,让它穿过;只包装未知的或更低层的失败。在已知上下文的地方映射失败,而不是把整个多步流水线包进一个泛化错误。
- 不要用一个具体错误标签和一个单值的
operation、reason、kind或phase字面量把同一个区别编码两次。当判别器驱动调用方控制流或面向用户的消息时,拆成单独的错误类;只用于诊断的判别器可以留作字段。通过 HTTP、RPC、已持久化状态或 UI 暴露的、调用方可见的消息是行为,必须在结构重构后仍然存在。 - 不要添加一个行为仅仅是
(...args) => new SomeError({ ...args })的辅助函数。在失败边界构造错误。只有在映射器做真正的规范化、让领域错误穿过,或添加可复用上下文时才保留它;当这样的映射器属于目标错误类型时,优先在该类上使用静态工厂。 - 把谓词直接导出为
export const isFoo = Schema.is(Foo)。标出被同签名函数包着的私有Schema.is常量。 - 用
Effect.catchTags({ ... })捕获静态已知的带标签失败,包括单个标签;不要为此使用带模式谓词的catchTag或catchIf。整个错误通道都被处理时,Effect.catch没有问题;平台错误码这类结构谓词用catchIf没有问题。
变更纪律
- 每一条新增或放宽的、禁用 lint、类型检查器、LSP 或静态分析诊断的指令,都需要旁边有注释说明原因。指令本身不是解释;缺少注释是具体违规。
- 如果后端行为改变,要求聚焦的测试,这些测试只对外部服务使用测试层,绝不模拟核心业务逻辑。不要为机械重构或仅导入的改动要求新测试。
- 不要要求
Layer.effect、普遍的命名空间导入、给抽象端口实现使用泛化的make/layer名字,或为仅用于诊断的字段单独建立错误类。
报告
只报告由改动行引入的违规。每条都作为精确的行内评论发在最小的相关范围上,并说明期望的修复。清楚的约定违规可以使检查失败;可选的风格偏好和未触及的遗留代码不可以。
没有发现时,让整段最终回复恰好是单独一行的 All clear,不要有其他内容。