claude-mem 服务端存储边界:Phase 4 的 Server-Owned SQLite 表、Zod 契约与 Observation 到 Memory 的迁移契约
本文基于仓库文档 docs/server-storage-boundary.md,系统讲解 claude-mem Phase 4 引入的服务端自持(server-owned)存储边界:9 张新 SQLite 表的完整结构与初始化机制、src/core/schemas/ 下的共享 Zod 契约、Observation 到 Memory 的幂等回填契约,以及由 SQLite 触发器强制执行的项目隔离规则。读完后你可以清楚知道新存储层与既有 worker 存储层之间"哪里共存、哪里隔离、哪里禁止越界",并能在 src/storage/sqlite/ 中找到对应实现验证每一个结论。
定位:只做增量,绝不替换现有存储路径
Phase 4 的核心原则是 additive only(纯增量)。文档开宗明义:
Phase 4 adds the contracts and SQLite tables for the future server-owned storage model. It is additive only: worker routes, providers, existing search, and legacy observation writes still use the current
sdk_sessions,observations,session_summaries,user_prompts, andpending_messagestables.
也就是说,本阶段只"铺设"未来服务端存储模型所需的契约与表,而不改动任何现有运行链路:
- worker 路由、provider、既有搜索继续走旧表;
- legacy observation 写入仍落在
observations表; - 旧的
sdk_sessions、observations、session_summaries、user_prompts、pending_messages五张表在整个 Phase 4 中保持"事实来源(source of truth)"的地位不变。
这个设计意图在文档的"Observation To Memory Translation"一节被再次强调:legacy observations 仍是唯一可信来源,直到后续迁移明确执行 backfill 并切换读取端。下文所有讨论都以此为前提。
Server-Owned 表清单
服务端自持表由 src/storage/sqlite/schema.ts 中的 ensureServerStorageSchema() 创建,共 9 张:
| 表名 | 职责 |
|---|---|
projects |
项目规范行(canonical row),是所有 server-owned 数据的隔离根 |
server_sessions |
服务端会话,关联 content_session_id 与 memory_session_id |
agent_events |
跨来源(hook/worker/provider/server/api)的代理事件流 |
memory_items |
统一记忆条目(observation/summary/prompt/manual 四种 kind) |
memory_sources |
记忆条目的溯源记录(可指向 legacy 表行或外部 URI) |
teams |
团队 |
team_members |
团队成员及角色 |
api_keys |
API 密钥占位(本地存储 hash/prefix/scopes/status) |
audit_log |
审计日志 |
表名清单在源码中以常量导出,供迁移与校验复用:
// src/storage/sqlite/schema.ts#L5-L17
export const SERVER_STORAGE_SCHEMA_VERSION = 33;
export const SERVER_OWNED_TABLES = [
'projects',
'server_sessions',
'agent_events',
'memory_items',
'memory_sources',
'teams',
'team_members',
'api_keys',
'audit_log'
] as const;
关键表结构详解(来自建表 DDL)
以下字段说明直接提取自 schema.ts#L24-L151 的 CREATE TABLE 语句,可作为后续开发这些表时的字段级参考:
projects:id(TEXT 主键)、name、slug(UNIQUE)、root_path(UNIQUE)、metadata(JSON TEXT,默认 '{}')、created_at_epoch / updated_at_epoch(INTEGER,epoch 秒)。slug 与 root_path 的 UNIQUE 约束意味着一个项目可通过名称 slug 或磁盘根路径唯一寻址。
server_sessions:核心列是 content_session_id 与 memory_session_id 两个可空外键式字段——它们是把 server 会话"桥接"回 legacy 世界的关键。status 受 CHECK 约束限定为 'active' | 'completed' | 'failed',platform_source 默认 'claude'(对应 Claude Code 等平台来源),project_id 外键 ON DELETE CASCADE。
agent_events:source_type 受 CHECK 约束限定为 'hook' | 'worker' | 'provider' | 'server' | 'api',event_type 为自由字符串;server_session_id 外键 ON DELETE SET NULL(会话删除后事件保留、仅解除关联)。
memory_items:统一记忆模型。kind 受 CHECK 约束限定为 'observation' | 'summary' | 'prompt' | 'manual';结构化数组字段 facts、concepts、files_read、files_modified 均以 JSON TEXT 存储,默认 '[]';legacy_observation_id(INTEGER,可空)是 Observation → Memory 回填的锚点列。
memory_sources:溯源表。source_type 限定为 'observation' | 'session_summary' | 'user_prompt' | 'manual' | 'import';legacy_table + legacy_id 组合指向 legacy 表的具体行,source_uri 用于外部来源。
teams / team_members:成员 role 限定为 'owner' | 'admin' | 'member' | 'viewer',UNIQUE(team_id, user_id) 防止重复成员。
api_keys:只存 key_hash(UNIQUE)与 prefix,不存明文密钥;scopes 为 JSON 数组,status 限定 'active' | 'revoked',另有 last_used_at_epoch、expires_at_epoch。
audit_log:actor_type 限定 'user' | 'api_key' | 'system',action 为自由字符串,target_type/target_id 可空,团队与项目外键均为 ON DELETE SET NULL(团队/项目删除不丢审计记录)。
FTS5 全文索引与自动重建
memory_items 之外,schema.ts#L170-L197 还创建了一张 FTS5 虚拟表 memory_items_fts(porter unicode61 分词器),索引 title、subtitle、text、narrative、facts、concepts 六列,memory_item_id 与 project_id 标记为 UNINDEXED(仅作关联键)。值得注意的是初始化时的自愈逻辑:
// src/storage/sqlite/schema.ts#L183-L197
const memoryItemCount = db.prepare('SELECT COUNT(*) AS count FROM memory_items').get();
const ftsItemCount = db.prepare('SELECT COUNT(*) AS count FROM memory_items_fts').get();
if (memoryItemCount.count !== ftsItemCount.count) {
const rebuildMemoryItemsFts = db.transaction(() => {
db.run('DELETE FROM memory_items_fts');
db.run(`INSERT INTO memory_items_fts (...) SELECT ... FROM memory_items`);
});
rebuildMemoryItemsFts();
}
即当主表与 FTS 表行数不一致时,初始化过程会在一个事务内整体重建 FTS 索引。配合 schema.ts#L273-L302 的三个 AFTER INSERT/UPDATE/DELETE 触发器(对 UPDATE 采用"先删后插"策略),FTS 索引与主表保持逐行同步——这是为未来服务端搜索能力预留的基础设施,但 Phase 4 并未把它接入任何现有搜索路径。
其余索引覆盖项目时间线查询(idx_memory_items_project_time、idx_agent_events_project_time 等均为 (project_id, epoch DESC) 组合)、legacy 回填锚点(idx_memory_items_legacy_observation、idx_memory_sources_legacy)以及审计时间线(idx_audit_log_team_time / idx_audit_log_project_time)。
初始化与版本控制:schema version 33
文档说明 MigrationRunner 会把这 9 张表记录为 schema version 33,且各 repository 也会调用同一个 helper,目的是"让未来的 server bootstrap 代码能够不依赖 worker 初始化流程就使用存储边界"。
源码可印证两点:
- 版本常量:
SERVER_STORAGE_SCHEMA_VERSION = 33(schema.ts#L5)。 - 迁移执行位置:src/services/sqlite/SessionStore.ts 的迁移序列中,version 33 的分支会检查
schema_versions表,未应用时执行建表并INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (33, ...)(见 SessionStore.ts#L197-L283)。
而"repository 也调用同一 helper"这一点可以从调用方集合确认:ensureServerStorageSchema 被 src/storage/sqlite/projects.ts、server-sessions.ts、agent-events.ts、memory-items.ts、auth.ts 以及 src/server/auth/sqlite-api-key-service.ts 等模块引用,任意一个 repository 打开数据库都会先幂等地确保 schema 就绪。
初始化函数本身通过 WeakSet<Database> 做进程内去重——同一个 Database 实例只会执行一次建表(schema.ts#L19-L22),所有 DDL 使用 IF NOT EXISTS,因此整体是幂等且可重复调用的。
共享 Zod 契约:src/core/schemas/
文档"Contracts"一节指出:共享 Zod 契约位于 src/core/schemas/,repository 方法的输入输出都经过这些 schema 解析,结构化字段以 JSON TEXT 存储,与既有 Bun SQLite 风格保持一致。
目录 src/core/schemas/ 下实际有 6 个契约文件,与 9 张表一一对应:
| 契约文件 | 覆盖的表 |
|---|---|
| project.ts | projects |
| session.ts | server_sessions |
| agent-event.ts | agent_events |
| memory-item.ts | memory_items、memory_sources |
| team.ts | teams、team_members |
| auth.ts | api_keys、audit_log |
契约遵循统一的"读模型 / 写模型"模式,以 MemoryItemSchema 为例(memory-item.ts#L8-L44):
- 完整模型(
MemoryItemSchema):包含id、createdAtEpoch、updatedAtEpoch; - 创建模型(
CreateMemoryItemSchema):omit掉id与两个时间戳,再对serverSessionId、legacyObservationId、title、facts等字段partial()化——即创建时这些字段均可省略,且 schema 内置默认值(serverSessionId默认null、facts默认[]等)。
CreateAgentEventSchema、CreateServerSessionSchema、CreateProjectSchema、CreateTeamMemberSchema、CreateApiKeySchema、CreateAuditLogSchema 均遵循同样的"omit 服务端字段 + partial 可选字段"套路。另外 agent-event.ts#L13-L17 中新增的 platformSource 字段(标注 #2560)用于记录事件来自哪个平台(claude-code、opencode、cursor 等),在 SQLite repo 中被忽略,Postgres 侧持久化——说明该契约层同时服务于双存储后端。
JSON TEXT 编解码约定
"结构化字段存 JSON TEXT"并非随意约定,仓库提供了统一的编解码辅助 src/storage/sqlite/serde.ts:
stringifyJson(value):JSON.stringify(value ?? {}),空值安全地落为'{}';parseJsonObject(value):解析失败时记录logger.warn并回退为空对象{};parseJsonArray(value):解析失败回退为空数组[],且只保留字符串元素。
这意味着 repository 读出的 metadata、facts、concepts、scopes 等列即使遇到历史脏数据也不会抛错,而是降级为空容器并留痕告警——这是"matching the existing Bun SQLite style"的具体落地。
Observation 到 Memory 的翻译契约
这是文档中最具决策价值的部分。文档明确:翻译层被有意地"documented but not wired"——已记录契约但尚未接入现有搜索。
决策基线
legacy
observationsremain the source of truth until a later migration explicitly backfills and switches readers.
在 backfill 存在之前:
- 新的 repository 可以为 server-owned 工作流直接写
memory_items; - 但任何 worker 路径都不应把
memory_items当作observations的替身来读取。
未来翻译器的逐行映射规则
文档给出了未来 translator 应执行的精确映射(这是回填实施的规范,务必逐条对照):
memory_items 字段 |
取值来源 |
|---|---|
kind |
固定 'observation' |
type |
observations.type |
project_id |
由 observations.project 在规范 projects 行中解析 |
server_session_id |
经 server_sessions.memory_session_id = observations.memory_session_id 关联解析 |
legacy_observation_id |
observations.id |
title / subtitle / text / narrative / facts / concepts / files_read / files_modified |
逐字段从 legacy 行拷贝 |
并且为每条 memory_items 回填行配套一条 memory_sources 行:source_type = 'observation'、legacy_table = 'observations'、legacy_id = observations.id。
幂等性的 Schema 级保证
映射契约不是纯文档约定,schema 用部分唯一索引(partial unique indexes)把它固化成了可重复执行的幂等回填目标:
-- src/storage/sqlite/schema.ts#L164-L168
CREATE UNIQUE INDEX IF NOT EXISTS ux_memory_items_legacy_observation
ON memory_items(legacy_observation_id)
WHERE legacy_observation_id IS NOT NULL;
-- src/storage/sqlite/schema.ts#L200-L204
CREATE UNIQUE INDEX IF NOT EXISTS ux_memory_sources_legacy_source
ON memory_sources(source_type, legacy_table, legacy_id)
WHERE legacy_table IS NOT NULL AND legacy_id IS NOT NULL;
两条索引共同保证:同一条 legacy observation 至多产生一行 memory_items、一条 memory_sources 溯源行。回填脚本无论跑多少次、是否中途重入,都不会产生重复记忆。这是"先立契约后写迁移"的典型做法——约束先行,使未来 backfill 天然幂等。
项目隔离:SQLite 触发器强制的跨项目防线
文档强调:"引用 server_sessions 的行必须停留在同一个 project_id 内"。SQLite 触发器会拒绝跨项目的 agent_events 与 memory_items 关联,确保按项目作用域的读取不会意外混入其他项目的记忆。
schema.ts#L213-L271 定义了五个触发器,实际覆盖比文档摘要更完整:
| 触发器 | 时机 | 拒绝的行为 |
|---|---|---|
trg_server_sessions_project_update |
更新 server_sessions.project_id 前 |
会话下已有属于旧项目的 agent_events 或 memory_items 时,禁止改挂项目 |
trg_agent_events_session_project_insert |
插入 agent_events 前 |
server_session_id 所属会话的 project_id 与事件自身 project_id 不一致 |
trg_agent_events_session_project_update |
更新 agent_events.project_id / server_session_id 前 |
同上(更新路径) |
trg_memory_items_session_project_insert |
插入 memory_items 前 |
server_session_id 不归属于自身 project_id |
trg_memory_items_session_project_update |
更新 memory_items.project_id / server_session_id 前 |
同上(更新路径) |
触发器报错信息也很明确,例如 'agent_events server_session_id must belong to project_id'、'server_sessions project_id cannot change while children belong to the previous project'。所有触发器均使用 SELECT RAISE(ABORT, ...) 回滚当前语句。从源码结构看,这构成项目级记忆的硬边界:即使应用层漏了过滤条件,数据库层也会拒绝产生跨项目脏数据。
Auth 占位:api_keys 与未来的 Better Auth
文档"Auth Placeholder"一节说明:api_keys 是面向未来 Better Auth 集成的本地占位——
- 本阶段仅本地存储 hash、prefix、scopes、status;
- 不引入 Better Auth 运行时依赖;
- 不接线任何 auth 中间件。
与 schema 一致:api_keys.key_hash 是 UNIQUE 的非空列,prefix 可空、scopes 默认 '[]'、status 限定 'active' | 'revoked'。契约层 src/core/schemas/auth.ts 同步定义了 ApiKeySchema 与 AuditLogSchema(actor_type 限定 user | api_key | system),CreateApiKeySchema 刻意 omit 了 status、lastUsedAtEpoch 等状态字段——创建方无法通过创建接口伪造"已使用"状态。调用方可参考 src/storage/sqlite/auth.ts 与 src/server/auth/sqlite-api-key-service.ts 查看当前对 api_keys 的 repository 与服务端用法。
小结:Phase 4 的边界清单
把文档结论与源码证据汇总成一份可直接对照的边界清单:
- 共存:新 9 表与旧 5 表(
sdk_sessions、observations、session_summaries、user_prompts、pending_messages)在同一数据库中共存,旧链路零改动。 - 来源:legacy
observations仍是事实来源;memory_items仅可对 server-owned 新工作流直接写入,worker 路径不得以其替代observations读取。 - 回填:Observation → Memory 映射规则已文档化,
ux_memory_items_legacy_observation与ux_memory_sources_legacy_source两条部分唯一索引把回填固化为幂等操作。 - 隔离:五个触发器在数据库层拒绝跨项目的
agent_events/memory_items会话关联。 - 版本:schema 记录为 version 33,
ensureServerStorageSchema()由 MigrationRunner 与各 repository 共同调用,保证不依赖 worker 初始化的 bootstrap 也可用。 - Auth:
api_keys为 Better Auth 占位,本地存储 hash/prefix/scopes/status,无运行时依赖、无中间件接线。
对要在该仓库上开发服务端功能的读者,建议的阅读路径是:先读 docs/server-storage-boundary.md 本文档,再看 src/storage/sqlite/schema.ts 的完整 DDL,然后按表进入 src/storage/sqlite/ 下的各 repository(projects.ts、server-sessions.ts、agent-events.ts、memory-items.ts、auth.ts),最后对照 src/core/schemas/ 中的 Zod 契约确认输入输出形状——三者一一对应,任何一处改动都应保持这份一致性。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00