Appearance
历史版本与回滚
在多人协同(乐观锁 + 三路合并,见核心概念 · 后端同步)之上,编辑器追加历史版本管理——查看某要素的全部版本、对比两版差异、回滚到过去某版本,以及时点(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 / GvHistoryPanel | useFeatureHistory / HistoryPanel |
| 两版本 diff | useVersionDiff / GvVersionDiff | useVersionDiff / VersionDiff |
| 快照打标 / 列表 / 浏览 | useDatasetSnapshots / GvSnapshotList | useDatasetSnapshots / 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}/history | listVersions(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}/features | getSnapshotFeatures(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 → 一键回滚(走普通编辑链路)。
- 数据集快照标签——编辑数层 → 打硬/软快照 → 点快照浏览时点要素(幽灵层叠加)→ 裁剪历史后硬快照仍稳定、软快照失真。
