macro GraphQL Work Feed 适配器详解:workFeed 分页、完成/撤销变更与实时补丁订阅
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
在 macro 这个把邮件、聊天、文档、任务、Agent、通话与 CRM 统一到同一工作区的项目里,"工作信息流"(work feed)是 Home 页的核心:它把需要用户注意的通知和用户近期做过的自有工作聚合成一条可分页、可堆叠、可实时更新的列表。本文以crates/graphql_work_feed的适配器文档为骨架,结合 crate 源码与static_assets/schema.graphql中的真实 schema,完整讲清这个 GraphQL 入站适配器暴露的三个入口——workFeed查询、完成/撤销变更、workFeedUpdates订阅——的契约、实现细节与客户端接入方式。
一、这个 crate 是什么
按照 crates/graphql_work_feed/AGENTS.md 的定义,graphql_work_feed属于 work feed 特性的GraphQL 入站适配器(inbound adapter):它把一个"查看者(viewer)视角下、任务与待关注条目的聚合信息流"翻译成 GraphQL 的查询、变更与订阅。
它只做协议层的事情,不负责信息流本身的组装与排序。文档明确划分了归属:
| 维度 | 内容 |
|---|---|
| 拥有 | 工作信息流页面的 GraphQL 类型、解析器与订阅 |
| 不在这里 | 信息流组装与条目排序 →crates/work_feed;通知内容 →crates/graphql_notification |
| 依赖 | work_feed、graphql_notification、graphql_soup、entity_access(启用inbound特性)、graphql_common |
| 被谁使用 | complete_graph(负责 schema 组合) |
这些依赖在 crates/graphql_work_feed/Cargo.toml 中有对应体现:entity_access以default-features = false, features = ["inbound"]引入(因为它要用inbound::axum_extractors::user_team_receipt铸造团队凭证),soup也只启用ports特性。
crate 根文件 crates/graphql_work_feed/src/lib.rs 用文档注释重述了同一句话:解析viewer下的workFeed页面、done/undo 变更以及workFeedUpdates订阅,schema 本身由complete_graph组合。该文件同时开启了#![deny(missing_docs)]与#![deny(clippy::missing_docs_in_private_items)],因此 crate 内连私有项都强制带文档——这也是 schema 里每个字段都自动带一段"""描述的原因。
二、从哪读起:源码导览
原文档给出的入口清单,对应到实际职责如下:
| 文件 | 关键符号 | 职责 |
|---|---|---|
| src/query.rs | resolve_work_feed | 一页workFeed的解析入口 |
| src/objects.rs | GraphqlWorkFeedPage、GraphqlWorkFeedEntry、GraphqlWorkFeedItem、GraphqlWorkFeedStack、GraphqlWorkFeedPatch | 输出对象与领域模型 → GraphQL 类型的映射 |
| src/mutations.rs | WorkFeedMutationRoot | mark-done / undo-done |
| src/subscriptions.rs | WorkFeedSubscriptionRoot | 实时补丁流 |
| src/context.rs | WorkFeedGraphqlContext、WorkFeedViewerResolver | 请求上下文与查看者解析 |
| src/inputs.rs | WorkFeedInput、WorkFeedScopeInput等 | 输入校验与领域转换 |
再加上一个原文档提到但未展开的文件:src/inputs/test.rs与src/subscriptions/test.rs是单元测试,分别覆盖输入校验和触发批次合并。
resolve_work_feed本身很短,却把整条链路串了起来:
pub async fn resolve_work_feed<E: SoupEntityEdges>( ctx: &Context<'_>, user: MacroUserIdStr<'static>, input: WorkFeedInput, ) -> async_graphql::Result<GraphqlWorkFeedPage<E>> { let context = ctx.data::<WorkFeedGraphqlContext>()?; let query = input.into_query()?; // 输入校验 → WorkFeedQuery let viewer = context.viewer(user).await?; // 铸造 viewer(含团队凭证) let page = context.service.page(viewer, query).await.map_err(to_graphql_error)?; Ok(GraphqlWorkFeedPage::new(page)) }注意执行顺序:先校验输入,再解析 viewer,最后才调用服务。crates/complete_graph/src/schema/test/work_feed.rs#L303-L320的work_feed_rejects_a_bad_cursor_before_reading测试正是断言了这一点——坏 cursor 会直接报invalid work feed cursor,而不会触发任何一次数据读取。
三、上下文装配:把领域能力注入请求
GraphQL 层不直接持有数据库或服务实现,而是通过WorkFeedGraphqlContext拿到三样能力(crates/graphql_work_feed/src/context.rs#L125-L162):
| 字段 | 类型 | 作用 |
|---|---|---|
service | Arc<dyn WorkFeedGraphqlService> | 分页、重算、完成、撤销四个用例 |
triggers | Arc<dyn WorkFeedTriggers> | 订阅使用的实时变更信号源 |
viewers | Arc<dyn WorkFeedViewerResolver> | 从用户解析团队访问凭证 |
3.1 为什么需要WorkFeedGraphqlService这个 trait
work_feed的WorkFeedService是泛型关联类型(type Entity)的 trait,不能直接放进async_graphql的ctx.data()。适配器用一层对象安全的 trait 抹平泛型:把Entity固定为SoupProjectionHydration,方法返回BoxFuture,再为所有T: WorkFeedService<Entity = SoupProjectionHydration>提供 blanket impl(context.rs#L56-L93)。这样 schema 里就不必携带类型参数,而实际实现仍然可以直接复用领域服务。
四个方法对应work_feed的四个用例,语义直接引用领域文档:
page(viewer, query)— 一页信息流,按时间倒序;recompute(viewer, scope, keys)— 已知条目的当前归属与内容,返回 upsert 或 removed;mark_done(viewer, targets)— 确认已观察到的注意力;undo_done(viewer, scope, receipt)— 精确回滚一次完成操作。
3.2 查看者是怎么来的
请求与订阅都只带"已认证用户",不含团队信息。适配器在 entity access 边界现场铸造团队凭证:EntityAccessWorkFeedViewers<S>调用user_team_receipt::<MemberTeamRole, S>,把结果包成WorkFeedViewer { user, team }(context.rs#L95-L123)。团队凭证的意义在work_feed的模型注释里写得很清楚:为团队存储的外部实体(foreign entity)可以经由它可见。
context.viewer()对失败的处理是刻意保守的:内部错误只写tracing::error!,对外统一返回the work feed is unavailable。
3.3 组装点
真实的装配发生在文档存储服务里(services/document_storage_service/src/server.rs#L2099-L2124)。其注释概括了选型原因:Soup 候选取自只读副本、通知确认走主库、实时更新由查看者的通知/活动/Soup 三条流重算;done还会发布一条按查看者寻址的 Soup patch,让该用户的其他会话也重算——因为GraphQL 的通知写入本身不发布实时更新。
四段实现分别是SoupWorkFeedSource、ReaderWorkFeedNotifications、EmailWorkFeedMail、SoupRealtimeWorkFeedSignals;触发源是RealtimeWorkFeedTriggers合并的通知、活动与 Soup 实时订阅;查看者解析用EntityAccessWorkFeedViewers。
四、查询:workFeed分页
4.1 入口签名
在组合后的 schema 中,workFeed挂在user下(crates/complete_graph/src/schema.rs#L856-L871,static_assets/schema.graphql第 9516 行附近):
workFeed(input: WorkFeedInput): WorkFeedPage!input可省略,None会走WorkFeedInput::default()。
4.2WorkFeedInput参数
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
scope | WorkFeedScopeInput | 全部类型 /WORK模式 / 不含 snippet | 决定信息流显示哪些条目 |
state | WorkFeedItemState | 不筛选 | 只能取UNSEEN或SEEN,传DONE会报错 |
limit | Int | 50 | 上限 100 |
cursor | String | 无 | 上一页的nextCursor,首页留空;必须与相同的scope/state一起发送 |
state的注释揭示了一个易被误解的行为:订阅与 undo 会把条目放进任意状态,所以一个正在展示的条目即使被标记为已读,也要等到重新拉取分页时才会消失。这不是 bug,而是刻意避免列表在用户眼前跳动。
4.3WorkFeedScopeInput
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
mode | WorkFeedMode | WORK | WORK= 未处理的注意力 + 近期自有工作;ATTENTION= 只看注意力 |
types | [WorkFeedItemType!] | 空 = 全部 | 限定条目种类 |
includeSnippets | Boolean | false | 是否包含 snippet 文档 |
WorkFeedItemType的十个取值与它们在领域层的实体映射(WorkFeedItemType::entity_type)如下:
| GraphQL 枚举 | 含义 | 对应实体类型 |
|---|---|---|
DOCUMENT | 非任务的文档 | Document |
TASK | 任务 | Document |
CHAT | AI 会话 | Chat |
PROJECT | 项目(文件夹) | Project |
EMAIL | 邮件会话 | EmailThread |
CHANNEL | 频道活动(不含独立会话线程条目) | Channel |
CHANNEL_THREAD | 一个频道会话线程 | ChannelMessage |
CALENDAR_EVENT | 日历提醒 | CalendarEvent |
PULL_REQUEST | GitHub PR | ForeignEntity |
AGENT_SESSION | Agent 会话 | AgentSession |
注意DOCUMENT与TASK共享Document实体类型,区分靠文档子类型过滤:只请求二者之一时,policy::document_filter会加上SubType(Task)的正/负条件;两者都请求则不加子类型条件(crates/work_feed/src/domain/policy.rs#L75-L101)。
4.4 参数校验的精确规则
into_query()(crates/graphql_work_feed/src/inputs.rs#L126-L153)做了四件事:
- limit:调用
graphql_common::parse_limit(limit, 50, 100)(crates/graphql_common/src/limit.rs)。该函数会拒绝<= 0的值(limit must be positive)以及超过上限的值(limit must not exceed 100)。 - cursor:
WorkFeedPosition::decode失败时报invalid work feed cursor。 - state:
DONE被显式拒绝,错误信息为work feed pages hold only unseen and seen items。 - scope:套用默认值后转成领域
WorkFeedScope。
cursor 的编码方式是"base64url 无填充 + JSON",字段名被压缩为短键s(sort_at)与i(entity_id),见 crates/work_feed/src/domain/models.rs#L300-L326 及文件末尾的encode_token/decode_token。客户端应把它当作不透明字符串,不要解析。
4.5 返回结构
type WorkFeedPage { entries: [WorkFeedEntry!]! nextCursor: String nextPosition: WorkFeedPosition }entries:本页条目,sortAt倒序;nextCursor:下一页游标,信息流耗尽后为null;nextPosition:本页实际读到的位置(sortAt+entityId),nextCursor从这里继续。
nextPosition的存在有明确用途,源码注释解释得很细:因为存在状态过滤,一页可能在源分页中途才读满,于是读取位置可能越过最后一条展示条目。客户端据此可以判断:落在这条已展示条目之后、但没越过nextPosition的实时变更,属于本页而不是下一页。
排序规则是:sortAt新的在前,同一sortAt下按entityId倒序(WorkFeedPosition的注释)。
4.6 一个可运行的查询示例
下面的查询取自crates/complete_graph/src/schema/test/work_feed.rs#L215-L301的集成测试,字段全部在 schema 中存在:
query { user { workFeed(input: { scope: { mode: ATTENTION, types: [DOCUMENT, TASK, CHANNEL_THREAD] }, state: UNSEEN, limit: 10 }) { nextCursor nextPosition { sortAt entityId } entries { sortAt touchedAt revision primaryReason primaryStackIndex item { id state attentionAt unseenCount entity { __typename id } stacks { kind threadId latestAt unseen count notifications(limit: 1) { id eventType state } } } } } } }WorkFeedItem.entity是GraphqlSoupEntity(由graphql_soup提供的 union/interface),所以实际响应中会看到__typename形如GraphqlSoupDocument的实体类型,id是实体自身 id。
五、对象模型:条目、条目项与堆栈
适配器把领域模型拆成三层,这个拆分是理解整个 schema 的关键。
5.1WorkFeedEntry:一次"摆放"
一个条目项(item)可能同时出现在多个信息流里,摆放位置和展示原因属于 entry,不属于 item。GraphqlWorkFeedEntry的字段(src/objects.rs#L202-L259):
| 字段 | 类型 | 含义 |
|---|---|---|
item | WorkFeedItem! | 条目项本身 |
sortAt | String! | 排序时间(RFC 3339),取该信息流中注意力时间与自有工作时间的较晚者 |
touchedAt | String | 该信息流计入的自有新工作时间;ATTENTION模式下恒为空 |
revision | String! | 该 entry 展示的注意力的不透明令牌,做 done 时回传 |
primaryReason | WorkFeedPrimaryReason! | OWN_WORK或ATTENTION |
primaryStackIndex | Int | 主因为注意力时,指向 item 的stacks索引 |
revision的构造成本极低但语义重要:item.revision()只打包attention_through = attention_at,编码后即为令牌。所以"回传 revision"等价于告诉服务端"我看到的是截至这个时刻的注意力"。
主因的判定逻辑在领域层(crates/work_feed/src/domain/service.rs#L161-L165):如果自有工作时间晚于或等于最新注意力时间,主因是OwnWork;否则若有非空堆栈,主因是Attention { stack: 0 };其余情况退回OwnWork。
5.2WorkFeedItem:条目项
| 字段 | 类型 | 含义 |
|---|---|---|
id | ID! | 稳定 id,格式<entity type>:<entity id> |
entity | GraphqlSoupEntity! | 条目所指向的实体;频道会话线程指向其根消息 |
state | WorkFeedItemState! | UNSEEN/SEEN/DONE |
attentionAt | String | 作用域内最新有效通知的创建时间 |
unseenCount | Int! | 未读有效通知条数 |
stacks | [WorkFeedStack!]! | 按展示方式分组后的通知,最新堆栈在前 |
id的生成与解析都在WorkFeedItemKey::id()/parse()(crates/work_feed/src/domain/models.rs#L123-L136):以第一个冒号切分,后半段非空,且前半段必须能解析成合法实体类型。所以document:<uuid>合法,nope非法——inputs/test.rs的done_input_parses_ids_and_revisions直接验证了这两条。
unseenCount不是简单数通知条数,而是过滤counts_as_unseen(models.rs#L201-L219):通知必须处于Unseen状态;非频道实体的通知直接算未读;频道类通知只有 mention、message send、reaction、reply、document mention 才算未读,邀请、通话这类事件永远不会让条目变未读。
5.3WorkFeedStack:堆栈
| 字段 | 类型 | 说明 |
|---|---|---|
kind | WorkFeedStackKind! | 分组方式 |
threadId | String | 所属会话线程 id(频道线程根、文档评论线程或讨论线程) |
latestAt | String! | 堆栈内最新通知的创建时间 |
unseen | Boolean! | 堆栈内是否有未读 |
count | Int! | 堆栈完整通知数 |
notifications(limit: Int) | [GraphqlNotification!]! | 堆栈内通知,最新在前;默认 20、上限 100 |
notifications的分页与count是刻意解耦的:count是完整大小,limit只影响本次返回的条数,默认值DEFAULT_STACK_NOTIFICATION_LIMIT = 20、上限MAX_STACK_NOTIFICATION_LIMIT = 100(src/objects.rs#L10-L13),同样走parse_limit校验。
WorkFeedStackKind的十个取值,来自领域枚举的注释:
| 枚举值 | 含义 |
|---|---|
CHANNEL_THREAD | 一个频道线程的回复、线程内提及与被吸收的根消息 |
CHANNEL_MENTION | 线程尚无回复时的单条根提及 |
CHANNEL_MESSAGES | 频道内的新根消息 |
CHANNEL_REACTIONS | 对查看者频道消息的回应 |
COMMENT_THREAD | 同一文档评论线程中的多条评论/回复/提及 |
COMMENT_MENTION | 文档评论中的独立提及 |
COMMENTS | 文档上的独立根评论 |
DOCUMENT_MENTIONS | 频道中对文档的提及 |
DISCUSSION | 项目、公司或联系人上的一条讨论线程 |
EVENT | 其他单一事件 |
六、变更:完成与撤销
6.1markWorkFeedItemsDone
mutation { markWorkFeedItemsDone(input: { items: [{ id: "document:<uuid>", revision: "<opaque>" }] }) { itemIds undoToken } }输入与输出定义见 src/mutations.rs#L14-L66 与 src/inputs.rs#L155-L194:
| 输入字段 | 必填 | 说明 |
|---|---|---|
items[].id | 是 | 条目项 id,<entity type>:<entity id> |
items[].revision | 否 | 调用方观察到的 entry revision;比它更新的原因保持有效。省略表示确认当前全部 |
| 输出字段 | 说明 |
|---|---|
itemIds | 已完成条目;已经消失的条目也会被包含,便于前端直接移除 |
undoToken | 可精确反悔本次操作的不透明令牌 |
三条硬性约束:
- 批量上限 200。
MAX_DONE_ITEMS = 200,超出报at most 200 items can be marked done at once。 - 必须已认证。crate 用
graphql_common::require_authenticated_user,未认证时返回authentication required(见crates/complete_graph/src/schema/test/work_feed.rs#L387-L404的测试)。 - id 与 revision 都必须是合法令牌,否则分别报
invalid work feed item id/invalid work feed revision。
语义上最容易被误解的一点是:"完成"不等于"完成底层实体"。字段文档写得很直白——它确认的是每个 entry 展示的通知,并归档收件箱邮件;任务、讨论与 PR 本身不会被完成。而且"自有工作不会被确认",所以一个用户同时也在推进的条目仍会以SEEN状态留在WORK模式的信息流里。
领域实现进一步说明了这条路径(crates/work_feed/src/domain/service.rs#L366-L445):
- 无论调用方当时在看哪个信息流,
mark_done都用WorkFeedScope::everything()重新评估条目,确保"确认的是他看到的东西"; - 只把
created_at <= revision.attention_through的通知置为 done; - 邮件线程若在收件箱可见,则调用
set_archived(..., true),且归档是尽力而为:失败只打 warn,因为通知已 done、注意力已经消失; - 最后通过
signals.items_changed广播按查看者寻址的变更。
6.2undoWorkFeedItemsDone
mutation { undoWorkFeedItemsDone(input: { undoToken: "<markWorkFeedItemsDone 返回的 undoToken>" scope: { mode: WORK } }) { patches { __typename ... on WorkFeedEntryUpserted { entry { item { id state } sortAt } } ... on WorkFeedEntryRemoved { itemId } ... on WorkFeedInvalidated { refresh } } } }undoToken解码出WorkFeedDoneReceipt,其内部字段被压缩为短键(models.rs#L393-L431):k为完成的条目 id 列表、n为被置为 done 的通知 id、e为被归档的邮件线程 id。这正是"精确反悔"的凭据:撤销只回滚这三类副作用,不会覆盖之后发生的任何事。
撤销流程(service.rs#L447-L474):先把通知set_done(false)、把邮件set_archived(false)(同样尽力而为),然后对条目 id 做recompute,把恢复后的摆放位置作为 patches 返回。scope决定 patch 按哪个信息流计算——它不影响回滚哪些数据,只影响返回的摆放结果。
单元测试mark_done_completes_the_observed_revision_and_undo_restores_it验证了完整闭环:回传at(7)的 revision 只会把created_at <= at(7)的通知置 done(测试里恰好是 id 为 1 的那条),undo 后收到notification_ids = [1]。
七、订阅:workFeedUpdates
subscription { workFeedUpdates(scope: { mode: WORK }) { __typename ... on WorkFeedEntryUpserted { entry { sortAt item { id state unseenCount } } } ... on WorkFeedEntryRemoved { itemId } ... on WorkFeedInvalidated { refresh } } }scope可选,默认值同查询。返回类型是[WorkFeedPatch!]!——每个 payload 是一批需要一起应用的补丁。
7.1 三种补丁
WorkFeedPatch是 union(static_assets/schema.graphql第 11004 行附近):
| 成员 | 语义 | 客户端动作 |
|---|---|---|
WorkFeedEntryUpserted | 条目属于该信息流,带最新内容与摆放 | 插入,或移动到新的sortAt |
WorkFeedEntryRemoved | 条目不再属于该信息流:done、被过滤或无权访问 | 按itemId移除;实体本身不一定被删除 |
WorkFeedInvalidated | 可能漏掉了变更 | 重新拉取信息流(refresh恒为true) |
Removed的措辞很重要:它不代表实体被删除。WorkFeedChange::Removed的领域注释列举了三种来源——done、被过滤、不可访问。
7.2 合并窗口与批次构建
订阅的实现有一个 300ms 的合并窗口(COALESCE_WINDOW = Duration::from_millis(300),src/subscriptions.rs#L16-L18)。注释解释了动机:一串实时事件(繁忙线程、批量 done)只花一次信息流查询。
批次构建由TriggerBatch完成(src/subscriptions.rs#L20-L46):
Changed(entities)逐个转成WorkFeedItemKey,用HashSet去重,并保持到达顺序;Invalidated只置位invalidated,不收集 key。
流的主循环先取一条触发,开启窗口,然后在tokio::select!里并发等待"窗口到期"与"下一条触发",把窗口内所有触发吸进同一批。批次处理有两条分支:
- 若
invalidated为真,直接yield一条WorkFeedInvalidated并continue——不做重算,因为已经知道有丢失; - 否则调用
service.recompute(viewer, scope, keys)。空结果不产出 payload(changes.is_empty()时静默);出错则降级为一条 invalidation,并在服务端记录 error。
crates/graphql_work_feed/src/subscriptions/test.rs的batches_deduplicate_keys_in_arrival_order用一个文档键和一个会话键验证了去重与顺序,crates/complete_graph/src/schema/test/work_feed.rs#L406-L491则验证了端到端行为:两次窗口内的触发合并成一次recompute,产出两条补丁(upsert + removed);随后一条 invalidation 只产出一条WorkFeedInvalidated。
7.3 客户端的正确用法
字段文档给出了一套明确的协议,值得逐条照做:
- 先订阅,再取第一页。否则两步之间发生的变更会静默丢失。
- 每个 payload 作为一批原子应用(同一批里的 upsert 与 removed 属于同一次重算结果)。
- 收到
WorkFeedInvalidated后重新拉取信息流。 - 流会以错误结束(
work feed subscription ended; resubscribe and refetch the feed),此时应重订阅并重取。test中drop(sender)后断言!response.errors.is_empty()就是对这条约定的固化。
还有一条隐性约束:订阅的scope必须与分页的scope一致,否则补丁会落在不属于它的列表里。文档在WorkFeedScopeInput上专门写了"同一个信息流的 pages、subscriptions 和 undo 发送相同的 scope"。
7.4 触发从哪来
订阅本身不产生信号,WorkFeedTriggers的实现RealtimeWorkFeedTriggers合并三条查看者已经在接收的实时流(crates/work_feed/src/outbound/triggers.rs):
- 通知流:
New/Updated转成Changed(key 由notification_trigger_keys计算);Deleted因为行里已不再带实体信息,只能升级为Invalidated; - 活动流:只有"查看者本人 + 非
Opened动作"才产生Changed,即只有真正的自有工作才影响信息流;Invalidated直通; - Soup 实时流:同样并入触发器。
notification_trigger_keys(crates/work_feed/src/domain/realtime.rs#L16-L34)体现了频道通知的双重归属:一条频道通知可能属于频道条目,也可能属于某个线程条目,所以两者都要重算;普通消息还会额外命名它可能作为根的那个线程。文件开头的注释点出了这套设计的成本模型:触发只给候选 key,服务从存储重算,命名了信息流之外条目的触发只花一次查询、什么也不改变。
八、边界与错误信息速查
GraphQL 层的错误被刻意收敛成两类(to_graphql_error,src/context.rs#L164-L174):InvalidInput直接把消息透出给客户端;Unavailable只记日志,对外统一the work feed is unavailable,不泄露依赖细节。
| 触发场景 | 客户端看到的错误 |
|---|---|
| 未认证 | authentication required |
limit <= 0 | limit must be positive |
limit > 100 | limit must not exceed 100 |
| cursor 无法解码 | invalid work feed cursor |
state: DONE | work feed pages hold only unseen and seen items |
| done 条目 id 非法 | invalid work feed item id |
| revision 非法 | invalid work feed revision |
| done 超过 200 条 | at most 200 items can be marked done at once |
| undoToken 非法 | invalid work feed undo token |
| 下游依赖失败 | the work feed is unavailable |
| 订阅源结束 | work feed subscription ended; resubscribe and refetch the feed |
此外,任何WorkFeedGraphqlContext缺失的情况都会由ctx.data::<WorkFeedGraphqlContext>()?直接暴露为 schema 装配错误——这也是为什么complete_graph的测试必须显式.data(context(...))。
九、扩展指引
原文档给出了两条最常用的改动路径,这里补上每一步的实际落点:
新增一个条目项字段
- 在 src/objects.rs 的对应
impl里加字段方法; - 若数据来自领域层,先确认
work_feed的WorkFeedItem/WorkFeedEntry已有该数据(否则要改 crates/work_feed/src/domain/models.rs); - 若是给
GraphqlWorkFeedEntry::new/GraphqlWorkFeedPage::new增加构造参数,需要同步修改所有构造点; - 若需要新的输入维度来影响筛选,则改 src/inputs.rs,并同步
work_feed的policy::signal_filter与candidate_types。
新增一个变更
- 在 src/mutations.rs 加方法,参数用
InputObject; - 在 src/inputs.rs 加输入对象与
into_*转换,把所有合法性检查放在转换里(要参考现有实现那样返回async_graphql::Error,不要unwrap); - 通过
work_feed的领域端口落地业务,而不是在适配器里写业务逻辑——WorkFeedGraphqlService若缺方法,先加 trait 方法,再在 blanket impl 转发; - 别忘了
require_authenticated_user(ctx)?与context.viewer(user).await?。
十、校验与测试
原文档给出一条命令:
cargo test -p graphql_work_feed并特别注明SQLX_OFFLINE未设置。这条提醒的实际含义是:该 crate 的测试会在需要真实 schema/连接信息的离线模式下失败,所以运行前不要启用SQLX_OFFLINE。
除了 crate 自身的测试,schema 层的端到端行为在 crates/complete_graph/src/schema/test/work_feed.rs 中覆盖,包含四个有代表性的用例:
work_feed_pages_hydrated_stacked_entries_for_the_viewer— 验证入参到WorkFeedQuery的完整映射、sortAt/revision/primaryReason/primaryStackIndex、item.id格式、堆栈的limit截断,以及nextCursor与nextPosition一致;work_feed_rejects_a_bad_cursor_before_reading— 验证校验先于数据读取;mark_done_completes_the_observed_revision_and_undo_restores_it— 验证 revision 生效范围与 undo 令牌回放;work_feed_updates_batch_recomputed_patches— 验证 300ms 合并、去重、invalidation 短路与流结束语义。
相关的领域测试位于crates/work_feed/src/domain/下按模块划分的test.rs(models、policy、service、stacking、realtime),可配合cargo test -p work_feed一起运行(同样要求SQLX_OFFLINE未设置)。
十一、相关规则
原文档最后指向.agents/rules/rust/architecture.md,用于约束 Rust 架构规则。结合本 crate 的实际写法,可以看出这套适配器遵循的分层惯例:适配器只做协议翻译与输入校验(inputs.rs+objects.rs),用例与策略在领域层(work_feed::domain),外部依赖通过 trait 与 blanket impl 注入(context.rs),错误在边界统一收敛(to_graphql_error)。理解这四条,再回头看graphql_work_feed的五个源文件,脉络就非常清晰了。
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
相关推荐
Macro 中 graphql_activity 的 GraphQL Activity 适配层解析:feed、overview、实体边与实时订阅
Macro 中 graphql_activity 的 GraphQL Activity 适配层解析:feed、overview、实体边与实时订阅 graphql
后端前端协同办公即时通讯企业应用CRMAI Agent人工智能Macro 活动流 GraphQL 适配层解析:feed、年度概览、实体 activity 边与实时订阅
Macro 活动流 GraphQL 适配层解析:feed、年度概览、实体 activity 边与实时订阅 本篇以 graphql_activity 这个 cra
后端前端协同办公即时通讯企业应用CRMAI Agent人工智能Macro 通知 GraphQL 适配层深度解析:graphql_notification 的对象、DataLoader 边、变更与实时订阅
Macro 通知 GraphQL 适配层深度解析:graphql_notification 的对象、DataLoader 边、变更与实时订阅 本文以 crate
后端前端协同办公即时通讯企业应用CRMAI Agent人工智能
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考