fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复
将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
aae7b15ccc
69 files changed
+3571
-293
No files matched your search
@@ -49,6 +49,9 @@ func (r *Repository) DeleteAdminResources(resources []model.Resource, deletionJo
|
||||
resourceIDs = append(resourceIDs, resource.ID)
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
var current []model.Resource
|
||||
query := tx.Where("id IN ?", resourceIDs)
|
||||
if r.Dialect() == "postgres" {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrCanvasHistoryResourceMissing = errors.New("canvas history resource missing")
|
||||
var ErrCanvasHistoryResourceReferenced = errors.New("resource referenced by canvas history")
|
||||
|
||||
const canvasSnapshotSummaryColumns = "id, canvas_id, user_id, revision, title, node_count, connection_count, payload_bytes, reason, content_updated_at, created_at"
|
||||
|
||||
func (r *Repository) CanvasProjectMetadata(userID, id string) (*model.CanvasProject, error) {
|
||||
var project model.CanvasProject
|
||||
err := r.db.Omit("payload_json").Where("id = ? AND user_id = ?", id, userID).First(&project).Error
|
||||
return &project, err
|
||||
}
|
||||
|
||||
func (r *Repository) CanvasSnapshots(userID, canvasID string, limit int) ([]model.CanvasSnapshot, error) {
|
||||
items := []model.CanvasSnapshot{}
|
||||
err := r.db.Select(canvasSnapshotSummaryColumns).Where("user_id = ? AND canvas_id = ?", userID, canvasID).
|
||||
Order("revision DESC").Limit(limit).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *Repository) CanvasSnapshot(userID, canvasID, id string) (*model.CanvasSnapshot, error) {
|
||||
var item model.CanvasSnapshot
|
||||
err := r.db.Where("id = ? AND user_id = ? AND canvas_id = ?", id, userID, canvasID).First(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
|
||||
// The successful CAS takes the canvas row lock before inspecting the history.
|
||||
// Snapshot creation, reference protection and retention commit with the content.
|
||||
func (r *Repository) SaveCanvasWithSnapshot(project *model.CanvasProject, snapshot *model.CanvasSnapshot, resourceIDs, restoredResourceIDs []string, cutoff time.Time, limit int, force bool) error {
|
||||
next := *project
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := New(tx).UpsertCanvasProject(&next); err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
var last model.CanvasSnapshot
|
||||
err := tx.Select("id", "revision", "created_at").Where("canvas_id = ?", project.ID).Order("revision DESC").First(&last).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
capture := err != nil || (last.Revision != snapshot.Revision && (force || !last.CreatedAt.After(cutoff)))
|
||||
requiredIDs := append([]string{}, restoredResourceIDs...)
|
||||
if capture {
|
||||
requiredIDs = append(requiredIDs, resourceIDs...)
|
||||
}
|
||||
slices.Sort(requiredIDs)
|
||||
requiredIDs = slices.Compact(requiredIDs)
|
||||
if len(requiredIDs) > 0 {
|
||||
var resources []model.Resource
|
||||
query := tx.Select("id").Where("id IN ? AND status = ?", requiredIDs, model.ResourceStatusReady).Order("id")
|
||||
if r.Dialect() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&resources).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resources) != len(requiredIDs) {
|
||||
return ErrCanvasHistoryResourceMissing
|
||||
}
|
||||
}
|
||||
if !capture {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(snapshot).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
refs := make([]model.CanvasSnapshotResource, 0, len(resourceIDs))
|
||||
for _, id := range resourceIDs {
|
||||
refs = append(refs, model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: id})
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
if err := tx.CreateInBatches(refs, 200).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var expired []string
|
||||
if err := tx.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Order("revision DESC").Offset(limit).Limit(100).Pluck("id", &expired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteCanvasSnapshots(tx, expired)
|
||||
})
|
||||
if err == nil {
|
||||
*project = next
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CanvasHistoryReferencesObject(resource *model.Resource) (bool, error) {
|
||||
var count int64
|
||||
aliases := r.db.Model(&model.Resource{}).Select("id").Where("endpoint = ? AND bucket = ? AND object_key = ?", resource.Endpoint, resource.Bucket, resource.ObjectKey)
|
||||
err := r.db.Model(&model.CanvasSnapshotResource{}).
|
||||
Where("resource_id = ? OR resource_id IN (?)", resource.ID, aliases).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func deleteCanvasSnapshots(tx *gorm.DB, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("snapshot_id IN ?", ids).Delete(&model.CanvasSnapshotResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id IN ?", ids).Delete(&model.CanvasSnapshot{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CanvasHistoryResourceReferences(resourceIDs []string) ([]ResourceDirectReference, error) {
|
||||
refs := []ResourceDirectReference{}
|
||||
if len(resourceIDs) == 0 {
|
||||
return refs, nil
|
||||
}
|
||||
err := r.db.Table("canvas_snapshot_resources AS refs").
|
||||
Select("'画布历史版本' AS kind, snapshots.canvas_id AS id, snapshots.title, refs.resource_id").
|
||||
Joins("JOIN canvas_snapshots AS snapshots ON snapshots.id = refs.snapshot_id").
|
||||
Where("refs.resource_id IN ?", resourceIDs).Distinct().Scan(&refs).Error
|
||||
return refs, err
|
||||
}
|
||||
|
||||
func (r *Repository) RequireNoCanvasHistoryReferences(resourceIDs []string) error {
|
||||
if len(resourceIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var resources []model.Resource
|
||||
query := r.db.Select("id").Where("id IN ?", resourceIDs).Order("id")
|
||||
if r.Dialect() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&resources).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
refs, err := r.CanvasHistoryResourceReferences(resourceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return ErrCanvasHistoryResourceReferenced
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testCanvasHistory(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if db.Dialector.Name() == "sqlite" {
|
||||
if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasShare{}, &model.Task{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := New(db)
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
project := model.CanvasProject{ID: "history-canvas", UserID: "owner", Title: "original", PayloadJSON: `{"nodes":[{"id":"video"}]}`, UpdatedAt: now}
|
||||
if err := repo.UpsertCanvasProject(&project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{ID: "history-video", UserID: "owner", Status: model.ResourceStatusReady}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := func(p model.CanvasProject, at time.Time) *model.CanvasSnapshot {
|
||||
return &model.CanvasSnapshot{ID: fmt.Sprintf("history-%d", p.Revision), CanvasID: p.ID, UserID: p.UserID, Revision: p.Revision, Title: p.Title, PayloadJSON: p.PayloadJSON, NodeCount: 1, CreatedAt: at}
|
||||
}
|
||||
save := func(at time.Time, force bool) {
|
||||
t.Helper()
|
||||
old := project
|
||||
project.Title = fmt.Sprintf("edit-%d", old.Revision)
|
||||
if err := repo.SaveCanvasWithSnapshot(&project, snapshot(old, at), []string{resource.ID}, nil, at.Add(-5*time.Minute), 20, force); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
save(now, false)
|
||||
save(now.Add(4*time.Minute), false)
|
||||
metadata, err := repo.CanvasProjectMetadata(project.UserID, project.ID)
|
||||
if err != nil || metadata.Revision != project.Revision || metadata.UserID != project.UserID || metadata.PayloadJSON != "" {
|
||||
t.Fatalf("canvas metadata: %#v %v", metadata, err)
|
||||
}
|
||||
items, err := repo.CanvasSnapshots("owner", project.ID, 20)
|
||||
if err != nil || len(items) != 1 || items[0].PayloadJSON != "" {
|
||||
t.Fatalf("summary/throttle: %#v %v", items, err)
|
||||
}
|
||||
save(now.Add(5*time.Minute), false)
|
||||
items, _ = repo.CanvasSnapshots("owner", project.ID, 20)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("5 minute boundary = %d", len(items))
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID})
|
||||
}); !errors.Is(err, ErrCanvasHistoryResourceReferenced) {
|
||||
t.Fatalf("history protection = %v", err)
|
||||
}
|
||||
refs, err := repo.CanvasHistoryResourceReferences([]string{resource.ID})
|
||||
if err != nil || len(refs) == 0 || refs[0].Kind != "画布历史版本" {
|
||||
t.Fatalf("references = %#v %v", refs, err)
|
||||
}
|
||||
if err := db.Delete(&resource).Error; err == nil {
|
||||
t.Fatal("database allowed deletion of a history resource")
|
||||
}
|
||||
if _, err := repo.CanvasSnapshot("other", project.ID, items[0].ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("wrong owner = %v", err)
|
||||
}
|
||||
|
||||
// Snapshot failure must roll back the successful content CAS as well.
|
||||
before := project
|
||||
failed := project
|
||||
failed.Title = "must rollback"
|
||||
err = repo.SaveCanvasWithSnapshot(&failed, snapshot(project, now.Add(10*time.Minute)), []string{"missing"}, nil, now, 20, true)
|
||||
if !errors.Is(err, ErrCanvasHistoryResourceMissing) {
|
||||
t.Fatalf("missing resource = %v", err)
|
||||
}
|
||||
stored, _ := repo.CanvasProjectForUser("owner", project.ID)
|
||||
if stored.Revision != before.Revision || stored.Title != before.Title || failed.Revision != before.Revision {
|
||||
t.Fatal("failed snapshot changed content/revision")
|
||||
}
|
||||
|
||||
// Concurrent writers create exactly one history entry along with one new revision.
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
copy := project
|
||||
<-start
|
||||
results <- repo.SaveCanvasWithSnapshot(©, snapshot(project, now.Add(10*time.Minute)), []string{resource.ID}, nil, now, 20, true)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("concurrent saves: success=%d conflict=%d", success, conflict)
|
||||
}
|
||||
stored, _ = repo.CanvasProjectForUser("owner", project.ID)
|
||||
project = *stored
|
||||
for i := 0; i < 23; i++ {
|
||||
save(now.Add(time.Duration(20+i*5)*time.Minute), false)
|
||||
}
|
||||
var count, refCount int64
|
||||
db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count)
|
||||
db.Model(&model.CanvasSnapshotResource{}).Count(&refCount)
|
||||
if count != 20 || refCount != 20 {
|
||||
t.Fatalf("retention: snapshots=%d refs=%d", count, refCount)
|
||||
}
|
||||
save(now.Add(131*time.Minute), true)
|
||||
items, _ = repo.CanvasSnapshots("owner", project.ID, 20)
|
||||
if items[0].Revision != project.Revision-1 || len(items) != 20 {
|
||||
t.Fatal("forced pre-restore backup missing or retention exceeded")
|
||||
}
|
||||
if err := repo.DeleteCanvasProject("owner", project.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count)
|
||||
db.Model(&model.CanvasSnapshotResource{}).Count(&refCount)
|
||||
if count != 0 || refCount != 0 {
|
||||
t.Fatal("canvas deletion left orphan history")
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID})
|
||||
}); err != nil {
|
||||
t.Fatalf("expired reference not released: %v", err)
|
||||
}
|
||||
if err := db.Delete(&resource).Error; err != nil {
|
||||
t.Fatalf("expired foreign key not released: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestCanvasRevision(t *testing.T) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "canvas.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
testCanvasRevision(t, db)
|
||||
})
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set CANVAS_TEST_POSTGRES_DSN to an isolated test database")
|
||||
}
|
||||
config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
|
||||
admin, err := gorm.Open(postgres.Open(dsn), config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := fmt.Sprintf("canvas_revision_%d", time.Now().UnixNano())
|
||||
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error
|
||||
connection, _ := admin.DB()
|
||||
_ = connection.Close()
|
||||
})
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
db, err := gorm.Open(postgres.Open(parsed.String()), config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, _ := db.DB()
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
testCanvasRevision(t, db)
|
||||
})
|
||||
}
|
||||
|
||||
func testCanvasRevision(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.AutoMigrate(&model.CanvasProject{}, &model.CanvasUnitLink{}, &model.Project{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := New(db)
|
||||
initial := model.CanvasProject{ID: "canvas", UserID: "owner", Title: "initial", PayloadJSON: `{"nodes":[{"id":"old"}]}`}
|
||||
if err := repo.UpsertCanvasProject(&initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initial.Revision != 1 {
|
||||
t.Fatalf("initial revision = %d", initial.Revision)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for _, title := range []string{"editor A", "editor B"} {
|
||||
workers.Add(1)
|
||||
go func(title string) {
|
||||
defer workers.Done()
|
||||
copy := initial
|
||||
copy.Title = title
|
||||
copy.PayloadJSON = fmt.Sprintf(`{"nodes":[{"id":%q}]}`, title)
|
||||
<-start
|
||||
results <- repo.UpsertCanvasProject(©)
|
||||
}(title)
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
close(results)
|
||||
successes, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successes++
|
||||
} else if errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != 1 {
|
||||
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
|
||||
}
|
||||
winner, err := repo.CanvasProjectForUser("owner", initial.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if winner.Revision != 2 {
|
||||
t.Fatalf("winner revision = %d", winner.Revision)
|
||||
}
|
||||
old := initial
|
||||
old.UpdatedAt = time.Now().Add(time.Hour)
|
||||
if err := repo.UpsertCanvasProject(&old); !errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
t.Fatalf("stale write = %v", err)
|
||||
}
|
||||
wrongOwner := *winner
|
||||
wrongOwner.UserID = "another-user"
|
||||
if err := repo.UpsertCanvasProject(&wrongOwner); !errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
t.Fatalf("wrong owner = %v", err)
|
||||
}
|
||||
createCollision := initial
|
||||
createCollision.Revision = 0
|
||||
if err := repo.UpsertCanvasProject(&createCollision); !errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
t.Fatalf("create collision = %v", err)
|
||||
}
|
||||
stored, _ := repo.CanvasProjectForUser("owner", initial.ID)
|
||||
if stored.PayloadJSON != winner.PayloadJSON || stored.Revision != 2 {
|
||||
t.Fatal("rejected save changed the canvas")
|
||||
}
|
||||
|
||||
if err := repo.AssignCanvasToProject("owner", initial.ID, "project"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.UpsertCanvasProject(winner); !errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
t.Fatalf("association did not invalidate old content: %v", err)
|
||||
}
|
||||
if err := repo.UnassignCanvasFromProject("owner", "project", initial.ID, `{"nodes":[]}`, time.Now(), 2); err == nil {
|
||||
t.Fatal("stale unassignment replaced canvas content")
|
||||
}
|
||||
stored, _ = repo.CanvasProjectForUser("owner", initial.ID)
|
||||
if stored.PayloadJSON != winner.PayloadJSON || stored.Revision != 3 {
|
||||
t.Fatal("stale unassignment changed the canvas")
|
||||
}
|
||||
if err := db.Delete(&model.CanvasProject{}, "id = ?", initial.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.UpsertCanvasProject(stored); !errors.Is(err, ErrCanvasRevisionConflict) {
|
||||
t.Fatalf("deleted canvas was recreated: %v", err)
|
||||
}
|
||||
t.Run("history", func(t *testing.T) { testCanvasHistory(t, db) })
|
||||
}
|
||||
@@ -105,17 +105,7 @@ func (r *Repository) RevokeCreationSubmissions(runID string) error {
|
||||
return r.db.Model(&model.CreationSubmission{}).Where("run_id = ? AND task_id IS NULL AND revoked_at IS NULL", runID).Update("revoked_at", time.Now()).Error
|
||||
}
|
||||
func (r *Repository) CreateCreationCanvas(canvas *model.CanvasProject) error {
|
||||
return r.db.Create(canvas).Error
|
||||
}
|
||||
func (r *Repository) CompareSaveCreationCanvas(canvas *model.CanvasProject, previous string) error {
|
||||
result := r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND payload_json = ?", canvas.ID, canvas.UserID, previous).Updates(map[string]any{"payload_json": canvas.PayloadJSON, "title": canvas.Title, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrCreationConflict
|
||||
}
|
||||
return nil
|
||||
return r.UpsertCanvasProject(canvas)
|
||||
}
|
||||
|
||||
// Capture server-side configuration versions, never channel credentials.
|
||||
|
||||
@@ -35,6 +35,8 @@ var ErrProjectHasActiveTasks = errors.New("project has active tasks")
|
||||
|
||||
var ErrProjectUnitShotsChanged = errors.New("project unit shots changed")
|
||||
|
||||
var ErrCanvasRevisionConflict = errors.New("canvas revision changed")
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -1152,7 +1154,7 @@ func (r *Repository) CanvasProjects(userID string) ([]model.CanvasProject, error
|
||||
|
||||
func (r *Repository) CanvasProjectSummaries(userID string) ([]model.CanvasProject, error) {
|
||||
var projects []model.CanvasProject
|
||||
err := r.db.Select("id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error
|
||||
err := r.db.Select("id", "title", "revision", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
@@ -1165,17 +1167,51 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
|
||||
expected := project.Revision
|
||||
if expected < 0 {
|
||||
return ErrCanvasRevisionConflict
|
||||
}
|
||||
if expected == 0 {
|
||||
created := *project
|
||||
created.Revision = 1
|
||||
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&created)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrCanvasRevisionConflict
|
||||
}
|
||||
project.Revision = 1
|
||||
return nil
|
||||
}
|
||||
// The revision predicate and increment must be in the same SQL statement.
|
||||
// A missing row is a conflict, never an invitation to recreate a deleted canvas.
|
||||
result := r.db.Model(&model.CanvasProject{}).
|
||||
Where("id = ? AND user_id = ?", project.ID, project.UserID).
|
||||
Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt})
|
||||
if result.Error != nil || result.RowsAffected > 0 {
|
||||
Where("id = ? AND user_id = ? AND revision = ?", project.ID, project.UserID, expected).
|
||||
Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt, "revision": expected + 1})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return r.db.Create(project).Error
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrCanvasRevisionConflict
|
||||
}
|
||||
project.Revision = expected + 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteCanvasProject(userID string, id string) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Serialize deletion with saves before reading the history IDs to remove.
|
||||
if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND id = ?", userID, id).UpdateColumn("revision", gorm.Expr("revision")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var snapshotIDs []string
|
||||
if err := tx.Model(&model.CanvasSnapshot{}).Where("user_id = ? AND canvas_id = ?", userID, id).Pluck("id", &snapshotIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteCanvasSnapshots(tx, snapshotIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1257,8 +1293,8 @@ func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []mod
|
||||
}
|
||||
for _, canvas := range canvasUpdates {
|
||||
result := tx.Model(&model.CanvasProject{}).
|
||||
Where("id = ? AND user_id = ? AND project_id = ?", canvas.ID, userID, id).
|
||||
Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt})
|
||||
Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvas.ID, userID, id, canvas.Revision).
|
||||
Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt, "revision": canvas.Revision + 1})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -1466,13 +1502,13 @@ func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error {
|
||||
|
||||
func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) {
|
||||
var canvases []model.CanvasProject
|
||||
err := r.db.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error
|
||||
err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error
|
||||
return canvases, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) {
|
||||
var canvases []model.CanvasProject
|
||||
err := r.db.Select("id", "title", "payload_json").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error
|
||||
err := r.db.Select("id", "title", "payload_json", "revision").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error
|
||||
return canvases, err
|
||||
}
|
||||
|
||||
@@ -1496,16 +1532,16 @@ func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, uni
|
||||
}
|
||||
|
||||
func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error {
|
||||
return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Update("project_id", projectID).Error
|
||||
return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Updates(map[string]any{"project_id": projectID, "revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time) error {
|
||||
func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time, revision int64) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ?", canvasID, userID, projectID).Updates(map[string]any{
|
||||
"project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt,
|
||||
result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvasID, userID, projectID, revision).Updates(map[string]any{
|
||||
"project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt, "revision": revision + 1,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -2351,18 +2387,6 @@ func (r *Repository) DeleteCanvasShare(userID string, projectID string) error {
|
||||
return r.db.Delete(&model.CanvasShare{}, "user_id = ? AND project_id = ?", userID, projectID).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceCanvasProjects(userID string, projects []model.CanvasProject) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&model.CanvasProject{}, "user_id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&projects).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) PromptTemplates() ([]model.PromptTemplate, error) {
|
||||
var templates []model.PromptTemplate
|
||||
err := r.db.Order("operation asc, version desc").Find(&templates).Error
|
||||
|
||||
@@ -86,6 +86,11 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
if len(resourceIDs) == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
history, err := r.CanvasHistoryResourceReferences(resourceIDs)
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
snapshot.Direct = append(snapshot.Direct, history...)
|
||||
|
||||
var assets []model.Asset
|
||||
assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID)
|
||||
@@ -322,6 +327,9 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R
|
||||
|
||||
func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID)
|
||||
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
return err
|
||||
|
||||
Reference in new issue
Block a user