From 488f11bd56db4f7e17b4b4bd9bd6e6f4f4a394c5 Mon Sep 17 00:00:00 2001 From: Vladimir V Maksimov Date: Tue, 4 Nov 2025 00:18:00 +0300 Subject: [PATCH] alpha --- .gitignore | 3 + go.mod | 21 ++++ go.sum | 31 ++++++ models/models.go | 28 +++++ service/version.go | 231 ++++++++++++++++++++++++++++++++++++++++++ store/gorm.go | 75 ++++++++++++++ store/store.go | 19 ++++ tests/service_test.go | 124 +++++++++++++++++++++++ 8 files changed, 532 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 models/models.go create mode 100644 service/version.go create mode 100644 store/gorm.go create mode 100644 store/store.go create mode 100644 tests/service_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3b244 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +main +.db +.json \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71601d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module git.gm6.ru/icewind/seadoc + +go 1.25.1 + +require ( + github.com/evanphx/json-patch/v5 v5.9.11 + github.com/google/uuid v1.6.0 + github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ed8bebc --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 h1:hQWBtNqRYrI7CWIaUSXXtNKR90KzcUA5uiuxFVWw7sU= +github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..8223310 --- /dev/null +++ b/models/models.go @@ -0,0 +1,28 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// Document — логическая сущность (например, пользователь, настройки и т.п.) +type Document struct { + ID uuid.UUID `gorm:"type:char(36);primaryKey"` + Name string + CreatedAt time.Time + UpdatedAt time.Time + LatestVersion uuid.UUID `gorm:"type:char(36);"` +} + +// Version — конкретная версия документа +type Version struct { + ID uuid.UUID `gorm:"type:char(36);primaryKey"` + DocumentID uuid.UUID `gorm:"type:char(36);index"` + ParentID *uuid.UUID `gorm:"type:char(36);"` + IsSnapshot bool + Patch json.RawMessage `gorm:"type:json"` // JSON Patch или nil, если Snapshot + Snapshot json.RawMessage `gorm:"type:json"` // Полный JSON, если IsSnapshot = true + CreatedAt time.Time +} diff --git a/service/version.go b/service/version.go new file mode 100644 index 0000000..37c5bc6 --- /dev/null +++ b/service/version.go @@ -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 +} diff --git a/store/gorm.go b/store/gorm.go new file mode 100644 index 0000000..ed996ce --- /dev/null +++ b/store/gorm.go @@ -0,0 +1,75 @@ +package store + +import ( + "git.gm6.ru/icewind/seadoc/models" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GormStorage реализует интерфейс VersionStorage через GORM (SQLite) +type GormStorage struct { + db *gorm.DB +} + +func NewGormStorage(db *gorm.DB) *GormStorage { + return &GormStorage{db: db} +} + +func (s *GormStorage) CreateDocument(name string) (uuid.UUID, error) { + doc := models.Document{ + ID: uuid.New(), + Name: name, + } + if err := s.db.Create(&doc).Error; err != nil { + return uuid.Nil, err + } + return doc.ID, nil +} + +func (s *GormStorage) GetDocument(id uuid.UUID) (*models.Document, error) { + var d models.Document + if err := s.db.First(&d, "id = ?", id).Error; err != nil { + return nil, err + } + return &d, nil +} + +func (s *GormStorage) UpdateLatestVersion(docID, versionID uuid.UUID) error { + return s.db.Model(&models.Document{}). + Where("id = ?", docID). + Update("latest_version", versionID).Error +} + +func (s *GormStorage) CreateVersion(v *models.Version) error { + return s.db.Create(v).Error +} + +func (s *GormStorage) GetLatestVersion(docID uuid.UUID) (*models.Version, error) { + var v models.Version + if err := s.db. + Where("document_id = ?", docID). + Order("created_at desc"). + First(&v).Error; err != nil { + return nil, err + } + return &v, nil +} + +func (s *GormStorage) GetVersionByID(id uuid.UUID) (*models.Version, error) { + var v models.Version + if err := s.db.First(&v, "id = ?", id).Error; err != nil { + return nil, err + } + return &v, nil +} + +func (s *GormStorage) GetParentVersion(v *models.Version) (*models.Version, error) { + if v.ParentID == nil { + return nil, nil + } + var parent models.Version + if err := s.db.First(&parent, "id = ?", v.ParentID).Error; err != nil { + return nil, err + } + return &parent, nil +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..42628d5 --- /dev/null +++ b/store/store.go @@ -0,0 +1,19 @@ +package store + +import ( + "git.gm6.ru/icewind/seadoc/models" + "github.com/google/uuid" +) + +type IVersionStorage interface { + // Document + CreateDocument(name string) (uuid.UUID, error) + GetDocument(id uuid.UUID) (*models.Document, error) + UpdateLatestVersion(docID, versionID uuid.UUID) error + + // Version + CreateVersion(v *models.Version) error + GetLatestVersion(docID uuid.UUID) (*models.Version, error) + GetVersionByID(id uuid.UUID) (*models.Version, error) + GetParentVersion(v *models.Version) (*models.Version, error) +} diff --git a/tests/service_test.go b/tests/service_test.go new file mode 100644 index 0000000..504ffe0 --- /dev/null +++ b/tests/service_test.go @@ -0,0 +1,124 @@ +package tests + +import ( + "bytes" + "encoding/json" + "testing" + + "git.gm6.ru/icewind/seadoc/models" + "git.gm6.ru/icewind/seadoc/service" + "git.gm6.ru/icewind/seadoc/store" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// helper: инициализация базы и сервиса +func newTestService(t *testing.T) (*gorm.DB, *service.VersionService, *store.GormStorage) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open in-memory sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Document{}, &models.Version{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + storage := store.NewGormStorage(db) + service := service.NewVersionService(storage) + return db, service, storage +} + +func TestVersioningLifecycle(t *testing.T) { + db, service, store := newTestService(t) + + // 1️⃣ Создаём документ + docID, err := store.CreateDocument("test.json") + if err != nil { + t.Fatalf("create document: %v", err) + } + + // 2️⃣ Данные + v1 := []byte(`{"name": "Alice", "age": 30}`) + v2 := []byte(`{"name": "Alice", "age": 31, "city": "Paris"}`) + v3 := []byte(`{"name": "Alice", "age": 32, "city": "Paris", "lang": "fr"}`) + + // 3️⃣ Сохраняем версии + ver1, err := service.SaveNewVersion(docID, v1) + if err != nil { + t.Fatalf("save v1: %v", err) + } + if !ver1.IsSnapshot { + t.Errorf("v1 должен быть snapshot, но IsSnapshot=%v", ver1.IsSnapshot) + } + + ver2, err := service.SaveNewVersion(docID, v2) + if err != nil { + t.Fatalf("save v2: %v", err) + } + if ver2.IsSnapshot { + t.Errorf("v2 не должен быть snapshot") + } + + ver3, err := service.SaveNewVersion(docID, v3) + if err != nil { + t.Fatalf("save v3: %v", err) + } + + // 4️⃣ Проверяем восстановление v2 + data2, err := service.ReconstructVersion(ver2.ID) + if err != nil { + t.Fatalf("reconstruct v2: %v", err) + } + if !jsonEqual(data2, v2) { + t.Errorf("восстановленный v2 не совпадает:\n got: %s\nwant: %s", data2, v2) + } + + // 5️⃣ Проверяем восстановление v3 + data3, err := service.ReconstructVersion(ver3.ID) + if err != nil { + t.Fatalf("reconstruct v3: %v", err) + } + if !jsonEqual(data3, v3) { + t.Errorf("восстановленный v3 не совпадает:\n got: %s\nwant: %s", data3, v3) + } + + // 6️⃣ Проверим, что версии записались в базу + var count int64 + if err := db.Model(&models.Version{}).Count(&count).Error; err != nil { + t.Fatalf("count versions: %v", err) + } + if count != 3 { + t.Errorf("ожидалось 3 версии, а в БД %d", count) + } +} + +// сравнение JSON без учёта порядка полей +func jsonEqual(a, b []byte) bool { + var ja, jb interface{} + _ = json.Unmarshal(a, &ja) + _ = json.Unmarshal(b, &jb) + ra, _ := json.Marshal(ja) + rb, _ := json.Marshal(jb) + return bytes.Equal(ra, rb) +} + +func TestDiffVersions(t *testing.T) { + _, service, store := newTestService(t) + + docID, _ := store.CreateDocument("diff.json") + + v1 := []byte(`{"name":"Alice","age":30}`) + v2 := []byte(`{"name":"Alice","age":31,"city":"Paris"}`) + + ver1, _ := service.SaveNewVersion(docID, v1) + ver2, _ := service.SaveNewVersion(docID, v2) + + diff, err := service.DiffVersions(ver1.ID, ver2.ID) + if err != nil { + t.Fatalf("diff error: %v", err) + } + + expected := `{"age":31,"city":"Paris"}` + if !jsonEqual(diff, []byte(expected)) { + t.Errorf("unexpected diff:\n got: %s\nwant: %s", diff, expected) + } +}