Skip to content

历史版本与回滚

在多人协同(乐观锁 + 三路合并,见核心概念 · 后端同步)之上,编辑器追加历史版本管理——查看某要素的全部版本、对比两版差异、回滚到过去某版本,以及时点(as-of)加载(RFC-0006)。

三个"版本"先厘清

概念目的现状
乐观锁版本 feature.version并发控制——判断"谁的写赢"已实现
历史版本可追溯——每次修改留痕,可看 / 对比 / 回滚本页
数据集快照整层时点打标,可整体 as-of / 恢复本页(V2 硬化)

要点:乐观锁版本号天然即历史序号(每次编辑 +1),历史管理不引入第二套编号

设计:兼容的基座 + 独立的只读模块

  • 数据落库兼容:历史由同一次写经 DB 触发器派生(PostGIS feature_history 表 + AFTER INSERT/UPDATE 触发器),热编辑路径零侵入、不增提交延迟。
  • 查询 API 独立history / version / diff / asOf只读模块,读多写少、与热路径解耦。
  • 回滚 = 一次普通编辑(精髓,见下)。

editor-core 不绑定任何后端:历史查询走 HistoryStore 契约(app 实现,对照 REST 端点);联调 / 测试 / 示例用内存实现 MemoryHistoryStore(对照 DB 表 + 触发器)。

回滚 = 一次普通编辑(兼容的精髓)

把要素回滚到版本 N,不是服务端特殊操作,而是前端 dispatch 一个普通编辑:after = 版本 N 的几何 / 属性、before = 当前态、baseVersions = 当前版本。于是走同一撤销栈 + 乐观锁 + 三路合并 + 审计链路。

ts
import {
  EditSession,
  MemoryHistoryStore,
  MemoryEditBackend,
  SyncClient,
} from '@geoverse/editor-core';

// 内存历史(对照 DB feature_history + 触发器)
const history = new MemoryHistoryStore();
const backend = new MemoryEditBackend({ history }); // 每次写派生历史行
const session = new EditSession({ features: seed, history });
const sync = new SyncClient(session.engine, backend);

// …若干次编辑 + 提交,history 自动记录每个版本…

// 回滚要素到版本 1(从 history 取快照 → 落普通编辑)
await session.restoreToVersion('a', 1);
await sync.commit(); // 库内变 version=当前+1、几何/属性=v1

由此天然获得:

  • 多人协同安全:回滚带当前版本做 base,期间他人改过该要素 → 409 → 三路合并裁决,不盲目覆盖他人新编辑
  • 回滚可被再回滚:历史连续。
  • 零新增写路径:无需为回滚单开端点 / 事务 / 冲突逻辑。

底层是薄命令 RestoreFeatureVersionCommand(内部即 SetGeometry + 可选属性还原)。手头已有快照时也可直接用:

ts
import { RestoreFeatureVersionCommand } from '@geoverse/editor-core';

session.restoreVersion(
  'a',
  { geometry: snapV1.geometry, properties: snapV1.properties },
  1,
);
// 或:session.engine.dispatch(new RestoreFeatureVersionCommand('a', target, 1))

查询:版本列表 / 对比 / 时点

HistoryStore 契约(异步,贴合真实后端;MemoryHistoryStore 即内存实现):

ts
// 版本列表(升序)
const versions = await history.listVersions('a');
// → [{ version:1, op:'add', geometry, properties, validFrom, validTo }, …]

// 指定版本全量快照
const v2 = await history.getVersion('a', 2);

// 两版差异(几何变更标志 + 属性字段级 diff)
const d = await history.diff('a', 1, 3);
// → { geometryChanged: true, properties: [{ field:'name', before, after }], … }

// 时点整层状态(H2 as-of):截至 time 各要素有效版本(剔除 remove)
const snapshot = await history.asOf(Date.parse('2026-06-01T00:00:00Z'));

纯函数 diffVersions(a, b) 可单独对两份快照求差(无需 store)。

数据集快照标签(V2·硬化)

给整层在某时刻打标签,之后可整体回看该时点状态。快照契约是 SnapshotStore(与 HistoryStore 常由同一实现,MemoryHistoryStore 两者皆实现):

ts
const session = new EditSession({
  features: seed,
  history,
  snapshots: history,
});

// 打硬快照(默认):固化打标时各要素的有效版本号
const snap = await session.createSnapshot({
  label: '交付基线 v1',
  createdBy: '张三',
});
// → { id, label, takenAt, entries: [{ featureId:'a', version:3 }, …] }

const all = await session.listSnapshots(); // 按 takenAt 升序
const feats = await session.getSnapshotFeatures(snap.id); // 该时点要素集(只读浏览)

硬快照 vs 软快照——这是 V2「硬化」的关键:

硬快照(hard: true,默认)软快照(hard: false
存的是什么打标时固化各要素版本号entries,对照 PostGIS snapshot_feature只记打标时刻 takenAt
恢复方式按固化版本号逐一取历史行退化为 asOf(takenAt)
稳定性稳定——被引用的历史行受保留期裁剪保护,永不漂移会随历史裁剪 / 归档而失真
ts
// 软快照:不钉版本号,恢复靠 as-of
await session.createSnapshot({ label: '临时点', hard: false });

// 保留期裁剪(模拟归档):删除已被新版本取代的旧历史行
const removed = history.prune(Date.parse('2026-06-01T00:00:00Z'));
// 被任一硬快照 entries 引用的 (featureId, version) 受保护、永不裁剪
// → 故硬快照 getSnapshotFeatures 仍精确;软快照的 asOf 则可能落空/失真

打标含 remove 版(忠实还原「该要素当时已删除」);getSnapshotFeatures 读时再剔除 remove。快照按需选硬/软:需长期可复现的基线用硬快照,短时对照用软快照即可。

联调:HTTP 后端契约 + 内存 Mock

editor-core 不含真实后端,但给出 REST 契约的线格式与一套内存 Mock,让前端在真后端就绪前即可端到端联调、把接口交互定死:

ts
import {
  HttpHistoryStore,
  MemoryHistoryStore,
  createMockHistoryFetch,
} from '@geoverse/editor-core';

const mem = new MemoryHistoryStore(); // …record 若干版本 / 打快照…
// 把 REST 请求路由到内存实现(无真实后端)
const fetchImpl = createMockHistoryFetch(mem, { baseUrl: '/api' });

// HttpHistoryStore 同时实现 HistoryStore & SnapshotStore,直接喂进 EditSession
const store = new HttpHistoryStore({ baseUrl: '/api', fetch: fetchImpl });
const session = new EditSession({
  features: seed,
  history: store,
  snapshots: store,
});

真实后端按同一路由 / DTO(FeatureVersion / VersionDiff / DatasetSnapshot 即 JSON 线格式)落地后,去掉 fetch 注入换成全局 fetch 即可无缝替换。

前端历史面板(Vue / React)

@geoverse/vue@geoverse/react 提供无样式 headless 的 composable/hook 与参考组件,异步契约统一暴露 loading / error / reload,本地编辑后自动重载、带竞态守卫:

能力Vue composable / 组件React hook / 组件
版本时间轴 + 回滚useFeatureHistory / GvHistoryPaneluseFeatureHistory / HistoryPanel
两版本 diffuseVersionDiff / GvVersionDiffuseVersionDiff / VersionDiff
快照打标 / 列表 / 浏览useDatasetSnapshots / GvSnapshotListuseDatasetSnapshots / SnapshotList
ts
// Vue
const { versions, restore } = useFeatureHistory(session, () => selectedId);
const { snapshots, create, loadFeatures } = useDatasetSnapshots(session);
await create({ label: '交付基线' }); // 默认硬快照,成功后自动重载列表

参考组件为「无样式骨架」,直接可用亦可照抄再定制;回滚一律走 session.restoreToVersion(= 一次普通编辑)。

数据模型(后端,参考)

DB 侧 feature_history 每版本一行全量快照(看第 N 版 / 回滚 O(1)),由 feature 表写触发器自动落、封上一版 valid_to;as-of 靠 valid_from <= T AND (valid_to > T OR NULL)DISTINCT ON (feature_id) … ORDER BY version DESC。完整 DDL / 触发器 / REST 端点见 RFC-0006。

端点(参考)对应 HistoryStore
GET /features/{id}/historylistVersions(id)
GET /features/{id}/versions/{v}getVersion(id, v)
GET /features/{id}/diff?from=&to=diff(id, from, to)
GET /layers/asof?t=asOf(t)
POST /snapshots {label, hard?}createSnapshot(input)
GET /snapshots · GET /snapshots/{id}listSnapshots() · getSnapshot(id)
GET /snapshots/{id}/featuresgetSnapshotFeatures(id)
回滚 → 复用 POST /edits(见上)

数据集快照对应 dataset_snapshot(标签元数据)+ snapshot_feature(硬快照固化的 feature_id, version)两表。

范围说明

  • 当前实现 = H1(要素历史 + 回滚)+ H2 读(as-of)+ H3(数据集快照标签,硬/软) 的框架无关核心与内存实现,含 HTTP 契约 + Mock 服务端与 Vue/React 历史面板参考组件。
  • 真实持久化(PostGIS 表 / 触发器 / REST)为 app / 后端侧落地,本库给出契约、DTO 线格式与内存 Mock,可原样替换。
  • 数据集分支 / 合并(H4,GeoGig 式)属另一量级,独立立项,不在编辑核心内。

在线演示见两个 Playground 预设:

  • 历史版本与回滚——编辑要素 → 时间轴列版本 → 选两版看 diff → 一键回滚(走普通编辑链路)。
  • 数据集快照标签——编辑数层 → 打硬/软快照 → 点快照浏览时点要素(幽灵层叠加)→ 裁剪历史后硬快照仍稳定、软快照失真。

下一步

  • 核心概念 —— 撤销重做 / 后端同步(乐观锁 + 三路合并)
  • 属性操作 —— 属性变更同样进历史