Files
seadoc/service/version.go
Vladimir V Maksimov 488f11bd56 alpha
2025-11-04 00:18:00 +03:00

232 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}