Rocket.Chat `chat.syncMessages` 新增 `fromTs` 参数:为 `loadMissedMessages` 迁移补全增量同步窗口
导读
本文围绕 Rocket.Chat 仓库中 @rocket.chat/meteor 与 @rocket.chat/rest-typings 两个包的一项 minor 变更展开:changeset 记录为 REST 端点 GET /v1/chat.syncMessages 增加了可选的 fromTs 查询参数,使它可以精确替代已弃用的 loadMissedMessages DDP 方法。读完本文,你将理解 fromTs 的语义、它与 lastUpdate/游标分页的约束关系、底层消息查询实现,以及从 DDP 方法平滑迁移到 REST 端点的完整方案。
1. 变更概要:一次补齐“替代品短板的”补丁式改动
该变更仅涉及一个可选查询参数,却是完成 chat.syncMessages 对 loadMissedMessages 迁移闭环的关键一步。按 .changeset/sync-messages-from-ts.md 的描述,变更包含三点语义:
- 为
chat.syncMessages增加可选的fromTs查询参数; - 使其可作为已弃用的
loadMissedMessagesDDP 方法的迁移替代方案; fromTs用于限定同步窗口,必须与lastUpdate一起使用;如果与游标分页参数(next/previous)同时发送,将直接返回错误而非静默忽略。
从中可以读出设计意图:fromTs 负责“从哪里开始”的下界约束,lastUpdate 负责“何时之后发生了变化”的上界判定,两者配合才能完整还原 loadMissedMessages(rid, ts) 的“拉取指定时间点之后的可见消息”这一增量同步语义。
2. 认识宿主端点:GET /v1/chat.syncMessages
在深入 fromTs 之前,先回顾它挂载的宿主端点。REST 路由定义在 apps/meteor/server/api/v1/chat.ts,对应 TypeScript 客户端类型与方法注册在 packages/rest-typings/src/v1/chat.ts。
2.1 请求参数(查询字符串)
根据 ChatSyncMessages 类型与 AJV 校验 Schema(packages/rest-typings/src/v1/chat.ts),端点支持的查询参数如下:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
roomId |
string | 是 | 目标房间 ID,Schema 中唯一 required 项 |
lastUpdate |
string(日期时间) | 否 | 增量同步的“最近同步时间”;仅返回该时间之后更新的消息/删除记录 |
fromTs |
string(iso-date-time) |
否 | 本文新增参数,用于限定消息的起始时间窗口下界 |
next |
string | 否 | 向前游标(与 type 配合做游标分页) |
previous |
string | 否 | 向后游标(与 type 配合做游标分页) |
type |
'UPDATED' | 'DELETED' |
否 | 游标分页时指定拉取“更新消息”或“删除记录” |
count |
number | 否 | 单页条数(游标分页场景生效,服务端默认值见下文) |
fromTs 在 Schema 中被标注为 format: 'iso-date-time' 的字符串,与 lastUpdate 一样,服务端在解析后会转为 Date 实例再参与查询(见下一节)。
2.2 成功响应结构
端点内部校验通过 isChatSyncMessagesProps(AJV 编译产物),响应体 Schema 定义在 apps/meteor/server/api/v1/chat.ts,核心载荷为:
{
"success": true,
"result": {
"updated": ["…消息对象数组(IMessage)…"],
"deleted": [{ "_id": "…", "_deletedAt": "2024-…" }],
"cursor": { "next": null, "previous": null }
}
}
updated:按需返回的已更新消息数组;deleted:被软删除的消息记录数组,每项只含_id与_deletedAt;cursor:仅在游标分页路径下出现,指向next/previous下一页的游标。
3. fromTs 的核心语义与校验规则
fromTs 是一个窗口下界约束:它并不取代 lastUpdate,而是在其后追加一层时间过滤。这一点可以从 apps/meteor/server/api/v1/chat.ts 的 action 实现看得非常清楚:
const { roomId, lastUpdate, fromTs, count, next, previous, type } = this.queryParams;
// roomId 缺失直接报错
if (!roomId) {
throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing');
}
// lastUpdate 与 type 至少要给一个
if (!lastUpdate && !type) {
throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided');
}
// lastUpdate 必须是合法日期
if (lastUpdate && isNaN(Date.parse(lastUpdate))) {
throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date');
}
const getMessagesQuery = {
...(lastUpdate && { lastUpdate: new Date(lastUpdate) }),
...(fromTs && { fromTs: new Date(fromTs) }), // ← fromTs 被显式解析为 Date 后透传
...(next && { next }),
...(previous && { previous }),
...(count && { count }),
...(type && { type }),
};
随后把 getMessagesQuery 交给公共的历史消息服务函数 getMessageHistory(roomId, this.userId, getMessagesQuery)。
3.1 三条硬性规则
真正的参数组合约束位于 apps/meteor/server/publications/messages.ts 的 getMessageHistory:
fromTs必须搭配lastUpdate:若fromTs && !lastUpdate,直接抛出error-fromTs-requires-lastUpdate(“The 'fromTs' parameter can only be used together with 'lastUpdate'”);lastUpdate不能与游标共用:若(next || previous) && lastUpdate,抛出error-cursor-and-lastUpdate-conflict,游标分页走的是另一条独立路径;next与previous互斥:两者同时出现抛error-cursor-conflict。
规则 1 与规则 2 叠加,实际效果正如 changeset 所说:fromTs + lastUpdate 走无分页的窗口同步路径;fromTs 与游标分页(next/previous/type)同时出现时一定会被拒绝——因为游标路径并不执行 fromTs 过滤,若只是忽略该参数,客户端会拿到超宽结果集而不自知。源码中的注释原话即说明该意图:
fromTsonly bounds the query on thelastUpdatepath; neither cursor pagination nor the channel history fallback honors it, so accepting it there would silently widen the result set.
3.2 校验错误码速查
| 错误码 | 触发场景 |
|---|---|
error-param-required |
缺 roomId,或 lastUpdate/type 均未提供 |
error-lastUpdate-param-invalid |
lastUpdate 无法被 Date.parse 解析 |
error-fromTs-requires-lastUpdate |
提供了 fromTs 但未提供 lastUpdate |
error-cursor-and-lastUpdate-conflict |
next/previous 与 lastUpdate 同时出现 |
error-cursor-conflict |
next 与 previous 同时出现 |
error-type-param-required |
使用 next/previous 时未提供 type |
error-type-param-not-supported |
type 不是 UPDATED 或 DELETED |
4. 源码级原理:fromTs 在底层查询中如何生效
4.1 无分页窗口路径 handleWithoutPagination
当同时提供 lastUpdate 与 fromTs 时,getMessageHistory 走 handleWithoutPagination(见 apps/meteor/server/publications/messages.ts 的分支调度),核心实现是两条并行查询:
export async function handleWithoutPagination(rid: IRoom['_id'], lastUpdate: Date, fromTs?: Date) {
const options: FindOptions<IMessage> = { sort: { ts: -1 } };
const [updatedMessages, deletedMessages] = await Promise.all([
Messages.findForUpdates(rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, options).toArray(),
Messages.trashFindDeletedAfter(
lastUpdate,
{ rid, ...(fromTs && { ts: { $gte: fromTs } }) },
{ projection: { _id: 1, _deletedAt: 1 }, ...options },
).toArray(),
]);
return { updated: updatedMessages, deleted: deletedMessages };
}
逐条拆解其过滤条件:
- updated(消息正文更新):
Messages.findForUpdates以updatedAt > lastUpdate为主条件,minTs: fromTs作为下界过滤,即只返回“最近同步时间之后更新过、且消息时间戳不早于fromTs”的消息; - deleted(软删除记录):
Messages.trashFindDeletedAfter首先限定_deletedAt > lastUpdate(最近同步之后发生的删除),再叠加ts >= fromTs过滤(该消息本身必须位于同步窗口内)。
两条查询使用相同的 sort: { ts: -1 } 与 Promise.all 并行执行,这正是 lastUpdate 负责“时间上限侧变化”、fromTs 负责“消息自身时间下界”的精确落地,也解释了为什么二者必须成对出现。
4.2 游标路径为何“拒绝”而非“忽略”
与上述窗口路径并列,handleCursorPagination 处理 type + next/previous 的翻页场景:它按 type 分别用 updatedAt 游标或 _deletedAt 游标在消息表/回收站表中取页,本身并不接受“消息时间戳下界”概念。若客户端在翻页时附带 fromTs,服务端无法在不破坏游标语义的前提下应用该过滤,于是通过“fromTs 要求 lastUpdate + lastUpdate 与游标互斥”的组合规则在 apps/meteor/server/publications/messages.ts 直接报错,避免数据悄悄不完整。
5. 迁移视角:从 loadMissedMessages 到 chat.syncMessages
5.1 旧 DDP 方法为何需要被替代
被替代的 DDP 方法定义在 apps/meteor/server/meteor-methods/messages/loadMissedMessages.ts,核心逻辑如下:
Meteor.methods<ServerMethods>({
async loadMissedMessages(rid, start) {
methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages');
check(rid, String);
check(start, Date);
const fromId = Meteor.userId() ?? undefined;
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUsersOfRoom' });
}
if (!(await canAccessRoomIdAsync(rid, fromId))) {
return false; // 无权限时返回 false
}
return Messages.findVisibleByRoomIdAfterTimestamp(rid, start, true, {
sort: { ts: -1 },
}).toArray();
},
});
该文件本身就是迁移信号的载体:方法体第一行便通过 methodDeprecationLogger.method(...) 登记弃用(日志引用版本 '9.0.0',并明确指引替换路径为 '/v1/chat.syncMessages')。它返回 false | IMessage[]:无房间访问权限时返回 false,否则返回“该房间 start 之后所有可见消息、按 ts 倒序”。其身份校验位于 apps/meteor/server/meteor-methods/index.ts,客户端的旧调用封装则见 apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts。
权限语义差异提醒:旧 DDP 方法在无权限时静默返回
false,而新 REST 端点走canAccessRoomIdAsync校验失败会返回错误响应(见 apps/meteor/server/publications/messages.ts),迁移时需要相应调整失败处理逻辑。
5.2 迁移前后对照
旧实现本质上是「给定起始时间戳 start,全量拉取其后的可见消息」。要在 REST 世界中还原这一行为,需要把“起始下界”交给 fromTs、把“增量同步时间点”交给 lastUpdate:
| 维度 | 旧 DDP loadMissedMessages(rid, start) |
新 REST GET /v1/chat.syncMessages |
|---|---|---|
| 房间标识 | 方法参数 rid |
查询参数 roomId |
| 起始下界 | 参数 start(Date) |
fromTs(iso-date-time 字符串) |
| 增量判定点 | 隐含为“当前/最近拉取点” | lastUpdate |
| 返回形态 | false(无权限)或 IMessage[] |
{ updated, deleted, cursor } |
| 补充能力 | — | 可额外获得删除记录(deleted)与游标分页 |
5.3 可运行的调用示例
以 curl 形式发送(需携带登录后的身份凭据):
# 拉取 roomId 中、从 2024-06-01T00:00:00Z 起、且自 lastUpdate 之后发生变化的消息与删除记录
curl -G 'https://your-rocketchat.example/api/v1/chat.syncMessages' \
-H "X-Auth-Token: YOUR_AUTH_TOKEN" \
-H "X-User-Id: YOUR_USER_ID" \
--data-urlencode 'roomId=GENERAL_ROOM_ID' \
--data-urlencode 'lastUpdate=2024-06-10T00:00:00.000Z' \
--data-urlencode 'fromTs=2024-06-01T00:00:00.000Z'
带 fromTs 但遗漏 lastUpdate(或同时携带 next/previous)的请求,将分别被 error-fromTs-requires-lastUpdate 与 error-cursor-and-lastUpdate-conflict/error-fromTs-requires-lastUpdate 拒绝。客户端侧的既有接入可参考 useLoadMissedMessages.ts 的 REST 调用方式(sdk.rest.get('/v1/chat.syncMessages', …)),其配套测试 useLoadMissedMessages.spec.ts 验证了对同一端点多次调用时 roomId/lastUpdate 等参数的组装,可作为自定义客户端迁移的最小参照。
6. 变更波及范围与验证线索
- 包与版本节奏:changeset 中标注
@rocket.chat/meteor: minor与@rocket.chat/rest-typings: minor,属于向后兼容的能力增强——fromTs为可选参数,旧调用方不受影响,ChatSyncMessages类型与 Schema 同步更新。 - 服务端路由:apps/meteor/server/api/v1/chat.ts(端点编排与响应归一化)。
- 类型契约与校验:packages/rest-typings/src/v1/chat.ts(
ChatSyncMessages/ChatSyncMessagesSchema/isChatSyncMessagesProps)。 - 底层历史服务:apps/meteor/server/publications/messages.ts(
handleWithoutPagination/handleCursorPagination/getMessageHistory,含全部组合校验与注释说明)。 - 被替代方法:apps/meteor/server/meteor-methods/messages/loadMissedMessages.ts(弃用登记与旧实现)。
对 fromTs 行为边界做最直接验证的方式,是阅读 getMessageHistory 中 参数组合校验 的注释与抛错分支:它们既是运行时行为,也是该参数的权威设计文档。
7. 小结:什么时候该用 fromTs
- 适合使用:客户端希望从某个历史时间点起做“增量补拉”,即原
loadMissedMessages场景——请使用lastUpdate+fromTs的组合,一次请求同时拿回该窗口内的消息更新与删除记录; - 禁止使用:任何携带
next/previous/type的游标翻页请求,服务端会直接报错以阻止静默扩宽结果集; - 无需使用:仅需“最近同步之后的所有变更”时,单传
lastUpdate即可,fromTs是可选的额外收紧条件。
一言以蔽之:fromTs 让 chat.syncMessages 从“只能表达最近增量”进化为“可表达任意起点的时间窗同步”,从而完整接住 loadMissedMessages 的迁移需求——这正是本 changeset 的价值所在。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00