第 5 篇 · 5.1 packages/shared 是契约中心
学习目标
完成本节后,你将能够:
- 判断什么代码应该进入
packages/shared,什么不应该。 - 说清 Langfuse 的主要契约:domain、queue、filter、query、client。
- 理解为什么 shared 变更通常比单个页面或 processor 变更风险更高。
5.1.1 先给结论:shared 不是 utils 包
packages/shared 的价值不是“复用代码”,而是把多个运行时必须共同理解的规则放在一个地方。
Langfuse 有两个主要运行时:
web:同步入口,处理 UI、tRPC、Public API、ingestion HTTP。worker:异步执行,消费 BullMQ job,写 ClickHouse,跑 eval/export/retention/webhook。
它们不能互相 import,但必须对一些东西达成一致:
所以判断一个抽象是否该放进 shared,只问一句:
如果这里改了,是否至少两个运行时必须同时理解?
如果答案是是,它通常属于 shared。如果只影响一个页面、一个弹窗、一个 processor 内部步骤,就不应该提升为 shared 契约。
5.1.2 五类核心契约
| 契约类型 | 作用 | 生产者 | 消费者 | 代表源码 |
|---|---|---|---|---|
| Domain contract | 描述 trace、observation、score、prompt、event 等领域对象形状 | API、worker、repository | UI、worker、tests、SDK-facing adapters | packages/shared/src/domain/** |
| Queue contract | 描述 job name、queue name、payload schema | web/shared/worker producer | worker consumer、DLQ、replay scripts | packages/shared/src/server/queues.ts |
| Filter contract | 描述 UI/API 能表达哪些过滤条件 | UI、Search Bar、Public API filter param | ClickHouse SQL builder、Prisma fallback、InMemoryFilterService | packages/shared/src/interfaces/filters.ts |
| Query contract | 描述事件表字段、field set、tenant filter、events_core/full 选择 | services/repositories | ClickHouse | event-query-builder.ts、factory.ts |
| Client/storage contract | 描述如何连接 DB、Redis、ClickHouse、S3,如何带 telemetry context | web、worker、scripts | 状态层 | db.ts、server/redis/**、StorageService |
这五类契约合起来,才是当前 repo 的架构骨架。
5.1.3 契约和实现的区别
以 ingestion 为例:
| 层 | 是契约吗 | 原因 |
|---|---|---|
IngestionEvent queue schema | 是 | producer 和 worker consumer 都要理解。 |
bucketPrefix optional | 是 | 影响 rolling deploy 和旧 job 兼容。 |
ingestionQueueProcessorBuilder 的局部变量 | 不是 | 只属于 worker consumer 内部实现。 |
IngestionService.mergeAndWrite 的实体分派 | 半契约 | worker 内部实现,但它输出 ClickHouse record,受 domain/CH schema 约束。 |
ClickhouseWriter retry 策略 | 实现策略 | 多个 processor 共享,但不跨进程传输。 |
这能帮助你判断改动风险:改 queues.ts 往往比改某个 processor 内部变量风险更高。
5.1.4 为什么 queue payload 字段经常 optional
队列契约和普通函数参数不同。函数参数只在当前进程里传递;queue payload 会被序列化到 Redis,可能在旧版本 producer 和新版本 worker 之间流动。
IngestionEvent.data.bucketPrefix 是一个典型例子:
- 新 producer 会把自己实际写入 S3 的绝对 prefix 放进 job。
- 新 worker 优先使用这个 prefix,避免重新按本地 env 拼路径导致漂移。
- 字段仍然 optional,因为 Redis 里可能还有旧 producer 入队的 job。
- worker 需要 fallback 到旧的 raw prefix 逻辑。
这就是为什么教程反复强调“queue payload 是跨进程协议,不是普通 DTO”。
5.1.5 FilterState 是查询语言,不只是 UI 状态
FilterState 看起来像前端 filter 数组,但它实际承担三件事:
- UI 侧用于 sidebar filter、Search Bar、URL query。
- API/service 侧作为过滤参数。
- 后端 lowering 成 ClickHouse SQL 或 Prisma where。
它的 schema 在 packages/shared/src/interfaces/filters.ts:
timeFilter限定 datetime operator;stringFilter、numberFilter、booleanFilter限定基础类型;stringObjectFilter、numberObjectFilter表达 metadata/scores 这类 key-value 字段;eventsTableSingleFilter扩展了matches全文检索 operator。
所以新增一个 filter operator 不是“前端加一个选项”那么简单。你还要确认:
- UI 能生成它;
- Zod schema 接受它;
createFilterFromFilterState能 lower 它;- ClickHouse column mapping 支持它;
- Search Bar grammar 和 validate/lower 逻辑保持一致。
5.1.6 QueryBuilder 是状态层保护边界
EventsQueryBuilder 的作用不是拼字符串,而是把事件查询的几个硬约束集中起来:
| 约束 | 为什么重要 |
|---|---|
constructor 传 projectId | tenant filter 默认进入 WHERE,避免跨项目读取。 |
FIELD_SETS | 列表页只选必要字段,减少宽表读取成本。 |
selectIO(truncated) | 默认不读完整 input/output,需要时显式选择。 |
events_core / events_full | 轻量查询走 core,完整 I/O 和 metadata 走 full。 |
| split query | 先在 core 上 filter/order/limit,再对命中的行回 full 拿大字段。 |
这就是“查询契约”:调用者表达自己需要什么字段,builder 决定用哪个表、带哪些参数、如何安全组合 SQL。
5.1.7 改 shared 前的检查清单
改 shared 之前先问:
- 这个类型是不是被
web和worker同时消费? - 这个字段是否会出现在 Redis job、S3 path、ClickHouse row 或 Public API response 中?
- 旧版本 producer 产生的数据/队列是否还能被新版本 consumer 读取?
- 是否需要 Zod schema 保持运行时校验?
- 是否要更新 Fern API 或 generated client?
- 是否要加 ClickHouse migration 或 Prisma migration?
- 是否要补 web 和 worker 两侧测试?
如果你要做类似 infra,这个检查清单比“怎么写 TypeScript 类型”更重要。真正的风险在边界和兼容性。