This commit is contained in:
Vladimir V Maksimov
2025-11-04 00:18:00 +03:00
parent 1ce7af3663
commit 488f11bd56
8 changed files with 532 additions and 0 deletions

231
service/version.go Normal file
View File

@@ -0,0 +1,231 @@
package service
import (
"errors"
"fmt"
"time"
"git.gm6.ru/icewind/seadoc/models"
"git.gm6.ru/icewind/seadoc/store"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// VersionService — слой логики версионирования, использующий интерфейс VersionStorage.
type VersionService struct {
storage store.IVersionStorage
// snapshotInterval (опционально) — через сколько версий делать full snapshot
snapshotInterval int
}
// NewVersionService создаёт новый сервис
func NewVersionService(storage store.IVersionStorage) *VersionService {
return &VersionService{storage: storage, snapshotInterval: 0}
}
// SetSnapshotInterval задаёт через сколько версий сохранять snapshot (если >0)
func (s *VersionService) SetSnapshotInterval(n int) {
s.snapshotInterval = n
}
// SaveNewVersion создаёт новую версию документа (merge-patch или snapshot)
func (s *VersionService) SaveNewVersion(docID uuid.UUID, newJSON []byte) (*models.Version, error) {
lastVer, err := s.storage.GetLatestVersion(docID)
var patchBytes []byte
isSnapshot := false
var parentID *uuid.UUID
if err != nil {
// первая версия — snapshot
if errors.Is(err, gorm.ErrRecordNotFound) {
isSnapshot = true
parentID = nil
} else {
return nil, err
}
} else {
// есть предыдущая версия — создаём merge-patch
parentID = &lastVer.ID
var oldJSON []byte
if lastVer.IsSnapshot {
oldJSON = lastVer.Snapshot
} else {
oldJSON, err = s.ReconstructVersion(lastVer.ID)
if err != nil {
return nil, err
}
}
// Создаём JSON Merge Patch (RFC 7386)
// CreateMergePatch возвращает []byte с содержимым merge-patch
patchBytes, err = jsonpatch.CreateMergePatch(oldJSON, newJSON)
if err != nil {
return nil, fmt.Errorf("create merge patch: %w", err)
}
// Если patch пустой (документы равны), можно не сохранять новую версию
// В данном примере создаём версию даже если patch пустой — можно изменить поведение
}
newVer := models.Version{
ID: uuid.New(),
DocumentID: docID,
ParentID: parentID,
IsSnapshot: isSnapshot,
Patch: patchBytes,
CreatedAt: time.Now(),
}
if isSnapshot {
newVer.Snapshot = newJSON
}
// Сохраняем версию в хранилище
if err := s.storage.CreateVersion(&newVer); err != nil {
return nil, err
}
// Обновляем ссылку на последнюю версию документа
if err := s.storage.UpdateLatestVersion(docID, newVer.ID); err != nil {
return nil, err
}
// Опционально: если указан snapshotInterval, проверяем нужно ли создать snapshot
if s.snapshotInterval > 0 && !isSnapshot {
// считаем количество версий от этого snapshot'а до новой версии
// простая реализация: пройти вверх по родителям и посчитать
count := 0
curr := &newVer
for {
count++
if curr.ParentID == nil {
break
}
parent, err := s.storage.GetParentVersion(curr)
if err != nil {
break
}
if parent == nil {
break
}
curr = parent
if curr.IsSnapshot {
break
}
}
if count >= s.snapshotInterval {
// делаем snapshot: восстанавливаем полную версию и сохраняем snapshot-версию
full, err := s.ReconstructVersion(newVer.ID)
if err == nil {
snapVer := models.Version{
ID: uuid.New(),
DocumentID: docID,
ParentID: &newVer.ID,
IsSnapshot: true,
Snapshot: full,
CreatedAt: time.Now(),
}
// сохраняем snapshot-версию и обновляем latest_version
_ = s.storage.CreateVersion(&snapVer)
_ = s.storage.UpdateLatestVersion(docID, snapVer.ID)
}
}
}
return &newVer, nil
}
// ReconstructVersion восстанавливает полную JSON-версию по ID
// Патчи — в формате JSON Merge Patch (RFC 7386) применяются последовательно к ближайшему snapshot.
func (s *VersionService) ReconstructVersion(versionID uuid.UUID) ([]byte, error) {
v, err := s.storage.GetVersionByID(versionID)
if err != nil {
return nil, err
}
// Если это snapshot — возвращаем сразу
if v.IsSnapshot {
return v.Snapshot, nil
}
// Собираем цепочку версий от ближайшего snapshot (включая snapshot) до целевой версии
chain := []models.Version{}
curr := v
for {
// prepend
chain = append([]models.Version{*curr}, chain...)
if curr.ParentID == nil {
// дошли до корня
break
}
parent, err := s.storage.GetParentVersion(curr)
if err != nil {
return nil, err
}
if parent == nil {
break
}
curr = parent
// Если parent — snapshot, добавим его и прервём цикл, т.к. от него будем применять
if curr.IsSnapshot {
chain = append([]models.Version{*curr}, chain...)
break
}
}
// Найдём snapshot в цепочке
startIndex := -1
for i, ver := range chain {
if ver.IsSnapshot {
startIndex = i
break
}
}
if startIndex == -1 {
return nil, fmt.Errorf("snapshot not found in version chain for %s", versionID)
}
// Начинаем с snapshot
data := chain[startIndex].Snapshot
// Применяем последовательно merge-patch'и от snapshot+1 до конца chain
for i := startIndex + 1; i < len(chain); i++ {
if len(chain[i].Patch) == 0 {
continue
}
// MergePatch принимает исходный документ и merge-patch и возвращает новый документ
newData, err := jsonpatch.MergePatch(data, chain[i].Patch)
if err != nil {
return nil, fmt.Errorf("apply merge patch at version %s: %w", chain[i].ID, err)
}
data = newData
}
return data, nil
}
// DiffVersions создаёт JSON Merge Patch между двумя версиями.
// Возвращает JSON-патч (diff), показывающий, какие поля изменились.
func (s *VersionService) DiffVersions(v1ID, v2ID uuid.UUID) ([]byte, error) {
// Восстанавливаем обе версии полностью
data1, err := s.ReconstructVersion(v1ID)
if err != nil {
return nil, err
}
data2, err := s.ReconstructVersion(v2ID)
if err != nil {
return nil, err
}
// Вычисляем разницу
patch, err := jsonpatch.CreateMergePatch(data1, data2)
if err != nil {
return nil, err
}
return patch, nil
}