项目:webCommand | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇挑选三条最有"教学密度"的代码路径做逐段带码走读:走读一是命令执行核心链路——从用户按下回车到服务端裁决返回,这是全仓库最长的一条贯穿线(前端解析 → API → 服务端),能同时看到前端解释器设计与横切关注点落地;走读二是 server/utils/migrations.ts——一个 115 行的自研 SQL 迁移器,小到可以整读,又完整覆盖"幂等、水位、最小接口、调用方容错"四个工程议题;走读三是云同步写前合并算法——useCloudSync.ts 的 flush() 与 cloudMerge.ts 的 mergeKeyed(),这是全仓库正确性责任最重的一段代码(《16》ADR-005/006 把合并正确性全部压给了它)。与前几篇的分工:《05-核心流程》用时序图讲"流程怎么走",本篇用代码原文讲"每一行为什么这么写";《07-模块详解》讲模块职责边界,本篇负责把其中三个模块拆到行级。
走读约定:每段代码先给出真实源码原文(允许为聚焦而截取,截取处用注释标明),随后给逐段解读、设计意图与边界处理,每段以「这段代码教会我们什么」收束。行号以当前工作区源码为准。读法建议:先只读代码自己推演一遍,再对照解读——本篇所有的"设计意图"都能从代码与注释直接推出,推不出来而需要背景的部分会显式引用《16》的 ADR 编号。
文件:app/pages/index.vue(全文仅 30 行,此处为核心 14 行)。
const onSubmitCommand = async (inputText: string) => {
if (!inputText) {
return
}
const terminal = terminalRef.value.terminal
await doCommandExecute(inputText, terminal)
}
const userStore = useUserStore()
const { loginUser } = storeToRefs(userStore)
onMounted(async () => {
await userStore.getAndSetLoginUser()
// remember 自动登录路径同样要初始化云同步:漏掉的类型既不会从云端拉取,
// 该端下一次改动还会把云端已有数据整包覆盖掉(#24 #25)
if (userStore.loginUser?.id) {
await initAllCloudSync()
}
})逐段解读:页面组件只做两件事——把"输入文本"与"终端实例"(terminalRef.value.terminal,由 Terminal.vue 通过 defineExpose({ terminal }) 暴露,接口是全局 namespace 里的 Terminal.TerminalType)交给执行器,以及在挂载时完成"会话对账 + 云同步初始化"。注意 onMounted 里那段注释:它记录了一个真实事故的修复——自动登录路径(cookie 还活着)曾经漏掉 initAllCloudSync(),后果不只是"云端数据没拉下来",更严重的是该设备下一次本地改动会把云端整包覆盖掉(因为写前合并没发生,整包上传的就是本地残缺数据)。这是数据丢失级 bug,注释里的问题编号 #24 #25 就是它的案底。
「这段代码教会我们什么」:入口层要薄,但入口层的遗漏往往是数据级的——初始化顺序("恢复会话"必须先于"初始化同步")本身就是契约,把这种顺序依赖写在注释里并用问题编号钉住,是比"我记得"可靠得多的防漂移手段。
文件:app/components/Terminal.vue(约 317-368 行,节选主干)。
const doSubmitCommand = async () => {
// 反向搜索模式下,回车表示接受当前匹配并退出搜索,不直接执行
if (isSearchMode.value) {
acceptSearchResult()
return
}
isRunning.value = true
setHint('')
try {
let inputText = inputCommand.value.text
// 执行某条历史命令
if (inputText.startsWith('!')) {
const commandIndex = Number(inputText.substring(1))
if (isNaN(commandIndex) || commandIndex < 1 || commandIndex > commandList.value.length) {
terminal.writeTextErrorResult('无效的历史命令序号')
inputCommand.value = { ...initCommand }
return
}
const command = commandList.value[commandIndex - 1]
if (command) {
inputText = command.text
}
}
const newCommand: CommandOutputType = {
text: inputText,
type: 'command',
resultList: [],
}
// 记录当前命令,便于写入结果
currentNewCommand = newCommand
await props.onSubmitCommand?.(inputText)
outputList.value.push(newCommand)
if (inputText) {
commandList.value.push(newCommand)
commandHistoryPos.value = commandList.value.length
}
inputCommand.value = { ...initCommand }
activeKeys.value.push(outputList.value.length - 1)
setTimeout(() => {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}, 50)
} finally {
// 无论命令是否抛错,都复位输入框,避免永久禁用
isRunning.value = false
}
}逐段解读:这个函数有五个边界处理值得逐个点名。第一,模式优先:Ctrl+R 反向搜索激活时回车语义被"劫持"为接受匹配,交互状态机的分支必须在执行器之前处理——这是终端类 UI 的通用结构(模式先于输入)。第二,! 历史展开:序号合法性三连判(NaN、小于 1、超出历史长度),非法时直接写错误输出并清空输入框后 return——注意这个 return 在 try 里,finally 保证 isRunning 复位。第三,输出与历史的分离:newCommand 这个对象先被挂到模块级的 currentNewCommand(后续命令执行的写入目标),命令执行完才 push 进 outputList(渲染层)与 commandList(历史层);空文本命令进输出列表但不进历史(if (inputText) 的判断),对齐真实 shell 的行为。第四,finally 里复位 isRunning——注释原文"无论命令是否抛错,都复位输入框,避免永久禁用",这行是防御"执行器抛错后终端锁死"的保险,曾经缺失时的症状是用户只能刷新页面。第五,滚动用 setTimeout(…, 50) 而不是 nextTick:因为命令是异步的,输出可能在 push 之后仍持续增长(如流式输出),延后 50ms 是务实的折中。
「这段代码教会我们什么」:UI 提交函数的正确性几乎全在异常路径——锁死、序号越界、空输入、模式冲突。把 finally 用在"状态必须复位"的场景,把"哪些输入不进历史"这类语义对齐成熟软件(真实 shell),是这类代码质量的核心。
文件:app/core/commandExecutor.ts(18-73 行)。
export const doCommandExecute = async (
text: string,
terminal: TerminalType,
parentCommand?: CommandType,
depth = 0,
) => {
// 防止子命令无限递归
if (depth > 10) {
terminal.writeTextErrorResult('命令层级过深')
return
}
// 去除命令首尾空格
text = text.trim()
if (!text) {
return
}
// 解析文本,得到命令
const command = getCommand(text, parentCommand)
if (!command) {
// 自定义命令回退:输入的名称匹配用户自定义的快捷命令时,执行其对应文本 (#73)
const token = (text.split(' ', 1)[0] ?? '').toLowerCase()
const customText = useCustomCommandStore().getCustom(token)
if (customText) {
// 保留自定义命令名之后追加的参数,如 g google.com => goto google.com
const restArgs = text.slice(token.length).trim()
await doCommandExecute(
restArgs ? `${customText} ${restArgs}` : customText,
terminal,
undefined,
depth + 1,
)
return
}
terminal.writeTextErrorResult('找不到命令')
return
}
// 解析参数(需传递不同的解析规则)
const parsedOptions = doParse(text, command.options)
const { _ } = parsedOptions
// 有子命令,执行
if (_.length > 0 && command.subCommands && Object.keys(command.subCommands).length > 0) {
// 把子命令当做新命令解析,user login xxx => login xxx
const spaceIndex = text.indexOf(' ')
const subText = spaceIndex > -1 ? text.substring(spaceIndex + 1) : ''
await doCommandExecute(subText, terminal, command, depth + 1)
return
}
// 执行命令
await doAction(command, parsedOptions, terminal, parentCommand)
}逐段解读:这是教科书级的解释器主循环,四个机制各占一拍。递归深度闸门:depth > 10 直接报错——因为子命令展开与自定义命令展开都走递归,理论上用户可以定义 g => g 这样的自指自定义命令,没有闸门就是无限递归栈溢出;depth + 1 在两个递归点都正确传递。自定义命令回退:只有当 commandMap 查不到时才查 customStore(内置命令优先级恒高于用户自定义,这是安全序——用户不能用 custom add 覆盖内置 todo);回退时把命令名之后的参数重新拼回(g google.com → goto google.com),并用 depth + 1 继续走同一套流程——自定义命令不是特殊分支,而是"文本宏展开后重新进入解释器"。子命令派发:判断条件是"还有位置参数且有 subCommands 表",然后取掉第一个 token 把剩余文本当全新命令递归——user login xxx 被拆成 login xxx 并带上 parentCommand,这样 login 的 --help 才能显示父命令上下文。传引用而非拷贝:terminal 一路透传,所有输出都进同一个 currentNewCommand.resultList,这就是为什么子命令的输出会出现在同一条命令回显下。
「这段代码教会我们什么」:解释器类代码的可扩展性来自统一入口——自定义命令、子命令、--help 都复用同一条 doCommandExecute 路径,而不是各开分支。递归系统必须自带深度上限,且上限要在递归点无遗漏地传递。
文件:app/core/commandExecutor.ts(112-138 行与 187-205 行)。
const doParse = (
text: string,
commandOptions: CommandOptionType[],
): getoptsNamespace.ParsedOptions => {
// 过滤掉关键词,支持多个连续空格
const args: string[] = text.split(/\s+/).slice(1).filter(Boolean)
// 转换
const options: getoptsNamespace.Options = {
alias: {},
default: {},
string: [],
boolean: [],
}
commandOptions.forEach((commandOption) => {
const { alias, key, type, defaultValue } = commandOption
if (alias && options.alias) {
options.alias[key] = alias
}
options[type === 'boolean' ? 'boolean' : 'string']?.push(key)
// 默认值为空字符串(如 curl 的 maxLength)时也应生效,故用 !== undefined 判断
if (defaultValue !== undefined && options.default) {
options.default[key] = defaultValue
}
})
const parsedOptions = getopts(args, options)
return parsedOptions
}const validateRequiredParams = (
command: CommandType,
options: getoptsNamespace.ParsedOptions,
terminal: TerminalType,
): boolean => {
if (command.subCommands && Object.keys(command.subCommands).length > 0) {
return true
}
const requiredParams = command.params?.filter((p) => p.required) ?? []
if (requiredParams.length === 0) {
return true
}
if (options._.length >= requiredParams.length) {
return true
}
const missing = requiredParams.map((p) => p.key).join('、')
terminal.writeTextErrorResult(`缺少参数:${missing},请使用 ${command.func} --help 查看用法`)
return false
}逐段解读:doParse 是一个适配器——把 command.d.ts 的声明式选项配置(key/alias/type/defaultValue)翻译成 getopts 的运行时配置。三个细节:切分用 /\s+/ 而非单空格(用户连敲多个空格不炸);type === 'boolean' ? 'boolean' : 'string' 把未知类型兜底为 string(宽容大于严格,解析器不应该在末端崩溃);最精彩的是 defaultValue !== undefined 这个判断——注释解释了为什么不用真值判断:curl 命令的 maxLength 默认值是空字符串,用 if (defaultValue) 会把合法的空串默认值静默丢弃。validateRequiredParams 则展示了"把重复劳动上收"的模式:每个命令声明 params[].required,执行器统一检查 options._(位置参数)数量并给出带 --help 指引的错误消息;有子命令的父命令直接放行(它的 params 描述的是子命令名,真正的校验由递归后的子命令自己完成——与 2.3 的派发逻辑严丝合缝)。
「这段代码教会我们什么」:!== undefined 与真值判断的差别在"空字符串是合法值"的场景里是 bug 与否的分界线;把校验从 40 个命令实现里上收到执行器,是 DRY 在解释器架构里的标准落点——代价只是父命令需要一条"跳过"规则。
文件:app/core/commands/weather/weatherCommand.ts(action 节选)。
async action(options, terminal) {
const { _ } = options
const city = _.join(' ')
if (!city) {
terminal.writeTextResult('用法:weather <城市名>\n示例:weather 北京')
return
}
try {
const json = await myAxios.get<string>('/weather', { params: { city } })
if (json.code !== 0 || !json.data) {
throw new Error(json.message || '天气服务返回异常')
}
// 浏览器 User-Agent 下 wttr.in 会返回 HTML 页面,需从 term-container 中提取纯文本
const text = json.data
const containerMatch = text.match(/<div class="term-container">([\s\S]*?)<\/div>/)
const cleanText = containerMatch
? containerMatch[1]!.replace(/<[^>]+>/g, '').trim()
: text.trim()
terminal.writeTextResult(decodeHtmlEntities(cleanText) || '暂无天气数据')
} catch (e) {
terminal.writeTextErrorResult(`天气查询失败:${errMsg(e)},请检查城市名或网络`)
}
}逐段解读:位置参数用 _.join(' ') 而非 _[0]——城市名"New York"带空格,getopts 的位置参数被逐词切开,join 还原是必要动作。code !== 0 的判断把业务失败(限流、服务不可用)统一转成异常走 catch——错误路径与成功路径在此合流。正则提取 term-container 是对上游行为妥协的记录(注释:浏览器 UA 下 wttr.in 返回 HTML),匹配失败时退回原文 text.trim(),两支都有产出;最后 decodeHtmlEntities 处理 / 这类实体,防止 smartText 二次转义后把实体原样显示给用户(该函数用先数字后十六进制再具名实体的顺序覆盖,见文件 1-15 行)。服务端侧对应的裁决代码极短(server/api/weather.get.ts 全文 18 行):proxyLimiter.hit(event) 命中即抛 RATE_LIMIT(HTTP 429);city 缺失抛 PARAMS;httpGetText 的 8 秒超时与 res.ok 校验在 httpClient.ts 统一兜底,上游失败转译为 THIRD_PART(502)。业务裁决顺序——限流 → 参数 → 出站调用 → 错误转译——与《08》§8 的纵深一致,这里每个抛出的 BizError 都被 bizHandler 收进同一个 {code, message} 包络(见《15》§1)。
「这段代码教会我们什么」:调用不可控第三方时,解析器要永远有兜底分支(正则不命中就退原文);而服务端代理的价值不在转发本身,在于把 CORS、UA、超时、错误码这些"第三方的事故"翻译成自家契约里的标准错误——命令实现因此只需要写一个 catch。
文件:server/utils/migrations.ts(13-46 行)。
export interface Migration {
id: string
statements: string[]
}
/** 与被 sequelize 定义的表保持一致的 DDL */
export const MIGRATIONS: Migration[] = [
{
id: '0001_init_user_and_user_data',
statements: [
`CREATE TABLE IF NOT EXISTS \`user\` (
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
\`username\` VARCHAR(255) NOT NULL,
\`password\` VARCHAR(255) NOT NULL,
\`email\` VARCHAR(255) NULL,
\`status\` INT NOT NULL DEFAULT 0,
\`createTime\` DATETIME NULL,
\`updateTime\` DATETIME NULL,
\`isDelete\` INT NOT NULL DEFAULT 0,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`user_username_unique\` (\`username\`),
UNIQUE KEY \`user_email_unique\` (\`email\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS \`user_data\` (
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
\`userId\` BIGINT NOT NULL,
\`type\` VARCHAR(32) NOT NULL,
\`content\` MEDIUMTEXT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`user_data_userId_type_unique\` (\`userId\`, \`type\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
],
},
]逐段解读:迁移的最小契约只有两个字段——字符串 id 与 SQL 语句数组。id 用 0001_ 这样的零填充前缀不是装饰:runMigrations 靠 id.localeCompare 排序,零填充保证字典序即时间序(10_ 会排在 2_ 之前的坑被前导零规避)。DDL 与 server/models/User.ts、UserData.ts 的 Sequelize 定义逐列对齐(头注释原文"与被 sequelize 定义的表保持一致的 DDL"),这是双份登记:模型文件服务运行时,DDL 服务灾备重建,任何一边改列必须同步另一边(目前靠人肉,⚠️ 待确认 是否补一条静态比对测试)。值得注意的列设计:status 默认 0 且全仓无写非 0 的通道(《13》⚠️-1 的封禁位预留);createTime/updateTime 显式 NULL(timestamps: false 的后果,《13》⚠️-17);isDelete 是 INT 不是 BOOLEAN(沿用旧库风格);复合唯一键 (userId, type) 是整条云同步协议的物理地基(见《16》ADR-008)。
「这段代码教会我们什么」:"表结构必须随代码入库"是事故换来的原则——这个文件存在的原因写在头注释里:换库或灾备恢复时旧形态根本建不出 user 表。让 DDL 与 ORM 模型互为镜像,是目前不引工具前提下最经济的防漂移。
文件:server/utils/migrations.ts(59-114 行)。
/** 记录已应用迁移的水位表 */
const MIGRATIONS_TABLE = 'schema_migrations'
export function migrationsTableDdl(): string {
return `CREATE TABLE IF NOT EXISTS \`${MIGRATIONS_TABLE}\` (
\`id\` VARCHAR(64) NOT NULL,
\`appliedAt\` DATETIME NOT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
}
/** 只要能被迁移器调用到的最小连接面,便于单测替换 */
export interface MigrationRunner {
query: (sql: string) => Promise<unknown>
}
export async function runMigrations(
runner: MigrationRunner,
migrations: Migration[] = MIGRATIONS,
): Promise<MigrationResult> {
await runner.query(migrationsTableDdl())
const done = await appliedIds(runner)
const applied: string[] = []
const skipped: string[] = []
for (const migration of [...migrations].sort((a, b) => a.id.localeCompare(b.id))) {
if (done.has(migration.id)) {
skipped.push(migration.id)
continue
}
for (const statement of migration.statements) await runner.query(statement)
await runner.query(
`INSERT INTO \`${MIGRATIONS_TABLE}\` (\`id\`, \`appliedAt\`) VALUES (${escapeSql(
migration.id,
)}, CURRENT_TIMESTAMP)`,
)
applied.push(migration.id)
}
return { applied, skipped }
}
/** 迁移 id 来自代码常量,这里只做字面量转义,不接受外部输入 */
function escapeSql(value: string): string {
return `'${value.replace(/[\\']/g, '\\$&')}'`
}逐段解读:核心循环只有五个动作:建水位表(幂等)、读水位(appliedIds 里对 Array<{id}> | string[] 两种返回形态都做了兼容——不同驱动/方言下 query 返回形状不同的防御)、按 id 升序遍历、跳过已应用、执行语句后立刻写水位。三个设计要点:其一,接口最小化——MigrationRunner 只有一个 query 方法,注释原文"只要能被迁移器调用到的最小连接面,便于单测替换",这让 test/migrations.spec.ts 可以用一个内存数组伪装 runner 全流程跑通,不需要真库;其二,逐语句写水位——水位 INSERT 紧跟在语句执行后,如果第二条语句失败,第一条的语句已执行但整个迁移不会被记录(注释原文"失败的迁移不会被记录,下次启动会重试"),配合全幂等语句,重试是安全的——但要注意这里存在"部分应用"的中间态:第一条已执行、水位未写,下次启动第一条会再跑一遍,幂等性正是为这个中间态买单;其三,注入面控制——唯一拼接进 SQL 的字符串是迁移 id,escapeSql 只处理反斜杠与单引号,注释明说"不接受外部输入",表名则写死在模板串里。缺什么同样重要:没有 GET_LOCK 互斥(《13》⚠️-16 的并发缺口)、没有 down 迁移(只能向前)、没有事务包裹全部语句(MySQL DDL 本身隐式提交,事务包裹意义有限——这是选幂等重试而非事务回滚的现实理由)。
「这段代码教会我们什么」:当约束是"零依赖 + 幂等"时,迁移器可以小到 115 行——幂等性替代了事务性,水位表替代了状态机。最小接口(单方法 runner)是让核心逻辑可测的关键投资;而"水位写在语句后、失败不记录"的取舍必须与"语句全幂等"成对出现,拆开就是 bug。
文件:server/plugins/migrate.ts(全文)。
export default defineNitroPlugin(async () => {
try {
const sequelize = useDb()
const runner: MigrationRunner = {
async query(sql: string) {
const [rows] = await sequelize.query(sql)
return rows
},
}
const { applied, skipped } = await runMigrations(runner)
setSchemaStatus(true)
if (applied.length) console.log('[migrate] 已应用迁移:', applied.join(', '))
else console.log('[migrate] 数据库结构已是最新(跳过 %d 条)', skipped.length)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
setSchemaStatus(false, message)
console.error('[migrate] 数据库迁移失败,注册/登录可能不可用:', message)
}
})逐段解读:调用方做三件事:把 Sequelize 适配成 MigrationRunner(const [rows] = await sequelize.query(sql)——解构正是 3.2 里"返回形状防御"的由来)、跑迁移、把结果写进 appStatus。关键决策在 catch 里:迁移失败不抛出,只 setSchemaStatus(false, message) 并打日志。注释给了理由:"数据库瞬时不可用时若直接抛错,容器会进入重启风暴;失败状态改由 /api/health 暴露(schemaReady=false),让探活和告警去发现。"与 server/plugins/check-env.ts(配置缺失时主动抛错阻断启动)对比,形成了清晰的分层:配置错是确定性错误,必须 fail-fast;数据库不可用是瞬时错误,应该降级运行 + 让探活说话。而 appStatus 被谁消费也有讲究——health.get.ts 把 schemaError 原文透传出去供排障,但就绪判定不读内存标志而是查真表(《16》ADR-012:dev 模式下模块重复实例化会让标志停在 false 造成误报)。
「这段代码教会我们什么」:错误分两类,处理方式相反——确定性错误(配置缺失)fail-fast,瞬时错误(数据库没起来)降级 + 可观测。同一份"失败",抛与不抛的决定依据不是严重程度,而是"重试能不能好"。
文件:app/composables/useCloudSync.ts(30-80 行节选)。
export function useCloudSync(type: string, options: CloudSyncOptions): CloudSync {
let syncTimer: ReturnType<typeof setTimeout> | undefined
let syncUserId: string | number | null = null
let unsubscribe: (() => void) | null = null
// 正在写入远端数据:此期间 store 变更不再触发上传,避免合并引发回环
let applyingRemote = false
/** 防抖到点后执行:先合并云端,再上传合并结果 */
async function flush() {
let cloudRaw: string | null = null
try {
cloudRaw = await fetchCloudRaw()
if (cloudRaw) applyCloud(cloudRaw)
} catch (e) {
// 拉取失败时仍上传本地数据,不因网络抖动丢掉本次变更
console.error(`${type} 同步前拉取失败`, errMsg(e))
}
const content = JSON.stringify(options.serialize())
// 云端已是最新:跳过上传,同时切断「合并 -> 订阅 -> 再同步」的回环
if (content === cloudRaw) return
try {
await myAxios.post('/data/sync', { type, content })
} catch (e) {
console.error(`${type} 同步失败`, e)
}
}
/** 同步到云端(防抖,未登录时跳过) */
function syncToCloud() {
if (!getUserId() || applyingRemote) return
if (syncTimer) clearTimeout(syncTimer)
syncTimer = setTimeout(() => void flush(), options.debounce ?? 800)
}逐段解读:flush() 五行主体浓缩了整条协议:拉云端 → 应用合并 → 序列化 → 判等短路 → 上传。四个细节各有深意。其一,applyingRemote 回环闸门:applyCloud 置 true → options.merge() 改 store → $subscribe 触发 syncToCloud → 若无闸门,合并本身会再次触发上传,形成"合并 → 订阅 → 再同步"的死循环;闸门在 finally 里复位(见文件 46-53 行),保证异常也不会卡死同步。其二,判等短路:content === cloudRaw 时跳过上传——这一行同时干了两件事:省一次无意义的写请求,以及切断回环的第二环(即使 applyingRemote 复位后订阅残留触发了一次 flush,合并结果不变则不上传)。其三,拉取失败仍上传:catch 后不 return,本地变更不会因网络抖动被滞留——代价是拉取失败时这次上传是"盲写",可能覆盖另一端的新增(下一段的合并算法兜不住网络分区,这是 blob 模型接受的一致性边界)。其四,防抖在 syncToCloud 层:800ms 窗口合并高频 mutation(如连续 todo add),initCloudSync 的幂等由 syncUserId 标记保证(按用户幂等,切换账号会重置)。
「这段代码教会我们什么」:写前合并协议的健壮性来自三重防回环(applyingRemote 闸门、判等短路、订阅先于 loadFromCloud 完成才挂载——见 initCloudSync 的 finally),而它的可用性来自"失败降级为盲写也要保本地变更"的取向。分布式协议里,每一个 else 分支都是一次一致性决策。
文件:app/composables/cloudMerge.ts(68-91 行)。
export function mergeKeyed<T extends Revivable>(
cloudItems: T[],
localItems: T[],
keyOf: (item: T) => string,
cloudDeleted: Record<string, number> = {},
localDeleted: Record<string, number> = {},
now = Date.now(),
): MergeResult<T> {
const deleted = pruneTombstones(mergeTombstones(cloudDeleted, localDeleted), now)
const byKey = new Map<string, T>()
for (const item of [...cloudItems, ...localItems]) {
const key = keyOf(item)
const exists = byKey.get(key)
if (!exists || (item.updateTime ?? 0) >= (exists.updateTime ?? 0)) byKey.set(key, item)
}
const items: T[] = []
for (const [key, item] of byKey) {
const deletedAt = deleted[key]
// 删除时间不早于条目修改时间 -> 视为已删除;条目改得更晚则视为「删掉后又重建」
if (deletedAt !== undefined && (item.updateTime ?? 0) <= deletedAt) continue
items.push(item)
}
return { items, deleted }
}逐段解读:13 行函数体分三步。第一步合并墓碑:mergeTombstones 对同一 key 取更晚删除时间(双方都删过,删得晚的说了算),pruneTombstones 丢掉超过 TOMBSTONE_RETENTION_MS(30 天)的墓碑——两个操作合起来保证"墓碑表既正确又有限"。第二步并集 + LWW:云端条目在前、本地在后逐个放入 Map,同 key 时 updateTime 更晚者胜,平局用 >=——注意方向:云端先放、本地用 >= 覆盖,意味着时间戳完全相等时本地赢(对"本端刚改完还没上传"的场景这是正确偏置);无 updateTime 的历史数据视为 0,即任何带时间戳的新条目都赢过旧数据。第三步墓碑裁决:deletedAt >= updateTime 的条目不进结果——用"删除时刻与修改时刻的比较"同时表达两种语义:删得比改得晚 → 真删除;改得比删得晚 → 删掉后又重建(复活合法)。边界登记:时钟回拨或设备间钟差会让 LWW 误判,updateTime ?? 0 让无时间戳的历史数据永远处于劣势(宁可保留也不要误删——数据丢失比数据冗余危害大);now 作为参数注入而非函数内取值,是为了测试可重现(test/cloudMerge.spec.ts 的用例都传固定时钟)。
「这段代码教会我们什么」:合并算法的难点不在"怎么合并",在为每种冲突语义画出判定矩阵(新建/修改/删除/删除后重建 × 云端/本地),然后用一行比较 updateTime <= deletedAt 把矩阵收拢。泛型 + keyOf 回调让同一个原语服务 id(todo/note)、路径(space)、命令名(custom)三种键,抽象的粒度恰好停在"数据不共享任何字段"的位置。
文件:app/core/commands/todo/todoStore.ts(文件尾部与 parseCloud)。
function parseCloud(data: unknown): TodoCloudShape {
if (Array.isArray(data)) return { items: data as TaskType[], deleted: {} }
if (data && typeof data === 'object') {
const obj = data as { items?: unknown; deleted?: unknown }
return {
items: Array.isArray(obj.items) ? (obj.items as TaskType[]) : [],
deleted:
obj.deleted && typeof obj.deleted === 'object'
? (obj.deleted as Record<string, number>)
: {},
}
}
return { items: [], deleted: {} }
}
const cloudSync = useCloudSync('todo', {
serialize: () => ({
items: getStore().taskList,
deleted: pruneTombstones(getStore().deleted),
}),
merge: (data) => {
const store = getStore()
const cloud = parseCloud(data)
const { items, deleted } = mergeKeyed<TaskType>(
cloud.items,
store.taskList,
(task) => task.id,
cloud.deleted,
store.deleted,
)
store.taskList = items
store.deleted = deleted
},
})逐段解读:这是合并算法的装配层,把通用原语接到具体数据结构上。serialize 上传 { items, deleted } 两段结构——注意 deleted 在上传前再剪枝一次(pruneTombstones),云端永远只存窗口内的墓碑,老墓碑不上云。merge 里先 parseCloud 再 mergeKeyed,parseCloud 是版本兼容层:第一分支处理旧版裸数组(blob 模型没有 schema 版本号,兼容只能靠运行时形状嗅探——《16》ADR-005 承认的代价);中间分支逐字段校验类型(Array.isArray(obj.items)、typeof obj.deleted === 'object'),任何字段损坏都退化成空值而不是抛错——损坏的云端数据宁可被本地数据合并修复,也不能让整条同步链路崩溃。getStore() 的惰性取用(而非闭包外直接 useTodoStore())规避了 store 与 sync 实例的循环依赖(模块级单例 cloudSync 在 store 定义之后创建)。最后 store.taskList = items 整体替换触发响应式更新与持久化插件写盘——合并结果落 localStorage 与落 Vue 是同一次赋值。
「这段代码教会我们什么」:通用算法(mergeKeyed)与具体结构(TodoCloudShape)之间的装配层必须承担三件事:结构兼容(parseCloud 的形状嗅探)、损坏容忍(逐字段退化而非抛错)、循环依赖规避(惰性 getStore)。没有装配层,兼容逻辑就会渗进算法里把它污染成不可复用的特例堆。
三条路径合起来看,本仓库有一种一致的代码气质,可以提炼成四条。第一,注释写的是"为什么不"而不是"是什么":从 depth > 10 到 content === cloudRaw 再到"迁移失败不抛出",几乎每个非显然分支的注释都在解释被否决的走法或踩过的坑(#73、#24、#25 这样的编号把注释和事故履历连了起来)。第二,边界处理先于主路径成型:! 序号三连判、updateTime ?? 0、parseCloud 的形状嗅探、escapeSql 的"不接受外部输入"声明——边界不是"补丁",而是与主逻辑同批设计的。第三,可测试性是结构属性而不是事后补丁:MigrationRunner 的单方法接口、cloudMerge 的 now 参数注入、outputSnapshot.ts 从 Terminal.vue 抽成纯函数(《09》§3 的 unit 测试对象),都是"为了让测试可以存在"而重塑结构的结果。第四,每一层只信任上一层给的契约:命令信任 bizHandler 的包络、迁移器信任 runner 的 query、store 信任 useCloudSync 的防回环——信任边界清晰,使得任何一层都可以单独读通。对想继续深读的读者,顺着本篇的三条路径向外扩展的自然方向是:执行器向上接 commandRegister(注册表模式,《07》§3)、迁移器向下接 db.ts(懒加载单例,《07》§5)、合并算法向外接 cloudSyncRegistry(六类数据的初始化总线,《05》§4)。