首页
/ claude-mem 服务端存储边界:Phase 4 的 Server-Owned SQLite 表、Zod 契约与 Observation 到 Memory 的迁移契约

claude-mem 服务端存储边界:Phase 4 的 Server-Owned SQLite 表、Zod 契约与 Observation 到 Memory 的迁移契约

2026-09-06 15:23:57作者:柏廷章Berta

本文基于仓库文档 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, and pending_messages tables.

也就是说,本阶段只"铺设"未来服务端存储模型所需的契约与表,而不改动任何现有运行链路:

  • worker 路由、provider、既有搜索继续走旧表;
  • legacy observation 写入仍落在 observations 表;
  • 旧的 sdk_sessionsobservationssession_summariesuser_promptspending_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_idmemory_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-L151CREATE TABLE 语句,可作为后续开发这些表时的字段级参考:

projectsid(TEXT 主键)、nameslug(UNIQUE)、root_path(UNIQUE)、metadata(JSON TEXT,默认 '{}')、created_at_epoch / updated_at_epoch(INTEGER,epoch 秒)。slugroot_path 的 UNIQUE 约束意味着一个项目可通过名称 slug 或磁盘根路径唯一寻址。

server_sessions:核心列是 content_session_idmemory_session_id 两个可空外键式字段——它们是把 server 会话"桥接"回 legacy 世界的关键。status 受 CHECK 约束限定为 'active' | 'completed' | 'failed'platform_source 默认 'claude'(对应 Claude Code 等平台来源),project_id 外键 ON DELETE CASCADE

agent_eventssource_type 受 CHECK 约束限定为 'hook' | 'worker' | 'provider' | 'server' | 'api'event_type 为自由字符串;server_session_id 外键 ON DELETE SET NULL(会话删除后事件保留、仅解除关联)。

memory_items:统一记忆模型。kind 受 CHECK 约束限定为 'observation' | 'summary' | 'prompt' | 'manual';结构化数组字段 factsconceptsfiles_readfiles_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_epochexpires_at_epoch

audit_logactor_type 限定 'user' | 'api_key' | 'system'action 为自由字符串,target_type/target_id 可空,团队与项目外键均为 ON DELETE SET NULL(团队/项目删除不丢审计记录)。

FTS5 全文索引与自动重建

memory_items 之外,schema.ts#L170-L197 还创建了一张 FTS5 虚拟表 memory_items_ftsporter unicode61 分词器),索引 titlesubtitletextnarrativefactsconcepts 六列,memory_item_idproject_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_timeidx_agent_events_project_time 等均为 (project_id, epoch DESC) 组合)、legacy 回填锚点(idx_memory_items_legacy_observationidx_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 初始化流程就使用存储边界"。

源码可印证两点:

  1. 版本常量:SERVER_STORAGE_SCHEMA_VERSION = 33schema.ts#L5)。
  2. 迁移执行位置: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"这一点可以从调用方集合确认:ensureServerStorageSchemasrc/storage/sqlite/projects.tsserver-sessions.tsagent-events.tsmemory-items.tsauth.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_itemsmemory_sources
team.ts teamsteam_members
auth.ts api_keysaudit_log

契约遵循统一的"读模型 / 写模型"模式,以 MemoryItemSchema 为例(memory-item.ts#L8-L44):

  • 完整模型MemoryItemSchema):包含 idcreatedAtEpochupdatedAtEpoch
  • 创建模型CreateMemoryItemSchema):omitid 与两个时间戳,再对 serverSessionIdlegacyObservationIdtitlefacts 等字段 partial() 化——即创建时这些字段均可省略,且 schema 内置默认值(serverSessionId 默认 nullfacts 默认 [] 等)。

CreateAgentEventSchemaCreateServerSessionSchemaCreateProjectSchemaCreateTeamMemberSchemaCreateApiKeySchemaCreateAuditLogSchema 均遵循同样的"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 读出的 metadatafactsconceptsscopes 等列即使遇到历史脏数据也不会抛错,而是降级为空容器并留痕告警——这是"matching the existing Bun SQLite style"的具体落地。

Observation 到 Memory 的翻译契约

这是文档中最具决策价值的部分。文档明确:翻译层被有意地"documented but not wired"——已记录契约但尚未接入现有搜索

决策基线

legacy observations remain 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_eventsmemory_items 关联,确保按项目作用域的读取不会意外混入其他项目的记忆。

schema.ts#L213-L271 定义了五个触发器,实际覆盖比文档摘要更完整:

触发器 时机 拒绝的行为
trg_server_sessions_project_update 更新 server_sessions.project_id 会话下已有属于旧项目的 agent_eventsmemory_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 同步定义了 ApiKeySchemaAuditLogSchemaactor_type 限定 user | api_key | system),CreateApiKeySchema 刻意 omitstatuslastUsedAtEpoch 等状态字段——创建方无法通过创建接口伪造"已使用"状态。调用方可参考 src/storage/sqlite/auth.tssrc/server/auth/sqlite-api-key-service.ts 查看当前对 api_keys 的 repository 与服务端用法。

小结:Phase 4 的边界清单

把文档结论与源码证据汇总成一份可直接对照的边界清单:

  1. 共存:新 9 表与旧 5 表(sdk_sessionsobservationssession_summariesuser_promptspending_messages)在同一数据库中共存,旧链路零改动。
  2. 来源:legacy observations 仍是事实来源;memory_items 仅可对 server-owned 新工作流直接写入,worker 路径不得以其替代 observations 读取。
  3. 回填:Observation → Memory 映射规则已文档化,ux_memory_items_legacy_observationux_memory_sources_legacy_source 两条部分唯一索引把回填固化为幂等操作。
  4. 隔离:五个触发器在数据库层拒绝跨项目的 agent_events / memory_items 会话关联。
  5. 版本:schema 记录为 version 33,ensureServerStorageSchema() 由 MigrationRunner 与各 repository 共同调用,保证不依赖 worker 初始化的 bootstrap 也可用。
  6. Authapi_keys 为 Better Auth 占位,本地存储 hash/prefix/scopes/status,无运行时依赖、无中间件接线。

对要在该仓库上开发服务端功能的读者,建议的阅读路径是:先读 docs/server-storage-boundary.md 本文档,再看 src/storage/sqlite/schema.ts 的完整 DDL,然后按表进入 src/storage/sqlite/ 下的各 repository(projects.tsserver-sessions.tsagent-events.tsmemory-items.tsauth.ts),最后对照 src/core/schemas/ 中的 Zod 契约确认输入输出形状——三者一一对应,任何一处改动都应保持这份一致性。

登录后查看全文
热门项目推荐
相关项目推荐