协议解析:基于 `POST /query` 的顶层时间线分页扩展)
Buzz 频道窗口Channel Window协议解析基于POST /query的顶层时间线分页扩展【免费下载链接】buzzA hive mind communication platform项目地址: https://gitcode.com/GitHub_Trending/buzz14/buzz本文聚焦 Buzz 项目在 HTTP bridgePOST /query上实现的Channel Window频道窗口扩展——一种以顶层行top-level rows为单位对频道时间线进行游标分页的读取模型。文章以 docs/bridge-channel-window.md 为契约骨架结合 crates/buzz-relay/src/api/bridge.rs 与 crates/buzz-db/src/store/thread.rs 的源码实现展开读者将掌握该扩展的请求/响应格式、顶层判定谓词、复合游标语义、has_more服务端事实以及39005/39006两类 overlay 事件的结构与信任模型。背景为什么需要频道窗口Nostr 的 NIP-01 filter 只能匹配tag 值无法表达某个 tag 不存在这一否定条件。频道中不是回复的消息——这是每个带线程的聊天客户端首先要渲染的时间线——因此无法用原生 filter 表达。通用 nostr 客户端只能分页拉取全部原始事件再在客户端重新组装线程代价与回复量成正比更糟的是分页正确性被破坏limit统计的是原始事件数一页 50 个事件可能只含 3 条顶层消息也可能含 50 条客户端无法请求再给我接下来 50 行。此外纯时间戳分页仅until存在第二个缺陷created_at只有 1 秒分辨率同一秒内爆发的事件会让时间戳游标在每一页边界出现丢失或重复。Buzz 中继在事件摄入ingest时已经计算了线程结构thread_metadata表中的depth、root、reply_count等字段因此可以直接对外提供顶层视图。Channel Window 就是在这一前提下诞生的raw-filter 扩展——与before_id、thread_cursor属于同一扩展家族。它没有新增任何端点线缆上传输的仍然只有签名过的 nostr 事件。NIP-CW 的关系本文档docs/bridge-channel-window.md是已批准的工程契约与内部设计记录而 docs/nips/NIP-CW.md 是该扩展的规范正文kinds 39005/39006、filter 扩展、游标与信任语义。两者措辞不一致时以 NIP-CW 为准。请求格式标准 filter 扩展字段一次窗口请求就是一个标准 bridge filter 加上若干扩展字段{ kinds: [9], #h: [channel-uuid], limit: 50, top_level: true, include_summaries: true, include_aux: true, until: 1751500000, before_id: 64-hex event id }字段语义如下字段取值说明top_leveltrue布尔必须为布尔true才走窗口路径缺失、false、字符串、数字均按普通 filter 处理。这也是 bridge 分发器判定窗口请求的唯一信号见下文分发逻辑。#h恰好一个 channel UUID窗口必须且只能锁定一个频道。零个或多个频道一律拒绝Buzz 返回 HTTP400请求者无权访问的频道按访问作用域处理不通过报错来泄露频道存在性。limit1 ~ 200默认 50行预算只统计行事件row eventssummaries、aux 与 bounds overlay 从不消耗它。untilbefore_id二者同有或同无复合请求游标(created_at, id)即上一页最后保留行的位置。只有其一必然被拒绝400——窗口路径没有任何仅时间戳回退。二者皆无 head 请求。include_summaries布尔可选追加每行一条 relay 签名的kind:39005线程摘要 overlay。include_aux布尔可选追加作用于保留行的反应/删除/编辑闭包aux closure。page/ OFFSET—窗口路径不支持偏移分页一律不生效。源码印证扩展字段如何在 Rust 侧提取由于nostr::Filter在反序列化时会静默丢弃未知字段bridge 必须先解析原始 JSON 才能拿到这些扩展extension_flag对top_level、include_summaries、include_aux取as_bool().unwrap_or(false)——字段缺失或非布尔一律视为false与 NIP-CW 对top_level的任意非真值即普通 filter定义一致。extract_before_id区分缺失 / 合法 64-hex / 畸形三种状态。畸形长度非 64 或 hex 解码失败必须拒绝请求绝不降级为半游标或 head 请求——这正是 NIP-CW 游标语法的要求。extract_channel_from_filter从 filter 的#hgeneric tag 中提取恰好一个可解析为 UUID 的值否则返回None触发400 top_level requires exactly one #h channel。分发逻辑窗口优先在 bridge 的 query 处理 中窗口 filter 被最先分发遍历原始 filter凡带top_level: true的一律交给handle_channel_window_filter并标记为已处理一个窗口 filter 永远不会被当成 feed/thread/catchall 查询。剩余 filter 再按feed_types等扩展继续处理。顶层谓词什么样的行能成为窗口行一行成为窗口行的条件是thread_metadata满足depth IS NULL OR depth 0 OR (depth 1 AND broadcast true)depth 0非回复事件线程根。depth 1 AND broadcast true作者通过[broadcast, 1]tag 显式选择同时出现在频道时间线的一级回复。depth IS NULLv1 裁决——在thread_metadata存在之前摄入的事件无 depth按顶层处理而不是从所有窗口中消失。这是对前索引数据的兼容规则fail-open而非第三种协议状态测试 harness 中的legacyReply场景正是用来决定是否需要回填迁移。删除行deleted_at IS NOT NULL的行在应用limit之前即被排除。SQL 中的落地get_channel_window_on 的核心 SQL 将顶层谓词直接写入 WHERE 子句WHERE e.community_id $1 AND e.channel_id $2 AND e.deleted_at IS NULL AND ( tm.depth IS NULL OR tm.depth 0 OR (tm.depth 1 AND tm.broadcast true) )thread_metadata表在 insert_thread_metadata 中于摄入期填充插入一条回复时在同一事务内递增父事件的reply_count并更新last_reply_at与根事件的descendant_count事务保证崩溃不会留下计数不一致F9 约束。排序与游标(created_at DESC, id ASC)复合键行按(created_at DESC, id ASC)排序——与中继其他所有读路径相同的复合键。下一页游标是最后保留行的(created_at, id)服务器在39006bounds overlay 中将其回显为next_cursor。键集keyset比较条件为created_at $ts OR (created_at $ts AND id $id)SQL 中对应为AND (e.created_at $ts OR (e.created_at $ts AND e.id $id)) ORDER BY e.created_at DESC, e.id ASC LIMIT $n由于复合游标同时携带时间戳与事件 id密集同秒dense seconds分页在构造上既不丢失也不重复。注意 id 的比较方向总序是created_at DESC, id ASC因此键集条件中必须是id $id字节序升序——把 id 不等式方向搞反正是复合游标要消灭的同秒行丢失/重复 bug。has_more服务端事实而非客户端推断has_more是服务端事实。中继在所有谓词访问、删除、顶层、kinds之后以内部预算limit 1行探测先取limit 1行若第limit 1行存在则has_more true哨兵行被丢弃——它绝不进入线缆、overlay 或 aux 闭包否则has_more false。// 探测行即服务端 has_more 证据 q q.bind(limit as i64 1); let mut db_rows q.fetch_all(mut *conn).await?; let has_more db_rows.len() limit as usize; db_rows.truncate(limit as usize);客户端绝不能从行数推断是否已翻完rows limit在恰好整除的最后一页exact-multiple final page上不说明任何问题——39006.has_more是唯一的权威信号。同样limit 1探测必须在所有谓词之后执行对超集做探测会在最后一页产生虚假的has_more true。next_cursor是扫描位置而非送达行当has_more为真时next_cursor取的是最后一个被保留的扫描候选scan candidate的复合游标——在逐事件重建/过滤之前捕获let next_cursor if has_more { match db_rows.last() { Some(row) Some((row.try_get(created_at)?, row.try_get(id)?)), None None, } } else { None };因此next_cursor可能引用一个未出现在响应中的事件例如被中继判定为不可重建而跳过的行它依然权威若改从送达行推导游标则每次遇到跳过事件都会卡住分页。客户端原样回显next_cursor绝不自行推导或校验它。响应结构扁平事件数组先按 kind 分区响应仍是 bridge 现有的扁平形状一个 JSON 数组由签名过的 nostr 事件组成。客户端在任何游标运算之前先按 kind 分区且不能依赖数组位置行排序除外分区内容说明1. Rows顶层事件按键集顺序唯一消耗limit的条目2. Aux closureinclude_aux反应kind 7、删除kind 5 / 9005、编辑40003以#e指向保留行外加指向这些 aux 事件的删除第二跳如对某条反应的删除一轮往返完成客户端无需#efan-out每一跳在服务端跨 DB 页钳制完整排空因此闭包是完整的而非最新 1000 条3. Thread summariesinclude_summaries每条有回复的行对应一条 relay 签名的kind:39005无回复的行不产生4. Window bounds每个窗口响应恰好一条relay 签名的kind:39006空页、耗尽页也总是携带Aux 闭包的实现细节handle_channel_window_filter 中aux 闭包使用 WINDOW_AUX_KINDS删除、反应、NIP-29 删除、流消息编辑与 WINDOW_AUX_DELETE_KINDS两种删除两轮查询第一轮以行为目标第二轮以第一轮命中事件为目标用HashSet去重且通过event_in_accessible_channel做访问检查删除可能无频道存储按访问而非频道约束处理避免被静默丢弃。每个 aux 跳由 query_all_pages 沿(created_at, id)键集逐页排空页大小对齐buzz_db::DEFAULT_MAX_PAGE_LIMIT上限 64 页防病态写模式死循环。因为结果按最新优先一次性查询会静默丢掉最旧的编辑与删除——那会导致渲染出原始内容或已删内容而不只是丢失装饰。此外aux 闭包在与窗口同一请求事务内执行当页面来自已证明的副本会话时心跳观测锚定了 REPEATABLE READ 快照aux 各跳看到的正是证明所覆盖的状态。kind:39005— 线程摘要 overlaytags: [e, root-id], [d, root-id], [h, channel-id] content: {reply_count:n,descendant_count:n,last_reply_at:ts|null,participants:[hex-pubkey,...]}reply_count直接回复数descendant_count线程子树内全部事件数last_reply_at最新后代的 unix 秒或nullparticipants最多 10 个去重作者 pubkey最近优先。tag 基数精确一个e、一个d、一个h别无其他。由中继密钥对签名查询时合成、永不落库。客户端将其视为按e/dtag 键控的 replace-by-target 元数据dtag 原生提供参数化可替换语义它绝不是行、绝不是游标输入、绝不是持久时间线历史。源码侧overlay 由nostr::EventBuilder配合state.relay_keypair签名生成见 sign_overlaycontent 通过serde_json::json!序列化参与者在一次整窗批量查询中按根事件填充每根最多 10 人见 get_channel_window_on 的ROW_NUMBER() ... rn 10批查询。kind:39006— 窗口边界 overlaytags: [d, channel_id:request-cursor-or-head], [h, channel_id] content: {has_more: bool, next_cursor: {created_at: ts, id: hex} | null}next_cursor null ⇔ has_more false该不变量必须成立。d-tag 后缀序列化规范形式head 请求为字面量head否则为created_at:event_id——十进制 unix 秒、冒号、完整 64 位小写 hex id。客户端必须校验后缀等于自己发送的游标不匹配即丢弃该 overlay这使每个 bounds overlay 与自己的请求绑定并发页响应不会混淆。保留字段oldest_retained保留缺口信号需要时可在不破坏线缆的前提下追加。与 39005 相同的 overlay 规则relay 签名、查询时合成、永不落库、绝不作行或游标输入。d-tag 后缀按请求游标逐页变化是刻意设计同一频道的并发页在可替换事件缓存中共存而非互相覆盖——若采用每频道单例第 N 页的 bounds 会覆盖第 N1 页的。两种 kind 均为relay-only客户端在摄入期提交这两种事件会被拒绝防止伪造的 relay 签名状态混入存储。访问作用域无行、无 overlay、无差异报错访问检查在以上任何步骤之前执行。语法合法但请求者无权访问包括频道不存在的窗口请求产生该 surface 的普通访问作用域结果——对 Buzz 的 query surface 而言即空数组与针对任何不可访问频道的普通 filter 完全一致。两个必须注意的推论恰好一条39006只适用于被服务的窗口访问成功者。bounds overlay 缺失因此是有意义的信号它告诉扩展感知客户端没有窗口被服务访问受限或该 relay 未实现本扩展。不可访问频道与不存在频道不可区分但与可访问的空频道可区分后者是被服务的窗口会返回39006has_more: false。这与中继普通读取的存在性披露姿态一致。客户端义务已冻结契约分页是不可变、权威的历史游标链式衔接新到的实时事件落在独立的 overlaylive subscriptionsince: now中绝不拼入已获取的页只在渲染时合并。重连时重新拉取第 0 页并重新武装 live 订阅更深的页无需修复路径。回复永不进入频道时间线线程面板使用既有的thread_cursorsurface#1418线程 filter 可选择include_aux以追加与频道窗口响应相同的授权两跳反应/编辑/删除闭包。bounds 完整性缺失39006、多于一条、d-tag 绑定不回显请求游标、content 不是可解析 JSON、或has_more/next_cursor违反等价关系的窗口响应都是不可用页面——客户端必须丢弃可重试绝不能猜测耗尽状态。overlay 是元数据绝不把39005/39006渲染成消息绝不喂入游标运算缓存摘要按其dtag 键控后到者胜。Overlay 信任模型二选一因为39006是分页权威客户端使用窗口快路径前必须采用以下两种信任画像之一认证传输画像Buzz 桌面端默认客户端在 TLSHTTPS/WSS上访问自己显式配置为事实来源的中继服务端来源认证来自 TLS 证书链。NIP-98 请求签名与 NIP-42 auth 认证的是请求者身份用于访问控制并非响应来源证据。此画像下relay 签名是TLS 来源声明而非客户端验证过的密码学声明§客户端义务中的 MUST 级结构检查仍然强制生效。身份验证画像客户端已带外或经 NIP-11 获得并信任中继身份 pubkey必须逐条验证每个 overlay 的事件 id、Schnorr 签名与签名者任何失败均按丢弃处理。适用于无法端到端认证传输的客户端。两者皆无的客户端不得使用窗口快路径——回退到标准 filter见下自行验证每个事件签名。降级路径扩展不感知的双方都安全本扩展的所有字段都是标准 filter 上的附加键未实现它的客户端和中继都无需改动不感知扩展的中继容忍未知键的 filter 解析器常见 NIP-01 实现如此会把该 filter 当作普通kinds#h查询服务——完整、正确、标准的原始事件流严格解析器则可能整体拒绝。两种都安全都不会产生看似正确实则错误的顶层时间线。客户端必须把两种信号——无合法39006的响应或报错/不支持响应——视为降级信号去掉所有扩展键重发标准 filter 并在客户端自行组装线程。Buzz 自己的 WS REQ 路径正是这样的容忍解析器filter 反序列化器丢弃扩展字段因此 REQ 上的窗口 filter 走的是标准查询。不感知扩展的客户端从不发送top_level从不看到 overlay kind观察到的完全是一个标准 relay。实现方可在 NIP-11 中广告支持客户端无需广告即可安全探测发一次 head 窗口请求并应用上述降级规则——是否存在合法39006就是能力信号。扩展家族与before_id、thread_cursor等的并存Channel Window 不是孤立的。bridge.rs中同属 bridge-only raw-filter 扩展、对普通 relay 与客户端不可见的还有扩展位置语义before_id要求untilextract_before_id通用查询的复合游标后半部分thread_cursor/thread_cursor_idextract_thread_cursor线程回复分页游标线缆编码为 8 字节大端 i64 秒 原始事件 id 字节depth_limitextract_depth_limit限制线程回复返回深度feed_typesextract_feed_typesfeed 查询类型筛选所有这些扩展都遵循同一哲学无新端点、无新信封只是对标准 filter 的附加字段忽略它们的各方得到的仍是完全标准的协议行为。安全与隐私要点overlay 是关于请求者本就可读数据的 relay 作者事实访问作用域应用于行与每个 aux 事件39005聚合的线程活动参与者、计数、新旧程度只描述请求者可读频道中的线程节省的是往返而非权限。客户端提交的39005/39006必须在摄入期拒绝relay-only kinds——伪造 overlay 一旦入库日后可冒充 relay 签名状态。待办硬化项MUST 级之外精确 tag 基数校验、运行时字段类型校验以及将 overlay 签名密码学绑定到 NIP-11 广告身份将与 NIP-DV、NIP-IA 等所有 relay 签名读取统一应用——在认证传输画像下目前并非既有保证。延伸阅读规范正文docs/nips/NIP-CW.mdkinds 39005/39006、filter 扩展、游标与信任语义的权威定义契约文档docs/bridge-channel-window.md本文骨架来源v2 冻结契约HTTP bridge 实现crates/buzz-relay/src/api/bridge.rshandle_channel_window_filter位于 L508-L690分发位于 L1188-L1204窗口 SQL 与线程元数据crates/buzz-db/src/store/thread.rsget_channel_window_on位于 L651-L835insert_thread_metadata位于 L138-L261相关 NIP 族NIP-01filter 语法与可替换语义、NIP-29htag 频道模型、NIP-98HTTP query surface 认证、NIP-11relay 身份与能力广告【免费下载链接】buzzA hive mind communication platform项目地址: https://gitcode.com/GitHub_Trending/buzz14/buzz创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考