yaf 项目导读 · mentor 版

yaf 项目导读

一本带你从零读懂 TS × React × Hono × Cloudflare 全栈项目的书 · 共 15 章 + 词汇表 · mentor 领读版

yaf 是一个开源的 flomo 式卡片笔记应用,部署在 Cloudflare 的免费套餐上:整个产品——网页、API、搜索、AI 洞察、微信机器人——打包成一个 Worker。你将在这本书里跟着 mentor 把它从头到尾读一遍:从「一次点击发生了什么」讲到「一行正则为什么这么写」。

先带着一个问题读

传统上,「一个网站 + 一个 API + 定时任务 + 消息机器人」至少需要一台服务器(或四台)。yaf 没有任何服务器,却全部做到了,而且免费。它是怎么做到的?带着这个问题读第 1 章和第 3 章,读完你会对「云计算还能这样做」有一个具体、可检验的答案,而不是一句「Serverless 很方便」。

这本书为谁写

为你——一个有 web 开发经验、但对 TypeScript 生态和 Cloudflare 平台完全陌生的开发者。所有概念默认零基础:先讲它解决什么问题,再给名字(中文正名 + 英文原词)。同时,这本书是为 vibe coding 的工作方式设计的:你平时让 AI agent 写代码,这本书的目标是让你达到三个具体的能力,而不是背诵语法——

  • 看得懂:agent 给你一段这个项目的代码,你能说出它在链路的哪一环、为什么写在这里;
  • 判得对:agent 说「改好了」,你知道怎么验证(看哪条链路、跑哪个命令、警惕什么边界);
  • 改得动:需要你亲自下场时,你知道改哪个文件、会牵动哪些上下游。

怎么读

  • 顺序读,别跳。章节按理解依赖排序:第 2 章的 TS 语法是后面所有章的积木;第 8 章的「向量」需要第 6 章的「全文检索」做对照。每章开头都标注了它接续上一章的哪个钩子。
  • 跟着贯穿案例走。全书用同一条主线:「一条 memo 的旅行」——你在输入框写下「明天给妈妈打电话 #家事」并按下保存,这条 memo 从浏览器出发,经过路由、鉴权、数据库、标签提取、AI 判定、向量索引,最后被你搜到、被洞察报告引用。每一章都让这条旅程多走一段。
  • 图是正文的一部分。流程图、时序图、状态机图都标注了箭头含义;带 ▶ 步骤按钮的图可以一步步点着看。颜色在全书中保持同一含义:
浏览器 / React 前端 Worker / Hono API(主线) 存储:D1 · R2 · Durable Objects AI 能力:Workers AI · Claude 后台任务:Workflow · Cron · waitUntil 安全:密码 · Token · 加密
  • 做完每章的自检。每章末尾有一两道「换输入预测结果」式的题,答案折叠在下方——先自己答,再展开对照。答错说明缺了哪块前置,回对应小节补。
  • 留意四种旁白框:mentor 旁白(为什么这样设计)、边界(容易误解的地方、教学简化和真实的差距)、概念(新名词的正式定义)、vibe 提示(配合 agent 开发时你必须亲自懂什么)。

随身工具箱:验证 agent 的五件观察工具

这个仓库没有测试套件(package.json 的 scripts 里没有 test),所以「判断 agent 改得对不对」不靠跑测试,靠观察。下面五件工具会在全书反复出场,先集合点名——以后每章末尾的自检,第一动作都是在这里选一件:

工具干什么用
git diff逐块审读 agent 的改动,只放行你看得懂的块——审读的第一动作,永远先行
curl 直打 /api/*绕开界面直接验证接口行为(带 Cookie;第 7 章之后可以用 API Token)
npx wrangler tail看后台日志——观察 waitUntil / Workflow / Cron 失败的唯一窗口
npx wrangler d1 execute yaf-db --local --command "…"直接查库:第 5 章验证事务写入、第 6 章验证「全文索引里存的到底是不是原文」都靠它
GET /api/search/status第 8 章起的语义索引进度表:已索引多少条、补算任务在不在跑

环境前提顺手交代一句:本地 npm run dev 时 AI 与向量库也直连线上(wrangler.jsonc 里标了 remote),所以需要一个登录过(wrangler login)的 Cloudflare 账号。也正因如此,你会亲眼看到一个重要现象:AI 不可用时 memo 照样存得进去,向量晚一点再补——「先落库、后理解」这个时间差,就是下一章的主角。

vibe 提示:这本书怎么配合 agent 使用

建议的循环是:读书建立模型 → 让 agent 动手 → 用书里的自检验证 agent。每章的「vibe 提示」会告诉你:这一章的知识里,哪些是 agent 替代不了你懂的(比如「为什么这里必须用事务」),哪些可以放心交给 agent(比如「把这段 SQL 抄成另一个表」)。第 15 章有一份完整的自检清单,读完可以把每个功能当考题自测。

目录

第 1 章 · 全景

yaf 是什么,一条 memo 的旅行

本章目标学完本章,你能指着一张图说出:yaf 由哪几块组成、一次「保存 memo」从浏览器到数据库经过哪几站、还有哪些后台角色在什么时候被唤醒。后面的每一章,都是把这张图上的一块放大。

30 秒版本:yaf 是什么

yaf 是一个 flomo 式的卡片笔记应用:打开网页,在输入框写下「明天给妈妈打电话 #家事」,回车,它变成一张卡片;写多了以后,你可以按标签翻、按关键词和意思搜索、让 AI 每周回顾一次并给你一份报告。它的特别之处在于怎么部署:网页、API、搜索索引、AI、定时任务、微信机器人,全部打包成一个 Cloudflare Worker,用免费套餐就能跑,数据都在你自己的云账号里。

它有五个入口,全部通向同一套核心逻辑:

入口谁在用走哪条路
网页你自己React 单页应用 + Cookie 会话
CLI yaf终端 / 脚本API Token(设备码登录授权)
MCPClaude Code 等 AI 助手API Token + MCP 协议
HTTP API任何程序API Token + scope 权限
微信手机上发消息Durable Objects 长轮询收录
mentor 旁白:为什么第一课是「地图」而不是「语法」

读代码最常见失败方式,是从 main.tsx 第一行顺着读——很快就会淹死在细节里。正确的姿势和看地图进城一样:先记住海岸线和几条主干道(本章),再每次只放大一个街区(后面每章)。你随时应该能回答「我在哪、这一块和整体什么关系」。

全景图:一个 Worker 装下一切

下面这张图是全书的「主地图」,颜色含义全书一致(见第 0 章图例):蓝色是浏览器,主色是 Worker 进程内部,绿色是存储,紫色是 AI,橙色是后台任务。带虚线动画的箭头表示「数据在流动」。

图 1-1 · yaf 全景架构
浏览器 · React SPA src/main.tsx 挂载整个应用 输入框 / 卡片 / 侧栏 fetch("/api/…") + Cookie 静态资源也由这个 Worker 发 Cloudflare Worker「yaf」 worker/index.ts · 入口只有一个 fetch 函数 Hono 路由分发 鉴权中间件 memo-service 读写 切词 → 写全文索引 响应返回后继续跑:ctx.waitUntil Clef 判定视图/隐私 bge-m3 算向量 D1 · SQLite memos / 标签 / 会话 / FTS Vectorize 向量索引(语义检索) R2 对象存储 微信图片 / 语音原文件 Workers AI Clef 判定 bge-m3 向量 Qwen 图片描述 qwen3 洞察 (或 Claude) Cron 定时器 每 15 分钟唤醒一次 两个 Workflow 补算向量 / 生成洞察 其他入口(第 11、12 章) CLI 终端 MCP · AI 助手 微信消息 最终都走同一套 memo-service 逻辑 POST /api/memos SQL(batch 事务) AI.run("…"):Clef 判定 · bge-m3 向量 upsert 向量 scheduled() 启动任务 · step.do 重试
实线 = 请求路径(用户在等);虚线 = 响应已返回后的后台路径(用户不用等)。这个区别是 yaf 架构的骨架,第 3 章会正式讲它(ctx.waitUntil)。

承载这张图的配置文件是 wrangler.jsonc——它声明「这个 Worker 需要哪些云资源」。现在只需要看懂结构,每个字段的含义后面各章会分别讲:

wrangler.jsonc · 云资源清单(节选)
{
  "main": "worker/index.ts",          // 入口:整个后端从这里开始
  "d1_databases": [{ "binding": "DB", ... }],      // 数据库,代码里用 env.DB 访问
  "ai": { "binding": "AI", ... },                  // Workers AI,env.AI.run(模型, 参数)
  "vectorize": [{ "binding": "VECTORS", ... }],    // 向量索引,env.VECTORS.query(...)
  "triggers": { "crons": ["*/15 * * * *"] },       // 每 15 分钟调用一次 scheduled()
  "workflows": [{ "name": "yaf-index", "binding": "INDEX_WORKFLOW", ... },
                { "name": "yaf-insight", "binding": "INSIGHT_WORKFLOW", ... }],
  "durable_objects": { "bindings": [{ "name": "WECHAT", ... }] }, // 微信长连接
  "r2_buckets": [{ "binding": "MEDIA", ... }]      // 图片/语音原文件(可选)
}
"binding"(绑定)是这个文件里最重要的词:它给一项云资源起一个变量名,代码里写 env.DB 就能拿到那项资源——不需要配置 IP、账号密码、连接池。第 3 章正式展开。

贯穿案例:一条 memo 的旅行

现在放大主地图上的一次点击。你在输入框写下「明天给妈妈打电话 #家事」按保存——接下来的 2 秒里,下面 12 站依次发生。点按钮跟着走,图上会高亮当前这一站(其余变暗)。

mentor 旁白:这 12 站里藏着全书的路线

①⑦ 是第 4 章(React);②③④ 是第 3、7 章;⑤ 是第 5 章;⑥ 是第 6 章;⑧ 是第 10 章;⑨⑪ 是第 8 章;⑩⑫ 是第 9 章。看不懂图里的某些词(FTS5?RRF?)完全正常——这正是后面各章要补的积木。现在你只需要记住旅程的形状:一条快的请求路径(用户在等),一条慢的后台路径(用户不等),以及一个定期巡逻的定时器。

仓库地图:每个目录住着什么

目录角色代表文件
worker/后端:API 与全部服务端逻辑(跑在 Cloudflare Workers 上)index.ts 入口、auth.ts 鉴权、memos.ts 接口、memo-service.ts 核心读写、search.ts 检索
src/前端:React 单页应用(构建后成为静态文件)main.tsx 挂载、App.tsx 顶层状态机、api.ts 接口封装、components/ 界面组件
shared/前后端共用:标签提取、中文切词tags.ts、segment.ts(第 6 章讲它为什么能共用)
migrations/数据库表结构的变更历史(只增不改)0001_init.sql 起步的 4 张表
packages/cli/yaf 命令行工具(独立的小包)src/index.ts
wrangler.jsoncCloudflare 资源清单(上面的主地图翻译成配置)—

前后端的代码量大致对半,但地位不对等:前后端只通过 /api/* 上的 JSON 接口说话。前端不知道 D1 的存在,后端不关心 React 的存在——这让两边可以独立理解和修改。这个边界是读这个项目最重要的坐标线。

自检 1 · CLI 发一条 memo 时,第 ④ 站(鉴权)和网页有什么不同?
网页带的是 Cookie(登录会话,全部权限);CLI 带的是 Authorization: Bearer yaf_pat_…(API Token,只有被授予的 scope 权限,而且看不到隐私 memo)。同一个中间件 requireScope() 两种都认(worker/auth.ts:82),但下游的 visibilityOf() 会据此决定它能看到的数据范围(worker/memos.ts:31)。「同一个入口,两种身份、两种可见范围」——这是第 7 章的主线。
自检 2 · 如果把第 ⑧ 步的 AI 判定放进第 ⑤ 步之前(先判定再返回),用户会感觉到什么?yaf 为什么不这么做?
保存会从几十毫秒变成 1–3 秒(要等一次模型调用),网络抖动时保存直接失败。yaf 把它放进 ctx.waitUntil:响应先走,判定后台跑,失败还有 Cron 兜底重判。「用户在等的事」和「可以晚点做的事」分开,是这个项目反复出现的权衡——第 3 章讲机制,第 8 章再遇一次(向量计算)。
下一章钩子

地图已经在你脑子里了。但上面出现的一切——interface、async、env.DB、{ ok: true }——都是用 TypeScript 写的。下一章我们用这个仓库自己的代码,把 TS 必需的积木一次补齐。

第 2 章 · 地基

TypeScript 最小必需:够读懂全项目的那些积木

本章目标学完本章,你拿到 yaf 的任意一个文件,能读懂它的类型签名;看到 Promise<T>、Partial<…>、phase.kind === "app" 这类写法,能说出它在约束什么。这些积木贯穿后面全部章节。

TS 不是另一门语言——它是「JavaScript + 类型标注」。你写的代码运行时仍然是 JS;类型在编辑和构建时被一个检查程序(tsc,即 TypeScript Compiler)核对,然后被剥掉。一句话记住它的价值:

概念 · 静态类型(static typing)

它解决的问题:JS 里把 memo.id 拼错成 memo.ids,要到用户点按钮那一刻才在浏览器里报错。TS 把这类错误提前到你保存文件的那一秒——检查器读你标注的「数据形状」,指出所有对不上的地方。类型标注是写给检查器的注释,运行时不存在。

yaf 在每次提交前跑 npm run typecheck(package.json:12,背后是 tsc --noEmit:只检查、不产出文件)。CI 也跑同一命令(.github/workflows/ci.yml)。这意味着:类型对不对,机器说了算——这正是 vibe coding 的地基:agent 改完代码,typecheck 过没过是一道客观的闸门。

interface:给数据的形状起名字

前后端之间传的每一样东西,yaf 都给它起了形状名。看前端最重要的一个(它和后端返回的 JSON 一一对应):

src/api.ts:1 · 一条 memo 的形状
export interface Memo {
  id: string;            // 形如 "8f3c…" 的随机串
  content: string;       // Markdown 正文
  createdAt: number;     // 毫秒时间戳
  tags: string[];        // 字符串数组
  source: string;        // web | wechat | api | mcp | cli
  attachments?: Attachment[];   // ← 问号:可选字段,可能不存在
  private?: boolean;     // 隐私 memo:content 已是遮挡文本
  lenses?: string[];     // 命中的视图,如 ["todo"]
}
读法:字段: 类型;? 表示「访问前必须先判断存在」;// 注释在编辑器里会变成这个字段的悬浮文档。后端返回多个字段没关系——形状描述的是「至少有这些」。

从此以后 memo.contnet 这种拼写错误在保存时就被抓出来;memo.attachments.length 不先判空也过不了 typecheck。名字是大写驼峰(Memo、ApiError),文件名是小写——这是 TS 生态的普遍约定。

联合类型与字面量类型:把「只能是这几种」写进代码

普通字符串装什么都行。但主题只有三种、来源只有五种——这种「封闭集合」用字面量联合表达:

src/theme.ts:3 与 worker/env.ts:37
type ThemePref = "system" | "light" | "dark";      // 只能是这三个字符串
const SOURCES = ["web", "wechat", "api", "mcp", "cli"] as const;
type Source = (typeof SOURCES)[number];            // 推导出同样的联合
as const 把数组冻结成只读字面量;(typeof SOURCES)[number] 是「取这个数组所有元素类型」的写法。好处:以后往 SOURCES 加一项,Source 类型自动跟着变——一处改动,全项目受检。

联合类型最强大的用法是判别联合(discriminated union):每种情形放一个共同的标签字段,检查器据此知道「此刻你拿到的是哪种情形」。yaf 前端的顶层就是一个四种情形的状态机:

src/App.tsx:9 · 应用启动的四态
type Phase =
  | { kind: "loading" }
  | { kind: "setup"; tokenRequired: boolean }   // 还没有管理员,去初始化
  | { kind: "login" }
  | { kind: "app"; user: User };                // 已登录,user 一定存在

const [phase, setPhase] = useState<Phase>({ kind: "loading" });
if (phase.kind === "app") {
  console.log(phase.user.email);   // ✅ 检查器允许:kind 是 app 时必有 user
}
console.log(phase.user);           // ❌ typecheck 报错:其他 kind 没有 user
这个「检查器根据 if 判断自动缩小类型」的行为叫类型收窄(narrowing)。它把「运行时才崩」的状态错误变成「保存时就红」——比任何注释都可靠。
图 2-1 · 试一试:改一个值,看类型如何收窄

interface 还是 type?

两者都能描述对象形状,项目里几乎可互换。yaf 的惯例(也是社区常见惯例):对象形状用 interface,别名和组合用 type。组合是 type 独有的舞台——把现有类型变形出新类型:

src/api.ts:130 与 worker/memo-service.ts:37
type PlanInput = Partial<Omit<InsightPlan, "id" | "createdAt">>;
// Omit:去掉某些字段;Partial:全部字段变可选 → 「创建时随便传哪几个都行」

type Memo = ReturnType<typeof toMemo>;
// typeof:取函数的类型;ReturnType:取它的返回值形状
// toMemo 改了返回结构,Memo 自动跟着变,不用手写两遍
Partial、Omit、ReturnType 叫工具类型(utility types):类型层面的函数,输入一个类型、输出一个新类型。读到「尖括号里套类型」的代码,基本都是它们。

泛型:类型的参数

前端所有请求都经过同一个函数——不管返回的是 memo 列表还是标签列表,发出请求、检查状态码的逻辑完全一样,唯一的变量是「返回值的形状」:

src/api.ts:205 · 一个函数服务所有接口
async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`/api${path}`, { ...init, credentials: "same-origin" });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new ApiError(res.status, ...);
  return data as T;          // 告诉检查器:按调用方说的形状对待
}

// 调用点用「类型的实参」指定 T:
api.listMemos(...)  // → request<{ memos: MemoHit[]; nextCursor: string | null }>
const { user } = await api.me();   // user 自动是 User 类型,有补全、拼错即报错
<T> 读作「类型 T 是个参数」。泛型是 TS 的「函数」:值层面的复用用参数,类型层面的复用用泛型。Promise<T> 同理——「将来会给出的 T」。

async / await:与「将来才到的值」共处

网络请求、读数据库都要等。JS 的处理方式不是「阻塞着等」,而是Promise——一张「值随后就到」的取货单。await 的意思是「在这里等取货单兑现,代码写法上保持从上到下」:

worker/search.ts:214 · 两路检索并行跑
const [kw, sem] = await Promise.all([
  keywordCandidates(env, userId, opts.q),   // 关键词路(查 D1)
  semanticCandidates(env, userId, opts.q),  // 语义路(查向量库)
]);
// 两路同时出发,都回来才继续 —— 总耗时 ≈ 最慢的一路,不是两路之和
三条规则:① await 只能出现在 async 函数里;② async 函数的返回值自动包成 Promise;③ 想并行就用 Promise.all,一个 await 接一个 await 是串行。错误用 try { … } catch (e) { … } 接住。
边界 · async 不等于多线程

await 期间 CPU 可以去做别的(等网络正合适),但同一份 JS 代码永远不会两段同时执行——第 3 章会看到 Workers 正是利用这一点,用一个「单线程小容器」安全地服务成千上万的请求。

模块:每个文件就是一个作用域

TS/JS 没有「全局共用一大坨」的传统,每个文件是一个模块:想给别人用就 export,想用别人的就 import。yaf 里前后端共用标签提取,就是一行 import 的事:

worker/memo-service.ts:2
import { extractTags } from "../shared/tags";   // ../ 上一级目录
import type { Source } from "./env";             // type 只导入类型,运行时不存在
// import 的是「绑定的名字」,不是文件路径里的变量;
// 路径不写扩展名,打包器(第 14 章的 Vite)负责找到确切文件并拼装。
import type 明确说「我只要类型」——打包时这行会整个消失。这本书里你会反复看到「编译时存在、运行时消失」的二分,这是理解 TS 项目的钥匙。

npm 与 package.json:依赖从哪来

npm 是 JS 的包管理器(相当于 pip / cargo / bundle)。package.json 声明三样东西:

package.json · 精简
"scripts": { "dev": "vite", "typecheck": "tsc --noEmit",
             "deploy": "node scripts/preflight.mjs && vite build && wrangler deploy" },
"dependencies": { "hono": "^4.13.13", "react": "^19.3.1", "zod": "^4.6.7", ... },
"devDependencies": { "typescript": "^7.0.2", "vite": "^8.3.4", "wrangler": "^4.151.0" },
"workspaces": ["packages/*"]
dependencies=运行时要打进产品的(hono、react);devDependencies=只有开发构建要用的(typescript 本身、打包器、部署工具)——^4.13.13 表示「4.x 里 ≥4.13.13 都可接受」。精确版本锁在 package-lock.json。workspaces 让 packages/cli 作为子包共用同一份 node_modules(第 11 章)。npm run 名字 执行 scripts 里的命令。

tsconfig:检查器的守则

检查器多严,由 tsconfig.json 决定。yaf 开了 "strict": true——最严模式:不可选字段不许没赋值、可能为 null 的值必须先判断。读代码时请记住:凡是 typecheck 通过的项目,每个「没写 if 判断」的地方都是真的不用判断——类型系统已经替你证明过了。这是读 TS 代码和读动态语言代码最大的心态差异。

vibe 提示 · 这一章里你必须亲自懂的

① 会读类型签名(interface、?、联合、泛型调用点)——这是你和 agent 沟通的共同语言,例如「把 visibility 参数加上,类型用 Visibility」;② 懂判别联合与收窄——状态机是前端 bug 的最大来源,审查 agent 的状态代码时你盯着 kind 字段看;③ 懂 Promise.all 与串行 await 的性能差别。而「具体的类型体操」「从报错反推改哪」可以放心交给 agent——typecheck 是客观闸门,错了它自己会修。

自检 1 · const s: Source = "web" 合法,const s: Source = "web "(多个空格)呢?
不合法。字面量联合是精确匹配,"web " 不是五种来源之一,typecheck 直接报错。想放「脏数据」进类型化世界的入口只有一处:外部输入(JSON.parse、表单),yaf 都在那里做了校验(比如 tokens.ts:33 的 parseScopes 逐项检查)。
自检 2 · 下面两段代码耗时差多少?
A:const a = await f(); const b = await g(); B:const [a, b] = await Promise.all([f(), g()]);
A 是串行:耗时 = f + g。B 是并行:耗时 = max(f, g)。yaf 的搜索(search.ts:214)和统计接口(memos.ts:158 的 db.batch)都刻意用并行——读代码时看到连续两个 await,值得停下来问一句「这里是不是该并行」。
下一章钩子

积木备齐了。现在回到第 1 章那张图上最奇怪的事实:这个项目没有服务器,env.DB 凭什么在代码里直接可用?下一章进入 Cloudflare Workers 的世界。

第 3 章 · 后端基座

没有服务器的服务器:Workers 与 Hono

本章目标学完本章,你能说清:Worker 的代码在哪儿运行、「绑定」是什么、一个 HTTP 请求从入口函数走到 Hono 处理函数经过了哪些环节、以及「响应之后继续干活」是怎么实现的。第 1 章全景图的主色区域,本章全部点亮。

Workers 是什么:从「租机器」到「上传函数」

你以前部署后端,多半是:租一台云服务器 → 装 Node → 配 Nginx → 跑一个常驻进程 → 操心补丁、内存、扩容。Cloudflare Workers 把这套东西整个收走,你只交出一个 JS/TS 文件,它把你的代码复制到全球三百多个节点;哪里有请求,就在哪里现场开一个极轻量的沙盒运行你的函数,请求结束沙盒即可回收:

图 3-1 · 传统部署 vs Workers
传统:一台常驻服务器 你的 Node 进程 24 小时常驻 · 你管重启 用户·纽约 用户·上海 · 流量都飞向这一个机房(延迟看用户运气) · 扩容 = 买机器;空闲时段照常计费 Workers:代码复制到边缘节点 节点·法兰克福isolate 沙盒 节点·新加坡isolate 沙盒 节点·圣何塞isolate 沙盒 用户·柏林 用户·洛杉矶 · isolate ≈ 同一进程里的一次性 V8 沙盒,启动 ≈ 0 毫秒 · 没请求 = 没消耗;免费套餐每天 10 万次请求
「无服务器(serverless)」不是说没有服务器,而是说服务器不再是你的问题。代价是你的代码必须「无状态、快速完成」——正好逼出好架构。isolate(隔离环境)之间互不可见,所以不同用户的请求天然隔离。

入口:一个导出的 fetch 函数

整个 yaf 后端的入口,就是导出一个带 fetch 方法的对象。Cloudflare 约定:有请求就调它,把「请求、云资源、执行上下文」作为三个参数递进来:

worker/index.ts:30 · 全项目的心脏(节选有删减)
export default {
  fetch(request, env, ctx) {              // 每个 HTTP 请求进来这里
    if (new URL(request.url).pathname === "/mcp") return handleMcp(request, env, ctx);
    return app.fetch(request, env, ctx);  // 其余交给 Hono(/api 路由 + 静态资源)
  },
  async scheduled(_controller, env, ctx) { // 定时触发(Cron Triggers):每 15 分钟进来这里(第 9 章)
    ctx.waitUntil(Promise.allSettled([runDuePlans(env), judgeBackfill(env).then(() => backfillAll(env))]));
  },
} satisfies ExportedHandler<Env>;   // satisfies:检查「形状对不对」,但不改变推断
三个参数是全项目的角色表:request=这次请求(URL、方法、头、体);env=云资源(下一节);ctx=本次执行的上下文,最重要的是 ctx.waitUntil()——「响应发出后请继续帮我跑这个 Promise」。第 1 章旅程的 ⑧⑨ 站就是它。

绑定(binding):云资源变成变量

概念 · 绑定 binding

它解决的问题:程序要连数据库,传统上需要「主机地址 + 账号 + 密码 + 连接配置」这类连接信息——写在哪都危险,泄漏了就是事故。绑定把资源直接挂到 env 对象上:你在 wrangler.jsonc 写 "binding": "DB",代码里 env.DB.prepare(sql) 就是那个数据库。没有地址、没有密码、没有连接池,平台代管一切。

yaf 的全部绑定定义在一处——worker/env.ts:1 的 Env 接口。这个文件是后端的「资源户口本」,值得整份读一遍(很短):

worker/env.ts:1 · 资源户口本(节选)
export interface Env {
  DB: D1Database;            // 必有:D1 数据库
  AI: Ai;                    // 必有:Workers AI
  VECTORS: Vectorize;        // 必有:向量索引
  MEDIA?: R2Bucket;          // 可选(问号):R2 没开通就没有
  ENCRYPTION_KEY?: string;   // secret:加密用户密钥用
  SETUP_TOKEN?: string;      // secret:防抢先注册管理员
}
配置分两类:vars(不敏感,写进 wrangler.jsonc,如 AI_GATEWAY_ID)和 secret(敏感,用 npx wrangler secret put 名字 写入,永远不进代码库)。类型上用 ? 表达「可选资源」——代码里就能被检查器逼着写 if (env.MEDIA),R2 没开通的程序逻辑自动降级而不是崩溃。
边界 · 本地开发时 env 从哪来

本地 npm run dev 时,Wrangler 会在本机模拟出这些绑定(本地 SQLite 当 D1),secret 则放在 .dev.vars 文件里(已被 .gitignore 忽略)。注意 yaf 的 AI 和 Vectorize 标了 "remote": true(wrangler.jsonc:21-24):这两项没有本地模拟器,本地开发也在调真实云端。

Hono:路由与处理函数

Hono 是一个极小的 Web 框架(定位类似 Express,但为 Workers 这类环境而生),它只干一件事:把「URL + 方法」映射到处理函数。yaf 在入口里建了一个挂在 /api 前缀下的总路由,再把各业务的子路由挂上来:

worker/index.ts:14 与 worker/memos.ts:63
const app = new Hono<AppEnv>().basePath("/api");  // 之后写 /memos 实际是 /api/memos
app.route("/auth", auth);   // 登录相关 → 子路由 auth(worker/auth.ts)
app.route("/", memos);      // memo 相关 → 子路由 memos(worker/memos.ts)
// …… tokens / insights / wechat / autoTags / usage 同样挂载

// 子路由里,一个典型接口长这样:
memos.get("/memos/:id", requireScope("memos:read"), async (c) => {
  const memo = await getMemo(c.env.DB, c.get("user").id, c.req.param("id"), ...);
  if (!memo) return c.json({ error: "不存在" }, 404);
  return c.json({ memo });
});
读法:c(context,上下文)是 Hono 给每个请求的随身物件——c.req 取输入(param 路径参数、query 查询串、json 请求体),c.json(值, 状态码) 造响应,c.env 拿绑定,c.get/c.set 存取本次请求内的共享变量。一个接口 = 一个 async 函数,进是 request,出是 response。

HTTP 方法在这里是显式的:get/post/patch/delete 对应「读/建/改/删」。yaf 的 URL 设计遵循常见惯例(名词复数 + id),完整对照表在 README 的「API」一节,随读随查即可。

中间件:洋葱圈

概念 · 中间件 middleware

它解决的问题:「先确认登录、再干活」这段逻辑如果复制进 30 个接口,一定会有第 31 个忘掉。中间件是插在路由前的公共环节,可以改写请求、可以直接拦截(不登录就回 401),也可以在放行后追加处理。放行就是调用 await next()。

图 3-2 · 一次穿过鉴权中间件的请求(洋葱模型)
请求 POST /api/memos(带 Cookie 或 Bearer Token) requireScope 中间件 ① 看 Authorization 头: Bearer → 查 Token 哈希 + scope 没有 → 查 Cookie 会话 ② 把 user 存进 c.set("user") 业务处理函数 只管干活,不再操心「你是谁」 await next() 响应 c.json({ memo }) 任何异常 → onError 兜底成 { error } 500(worker/index.ts:24)
「洋葱」之名:请求穿进第一层(前半段),到达核心,再穿出来(后半段)。yaf 里中间件以「中间件工厂(middleware factory)」形态出现——requireScope("memos:write") 传入所需权限、返回一个配置好的中间件,一份逻辑服务所有接口(worker/auth.ts:82)。
worker/auth.ts:82 · 工厂函数形态的中间件
export function requireScope(scope: Scope) {
  return createMiddleware<AppEnv>(async (c, next) => {
    const header = c.req.header("Authorization");
    if (header?.startsWith("Bearer ")) {          // Token 路线
      const found = await findToken(c.env.DB, header.slice(7).trim(), c.executionCtx);
      if (!found) return c.json({ error: "Token 无效或已过期" }, 401);
      if (!found.scopes.includes(scope)) return c.json({ error: `缺少权限 ${scope}` }, 403);
      c.set("user", found.user);                  // 存进上下文,下游 c.get("user")
      return next();
    }
    const user = await sessionUser(c);            // Cookie 路线(第 7 章细讲)
    if (!user) return c.json({ error: "未登录" }, 401);
    c.set("user", user);
    await next();
  });
}
注意 401 和 403 的分工:401「你是谁我都不知道」,403「我知道你是谁,但你没这个权限」。读接口时先看它挂在哪个中间件下,权限模型立刻清楚——这是审查 agent 产出的第一落点。

静态资源与 SPA:前端也由它发货

构建后(第 14 章),React 应用变成一堆静态文件。wrangler.jsonc 里的 assets 配置让同一个 Worker 顺带发货:run_worker_first: ["/api/*", "/mcp"] 表示这些路径先过代码,其余路径直接发静态文件;not_found_handling: "single-page-application" 表示找不到的路径也返回首页——因为 /insights 这类地址是前端路由(第 4 章),要交给 React 处理。前后端同域还白送一个好处:Cookie 不跨域,第 7 章的会话方案因此变得简单。

vibe 提示 · 这一章里你必须亲自懂的

① 绑定模型:新接口要用某个资源,先去 env.ts 和 wrangler.jsonc 确认绑定存在——agent 最爱在这里凭空 env.不存在的资源;② 中间件挂载:审查任何接口先看它的第二、三个参数,鉴权缺失是安全审查的第一站;③ waitUntil 语义:放进 waitUntil 的失败不会告诉用户,必须有兜底(yaf 用 Cron 巡逻)。写新接口、加新资源这类事 agent 做得很好——只要你在验收时问出「鉴权呢?失败兜底呢?」两个问题。

自检 1 · env.MEDIA?.delete(files) 和 env.MEDIA.delete(files) 在这个项目里有一个必然出 bug,是哪个?为什么?
后者。MEDIA 在 Env 接口里是 MEDIA?: R2Bucket(env.ts:10,R2 是可选资源,没开通时整个绑定不存在)。没写 ? / if 的版本对没开通 R2 的部署会直接抛 Cannot read properties of undefined。类型系统早就用问号提醒了这件事——这题考的是「读类型就是在读运行时风险」。
自检 2 · 你想给「统计接口 /api/stats」加一个新字段。要动哪些文件?(提示:顺着洋葱从外到内数)
通常只有一处:worker/memos.ts:154 的 stats 处理函数(改 SQL、往返回 JSON 里加字段),前端再在 src/api.ts 的 Stats 接口(api.ts:179)补类型、在 Sidebar 组件里使用。不需要动 worker/index.ts(路由已挂载)、不需要新绑定。能回答「只动两层、其余不牵动」,说明你已经建立起第 1 章那张地图和本章洋葱的连线。
下一章钩子

后端的骨架立住了。但用户首先看到的是浏览器里的界面——那个输入框、那排卡片是怎么画出来、又怎么在保存后自己更新的?下一章切到蓝区:React。

第 4 章 · 前端基座

React 与 Vite:界面是怎么「长」出来的

本章目标学完本章,你能解释:为什么改一个状态变量界面会自己更新;useState / useEffect / useRef 各管什么;yaf 不用路由库是怎么做页面切换的;以及 Home 组件里那段「请求编号」代码在防什么事故。

心智模型:界面 = 状态的函数

传统写法是「命令式」:拿到数据后,手动 appendChild、手动更新那一行文字、手动改按钮颜色——改的地方多了必然漏。React 换了一种思路(声明式):你只描述「当前状态长什么样」,界面更新由 React 负责。状态变了 → 重新执行组件函数 → 得到新的界面描述 → React 和旧描述比对 → 只把差异应用到真实 DOM:

图 4-1 · React 渲染循环(点「输入文字」看循环转起来)
① 用户事件输入 / 点击 → setState ② 组件函数重新执行返回新的 JSX 界面描述 ③ 比对(diff)新旧描述逐处对照 ④ 只更新差异处 DOM浏览器重绘 ⑤ 状态再变 → 回到 ②,如此循环(整个应用就是这样「活」的)
关键推论:组件函数可能被反复执行,所以它必须「纯」——同样的 props 和 state 得画出同样的界面,别在函数体里直接改外面的东西。要「做外面的事」(发请求、动 DOM、定时器),用下面的 useEffect。

挂载:从一张空 HTML 到整个应用

src/main.tsx · 全文只有 10 行
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode><App /></StrictMode>,
);
index.html 里只有一个空壳 <div id="root">——整个应用由 JS 现场画进去,这类应用叫 SPA(单页应用):浏览器只加载一次页面,之后一切变化都发生在 JS 里,URL 变了也不重新请求整页。! 是「我保证不是 null」的断言;StrictMode(React 严格模式) 是开发期的严格检查器,会故意把组件函数执行两遍,暴露「不纯」的写法。

JSX 与组件:长得很像 HTML 的函数

组件就是「接收 props、返回界面描述」的函数;返回的那种「像 HTML 的东西」叫 JSX——它其实是普通的函数调用和对象,构建时被转成 JS。三条语法要点:标签里用 {} 嵌任意表达式;class 写成 className(避免和 JS 的 class 关键字冲突);组件名必须大写。

src/App.tsx:34 · props 向下流动
if (phase.kind === "setup" || phase.kind === "login")
  return <AuthScreen mode={phase.kind} onDone={boot} />;
if (path === "/device") return <DeviceApprove />;
if (path.startsWith("/settings")) return <Settings user={phase.user} path={path} />;
return <Home user={phase.user} path={path} view={...} onLogout={logout} />;
App 是「选择渲染哪个页面」的薄壳;真正的东西(列表、侧栏、编辑器)在 Home 里,Home 再把更小的零件(Sidebar、Editor、MemoCard)拼起来。props 是父传子的只读参数——子组件想改变什么,就调用父组件传下来的函数(onLogout、onSubmit),数据始终单向流动,出问题好排查。

useState 与受控输入

概念 · useState 与受控组件

const [value, setValue] = useState(初始值):向 React 登记一块「会变的记忆」。setValue 不仅改值,还通知 React「该重画了」。输入框的 value 绑到状态、onChange 写回状态,就叫受控组件——文本永远以状态为准(Editor.tsx:45)。

useEffect:与「组件外面」打交道

发请求、订阅事件、操作定时器都属于副作用。useEffect(函数, 依赖数组) 的含义是:「渲染完成后,如果依赖变了,就执行这个函数;函数若返回另一个函数,下次执行前先调用它做清理」。yaf 最小的教科书在路由里,全文 18 行:

src/router.ts · 全文(自制路由)
export function navigate(path: string) {
  if (location.pathname + location.search + location.hash === path) return;
  history.pushState(null, "", path);            // 改地址栏,不刷新页面
  dispatchEvent(new PopStateEvent("popstate")); // 手动广播「地址变了」
}

export function usePath(): string {
  const [path, setPath] = useState(location.pathname);
  useEffect(() => {
    const on = () => setPath(location.pathname);
    addEventListener("popstate", on);           // 订阅地址变化
    return () => removeEventListener("popstate", on); // 清理:组件卸载时退订
  }, []);                                       // 空依赖 = 只在挂载后跑一次
  return path;
}
浏览器原生 API(history.pushState / popstate)负责「改地址不刷新」,React 侧 usePath 把地址变成状态——地址一变,App 重画,渲染对应页面。这就是 yaf 不引路由库的全部原因:它只有 3 个页面。读出「清理函数」是读 useEffect 的关键:每个 addEventListener 都配对了一个 removeEventListener,不然组件反复挂载会泄漏一堆监听器。
图 4-2 · 应用的四态状态机(对应第 2 章的判别联合)
loading 启动 setup 初始化 login 登录 app 主界面 没有用户 已有用户未登录 创建管理员成功 登录成功 登出(onLogout) ↑ 每条边就是一次 setPhase
把这张图和 App.tsx 的代码对照读:图上每条边对应一次 setPhase 调用。用「图先于代码」的方式读状态机,比顺着代码猜可靠得多。

实战:Home 组件里的三个经典范式

Home(src/components/Home.tsx)是全项目最大的组件,集中了你会反复遇到的三种写法。

范式一:搜索防抖。用户每敲一个字母都发请求会打爆接口。Home 用两个状态错开:query 跟随键盘,debounced 慢 250 毫秒,只有 debounced 变化才触发加载(Home.tsx:58-61)。

范式二:竞态守卫(重点,agent 常漏)。防抖也挡不住「先发的请求后回来」:先搜「猫」再搜「猫粮」,「猫」的响应如果慢一步,界面就会显示错误结果。看 yaf 怎么用 useRef 解决:

src/components/Home.tsx:70 · 给每次请求发号牌
const reqId = useRef(0);          // ref:跨渲染存活、改它不触发重画
const load = useCallback(async (reset: boolean, from: string | null) => {
  const id = ++reqId.current;     // 发一个新号牌
  setLoading(true);
  const r = await api.listMemos({...});
  if (id !== reqId.current) return;   // 号牌过期 = 已有更新的请求 → 丢弃本次结果
  setMemos(reset ? r.memos : [...prev, ...r.memos]);
  setLoading(false);
}, [debounced, tag, lens, mode, sort]);
useCallback 的作用是「函数身份稳定」:它把 load 缓存起来,只在依赖变化时换新——因为下面的 useEffect 以 load 为依赖,身份一变就重发请求。[...prev, ...m] 是「旧数组展开 + 新元素」造出新数组:React 靠「引用不同」才发现状态变了,直接 push 进旧数组它是不理你的。

范式三:无限滚动。列表底部藏一个哨兵元素,用浏览器原生 IntersectionObserver 观察它,一进视野且还有下一页(cursor 非空)就加载下一页(Home.tsx:115-123)。后端配套的是第 5 章的游标分页。

Vite:开发时的魔法师

Vite 干两件事。开发时(npm run dev):起本地服务器,按需编译你正在改的文件,保存后热更新(HMR)——浏览器不刷新,组件原地换新,状态都还在。构建时(npm run build):把几百个模块打包、压缩成几个浏览器直接可用的文件(细节在第 14 章)。yaf 还加了 @cloudflare/vite-plugin,于是本地 dev 时 后端也真的跑在 Cloudflare 的运行时里——本地和线上行为一致,是它「敢只用免费套餐」的底气之一。

vibe 提示 · 这一章里你必须亲自懂的

① 数据单向流动:审查界面 bug 时,先问「这个数据是谁的 state?谁改它?」——React 的 bug 九成出在「改了没 setState」「setState 了但依赖数组漏了」;② 依赖数组:agent 写的 useEffect,你逐个依赖核对(少了 = 用旧值,多了 = 反复执行);③ 异步竞态:任何「输入触发请求」的代码都要问一句「有号牌/防抖吗?」。至于样式调整、把现有组件抄一个变体,放心交给 agent。

自检 1 · 把 setMemos 改成 memos.push(newMemo),界面会更新吗?
不会。React 靠「setState 换了新的数组/对象引用」才知道要重画;push 是在旧数组上原地修改,引用没变,React 认为「没变化」。这就是为什么代码里到处是 filter / map / 展开运算符 造新值(如 Home.tsx:186 的删除:prev.filter((m) => m.id !== id))。术语叫「不可变更新(immutable update)」。
自检 2 · Home 的 useEffect(() => load(true, null), [load]):当用户点一个标签筛选时,会发生几次请求?为什么?
一次。点标签 → setTag → 重渲染 → useCallback 发现依赖 tag 变了,返回新的 load → useEffect 发现 load 变了,执行 load(true, null) 重新加载第一页。链条是「状态 → 函数 → 副作用」,每一环都有明确的依赖;如果 useCallback 的依赖数组漏写 tag,点标签就不会刷新——这类 bug 靠逐个核对依赖数组来抓。
下一章钩子

界面会动了。按下保存后,api.createMemo 发出的 POST 已经在第 3 章的洋葱里穿行——穿过鉴权后,它落进 memo-service。一条 memo 到底在数据库里长什么样、为什么删一条 memo 要动五张表?下一章进绿区:D1。

第 5 章 · 存储绿区

D1 上的 memo 读写:迁移、事务与游标分页

本章目标学完本章,你能画出 yaf 的表关系、说出「写一条 memo 动了哪几张表、为什么」、读懂 SELECT_MEMO 那段子查询聚合的 SQL、并解释 yaf 翻页为什么不用 OFFSET。

D1 是什么:长在 Worker 旁边的 SQLite

概念 · D1 与 SQLite

SQLite 不是「又一个 MySQL」:它没有服务器进程,就是一个读写标准 SQL 的单文件库——你的手机里每个 App 几乎都揣着几个它。D1 是 Cloudflare 托管的 SQLite:帮你管副本、备份、容灾,但使用方式就是 SQL。对你最重要的推论:D1 和你的 Worker 在同一位置,查询没有网络往返,await env.DB.prepare(sql).first() 快得像本地函数调用。

建表不用「连上去敲 SQL」,而是提交 迁移(migration)文件:migrations/0001_init.sql、0002_… 一路编号。每份迁移描述「从上一个状态怎么变到下一个状态」,npm run db:migrate:local / :remote 依次应用。两条铁律写在 CLAUDE.md 里:只新增文件、不改旧文件(旧文件已经在线上执行过了);合并前跑 typecheck。这个「只增不改」让任意版本升级都可回滚——第 14 章会回到这一点。

四张起步表与它们的关系

migrations/0001_init.sql · 精简
CREATE TABLE memos (
  id TEXT PRIMARY KEY,                 -- 随机 UUID 字符串,不用自增数字
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  created_at INTEGER NOT NULL          -- 毫秒时间戳
);
CREATE INDEX idx_memos_user_created ON memos(user_id, created_at DESC);

CREATE TABLE memo_tags (              -- 标签拆成独立表,才能按标签筛选
  memo_id TEXT NOT NULL REFERENCES memos(id) ON DELETE CASCADE,
  user_id TEXT NOT NULL,
  tag TEXT NOT NULL,                   -- 存完整路径,如 "读书/历史"
  PRIMARY KEY (memo_id, tag)
);
两个通用决策值得记住:① 主键用随机字符串而非自增数字——不需要中心化的计数器,多端并发插入不打架(分布式系统的普遍偏好);② ON DELETE CASCADE——删 memo 时它名下的标签、附件自动跟着删,不会留孤儿数据。会话表 sessions 同样挂在 users 上级联删除。索引按「查什么」建:最频繁的查询是「某用户的 memo 按时间倒序」,所以索引是 (user_id, created_at DESC)。

后来六个迁移各自加东西:0002 加 API Token 与来源字段,0003 加全文搜索表,0004 加洞察,0005 加微信,0006 加自动标签标记(vector_hash),0007 加视图标记 memo_labels。读到任何一张表,按编号回迁移文件即可考古。

写入:参数化与事务

worker/memo-service.ts:201 · createMemo(节选)
const id = crypto.randomUUID();
await db.batch([
  db.prepare("INSERT INTO memos (id, user_id, content, source, created_at, updated_at)
              VALUES (?, ?, ?, ?, ?, ?)").bind(id, userId, text, source, now, now),
  ...tagStatements(db, id, userId, text),   // 先 DELETE 旧标签,再按 extractTags 结果 INSERT
  ...ftsStatements(db, id, userId, text),   // 全文索引(第 6 章)
]);
return { id, content: text, tags: extractTags(text).sort(), ... };
两个所有 SQL 代码都该有的习惯,这里各占一行:① 永远用 ? 占位符 + .bind(值),值永远不当 SQL 拼接——这就是防 SQL 注入(用户输入 '; DROP TABLE memos;-- 也只是一个无害的字符串参数);② 一组必须同生共死的写操作用 db.batch——memo 写进去了、标签没写进去,搜索就会漏它,batch 保证三组语句要么全部生效、要么全部不生效(事务)。
边界 · 占位符防注入,但不防 LIKE 通配符

搜索时值会进 LIKE '%词%',而 % 和 _ 在 LIKE 里有特殊含义——用户输入 % 会变成「匹配一切」。所以 yaf 单独有 escapeLike()(memo-service.ts:41)转义这三个字符,并在 SQL 里声明 ESCAPE '\\'。注入防的是「改语句结构」,通配符防的是「语义走样」,两件事。

读取:一条 SQL 拼出整张卡片

渲染一张 memo 卡片需要:正文、标签列表、视图标记、附件。天真的写法是先查 memo,再对每条 memo 查 3 次子表——30 条卡片就是 90+ 次查询(所谓 N+1 问题)。yaf 的解法是 SELECT_MEMO 常量:主查询里嵌三个相关子查询,让数据库一次交齐:

worker/memo-service.ts:126 · 一行查询,三路聚合(节选)
SELECT m.id, m.content, ...,
  (SELECT group_concat(tag, char(31)) FROM memo_tags WHERE memo_id = m.id) AS tags,
  (SELECT json_group_object(label, json_array(score, source))
     FROM memo_labels WHERE memo_id = m.id) AS labels,
  (SELECT json_group_array(json_object('id', a.id, 'kind', a.kind, ...))
     FROM attachments a WHERE memo_id = m.id) AS attachments
FROM memos m
这是 SQLite 的聚合函数舞台:group_concat 把多行标签拼成一个字符串(用 char(31) 即不可见的 ␟ 分隔——它不会出现在正常文本里),json_group_array/json_object 直接在数据库里拼出 JSON 文本。随后 toMemo()(memo-service.ts:64)把这行「扁平结果」拆回结构化对象,顺带做权限处理:隐私 memo 一律换成遮挡文本,只有显式 { raw: true } 才给原文。

筛选:动态拼 WHERE 的安全姿势

列表页的筛选条件(关键词、标签、视图、可见范围)任意组合,SQL 得动态拼。yaf 的写法是「条件数组 + 参数数组同步生长」,最后 AND 连接:

worker/memo-service.ts:96 · buildFilter(节选)
const where = ["m.user_id = ?"];  params = [userId];       // 第一条铁律:必带归属
if (opts.visibility === "token") where.push(TOKEN_VISIBLE_SQL);  // 🔒 谁是 token、两个条件什么意思:第 7/10 章拆封
if (t) { where.push("EXISTS (SELECT 1 FROM memo_tags t WHERE t.memo_id = m.id
          AND (t.tag = ? OR t.tag LIKE ? ESCAPE '\\'))");
         params.push(t, `${t}/%`); }                       // 父标签含所有子标签
return { sql: where.join(" AND "), params };
注意第一条永远是 m.user_id = ?——多用户系统里每条查询都必须带归属条件,漏了就是越权事故。查 #读书 时同时用前缀 读书/% 匹配子标签,所以点父标签能看到全部子标签的 memo(README 承诺的行为在此实现)。

翻页:为什么不用 OFFSET

图 5-1 · OFFSET 分页 vs 游标分页
OFFSET:跳过前 N 条 第1页 1–30 第2页 31–60 ⭕ 翻页间隙新插入 1 条 全部行整体后移一格 → 第 2 页的 第一条其实是第 1 页见过的 → 重复显示; 且 OFFSET 100000 要先扫过 10 万行,越翻越慢 游标(yaf 的做法) cursor = "最后一条的 created_at _ id" …WHERE (created_at, id) < 游标 翻页间隙新插入 → 在游标之前,不影响 定位是绝对位置(时间戳+id),不看行号; 配合 (user_id, created_at DESC) 索引,每一页都一样快
实现只有几行(memo-service.ts:169-184):把游标拆开、追加一个比较条件、LIMIT 取 31 条只给 30 条——多取的一条用来判断「还有没有下一页」,返回 nextCursor 或 null。id 参与比较是为了同一毫秒的多条 memo 之间也能定出严格顺序(tie-break)。

统计与导出:两个小接口看惯用手法

统计(memos.ts:154):一次 db.batch 并发跑两条聚合——总数/标签数/天数 一条,16 周热力图按天分组一条。时区处理藏在 SQL 里:date(created_at/1000 + 偏移秒, 'unixepoch') 把毫秒时间戳平移到用户时区再取日期,前端把 getTimezoneOffset() 传进来(api.ts:248)。

导出(memos.ts:174):同一个查询结果拼成 Markdown 或 JSON,用 Content-Disposition: attachment; filename*=UTF-8''… 让浏览器存成文件(filename* 是文件名支持中文的标准写法)。「按标签导出 + 交给 AI 分析」正是 vibe coding 的常用管道。

为什么 memo-service 长成「纯函数」

注意到 listMemos(db, userId, opts) 的签名——数据库是参数而不是全局变量。这让同一套函数被三个入口直接复用:REST(memos.ts)、MCP(mcp.ts:48)、微信桥(bridge.ts:189)。加一个功能(比如自动标签)三端同时受益。这是后端代码最重要的分层习惯:接口层管 HTTP,服务层管业务,互不掺和。

vibe 提示 · 这一章里你必须亲自懂的

① 每条查询带 user_id:审查 agent 写的任何新 SQL,第一眼找归属条件——它是数据安全的最后闸门;② 写操作的原子边界:一次业务动作动几张表,就该是一个 batch;③ 占位符纪律:看到字符串模板拼 SQL(反引号里塞变量进语句本体)直接打回。让 agent 写 SELECT、加索引、写迁移文件都很安全——迁移只增不改 + typecheck + 本地迁移三道闸兜着。

自检 1 · 把 .bind(id, userId, text…) 换成 JS 模板字符串把值直接拼进 SQL,攻击者能干什么?
输入 x'); DELETE FROM memos;-- 之类的值就能改写语句结构——删除整张表、绕过归属条件读别人的数据(经典 SQL 注入)。参数化后它只是一坨无害文本。这题的另一面是:读 agent 产出的 SQL,看到反引号模板里出现变量(而不是整个常量语句)就该警惕。
自检 2 · 你正翻到第 3 页,这时新写了一条 memo。继续翻第 4 页:会重复看到某条旧 memo 吗?换成 OFFSET 写法呢?
游标分页不会:第 3 页末尾的游标是「时间戳_id」,第 4 页只取比它更旧的,新 memo 比游标新,根本进入不了后面的页——每条出现且只出现一次。OFFSET 则会把所有行整体后移,你会在第 4 页重新看到第 3 页的最后一条(或者漏看一条,取决于插入方向)。这就是无限滚动必须配游标的原因。
下一章钩子

读写都齐了,但搜索框还欠着两笔债:中文没有空格,「妈妈」怎么被切成词?以及第 1 章承诺的「意思相近也能搜到」的另一半。下一章先还第一笔:标签提取与全文检索。

第 6 章 · 检索的半边天

标签与中文全文检索:shared/ 里的一段双面胶

本章目标学完本章,你能解释:一份 shared/tags.ts 为什么能同时被浏览器和 Worker 使用;#家事。如何被提取成标签而 # 标题 不会;中文全文搜索为什么必须先切词;以及 FTS5 和 LIKE 兜底各管哪一段。

shared/:同一段代码,两个世界

「内容里写 #家事」这条规则,至少三个地方要用:后端写库时提取标签(memo-service.ts:215)、前端把卡片里的标签渲染成可点击链接(markdown.ts:23)、搜索时按标签过滤(buildFilter)。如果两端各写一版正则,迟早规则走样——后端存了 家事。,前端却链接到 家事,搜索立刻漏。所以这段逻辑放进 shared/,前后端各自 import:

前端 src/markdown.ts:3 与后端 worker/search.ts:2
import { normalizeTag, TAG_RE, TRAILING_PUNCT } from "../shared/tags";  // 前端
import { extractTags } from "../shared/tags";                            // 后端
没有魔法:共享代码就是纯 TS 文件(不碰 DOM、不碰 Node API),谁打包就跟着谁走——前端由 Vite 打进浏览器 bundle,后端由 Wrangler 打进 Worker(第 14 章)。前提只有一条:里面只能用两个运行时都有的能力(语言内置、正则、Intl)。

提取标签:一条正则的三道防线

shared/tags.ts · 全文(去注释)
export const TAG_RE = /(^|\s)#([^\s#]+)/gu;
export const TRAILING_PUNCT = /[.,;:!?,。;:!?、))\]】"'”’]+$/u;

export function normalizeTag(raw: string): string | null {
  const cleaned = raw.replace(TRAILING_PUNCT, "");      // 防线三:去尾部标点
  const parts = cleaned.split("/").map((p) => p.trim()).filter(Boolean);
  return parts.length ? parts.join("/") : null;         // 层级 a/b/c 规整
}

function stripCode(content: string): string {           // 防线二:代码不算
  return content.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
}

export function extractTags(content: string): string[] {
  const tags = new Set<string>();
  for (const m of stripCode(content).matchAll(TAG_RE)) {
    const tag = normalizeTag(m[2]);
    if (tag) tags.add(tag);
  }
  return [...tags];
}
读 TAG_RE:(^|\s) # 必须在行首或空白之后(防线一,# 标题 因 # 后有空格而天然出局);[^\s#]+ 标签本身不含空白、不嵌套 #;g 全局找所有、u 按Unicode 码点处理中文。matchAll 迭代全部匹配。「写 MD 里 #读书/历史 会得到完整路径」——父标签筛选(第 5 章的前缀查询)就建立在这个存储格式上。

跑一遍贯穿案例:「明天给妈妈打电话 #家事」→ stripCode 不变 → TAG_RE 命中 #家事 → normalizeTag 无尾标点 → ["家事"]。而写在行内代码里的 #ff0000 在防线二就被整块删掉了——颜色值不会被当成标签。

关键词搜索的真正难点:中文没有空格

数据库怎么知道「明天给妈妈打电话」里含有「妈妈」?暴力法是 LIKE '%妈妈%'——逐字符扫全表,数据一大就慢,而且无法按相关度排序。数据库自带的全文索引(SQLite 的 FTS5 扩展)把文本预先切成「词」建倒排索引,查得又快又能打分——但它的默认分词器按空格切词,对中文整段无效(「明天给妈妈打电话」会被当成一个超长词)。

yaf 的解法:自己切,再存。Intl.Segmenter 是浏览器和 Workers 都内置的标准 API(底层是 ICU 词典),能把中文按语义切词:

shared/segment.ts · 全文 15 行
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });

export function segmentWords(text: string): string[] {
  const out: string[] = [];
  for (const s of segmenter.segment(text))
    if (s.isWordLike) out.push(s.segment.toLowerCase());  // 去标点,统一小写
  return out;
  // "明天给妈妈打电话" → ["明天", "给", "妈妈", "打电话"]
}

export function queryTerms(q: string): string[] {          // 搜索词也切、也去重
  return [...new Set(q.trim().split(/\s+/).filter(Boolean)
    .flatMap((w) => segmentWords(w)))];
}
这是全项目最「四两拨千斤」的文件:不引任何中文分词库,用平台内置能力解决问题。注意两端都切:写索引时切(下一段),搜索时也切——两边规则一致才对得上,「妈妈」才能命中「给妈妈打电话」。
图 6-1 · 一条 memo 从原文到全文索引的管道
「#家事 明天给妈妈打电话 #ff0000」原文 plainText去代码块/MD 语法 segmentWordsIntl.Segmenter 切词 词序列家事 明天 给 妈妈打电话 memos_fts 虚表倒排索引 + memo_id 查「妈妈」→ queryTerms 切出「妈妈」→ MATCH '"妈妈"*' → 命中此行(bm25 打分排序)
写库端(第 5 章 createMemo 的 batch 第三条)与查询端共用同一套切词。plainText(search.ts:25)负责剥掉 Markdown 记号——搜索 加粗 不该只命中写了星号的文本。

FTS5 的使用姿势:虚表、先删后插、前缀匹配

worker/search.ts:45 与 150
export function ftsStatements(db, memoId, userId, content) {
  return [
    db.prepare("DELETE FROM memos_fts WHERE memo_id = ?").bind(memoId),  // 先删
    db.prepare("INSERT INTO memos_fts (body, memo_id, user_id) VALUES (?, ?, ?)")
      .bind(segmentWords(plainText(content)).join(" "), memoId, userId), // 再插
  ];
}

function ftsQuery(terms: string[]) {
  return terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(" ");
  //  → '"妈妈"* "打电话"*':每个词允许前缀扩展,词之间是 AND
}
三个惯用法:① memos_fts 是「虚表」——内容由 FTS5 管理,同时把 memo_id、user_id 作为普通列共存,方便过滤和回查原文;② 先删后插让「更新索引」天然幂等,编辑一篇旧 memo 也不会留下旧词;③ 查询串加引号防关键词注入(FTS 有自己的查询语法,NEAR 之类会被引号变成普通词),* 让「打电话」命中「打电话了」。整句放进 MATCH,再按 bm25() 排序——词越稀有、出现越多,分数越高。

兜底:LIKE 与它的位置

切词不是万能的:刚写入还没来得及进索引的、或切成「电话」却搜「话」的,FTS 会漏。所以 keywordCandidates(search.ts:154)在 FTS 结果之后追加一路 LIKE '%词%' 子串匹配补漏——但只补到候选数上限,且排在 FTS 结果后面。第 8 章你会看到同样的「双保险」哲学再次出现:FTS/LIKE 管字面,向量管意思,两路融合。

vibe 提示 · 这一章里你必须亲自懂的

① 规则一致性问题:凡是「写的时候做 X、读的时候也做 X」的逻辑(切词、标签、转义),先问 agent「两端共用的是不是同一份代码」——分叉是搜索类 bug 的头号来源;② 正则要能逐段读:TAG_RE 的三段结构值得亲手拆一遍,agent 写的正则你至少要会验「这一段排除了什么」;③ FTS5/LIKE/bm25 各自的角色边界。让 agent 调正则、加一种标签格式,风险低、验证容易(typecheck + 手测两条)。

自检 1 · 「重要的事情#家事。」提取出什么标签?「##大标题」呢?
前者提取出 家事:TAG_RE 命中「#家事。」,normalizeTag 把尾部中文句号剥掉。后者提取不出任何标签:(^|\s) 要求 # 在行首或空白后,第二个 # 紧跟第一个 #,不满足 [^\s#]+ 对「非#开头」的要求——Markdown 的多级标题因此天然安全。
自检 2 · 只在写入端切词、查询端不切(直接拿「给妈妈」去 MATCH),会发生什么?
索引里存的是「给」「妈妈」两个独立词,而查询词「给妈妈」在索引里根本不存在 → 查不到任何结果。反过来(写入不切、查询切)也一样漏。这就是「切词规则必须两端共用 shared/segment.ts」的原因——这条链路的每一环都要用同一本字典。
下一章钩子

到本章为止,第 1 章旅程的前 6 站已经全部落地。但第 4 站「鉴权」我们只看了洋葱的皮:密码怎么存才不怕拖库?Cookie 里那串东西凭什么能冒充登录态?CLI 又是怎么在没有浏览器的终端里完成登录的?下一章进红区:认证与授权。

第 7 章 · 安全区

登录、会话与 API Token:谁有资格动我的笔记

本章目标学完本章,你能说清:密码为什么不存原文、Cookie 里那串字符凭什么代表登录态、API Token 和会话的区别在哪、以及 CLI 在没有浏览器的终端里是怎么完成登录的(设备码流程)。

这是全书唯一专门讲安全的章节,因为 yaf 的安全设计集中在三处:密码的存储(抗拖库)、会话的发放与验证(抗冒用)、Token 的权限收敛(抗滥用)。理解这三处,你就有了审查任何「agent 写的登录代码」的清单。

密码:存「单向捣碎」的结果

概念 · 哈希与加盐

哈希函数是单向绞碎机:SHA-256(输入) 得到固定长度指纹,从指纹无法还原输入。但纯哈希防不住彩虹表(预计算的海量「常见密码 → 指纹」对照表),所以要加盐(salt):每个用户生成一段随机字节拼进密码再哈希,让预计算失效。再用 PBKDF2 把哈希重复搅十万次——拖库者每猜一个密码都要付出十万次的代价,而正常登录只是登录时慢 0.1 秒,无感。

worker/auth.ts:22 · 存与验
export async function hashPassword(password: string): Promise<string> {
  const salt = crypto.getRandomValues(new Uint8Array(16));      // 每人一段新盐
  const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); // 搅十万次
  return `pbkdf2$${PBKDF2_ITERATIONS}$${toB64(salt)}$${toB64(hash)}`;
}   // 库里存的是这串自带配方的文本,而不是裸指纹

export async function verifyPassword(password: string, stored: string) {
  const [algo, iter, salt, hash] = stored.split("$");   // 从存档里读出配方
  const actual = await pbkdf2(password, fromB64(salt), Number(iter));
  let diff = 0;                                          // 常量时间比较:
  for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i];
  return diff === 0;
}
两个值得抄走的细节:① 存储格式自带算法/迭代次数/盐——将来想升级到二十万次迭代,老密码也能按老配方验证后再悄悄重哈希;② 验证用常量时间比较(逐位异或累积,不用 === 提前返回)——防止攻击者通过「比较耗时差」逐位猜指纹(计时侧信道)。所有密码学原语都来自 crypto.subtle(WebCrypto 标准 API),Workers 原生支持,无需引第三方库。

会话:浏览器里那块 Cookie

登录成功后,后端生成一串高熵随机数当作会话 ID(sid),发给浏览器存进 Cookie;之后每个请求自动携带,后端据此查人。关键的安全决策在两行代码里:

worker/auth.ts:45 · createSession
const sid = randomToken();                     // 256 位随机,猜不出来
await c.env.DB.prepare("INSERT INTO sessions (id_hash, ...) VALUES (?, ...)")
  .bind(await sha256(sid), ...);               // 库里只存 sid 的哈希!
setCookie(c, "yaf_sid", sid, {
  httpOnly: true,                              // JS 读不到 → XSS 偷不走
  secure: 协议是 https,                        // 明文网络上传不了
  sameSite: "Lax",                             // 别的网站发起的请求不带 → CSRF 难
  maxAge: 30 天,
});
「库里只存哈希」和密码同理:就算整个数据库被拖走,攻击者拿到的哈希也换不成能用的 Cookie。四个 Cookie 属性各挡一种攻击,面试高频、审计必查。验证时的查询(auth.ts:60)顺带检查过期时间,并用 JOIN 一把带出用户信息。

API Token:给程序用的钥匙,权限切开卖

Cookie 会话是「你本人、全部权限」;CLI、MCP、脚本需要的是「可撤销、可限定、可过期」的钥匙——PAT(Personal Access Token,个人访问令牌)。yaf 的 PAT 长这样:yaf_pat_ 开头 + 24 字节随机串。权限用 scope(权限范围)表达,一共四档:

worker/env.ts:33 与 worker/lib/pat.ts:35
export const SCOPES = ["memos:read", "memos:write", "export", "insights:read"] as const;

export async function createToken(db, userId, name, scopes, expiresAt) {
  const secret = "yaf_pat_" + randomToken(24);          // 明文:只此一次,还给用户
  await db.prepare(`INSERT INTO api_tokens (..., token_hash, prefix, scopes, ...)
        VALUES (..., ?, ?, ?, ...)`)
    .bind(..., await sha256(secret), secret.slice(0, 12 + 4), scopes.join(" "), ...);
  return { secret, ... };
}
与第 3 章的洋葱对上号:接口挂 requireScope("memos:write") 时,中间件把 Token 哈希后查库、核对 scope、再把用户放进上下文。前缀 prefix 存明文的前 12 个字符(yaf_pat_ 8 字符 + 4 位随机),让设置页能显示 yaf_pat_a83f… 供辨认——又一个「存哈希 + 存无价值前缀」的组合。另外 Token 验证会顺手记录最后使用时间(pat.ts:22),但用 ctx.waitUntil 挪到响应后、还做了 60 秒节流:高频调用不会每次都写库。

还有一个容易忽略的分层:Token 管理本身只允许 Cookie 会话操作(tokens.ts 的所有接口挂 requireUser 而非 requireScope)。一把钥匙不能自己配钥匙、也不能改锁——就算 Token 泄漏,攻击者也拿它撤销不了别的 Token、改不了密码。修改密码接口同理(auth.ts:154)。

设备码登录:CLI 如何拿到钥匙

CLI 跑在终端里,没有网页可以输密码;把密码敲进终端再发给服务器则太危险。标准解法是设备码流程(OAuth 2.0 Device Flow 的简化版):CLI 只要一张「等批准的号牌」,批准动作由你在浏览器里完成:

图 7-1 · 设备码登录时序(对照 worker/tokens.ts:94-138)
CLI(终端) Worker + D1 浏览器(你) ① POST /api/device/code { client_name: "yaf CLI" } ② { device_code(保密), user_code: "KTPM-RWSV", /device 链接 } CLI 打印:「请打开 https://…/device?code=KTPM-RWSV 批准」 ③ 你打开链接 → 看到设备名与 4 项权限 → 点批准 POST /device/KTPM-RWSV { approve: true }(requireUser:必须已登录) ④ POST /api/device/token(每 3 秒轮询)→ 暂回 { error: "authorization_pending" } ⑤ 批准后:一次性删除设备码,发 { access_token: "yaf_pat_…" } CLI 存进 ~/.config/yaf/,从此带着它调 /api/*
安全要点:user_code 短(给人看、给人敲),device_code 长(给机器对暗号)且只存哈希、10 分钟过期;同一设备码只能换一次 Token(先删再发,tokens.ts:134);批准接口挂在登录会话下——「已登录的你」才是发钥匙的人。GitHub、Cloudflare 的 CLI 登录全是同款流程。

收尾:密码修改与「踢掉其他设备」

改密码(auth.ts:154)体现了一个小而完整的模式:验证当前密码 → 一个 db.batch 同时「更新密码哈希 + 删除除当前会话外的所有会话」——换锁顺动作废旧钥匙。设置页的「会话数 / 退出其他设备」就是对 sessions 表的两个简单查询(auth.ts:173-187)。另外,部署时可选设置 SETUP_TOKEN:创建管理员必须提供这个口令,防止你部署完还没来得及注册、管理员位就被陌生人抢先(auth.ts:112)。

vibe 提示 · 这一章里你必须亲自懂的

① 敏感操作的挂载:审查登录/Token/密码相关代码时,逐个接口核对中间件——「Token 能不能给自己续期」「改密码要不要 Token」是真实出现过的漏洞形态;② 三类「只存哈希」:密码(加盐慢哈希)、会话 sid、Token——看到明文入库就该打回;③ Cookie 四属性的含义。其他如「把 scope 加一档」「给 Token 加过期提醒」这类改动,agent 做得很好,验收时你检查四档清单即可。

自检 1 · 数据库整个被拖走了。攻击者能(a)登录你的账号(b)伪造你的 Cookie(c)看到你的 memo 明文吗?
(a)不能:密码只有 PBKDF2+盐的哈希,逐个试密码代价极高;(b)不能:sessions 表里只有 sid 的哈希,没有可用 Cookie 值;(c)能——memo 正文是明文存的。这正是 yaf 的威胁模型边界:它防「冒充你」,不防「拿到库的人读数据」(那就需要字段级加密,代价是搜索能力受损——隐私 memo 的遮挡方案是折中,第 10 章讲)。安全设计永远是「防到哪一层」的取舍。
自检 2 · 为什么 CLI 不直接让用户在终端输入「邮箱+密码」换 Token?
三个理由:① 密码会经过/停留在不受信任的环境(终端历史、输入法云同步、恶意脚本读取);② 给程序的凭证应该是可撤销、可限权的 Token,而不是主密码——密码换 Token 的兑换应由「已登录的你」在浏览器里批准;③ 设备码流程把「认证」和「授权」分开:CLI 永远接触不到密码,你还能看到它申请了哪些 scope 再决定。
下一章钩子

钥匙体系完备了。现在回到第 6 章留下的另一半承诺:LIKE 兜底之外,「意思相近但用词不同」的搜索是怎么实现的?那 1024 个数字是什么?下一章进入 AI 区。

第 8 章 · AI 区之一

混合检索:关键词 + 语义,RRF 融合

本章目标学完本章,你能解释:向量怎么让「意思相近」变得可计算、Vectorize 在查什么、两路结果如何用 RRF 公式融合、以及一条 memo 从写入到「可被语义搜到」要经过哪几级后台任务。

先看第 6 章方案的天花板:你记过「明天给妈妈打电话」,几周后想找它,输入的却是「联系妈妈」——字面上没有共同的词,LIKE 和 FTS 全部落空。人和人的记忆从不按字面索引,这一章补上「按意思」的那一半。

向量:把「意思」变成几何

概念 · 向量与嵌入(embedding)

Embedding(嵌入)模型是一个「语义压缩器」:读一段文字,输出一列数字(yaf 用的 bge-m3 输出 1024 个),训练目标保证——意思相近的文本,向量方向也相近。「给妈妈打电话」和「联系妈妈」字面几乎不重叠,但两个向量夹角很小。两列数字的「方向一致程度」用余弦相似度(cosine)衡量:1 表示同向,0 表示无关,yaf 实测无关文本在 0.3–0.45(search.ts:12 的注释),所以低于 0.53 的一律丢弃。

worker/search.ts:65 · 调模型,以及索引一条 memo
export async function embed(env: Env, texts: string[]): Promise<number[][]> {
  const res = (await env.AI.run("@cf/baai/bge-m3", { text: texts })) as { data?: number[][] };
  return res.data;                      // 每段文字 → 1024 个小数
}

// indexMemos(search.ts:79,节选):写一半的那条链路
const texts = rows.map((r) => embedText(r.content + (extra.get(r.id) ?? "")));
const vectors = await embed(env, withText.map((i) => texts[i]));
await env.VECTORS.upsert(withText.map((i, k) => ({
  id: rows[i].id,                       // 向量的主键 = memo 的 id
  values: vectors[k],                   // 1024 维
  metadata: { userId: ..., createdAt: ... },   // 随行小卡片,用于过滤
})));
两个细节都值得品:① embedText 把标签拼在正文前面再算向量——用户亲手打的 #家事 是最强的语义信号;图片描述、语音转写也拼进去(attachments 的 description),所以「发一张截图」将来能被描述文字搜到。② metadata 过滤:所有人的向量混居一个索引,查询时 filter: { userId } 只在自己的小宇宙里找(Vectorize 为此建了元数据索引,见 wrangler.jsonc:22 的注释)。

Vectorize:一个只会「找最近邻」的仓库

概念 · 向量数据库

它解决的问题:一百万个 1024 维向量里,「离这个查询向量最近的 30 个」——暴力算是十亿次乘法,专门的数据结构(近似最近邻算法)把它做成毫秒级。Vectorize 就是 Cloudflare 托管的这类仓库:你 upsert(插入或更新)、query(按向量找近邻)、queryById(按库里已有向量找近邻)。第三种正是「相关笔记」的实现(search.ts:252):拿 memo 自己的向量查邻居、排除自己、按 RELATED_MIN 阈值截断——点开任何一张卡片,都能看到「和它意思相近的旧笔记」。

RRF:两个排行榜怎么合成一个

现在有两条候选路:FTS 关键词(第 6 章)给一个名次表,语义给一个分数表。难点是量纲不同:bm25 分数可能是 7.3,余弦分数是 0.81,直接加权毫无意义。RRF(Reciprocal Rank Fusion,倒数名次融合)的聪明之处:不看分数,只看名次——

worker/search.ts:225 · 融合公式落地处
const RRF_K = 60;
kw.forEach((id, i) => {
  scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1));  // 关键词第 i 名
});
sem.forEach(({ id, score }, i) => {
  scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1));  // 语义第 i 名
});  // 两路都出现的 memo:两项相加 → 天然排到最前
手算一遍就懂:关键词第 1 名得 1/61≈0.0164,第 2 名 1/62≈0.0161——差距被 K=60 压得很平,防止某一路的头部霸榜;而两路同时命中的 memo 拿两份,稳居前排。「名次稳定、量纲无关、实现五行」,这是工业界检索融合的默认选择。
图 8-1 · 点着看 RRF 怎么把两路结果合成一个榜单

关键词路(FTS,搜「联系 妈妈」)

名次memoRRF 分
1给妈妈打的电话号码 #家事1/61 ≈ 0.0164
2妈妈生日礼物想法1/62 ≈ 0.0161

语义路(Vectorize,同一次查询的向量)

名次memo余弦RRF 分
1周末和妈妈视频,说好常联系0.711/61 ≈ 0.0164
2给妈妈打的电话号码 #家事0.681/62 ≈ 0.0161
3项目周会纪要(被 0.53 阈值拦下)0.38丢弃

融合榜单(两项相加)

#memo总分命中
1给妈妈打的电话号码 #家事0.0325关键词+语义
2周末和妈妈视频,说好常联系0.0164仅语义
3妈妈生日礼物想法0.0161仅关键词

索引从哪来:三级火箭

向量不会自己出现。yaf 用三级机制保证「每条非隐私 memo 终究有向量」,每一级都独立失败、独立补救:

worker/search.ts:129 · 第一级:响应后的即时计算
export function scheduleIndex(env, ctx, userId, memo) {
  ctx.waitUntil((async () => {
    const cur = await env.DB.prepare("SELECT content FROM memos WHERE id = ?")...
    const judged = await judgeMemo(env, row);        // 先判定(第 10 章)
    if (judged && !judged.private) await indexMemos(env, [row]);  // 非隐私才算
  })());
}
第二级(search.ts:113):vector_hash 记录「算向量时的文本指纹」(SHA-256)。编辑 memo 会把它清空,再被后台任务看到时就知道「内容变了,要重算」——这是个通用的「用哈希表达待办」技巧。第三级:Cron 每 15 分钟扫一遍「超过 5 分钟还没有向量」的 memo(backfillAll,search.ts:314),把它们交给 Workflow。
概念 · Cloudflare Workflows(持久执行)

它解决的问题:批量算 500 条向量的任务跑了 10 分钟后 Worker 被回收,进度全丢,从头再来。Workflows 把任务写成一步步有存档的流水线:step.do("名字", fn) 的每步结果都被持久化,重试时从失败的步继续,已完成的不重跑。写法像普通代码,语义像「自动存盘的游戏」。

worker/index-workflow.ts · 补算流水线全文(节选)
export class IndexWorkflow extends WorkflowEntrypoint<Env, { userId: string }> {
  async run(event, step) {
    for (let i = 0; i < 400; i++) {
      const n = await step.do(`batch ${i}`,
        { retries: { limit: 3, delay: "10 seconds", backoff: "exponential" }, timeout: "5 minutes" },
        async () => {
          const rows = await 取 50 条 vector_hash IS NULL 的 memo;
          await indexMemos(this.env, rows);
          return rows.length;
        });
      if (n < 50) break;          // 取不满一批 = 全部算完
    }
  }
}
每批 50 条是一个 step:模型超时就自动指数退避重试 3 次;跑到第 7 批崩了,重跑时前 6 批直接读存档。上限 400 批(2 万条)防异常死循环。「每步可重试 + 全局有上限」是后台任务的两个安全带。
边界 · 两个容易误解的点

① 隐私 memo 永不进向量:判定为隐私后 applyPrivate 会删掉已有向量、FTS 里也只留遮挡文本(judge.ts:111)——搜索子系统看到的世界,始终是被隐私策略过滤后的版本;② 本地开发也在调真的 Workers AI(wrangler.jsonc 里 "remote": true),免费额度(每天 1 万 neurons)在本地一样消耗。

vibe 提示 · 这一章里你必须亲自懂的

① RRF 和阈值:搜索结果「不相关/漏」时,你要能判断是调阈值(SEMANTIC_MIN)、还是换模型、还是索引没建——三条路的排查入口分别是 indexStatus、EMBED_MODEL、vector_hash 计数;② step 的代价:Workflow 每步都有持久化开销,「拆多细」是权衡不是默认;③ 向量只增不查也能跑:语义路失败时代码整体降级成关键词搜索(search.ts:218 的 catch)——「AI 挂了产品不死」是这类功能的底线设计,审查 agent 产出的 AI 功能时必问这句。

自检 1 · 某条 memo 关键词第 3 名、语义第 1 名;另一条关键词第 1 名、语义未命中。谁排前面?
第一条:1/(60+3) + 1/(60+1) ≈ 0.0159 + 0.0164 = 0.0323;第二条只有 1/61 ≈ 0.0164。两路命中的完胜单路命中——RRF 的设计意图就是奖励「字面和意思都沾边」的结果。
自检 2 · 「相关笔记」为什么用 queryById 而不是把 memo 内容再 embed 一遍去 query?
向量已经算好了,就存在库里(id = memo id)。queryById 直接引用现成向量:省一次模型调用(省 neurons、省 1–2 秒延迟),还保证「相关笔记」用的向量和搜索用的是同一个——不会因为模型升级出现两套语义体系。复用已计算的结果,是所有索引型功能的第一本能。
下一章钩子

搜索解决了「找到某一条」。但笔记的更大价值在「看不清的时候」——写了一个月,你自己的主题、拖延、矛盾,自己看不见。下一章:让 AI 每周替你重读一遍,还能证明它没瞎编。

第 9 章 · AI 区之二

洞察:让 AI 定期回顾你的笔记

本章目标学完本章,你能画出「每周日晚 9 点」从触发到报告入库的完整链条;说清 provider、结构化输出、幻觉校验三个 AI 工程概念在这个项目里的具体形态;以及为什么给模型看的编号是 W1/H1 而不是 UUID。

从一句 cron 到一次生成:调度链

「洞察计划」是用户在设置页配的定时任务:频率(每天/每周/每月/自定义 cron)、时间、时区、范围标签、回看窗口(windowDays)、分析视角、模型。保存时后端用 croner(一个 cron 表达式解析库)把「每周日 21:00」翻译成「下次运行的时间戳」存进 next_run_at(insights.ts:71)。之后是一条完全由平台驱动的链:

worker/insights.ts:164 · Cron 到点之后(节选)
export async function runDuePlans(env: Env) {
  const plans = await 查询 next_run_at 到期的计划(最多 20 条);
  for (const plan of plans) {
    // 先把 next_run_at 推进到下一次,才启动生成 —— 条件里带旧值,
    // 若两个 Cron 撞车,只有改到那一条的能成功(乐观锁)
    const res = await db.prepare("UPDATE insight_plans SET next_run_at = ?, last_run_at = ?
        WHERE id = ? AND next_run_at = ?").bind(nextRun(...), now, plan.id, plan.next_run_at).run();
    if (!res.meta.changes) continue;
    await startInsight(env, plan.user_id, planConfig(plan), now);
  }
}

export async function startInsight(env, userId, plan, now) {
  const id = crypto.randomUUID();
  await 插入一条 status = 'generating' 的洞察记录;      // 先立牌子:正在生成
  await env.INSIGHT_WORKFLOW.create({ id, params: { insightId: id, userId, plan, ... } });
}
这条链回答了「定时任务怎么不重复执行」:Cron 的触发语义是「至少一次」(at-least-once)——15 分钟的巡逻可能撞上网络重试。把「推进时间」做成带旧值条件的 UPDATE,重复触发自然被数据库挡住。前端看到 generating 的记录就能显示「生成中」,Workflow 完成后把它改成 ready。

AI SDK:把「换个模型」变成换一个参数

概念 · AI SDK 与 Provider

每家的模型 API 长得都不一样(Anthropic 一套、Workers AI 一套)。yaf 用 Vercel AI SDK(ai 包)做统一门面:业务代码只对 generateText({ model, prompt, ... }) 编程,各家的差异由 provider 适配器吸收(@ai-sdk/anthropic、workers-ai-provider)。换模型 = 换一行工厂调用。洞察可以选 Workers AI 的 qwen3(免费用)或 Claude(用户自带 key,密钥经信封加密(AES-GCM)入库,第 12 章拆)。

worker/insight-engine.ts:53 与 281 · 结构化输出
export const ReportSchema = z.object({
  title: z.string().describe("一句话标题,不超过 20 字"),
  summary: z.string().describe("2–4 句话的总体摘要"),
  themes: z.array(item).describe("反复出现的主题(2–4 条)"),
  connections: z.array(item).describe("新的连接……(0–3 条)"),
  questions: z.array(z.object({ question: z.string(), why: z.string(), refs }))
             .describe("值得继续追问自己的问题(1–3 条)"),
});

const { output } = await generateText({
  model, system: SYSTEM, prompt: formatMemos(memos),
  output: Output.object({ schema: ClusterSchema }),   // 让模型按 schema 说话
  maxRetries: 2,
});  // output 的类型自动是 z.infer<typeof ClusterSchema> —— TS 与 zod 在此会师
zod 是运行时的「interface」:TS 类型在运行时不存在,而模型输出是外部输入、必须运行时校验——zod 的 schema 一份定义两用:校验数据、生成 TS 类型(z.infer),连 describe() 文案都会喂给模型当格式说明。结构化输出(structured output)让「模型返回 JSON 并保证形状」成为可靠工程,而不是字符串祈祷。失败会自动重试两次。

流水线:取材 → 聚类 → Map → Reduce → 校验

图 9-1 · InsightWorkflow 的五步(worker/insight-workflow.ts:17)
① gather窗口内 memo + 向量召回历史 ② 聚类余弦 k-means 分组 ③ Map·簇1 总结模型调用×2 重试 ③ Map·簇2 总结各自独立 step ④ Reduce 报告汇总 + 生成问题 ⑤ checkReport校验引用 → 入库 ready 失败兜底标 failed + 原因 隐私 memo 在 ① 就被 WHERE is_private = 0 挡在门外;每步 retry: 2 次 × 指数退避,超时 10 分钟 「Map-Reduce」:多簇并行小结 → 一次汇总,避免一次塞几百条 memo 把模型撑爆

取材的三个心思(gatherMaterial)

心思一:短编号。给模型的材料不标 UUID,而是编成 W1、W2…(窗口内)和 H1、H2…(历史相关)。模型抄 UUID 极易抄错一位,抄错「W3」却一眼可查——给模型的标识符要短、要可校验,这是幻觉治理的第一道工序。

心思二:历史不是按时间拉,是按意思拉。窗口内每条 memo 拿自己的向量去 queryById(第 8 章的 queryById 又出场了),召回过去最相关的最多 30 条——「新的连接」类结论因此有据可依:它真的连接的是历史上意思相近的记录。

心思三:反馈进入提示词。你给过往报告点过「有用/没用」,下一次生成会把「用户觉得没用:『上周总结』,避免类似泛泛而谈」拼进提示(loadFeedbackHint,insight-engine.ts:161)。个人化不是玄学,是把用户信号写进上下文。

防幻觉:引用必须对得上号

概念 · 幻觉与接地

幻觉(hallucination):模型一本正经地编造不存在的东西(这里特指编造引用——说「你在 W7 说过」,其实没有 W7)。接地(grounding):强迫每个结论挂靠在可验证的源头上。yaf 的校验(checkReport,insight-engine.ts:346)是硬规则:解析每条结论的 refs → 在材料表里查找 → 找不到或全部无效 → 整条丢弃并计入 stats.dropped;有效的把 W3 换回真实 memo id,前端把引用渲染成可点击的卡片链接。

于是「AI 说的每句话都能点回原文」不是界面装饰,而是数据结构保证:报告里只存在校验通过的引用。你审查任何 AI 功能时,这是含金量最高的问题:「模型说错了,用户怎么发现?」

边角:模型选型的注释文化

insight-engine.ts:12 有一段罕见的注释:记录了 2026-10-25 的实测——qwen3-30b-a3b 中文与结构化输出稳定、5–10 秒;qwen3.8-27b 质量更好但长输入超时;deepseek/glm 不在免费套餐;gpt-oss 结构化输出解析失败。这其实就是 vibe coding 的正确打开方式:把实验结论作为团队记忆写进代码,agent 下次改这块时自动继承这些约束,而不是重新踩坑。

vibe 提示 · 这一章里你必须亲自懂的

① 定时任务三件套:at-least-once 触发、幂等/乐观锁去重、失败可见(failed + error 字段)——agent 写定时逻辑时逐项对照;② 结构化输出 + 运行时校验:模型输出永远不可信,schema 校验不是可选项;③ 引用校验模式:任何「AI 基于你的数据下结论」的功能都该有它。调提示词、换模型、加报告板块这类迭代可以放心交给 agent——反正 schema 校验和引用校验是自动闸门。

自检 1 · 两个 Cron 触发同时到达,同一个计划会被生成两份报告吗?逐行说出防它的那行代码。
不会。insights.ts:173 的 UPDATE 带着旧值条件:WHERE id = ? AND next_run_at = 旧值。第一个触发者改掉了 next_run_at,第二个的 WHERE 匹配不到行,res.meta.changes 为 0,直接 continue。「先占坑再干活」——用数据库行级条件实现单次执行,不需要额外的锁服务。
自检 2 · 报告里某条「新的连接」引用了 W3 和 H2,但 W3 在生成前刚被用户删掉了。会发生什么?
取材发生在生成开始时:W3 是从当时存在的 memo 快照里选出来的,删除不影响进行中的生成(材料已在 Workflow 的 step 结果里持久化)。校验查的是「引用是否在材料里」,而不是「memo 是否还存在」——所以报告正常产出;点开引用时前端按 id 取不到会显示找不到(这属于可接受的时效性,和「报告是某时点的快照」一致)。
下一章钩子

洞察是「重 AI」功能:大模型写报告。但 yaf 还有一类「轻 AI」:每条 memo 写入后 1 秒内,要回答「这是待办吗?写了密码吗?」——这类判断用不上大炮,用的是另一个神奇的小模型。下一章:视图与判定。

第 10 章 · AI 区之三

视图与判定:lenses、隐私遮挡与自动标签

本章目标学完本章,你能解释:「待办」「隐私」这两个视图是怎么被自动判定的;分数、阈值、询问区间三者的关系;为什么用户手改的标记永远不会被模型翻案;以及一段写满密码的 memo 是怎么被逐行遮挡的。

第 1 章旅程的 ⑧ 站只说「后台判定」,现在拆开它。yaf 想解决的问题是:懒得打标签的人也该有「待办清单」;写了密码的 memo 不该在 Token 调用、导出、AI 材料里裸奔。解法是在写入后自动给每条 memo 打两个分数。

视图 = 问题 + 阈值

worker/lenses.ts · 视图注册表(全文核心)
export interface Lens {
  key: string; name: string;
  question: string;    // 交给判定模型的问题
  threshold: number;   // 分数 ≥ 它 → 命中
  ask?: number;        // 分数落在 [ask, threshold) → 卡片上问用户
}

export const LENSES: Lens[] = [
  { key: "private", name: "隐私", threshold: 0.8,
    question: "这段文字是否写出了可以直接用来登录或访问账户的凭据的值……?" },
  { key: "todo", name: "待办", threshold: 0.7, ask: 0.4,
    question: "这条笔记是否记录了一件作者自己要去做的事……?" },
];
export const LENS_REV = 1;   // 视图集版本号:改问题或阈值就 +1
「加一种视图 = 加一条配置」——这是注册表模式:数据驱动,不动逻辑。而 LENS_REV 解决的是「判定标准变了,存量数据怎么办」:每条判定都记录当时的版本号(judged_rev),补判任务专找「版本落后」的 memo 重新判(judge.ts:125 的 judged_rev < LENS_REV)。改标准 → rev+1 → 全库自动重判,没有一次性脚本。

Clef:一次调用,答所有题

概念 · 分类式小模型(vs 生成式大模型)

第 9 章的洞察用生成式模型(写报告),这里用 Workers AI 的 Clef——一个只回答「是/否 + 概率」的分类式小模型:你给它一段材料(state)和一批问题,它一次返回每题的概率,不写一个字的正文。便宜、快(百毫秒级)、输出天然结构化。选型直觉:能分类的就别生成——生成会跑题,分类只有对错。

worker/judge.ts:71 · judgeMemo(节选)
const state = [row.content, ...附件描述].join("\n").trim();
const questions = {};   // 一次把两个视图的问题都带上
for (const lens of LENSES) questions[lens.key] = { type: "noul", instructions: lens.question };
const scores = await askClef(env, state, questions);   // → { private: 0.02, todo: 0.81 }

const userPrivate = await 查 memo_labels 里用户手标的 private;
const isPrivate = userPrivate ? userPrivate.score >= 0.5          // 用户优先
                              : (scores.private ?? 0) >= 0.8;
const masked = isPrivate ? await maskPrivate(env, row.content) : null;

await env.DB.batch([
  // 带上 content = ? 条件:判定期间用户又编辑了 → 这批结果全部作废(防竞态)
  db.prepare("UPDATE memos SET judged_rev = ?, is_private = ?, masked_content = ?
              WHERE id = ? AND content = ?")...,
  ...LENSES.map((l) => db.prepare(`INSERT INTO memo_labels ... source = 'model', ...
      ON CONFLICT(memo_id, label) DO UPDATE SET ... WHERE memo_labels.source = 'model'`)...),
]);
三处攻防细节:① 用户覆盖优先——你标过「是隐私」,模型说了不算;② 不覆盖用户——INSERT 冲突时的 UPDATE 带 WHERE memo_labels.source = 'model',你手改的标记永远稳如泰山;③ content 条件防竞态——判定是异步的(waitUntil),期间用户可能又编辑了正文,旧判定写回新正文就是数据错乱,用「内容没变才生效」一笔挡住。

分数轴:命中、询问、放行

图 10-1 · 拖动分数,看一条 memo 在「待办」视图里的三种待遇(threshold 0.7 / ask 0.4)
00.4(开始问)0.7(命中线)1.0

这个设计把「模型不确定」变成了产品交互:低分安静,高分直接打上「待办」角标(MemoCard 的 lens-chip),中间地带不硬猜,反问用户一句「这是待办吗?」(MemoCard.tsx:186 的 lens-ask 区块)。用户点的答案以 source: "user" 落库——训练信号和即时纠错一次完成。

隐私遮挡:逐行处决

判定为隐私后,遮挡不是「整段糊死」,而是 maskPrivate(judge.ts:39):把每一行变成一个独立问题(「L3 这一行是否写出了凭据的值?」),一次 Clef 调用批量判完,概率 ≥0.3 的行被遮挡——阈值故意放低,「宁可多遮」。还有一道兜底正则 SECRETY(同时含字母和数字的 8 位以上串):模型漏判时,长得像密钥的串照样被处理。遮挡保留行首标签名(GitHub: ••••),让你还记得这是哪个账号的密码:

遮挡前后(worker/judge.ts:31 的 maskLine)
GitHub: ghp_9f8e7d6c5b4a3210fedcba     →   GitHub: ••••
服务器 root 密码 Tr0ub4dor&3            →   服务器 root 密码 ••••
这只是讨论密码管理策略的一行            →   (原样保留:没有凭据值)
遮挡文本存进 masked_content,之后 applyPrivate(judge.ts:111)把全文索引换成遮挡版、删掉已有向量——第 8 章说过的「搜索子系统只见过滤后世界」在这里兑现。原文只有网页会话能看(/memos/:id/raw,memos.ts:70),且前端显示原文有 30 秒倒计时自动糊回去(MemoCard.tsx:53)。

两个小配角:自动标签与用量圆环

按来源自动加标签(auto-tags.ts:59):CLI/API/MCP 写入的 memo 可自动补 #cli 等标签,方便「只看机器写入的」。实现是纯文本手术:appendTags 把缺的标签追加到正文末尾——和第 6 章同一套 extractTags 判定「哪些已有」,规则不分叉。

AI 用量圆环(usage.ts):侧栏那个小圆环显示今天整个账户的 Workers AI 用量(每天免费 1 万 neurons)。两个技术点值得记:① 用 Cloudflare 的 GraphQL Analytics API 查用量,按 modelId 归类到「向量/图片/洞察/分类」(usage.ts:16 的 FEATURES 表——又是注册表);② 结果用 Cache API 缓存 5 分钟(usage.ts:83),缓存键里带日期——跨过 UTC 零点自然失效,无需定时清理。这是 Workers 平台自带的边缘缓存,一句 caches.default 就能用。

边界 · 判定不是真相,是「够用的猜测」

模型的两个分数永远可能错:漏判的密码靠 SECRETY 兜底但不敢说穷尽(这正是 SETUP/可见性层层设防的原因——遮挡只是第一道,Token 默认看不到未判定 memo 是第二道);误判的待办有一句「问用户」+ 手改覆盖兜底。AI 功能的健壮性不靠模型多准,靠错了之后系统的每条退路。

vibe 提示 · 这一章里你必须亲自懂的

① 异步写回的竞态:waitUntil 里写库,必须想「这期间数据变了怎么办」——yaf 的答案是条件 UPDATE(content 一致才生效),agent 极易漏;② 用户数据 > 模型输出:任何「AI 自动标 X」的功能都要有用户覆盖且永不被模型覆盖,这是一条可以原样搬走的审查规则;③ 阈值/ask 的产品含义。调阈值、加新视图(复制一条 Lens 配置)很适合交给 agent——LENS_REV 机制保证存量自动重判。

自检 1 · 你把一条 memo 手动标成「不是待办」(score=0)。下次 Cron 重判时它会被翻案吗?
不会。judgeMemo 写分数用的是 ON CONFLICT … DO UPDATE SET … WHERE memo_labels.source = 'model'(judge.ts:100-101):已存在的记录若是用户标的(source='user'),UPDATE 的 WHERE 不满足,模型的分数写不进去。判定可以反复重跑,用户意志只此一份。
自检 2 · 为什么遮挡用「逐行分类」而不是让生成式模型「把密码改写成 ••••」?
三个理由:① 生成式模型在改写时可能把密码原样复述进响应、日志或错误信息——分类模型只输出概率,密码从不离开判定材料的位置;② 逐行分类的结果稳定可复现(同输入同分数),生成的改写每次都可能不同,没法建测试;③ 一次调用判 64 行,比逐段生成快一个量级、便宜一个量级。「能分类别生成」在这里还是安全问题。
下一章钩子

至此 yaf 的 AI 三部曲完结:检索(找得到)、洞察(看得透)、判定(管得住)。但你可能一直在想那个问题:这些能力,凭什么只有网页能用?下一章把两把钥匙递出去——给 AI 助手的 MCP,给终端的 CLI。

第 11 章 · 出口

MCP 与 CLI:把笔记递给 AI 助手和终端

本章目标学完本章,你能解释 MCP 是什么、解决什么重复建设问题、yaf 的六个工具是怎么注册和鉴权的;以及 CLI 如何在几十行里复用第 7 章的设备码流程。你会意识到:前十章的一切都在为这一章的「顺手复用」铺路。

MCP:AI 助手的 USB-C 接口

概念 · MCP(Model Context Protocol)

它解决的问题:想让 Claude Code 查你的笔记、让另一个助手也查你的笔记,各写一套对接?M 个助手 × N 个数据源 = M×N 份适配代码。MCP 是一个开放协议,把对接标准化成 M+N:数据源方实现一次「MCP 服务器」,所有支持 MCP 的助手都能即插即用——像 USB-C,一根线什么设备都能插。三个核心名词:工具(tool)=助手可调用的函数(本项目的全部内容);资源(resource)=可读取的数据(yaf 未用到);传输(transport)=消息怎么走(yaf 用 Streamable HTTP:普通 HTTP POST,模型客户端把「调用哪个工具、什么参数」发到 /mcp)。

用户侧的接线只有一行(README 的 MCP 一节):

把 yaf 插进 Claude Code
claude mcp add --transport http yaf https://你的域名/mcp \
  --header "Authorization: Bearer yaf_pat_xxxx"
翻译:告诉 Claude Code「有个叫 yaf 的 MCP 服务器在那个地址,带着这把 Token 说话」。之后助手就能「发现」yaf 暴露的工具列表并按需调用——你不需要告诉它每个工具的存在,协议自带发现机制。

服务端:注册六个工具

worker/mcp.ts:25 · 工具三要素(search_memos 为例,节选)
server.registerTool(
  "search_memos",
  {
    description: "在用户的 yaf 笔记里搜索。传 query 时默认混合检索(关键词 + 语义," +
      "按相关度排序),能找到意思相近但用词不同的 memo;tag 不带 #……都不传时按时间倒序。",
    inputSchema: z.object({
      query: z.string().optional().describe("搜索内容,可以是关键词,也可以是一句话"),
      mode: z.enum(["hybrid", "keyword", "semantic"]).optional(),
      limit: z.number().int().min(1).max(50).optional().describe("返回条数,默认 20"),
    }),
  },
  async ({ query, mode, tag, limit }) => {
    if (!can("memos:read")) return denied("memos:read");   // 工具级权限检查
    const r = await search(env, user.id, { q: query, ..., visibility: "token" });
    return text(`找到 ${r.memos.length} 条(关键词 ${r.stats.keyword} · 语义 ${r.stats.semantic}):\n\n` + ...);
  },
);
六工具全景:search_memos(混合检索)、get_memo、add_memo、get_related、list_tags、list_insights。注意三点:① description 是写给模型看的文档——它会照着决定用不用、怎么传参,参数的 describe 同理;写它就是给 AI 写产品文档,是 MCP 开发的主要手艺;② 处理函数直接调用第 5 章的 search/createMemo——MCP 不是平行世界,是同一套服务层换了件协议外衣;③ visibility: "token"——助手看不到隐私 memo 和未判定的 memo(第 5 章 buildFilter 的第二种可见性)。

鉴权在 handleMcp(mcp.ts:160):进来先 findToken 验 Bearer(第 7 章同款函数),然后把「用户 + scopes」传进每个工具闭包。scope 检查在工具内部做而不是路由上,因为 /mcp 只有一个入口、却承载六种能力——权限粒度和工具粒度对齐,模型拿到无权限时收到的是一句它能理解、会转告用户的文本(isError: true),而不是一个它看不懂的 403 状态码。

CLI:一个会自己申请钥匙的终端工具

packages/cli 是 npm workspace 里的独立小包(MIT 单独授权),发布成 npm 上的 yaf-cli,全局安装后提供 yaf 命令:

CLI 的日常
yaf login --host https://你的域名     # 走第 7 章图 7-1 的设备码流程
echo "灵感:给导读加一张时序图 #yaf" | yaf add     # 管道当输入,POSIX 风格
yaf search 关键词 --tag inbox
yaf export --format md -o inbox.md
三个实现要点:① yaf add 从 stdin 管道读内容——「任何命令的输出都能变成一条 memo」,这是 Unix 哲学;② login 就是把第 7 章的时序图跑一遍:申请设备码 → 打开浏览器 → 轮询 → Token 存进 ~/.config/yaf/;③ 所有命令最终只是拼 HTTP 请求打 /api/*——CLI 与 MCP 一样,没有自己的业务逻辑,服务层唯一,入口繁多。

五个入口,一张总表

入口鉴权来源标记可见性落点
网页Cookie 会话web(默认,不显示图标)全量(隐私给遮挡)REST + Hono
CLIPAT(设备码申请)cli(X-Yaf-Source 头)token 视角REST
MCPPATmcptoken 视角工具调用
HTTP APIPATapitoken 视角REST
微信绑定关系(bot_token)wechat全量(写入方向)Durable Objects

看第二列和第四列的规律:鉴权方式决定可见性。会话=全量、Token=收敛、微信=只进不出。每条 memo 记下 source(卡片上有小图标,SourceIcon.tsx),数据从哪来回哪去,可审计。

vibe 提示 · 这一章里你必须亲自懂的

① 工具描述即界面:评价 MCP 功能好坏,先读 description——含糊的描述直接导致模型乱传参,这不是代码 bug,是文档 bug;② 权限面最小化:新工具上线前问「这个能力配哪个 scope?Token 视角该不该看到?」;③ 复用检查:agent 写新入口时,盯住「有没有绕过 memo-service 自己写 SQL」。给现有服务加一个 MCP 工具、加一个 CLI 子命令,都是低风险高回报的 agent 任务。

自检 1 · 用 MCP Token 能让 AI 助手「把某条 memo 改成非隐私」吗?顺着代码找三条拦截。
① 工具列表里没有改标签/改隐私的工具(六个工具全是读和增)——能力上不存在;② 就算它试图调 REST 的 PUT /memos/:id/labels/…,那个接口挂的是 requireUser(memos.ts:77),只认 Cookie 会话,PAT 直接 401;③ 即便它问「这条 memo 内容」,Token 视角下隐私 memo 根本不可见(TOKEN_VISIBLE_SQL)。纵深防御:能力层、路由层、数据层各拦一道。
自检 2 · 为什么 yaf 的 MCP 路由挂在 /mcp 而不是 /api/mcp?
看 worker/index.ts:32:入口函数在 Hono 之前就把 /mcp 单独分流了——它不是 REST 语义(请求体是 MCP 协议消息,不是业务 JSON),鉴权方式也不同(只认 Bearer,不认 Cookie)。把它和业务 API 平级分开,两边各自干净;wrangler.jsonc 的 run_worker_first: ["/api/*", "/mcp"] 也印证了这是两个平级入口。
下一章钩子

总表第五行还空着原理:微信这个入口为什么需要一个「Durable Objects」?消息怎么进来的、语音怎么变成可播放的文字、bot_token 凭什么加密存着?下一章进入全书最「平台特色」的一章。

第 12 章 · 平台深水区

微信收录:Durable Objects 与 R2

本章目标学完本章,你能回答:为什么微信连接非用 Durable Objects 不可;扫码绑定的状态机怎么转;微信消息如何变成一条带缩略图和可播放语音的 memo;以及「每用户一个密钥」这类秘密为什么要自己加密存库。

先声明边界:微信收录是实验性功能,走的是微信 ClawBot 的非官方协议(protocol.ts 是协议适配层,别当成官方 API)。正式讲机制前,先看清它对平台的特殊要求——它需要一个常驻、有状态、单实例的东西,而这恰好是 Workers 刻意不给你的。

为什么是 Durable Objects

概念 · Durable Objects(简称 DO)

普通 Worker 是「一次请求一个一次性沙盒」:没有常驻内存、没有本地存储、同一秒可能开几百个副本。而微信连接需要一个循环:「拉消息 → 处理 → 200 毫秒后再拉」,还要记住游标拉到哪了——不能开两个副本同时拉(消息会乱)。DO 就是 Cloudflare 给的解法:一个有名字的单实例对象(idFromName(userId) 保证同名永远同一实例),自带存储(键值 + 内置 SQL),支持 alarm——「未来某时刻叫醒我」。它像一台只属于一个用户、按秒计费的微型服务器。

worker/wechat/bridge.ts:46 · WechatBridge(节选)
export class WechatBridge extends DurableObject<Env> {
  async start(userId: string) {           // 扫码确认后由路由调用
    await this.ctx.storage.put("userId", userId);
    await this.ctx.storage.delete(["cursor", "lastMemo", "failures"]);
    await this.ctx.storage.setAlarm(Date.now());   // 立刻唤醒自己
  }

  async alarm() {                          // 被叫醒:拉一轮消息
    const binding = await 查 wechat_bindings 表(含加密的 bot_token);
    const token = await open(this.env.ENCRYPTION_KEY, binding.bot_token_enc);
    const cursor = (await this.ctx.storage.get("cursor")) ?? "";
    const resp = await getUpdates(base_url, token, cursor, POLL_TIMEOUT_MS);
    if (resp.get_updates_buf) await this.ctx.storage.put("cursor", resp.get_updates_buf);
    for (const msg of resp.msgs ?? []) await this.handle(userId, binding, token, rules, msg);
    await this.ctx.storage.setAlarm(Date.now() + 200);   // 200ms 后再来一轮
  }   // 出错时:failures 计数退避,最长 120 秒;连续失败写进 last_error 供设置页显示
}
读法:DO 的存储(ctx.storage)放游标、上次合并的 memo、失败计数——这些「循环状态」有了家。alarm 是心跳:不是常驻进程在死循环,而是「每轮结束预约下一轮」,平台在间隙可以回收计算资源,有消息时毫秒级唤醒。GET 长轮询 30 秒(POLL_TIMEOUT_MS)让消息到达几乎即时——这就是「微信发消息、网页几乎同时出现」的秘密。

绑定:一台状态机

图 12-1 · 扫码绑定状态机与消息轮询循环(对照 worker/wechat/routes.ts:87)
none 未绑 pending 扫码中 scaned 已扫 need_verifycode active 已连接 POST /qrcode 手机扫码 要验证码 验证后继续轮询 直接确认 5 分钟无扫码→ expired,点刷新重来 确认时存 bot_token(AES-GCM 加密) → confirmed 后立刻bridge.start(userId) 长轮询实现:前端每轮 GET /qrcode/status,服务端 hold 住 最多 20 秒才返回——比定时 刷新快,比 WebSocket 简单 DO 的 alarm 循环(active 期间) alarm() 被叫醒解密 token getUpdates长轮询 30 秒 有消息?逐条 handle 存 R2 / 图片描述(waitUntil) createMemo →回复「已记录」 setAlarm+200ms 下一轮 失败:退避 10s×ntoken 失效 → 停止+报错 游标 cursor 与「2 分钟合并窗口」的 lastMemo 都存在 DO storage:单实例,永不并发推进

一条微信消息的旅程(handle)

收到消息后(bridge.ts:149)按顺序过五道关:① 忽略自己说的(BOT 类型);② 只收绑定者本人(from_user_id 不符直接丢弃——防别人拉群污染你的笔记);③ 按 message_id 去重(协议会重复投递,DO 内置 SQL 建了 seen 表只留最近 2000 条,firstSeen 查过才继续);④ 拼正文——文字和语音转写进正文,图片/语音本体走附件;若开了合并窗口,2 分钟内的连续文字直接 updateMemo 追加成一条(连发几段 = 一张卡片);⑤ 落库 + 后续:

worker/wechat/bridge.ts:189 · 收尾三连
if (!memo) memo = await createMemo(this.env.DB, userId, appendTags(body, tags), "wechat",
                                   { allowEmpty: hasMedia });   // 纯图片允许空正文
if (hasMedia) {
  this.ctx.waitUntil(saveMedia(this.env, userId, memo.id, items, ...)
    .catch(async (e) => { /* 全失败时补一句说明,避免空白卡片 */ })
    .then(() => scheduleIndex(this.env, this.ctx, userId, memo)));  // 再进第 8 章的索引链
} else scheduleIndex(this.env, this.ctx, userId, memo);
if (rules.replyConfirm) this.ctx.waitUntil(sendText(..., "已记录 · #微信 #家事", ...));
看到没有——最后一步接回了第 1 章旅程的 ⑧⑨:判定、向量、FTS 全部复用。微信入口的价值不在新逻辑,而在它证明了核心逻辑真的入口无关。

图片与语音:media.ts 的流水线

微信的媒体是加密 CDN 资源,流水线是:下载 → AES-128-ECB 解密 → (R2 开通则)存原文件 → 登记 attachments 表 → 图片再要一句 AI 描述。三个平台知识点:

  • R2 对象存储(概念框:S3 式的「按 key 存文件」服务,MEDIA.put(key, bytes) / MEDIA.get(key),key 形如 u/用户/memo/附件.jpg)。它是可选绑定:没开通时 mediaEnabled=false,附件只存描述文字,功能照常降级。读取走 /api/attachments/:id(routes.ts:181):先查附件归属(user_id 对不上就 404),再从 R2 拿流式返回——文件私有,须经鉴权的接口转发,不搞公开 URL。
  • 图片描述用 Qwen3.8-27B(media.ts:8),注释里连「为什么关思考模式」的实测原因都写着(开着会先写推理,200 token 内正文被截断、用量翻倍)。描述文字拼进第 8 章的 embedText——图片因此可被语义搜索;前端只把它用于悬停提示和无障碍朗读。
  • 语音是 SILK 编码(微信私有格式),浏览器不认识。前端 silk.ts 用 silk-wasm(WebAssembly 版转码器)在浏览器里解码成 WAV 再交给原生 audio 播放——点击才下载解码(VoicePlayer,MemoCard.tsx:291),不预载。

信封加密:每用户一个秘密怎么办

worker/lib/secret-box.ts · 全文 20 行(节选)
export async function seal(secret: string | undefined, plain: string): Promise<string> {
  const iv = crypto.getRandomValues(new Uint8Array(12));       // 每次加密用新随机数
  const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(secret), ...);
  return `v1.${toB64(iv)}.${toB64(data)}`;    // 版本号.iv.密文,将来可换算法
}   // key(secret):ENCRYPTION_KEY 是部署级 secret(第 3 章),用户 token 是数据
为什么不让 wrangler secret put 管 bot_token?因为 secret 是部署级的——整个应用一份;而 bot_token、Claude key 是每用户一份、存在 D1 里。折中方案:库里只存密文,解密钥匙(ENCRYPTION_KEY)只有部署环境有——拖走数据库拿到的都是 AES-GCM 密文(带认证标签,篡改即解密失败)。这个「部署 secret 当根钥匙、加密用户级秘密入库」的模式可直接搬走。
vibe 提示 · 这一章里你必须亲自懂的

① 什么时候需要 DO:「有状态 + 单实例 + 定时心跳」三个信号同时出现才是它的领地,别让 agent 到处乱用(有免费额度限制);② alarm 不是死循环:每轮预约下一轮的语义决定了「状态放 storage、代码可重入」的写法;③ 用户级秘密的加密模式。扩展微信功能(新消息类型、新规则)适合交给 agent——协议细节在 protocol.ts 适配层里被隔离,业务层动不到。

自检 1 · 不用 DO,用每 15 分钟的 Cron 拉微信消息行不行?损失什么?
功能上可行(拉消息是普通 HTTP 调用),损失三样:① 实时性——「已记录」回复最多迟到 15 分钟,长轮询是秒级;② 状态归属——游标、合并窗口得塞进 D1,每个用户一行,并发安全要自己拿事务保证,而 DO 单实例天然串行;③ 成本形态——15 分钟全用户扫一遍是「全体巡逻」,DO alarm 是「有绑定才走针」。反过来 DO 的代价是要理解它的计费和限制。平台选型 = 用哪种约束换哪种能力。
自检 2 · 为什么附件读取要经 /api/attachments/:id 鉴权转发,而不是给 R2 一个公开链接?
memo 是私人的:公开链接等于把你的截图、语音挂上互联网(链接会被分享、被搜索引擎收录)。经接口转发让每次读取都过一遍 requireScope + 归属校验(routes.ts:182),并用 Cache-Control: private 防中间缓存。代价是每次多一跳 Worker——用延迟换隐私,对笔记应用是显然正确的取舍。
下一章钩子

后端故事讲完了。回到蓝区收尾:你每天盯着的那张卡片——Markdown 怎么变成安全的 HTML、搜索词怎么高亮、热力图怎么算、⌘K 怎么聚焦——前端还有一半的细节值得抠。下一章把前端从「会动」讲到「耐看」。

第 13 章 · 前端深水区

前端进阶:Markdown 的安全管道与组件细节

本章目标学完本章,你能画出一张 memo 从 Markdown 原文到屏幕上「带标签链接、命中高亮的卡片」的完整管道,并解释每一站为什么必须按这个顺序;还能读懂递归组件、热力图和「点击外部关闭」这几个高频前端模式。

把不可信文本变上屏:一条五站管道

概念 · XSS(跨站脚本攻击)

memo 内容是用户输入的——如果你写了 <script>偷别人Cookie()</script>,而应用把它原样插进页面,每个看到这条 memo 的浏览器都会执行这段脚本。这就是 XSS:不可信文本混进了「代码通道」。DOMPurify 的职责是白名单洗牌:解析 HTML、只保留安全标签(段落/加粗/表格…)、剥掉一切可执行的东西,然后才准上屏。

src/markdown.ts:73 · renderMarkdown 全文
export function renderMarkdown(src: string, terms: string[] = []): string {
  const html = marked.parse(src, { async: false });      // ① MD → HTML
  const clean = DOMPurify.sanitize(html, { RETURN_DOM: true }); // ② 净化,返回 DOM
  linkifyTags(clean);                                     // ③ #tag 变可点击链接
  highlight(clean, terms);                                // ④ 命中词包 <mark>
  clean.querySelectorAll("a[href]:not(.tag)").forEach((a) => {
    a.setAttribute("target", "_blank");                   // ⑤ 外链新窗口
    a.setAttribute("rel", "noopener noreferrer");         //    且切断反向访问
  });
  return clean.innerHTML;
}
顺序就是安全边界:③④ 必须在 ② 之后——如果先在字符串上做链接化再净化,净化可能把你刚造的结构洗掉;反过来先净化再操作 DOM,操作的是「已确认无害」的结构。RETURN_DOM: true 让净化结果直接是可遍历的 DOM 而非字符串——③④ 都是 DOM 操作。React 侧用 dangerouslySetInnerHTML 插入(MemoCard.tsx:172)——这个吓人的名字就是 React 的声明:「我已另行保证安全」,安全保证正是这条管道。
图 13-1 · 渲染管道五站(安全在第二站,之后全是无害区)
① markedMD → HTML(还不安全) ② DOMPurify白名单净化 → 安全 DOM ③ linkifyTags跳过 code/pre/a ④ highlight命中词包 mark ⑤ React 插入dangerouslySetInnerHTML ↑ 不可信区:任意文本 无害区:只剩已净化的 DOM 节点 ↑
记忆钩子:先洗菜再切菜。任何「用户内容 + 富文本渲染」的功能都长这样,顺序错了就是漏洞。

DOM 遍历:TreeWalker 与文档片段

③④ 的实现是同一个模式(markdown.ts:8 与 45),值得当成一套手艺学:TreeWalker 只遍历文本节点,并用 acceptNode 跳过 code/pre/a(代码里的 # 不是标签、链接里的词不该二次高亮);命中处不在原节点上硬改,而是用 createDocumentFragment 重组——「保留前段 + 插入链接 + 保留后段」最后 replaceWith 一次替换。为什么不在字符串上跑正则?因为那时文本已经带着合法 HTML 标签,正则会把标签内容也当文本处理,一改就烂——操作结构,用结构的方式。

MemoCard:小而完整的组件样本

  • useMemo 缓存重计算(MemoCard.tsx:43):html = useMemo(() => renderMarkdown(shown, terms), [shown, terms])——渲染管道不便宜(解析+净化+两次遍历),依赖不变就复用上次结果。第 4 章说「组件函数反复执行」,useMemo 就是给「贵的结果」上缓存的标准位置。
  • 倒计时状态机(MemoCard.tsx:46-51):隐私原文显示 30 秒后自动糊回去——一个每秒递减的 state,left ≤ 0 时清空 raw。计时器在 useEffect 里注册、在清理函数里销毁,组件随时卸载都不泄漏。
  • 判定追踪:新写入的 memo 判定要 1–2 秒,Home 的 followJudge(Home.tsx:147)在 2.5 秒和 6 秒各取回一次,到了就替换卡片数据、刷新侧栏计数——「先显示,后台补全」的乐观 UI 模式,和后端的 waitUntil 一唱一和。
  • 点击外部关闭(MemoCard.tsx:73 与 Sidebar 的 SidebarBottom):菜单打开时监听 document 的 mousedown,点击目标不在菜单内(!menuRef.current?.contains(e.target))就关闭,Esc 同理。 Sidebar 的用量圆环更进一步:用 createPortal 把弹出卡片挂到 body 上(Sidebar.tsx:246),避免被侧栏的滚动容器裁剪——「弹出层逃出父容器」的标准解法。

Sidebar:递归组件与热力图

递归组件:标签是树(读书/历史 嵌在 读书 下),buildTree(Sidebar.tsx:13)把扁平的 tag 计数拼成 TagNode 树,TagItem 组件在自己的 JSX 里渲染 <TagItem>——组件调用自己,树的深度决定递归深度。子标签选中时父级自动展开(containsActive 的 useEffect),这是树形 UI 最常见的两个需求。

热力图(Sidebar.tsx:73):16 周 = 112 个格子,useMemo 里从「今天往前推」生成日期序列,把后端按天分组的计数装进 Map 查表,level() 把条数映射成 0–4 档颜色(l0…l4 class)。没有任何图表库——网格图用 CSS 网格 + 类名切换就是全部,「上库」之前先想想是不是 20 行 CSS 的事。

主题:三种偏好,一处生效

useTheme(theme.ts)只有 30 行但很完整:偏好存 localStorage(try/catch 包住——隐私模式下写 localStorage 会抛错,功能不能因此挂);「跟随系统」时删掉自定义属性、交给 CSS 的 prefers-color-scheme;切换是三态轮换 system → light → dark。所有颜色都是 CSS 变量,暗色只是变量表换了一组值——主题不是两套样式,是一份样式两本字典。

vibe 提示 · 这一章里你必须亲自懂的

① XSS 三道闸的位置:净化在中间、后处理在净化后、React 插销是出口——agent 加任何「把用户内容渲染上屏」的代码,按图 13-1 逐站核对;② 贵计算上 useMemo、事件监听必有清理:这两条是 React 代码审查的固定动作;③ 无障碍不是附加题:本项目给图标按钮全配了 aria-label,给图片描述配了 alt——agent 产出的 UI 你抽查这三处。改样式、加角标、调交互细节,大胆交给 agent。

自检 1 · 把 DOMPurify 那行删掉,应用表面一切正常。多久之后会出事?
不知道——这正是 XSS 的阴险之处:它是别人引爆的。任何能往你笔记里塞内容的入口(微信消息、CLI 管道、MCP 的 add_memo、导出再导入)都可以埋一段脚本;等某个「会话里有 Token 权限」的客户端渲染到这条 memo,脚本就以用户身份运行(读 DOM、发请求——好在 Cookie 有 httpOnly,但 Token 视角的操作全在它射程内)。安全投资的回报形态就是「什么都没发生」。
自检 2 · 卡片列表滚动加载 10 页后,首屏卡片会重复执行 renderMarkdown 吗?
不会。React 只重渲染「状态变化波及」的组件;旧卡片只要 props(memo 对象、terms)引用不变,配合 useMemo 直接复用上次的 html。两个前提缺一不可:父组件列表更新用「造新数组」而非原地改(否则全部子组件重渲染);useMemo 的依赖数组写对(shown/terms)。这就是第 4 章「不可变更新」在性能侧的回报。
下一章钩子

代码之旅到此结束:三个区(前端、后端、AI)、五个入口、三级后台全部走完。最后一章谈「代码之外」——这本书怎么变成你电脑上跑着的服务:构建、部署、以及那套让 agent 替你干活还不出事的护栏。

第 14 章 · 上线

构建与部署:从源码到线上

本章目标学完本章,你能说出 npm run deploy 的每一步在做什么、preflight 拦下的是哪两种真实事故、升级为什么永远可以回滚、以及「deploy 分支模式」解决了什么问题。

流水线:一条命令的三段旅程

package.json:16 · 部署就是一行(但每一站都有讲究)
"deploy": "node scripts/preflight.mjs && vite build && wrangler deploy"
//         ① 检查:现在部署安全吗?        ② 打包              ③ 上传发布
&& 是「前一步成功才继续」——preflight 不过,后面根本不会跑。这就是把「流程」写成「代码」:不靠人记住顺序。
图 14-1 · 从 TS 源码到全球边缘
TS 源码src/ + worker/ + shared/ ② vite build打包·摇树·压缩 ① preflight两项上线前检查 ③ wrangler deploy代码+静态资源上传 全球边缘节点另:wrangler d1 migrations apply 产物两份:前端静态文件(发给浏览器)与 Worker 脚本(跑在边缘)——同一次构建、同一个仓库、一次部署
Vite 打包(bundle):几百个 ES 模块按 import 关系拼装、删掉没被引用的导出(摇树优化 tree-shaking)、压缩改名,产出浏览器能高效加载的少数文件。@cloudflare/vite-plugin 让同一次构建顺带产出 Worker 脚本(并把 worker 依赖的 npm 包处理成边缘运行时兼容的形式)。

preflight:把两次真实事故写成检查

scripts/preflight.mjs · 节选(普通 Node 脚本,.mjs = ES 模块的 JS)
const dbId = config.match(/^\s*"database_id"\s*:\s*"([0-9a-f-]{36})"/m)?.[1];
if (!dbId) fail("wrangler.jsonc 的 D1 配置缺少 database_id。继续部署会让 wrangler
  新建一个空库,原有数据不会被删除,但站点会读不到。……");

const out = spawnSync("npx", ["wrangler", "d1", "migrations", "list", dbName, "--remote"], ...);
if (!out.includes("No migrations to apply")) fail("线上数据库有未应用的迁移。
  部署前请先记录 Time Travel 书签,再应用迁移:……");
两个检查各对应一类真实事故:① database_id 缺失——wrangler 会「好心」创建一个全新空库,你的数据原地不动但网站读的是空库,看起来像「数据全丢了」;② 迁移没应用就上新代码——新代码默认新表结构存在,查询会直接报错。检查本身很土(正则 + 调 CLI 看输出),但「让机器在部署门口拦人」永远好过「让人记住教训」。

回滚:为什么升级永远是安全的

yaf 的升级策略建立在一条纪律上(CLAUDE.md 明文规定):迁移只增不改。新版本只添加表和列,从不修改或删除旧结构——于是「上一版代码跑在新表结构上」依然正确(它用的列都还在)。由此得到一个漂亮的性质:回滚代码永远安全,不需要「回滚数据库」。数据本身还有第二重保险:D1 Time Travel 可以把数据库恢复到过去 30 天内任意时间点,升级文档要求操作前先记一个「书签」。两层保险合起来:升坏了 → 代码退回上一版 + 数据回到书签,全程可逆。

deploy 分支模式:一个人部署,全人类可安装

yaf 是公开仓库,任何人 clone 后自己部署——但有个别扭的小东西:database_id。它是你数据库的身份证(wrangler.jsonc 里那串 UUID),每人不同、且不该提交到 main(main 要保持「任何人拿来即可安装」的干净状态)。解法是流程约定:部署者开一个自己的 deploy 分支,把 id 提交在那里;升级时把新版 main 合并进自己的 deploy 分支,冲突面极小。你此刻读的仓库正处于这个模式(当前分支就是 deploy)。

更妙的是 docs/AGENT-INSTALL.md 和 AGENT-UPGRADE.md 的存在:它们把「安装」「升级」写成给 AI agent 执行的操作手册——检查环境、引导登录、建资源、设 secret、部署、验证,每步都有预期输出和回滚方法。用户只需要对 agent 说「照这个文档帮我装」。这是 vibe coding 时代的「运维文档」形态:写给机器执行、人在关键处点确认。

秘密的最后清点

东西存哪为什么
ENCRYPTION_KEY / SETUP_TOKEN / CF 分析 Tokenwrangler secret put部署级秘密,进平台保险箱,代码库零痕迹
本地开发用同款秘密.dev.vars本地约定文件,.gitignore 已挡住
database_iddeploy 分支的 wrangler.jsonc不是秘密但是「个人配置」,不该进 main
用户的 Claude key / bot_tokenD1(AES-GCM 密文)每用户一份,用部署级 secret 当根钥匙加密(第 12 章)
vibe 提示 · 这一章里你必须亲自懂的

① preflight 的两个检查:它们是事故清单的代码化——agent 替你部署时,你要能看懂它是否真的过了这两关(而不是跳过);② 回滚路径:升级前记 Time Travel 书签、迁移只增不改、回滚只退代码——这套节奏你必须能口头复述,因为它决定你敢不敢让 agent 动线上;③ 什么进什么不进仓库:secret 永不进、个人配置进 deploy 分支。整个 AGENT-INSTALL 流程适合交给 agent,但「点 Allow」和「设管理员密码」两处永远是你。

自检 1 · preflight 为什么不干脆把「应用迁移」也自动做了(检查完顺便 migrate)?
可以把,但分开更稳:迁移改变数据形态,是比部署更重的动作,值得是一次显式的、看过输出的独立步骤(文档还要求迁移前先记 Time Travel 书签)。合并成一步会诱导「无脑一路回车」。这和 yaf 把「读操作快路径」与「写后台慢路径」分开是同一种哲学:把不可逆或重后果的动作单独摆出来,让人看清再确认。
自检 2 · 你升级到新版后发现某个功能坏了,想退回上一版。需要处理数据库吗?
不需要。迁移只增不改保证上一版代码在新表结构上照常运行(它依赖的旧列都还在);把代码退回上一版即可。只有当「坏的是数据本身」(比如迁移逻辑写错污染了数据)才动用 Time Travel 回到书签——那是另一类事故,另一件工具。
最后一章钩子

全项目的地图、代码、部署都走完了。最后一章不做新的阅读,只做两件事:回收第 1 章开头的那个问题,以及把全书散落的「你必须亲自懂」收拢成一张可以随身携带的清单。

第 15 章 · 尾声

vibe coding 自检清单:带着地图离开

本章目标回收第 0 章的问题;把全书散落的「必须亲自懂」收拢成一张分层的清单和一份验收 agent 的八问;最后给你四条由易到难的练手路线。

回收开篇问题:没有服务器,这一切是怎么做到的

第 0 章留过一个问题:传统上要四台服务器才能干的事,yaf 用一个免费 Worker 全做了。现在你可以给出四块拼图组成的完整答案:

  1. 事件驱动的执行模型:代码不常驻,平台在事件发生时叫它——HTTP 请求(fetch)、定时(cron)、未来某刻(alarm)、任务启动(Workflow)。网站、API、定时任务、消息机器人,全是同一个入口的不同事件而已(第 3、12 章)。
  2. 绑定:资源即变量:数据库、向量库、对象存储、AI 模型都是 env. 上的名字,连接信息归平台管(第 3 章)。
  3. 有状态的交给托管件:关系数据 D1、向量 Vectorize、文件 R2、单实例状态 DO——「服务器没了」是因为状态全部被专业化托管(第 5、8、12 章)。
  4. 后台三件套:响应后继续干(waitUntil)、可重试的持久流水线(Workflow)、定期巡逻兜底(cron)——快慢分离,失败必被接住(第 3、8、9 章)。
旅程地图:12 站与对应章节
①提交·4 ②入口·3 ③路由·3 ④鉴权·7 ⑤写库·5 ⑥索引·6 ⑦返回·4 ⑧判定·10 ⑨向量·8 ⑩兜底·8·9 ⑪搜索·8 ⑫洞察·9 蓝=用户在等 · 主色=Worker 主路径 · 绿=存储 · 紫=AI · 橙=后台 · 红=安全

清单一:你必须亲自懂的(按层)

层必须亲自懂出处
语言 · TS类型签名怎么读(interface/联合/泛型调用点);判别联合与收窄;Promise.all 与串行 await 的差别第 2 章
后端 · 结构绑定模型(env 里有什么先查 env.ts);中间件挂载(鉴权看第 2、3 参数);服务层复用(新入口不许绕过 memo-service)第 3、5、11 章
后端 · 数据每条 SQL 必带 user_id;写操作同生共死用 batch;游标分页的原理;占位符纪律第 5 章
后端 · 异步waitUntil 的失败用户看不见,必须有兜底;异步写回的竞态(条件 UPDATE);「先占坑再干活」的去重第 3、9、10 章
前端数据单向流动与不可变更新;useEffect 依赖数组逐项核对;异步请求的竞态守卫;XSS 净化管道的顺序第 4、13 章
AI模型输出永远要运行时校验(zod);引用必须可校验(防幻觉);AI 挂了要降级不死;能分类别生成第 8、9、10 章
平台secret 与 vars 的边界;Workflow step 的代价与重试;DO 的适用信号(有状态+单实例+心跳);迁移只增不改第 3、8、12、14 章
流程typecheck 是客观闸门;preflight 两检查;升级先记 Time Travel 书签;secret 永不进仓库第 14 章

清单二:验收 agent 产出的八问

agent 说「改好了」之后,按顺序问(也是你 code review 的固定动线):

  1. 鉴权挂了吗?——新接口的第二、三个参数是谁?(requireUser 还是 requireScope 还是裸奔)
  2. 查询带 user_id 了吗?——任何新 SQL 的 WHERE 第一条。
  3. 写操作的原子边界对吗?——动几张表就是一个 batch 里几条语句。
  4. 异步写回有竞态吗?——waitUntil / 后台任务写库,有没有「内容没变才生效」之类的条件。
  5. 失败路径呢?——网络挂、模型挂、额度尽,各降级成什么?谁知道?(日志/last_error/failed 状态)
  6. 用户内容上屏过净化管道吗?——图 13-1 的顺序不可调换。
  7. typecheck 过了吗?——不解释,跑 npm run typecheck。
  8. 隐私边界动过了吗?——Token 视角、遮挡文本、判定覆盖,三处只要牵到就停下来多看一眼。

清单三:四条练手路线(由易到难)

#任务你会练到
1给统计接口加一个字段(如「本周新增」),并在侧栏显示全链路小改:SQL → 接口 → api.ts 类型 → 组件(第 3、4、5 章的连线)
2给 MCP 加一个工具(如 random_memo:随机返回一条)照猫画虎扩展入口:工具注册、scope 检查、服务层复用(第 11 章)
3加一个新视图 Lens(比如「灵感」),走完 LENS_REV 重判注册表模式、阈值与 ask 的产品调参、rev 机制(第 10 章)
4给自己部署一个 yaf,再升级一次版本AGENT-INSTALL/UPGRADE 全流程、preflight、Time Travel 书签(第 14 章)——做完你就是这个系统的主人

遇到陌生概念时去哪查(检索词对照)

这本书的每个概念都有官方源头,带着书里的中文术语 + 下面的英文关键词去搜,一小时能补一课:TS 官方 Handbook(narrowing / generics / utility types);React 官方文档(thinking in React / useEffect / useMemo);MDN(fetch / IntersectionObserver / Cookie 的 SameSite / Intl.Segmenter);Cloudflare Docs(Workers bindings / D1 / Workflows / Durable Objects alarms / Vectorize / R2 / Wrangler commands);MCP 官网(tools / transports)。查的时候优先一手文档,读代码时回到本书对应的章——两相印证,概念才长在你身上。

附录:全书术语速查

按首次出现的章节分组;每条一句话,按「中文正名 + 英文原词」的格式——背不下没关系,读 agent 产出的代码时回来查。

第 1 章 · 平台与全景
Cloudflare Workers
在边缘节点按请求运行代码的平台,无服务器
Wrangler
Cloudflare 的命令行工具;wrangler.jsonc 声明云资源
绑定 (binding)
云资源在代码里的变量名(env.DB),无连接串
Hono
后端路由框架:URL+方法 → 处理函数
D1
托管的 SQLite 数据库
Workers AI
托管的模型推理服务(env.AI.run)
向量与嵌入 (embedding)
把文本变成一串数字,意思相近则向量相近
Vectorize(向量库)
存嵌入、做近邻查询的数据库
后台任务 (waitUntil)
响应返回后继续执行的机制
定时触发 (Cron Triggers)
按 cron 表达式调用 scheduled 入口
工作流 (Workflows)
可重试、可续跑的长任务编排
Durable Objects (DO)
有名字、有存储的单实例对象
R2
对象存储(存文件)
第 2 章 · TypeScript
接口 (interface)
描述对象形状的类型
联合 / 字面量类型
「A 或 B」与「只能是这几个值」
类型收窄 (narrowing)
if 判断后联合类型缩成具体分支
泛型 (generics)
类型的参数,如 request<T>
npm scripts
package.json 里定义的快捷命令
工作区 (workspaces)
npm 多包同仓库管理
严格模式 (strict)
tsconfig 的最严检查;与 React 严格模式是两回事
第 3–4 章 · 服务端与前端骨架
无服务器 (serverless)
不管理常驻进程,按请求加载代码
V8 隔离沙箱 (isolate)
Worker 代码的轻量一次性容器
环境对象 (env)
挂着全部绑定的参数
中间件 / 中间件工厂
路由外的公共检查层 / 带参生成中间件的函数
JSX / props / 状态 (useState)
类 HTML 语法 / 父传子只读参数 / 组件的可变记忆
副作用 (useEffect)
渲染之外的事:请求、订阅、定时器
受控输入
输入框的值以状态为准
判别联合
带 kind 标签的联合类型,配收窄安全分支
第 5–7 章 · 数据与安全
编号迁移 (migration)
只增不改的表结构变更史
参数绑定 (bind)
值永不当 SQL 拼接——防注入的根本
批量事务 (batch)
多条语句要么全成、要么全滚
游标分页 (cursor)
按「最后一条的位置」翻页,不用 OFFSET
PBKDF2 + 盐
慢哈希加盐,抗拖库暴力破解
服务端会话 (session)
库里只存 sid 哈希,Cookie 四属性各挡一击
个人访问令牌 (PAT) / scope
给程序的钥匙 / 细粒度权限
设备码流程
无浏览器设备经网页批准换 Token
第 8–10 章 · AI 三部曲
混合检索 (hybrid search)
关键词 + 语义两路同查
RRF (倒数排名融合)
按 1/(60+名次) 合并两路榜单
FTS5 / bm25 / 中文分词
全文虚表 / 相关度评分 / Intl.Segmenter 切词
step.do / vector_hash
Workflow 的可重试步 / 「内容变没变」的指纹
zod / 结构化输出
运行时校验 JSON / 让模型按 schema 说话
引用校验 (防幻觉)
引用编号不在材料里就整条丢弃
信封加密 (AES-GCM)
用户级秘密加密后才落库
Lens / LENS_REV / 遮挡文本
问题+阈值的视图 / 版本号驱动重判 / 隐私脱敏版
第 11–15 章 · 入口、前端与上线
MCP / MCP 工具
AI 助手的统一协议 / 注册给它的可调用函数
长轮询 vs 轮询
服务端 hold 住 vs 客户端反复问
Alarm / DO 内置存储
DO 的定时心跳 / 游标与状态的家
XSS / DOMPurify / TreeWalker
注入攻击 / 净化 / 文本节点遍历
防抖 / 竞态防护
停 250ms 再搜 / 请求编号丢弃迟到响应
打包 (bundle) / 摇树优化 (tree-shaking)
合并压缩模块 / 删掉没被引用的代码
preflight / Time Travel / 回滚
部署前检查 / 数据回到任意时点 / 代码退回上一版
mentor 结语

这本书从头到尾只做了一件事:把「一个真实项目」拆成你能消化的依赖顺序,再逐块给你「为什么」。合上书后,你对 yaf 的记忆不该是 15 章的目录,而是那 12 站的旅程——每站你都知道它在哪个文件、为什么在那、坏了会先坏哪。

vibe coding 不会淘汰「懂系统的人」,恰恰相反:agent 把打字的部分变便宜之后,判断的部分变贵了。你现在手里有两样判断的依据:一张能随时在脑中调出的架构地图,和一份对着 agent 产出逐条过的清单。去把练手路线的第 4 条做完——给自己部署一个 yaf。那时这本书才真正读完。