Files
foundry-core/internal/platform/canvas_undo.go
codex-bot d6ee8942f1
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m36s
Implement Canvas V2 subgraphs, assets and asynchronous AI execution
2026-11-06 18:36:00 +08:00

233 lines
7.2 KiB
Go

package platform
import (
"database/sql"
"encoding/json"
"net/http"
"strings"
)
func (s *service) undoCanvasTransaction(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
if !acceptsCanvasV2(r) {
reject(w, 409, "canvas_schema_upgrade_required")
return nil
}
key := canvasIdempotencyKey(r)
id := r.PathValue("transaction")
if !uuidSlugPattern.MatchString(key) || !uuidSlugPattern.MatchString(id) {
reject(w, 400, "invalid_transaction_request")
return nil
}
var input struct {
ExpectedVersions map[string]int64 `json:"expected_versions"`
}
if !decode(w, r, &input) {
return nil
}
if len(input.ExpectedVersions) == 0 {
reject(w, 428, "version_required")
return nil
}
workspace, board := r.PathValue("workspace"), r.PathValue("material")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
action := "undo/" + id
hash := payloadDigest(input)
done, err := transactionRetry(w, r, tx, project, account, board, action, key, hash)
if err != nil {
return err
}
if done {
return nil
}
var beforeRaw, versionsRaw, relationsRaw, afterResponse json.RawMessage
var originalAction string
var expired bool
var undone sql.NullTime
err = tx.QueryRowContext(r.Context(), `SELECT action,before_state,after_versions,relation_changes,response_body,expires_at<=now(),undone_at FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 FOR UPDATE`, project, workspace, board, id).Scan(&originalAction, &beforeRaw, &versionsRaw, &relationsRaw, &afterResponse, &expired, &undone)
if err != nil {
return err
}
if expired {
reject(w, 409, "undo_receipt_expired")
return nil
}
if undone.Valid {
reject(w, 409, "transaction_already_undone")
return nil
}
if originalAction != "create_subgraph" && originalAction != "migrate_subgraphs" && !strings.HasPrefix(originalAction, "dissolve_subgraph/") && !strings.HasPrefix(originalAction, "move_members/") {
reject(w, 409, "undo_not_supported")
return nil
}
var before []canvasSnapshot
var versions map[string]int64
if err = json.Unmarshal(beforeRaw, &before); err != nil {
return err
}
if err = json.Unmarshal(versionsRaw, &versions); err != nil {
return err
}
if len(versions) != len(input.ExpectedVersions) {
reject(w, 428, "version_required")
return nil
}
var changes []subgraphRelationChange
if err = json.Unmarshal(relationsRaw, &changes); err != nil {
return err
}
archivedParticipants := map[string]bool{}
for _, change := range changes {
actual, err := readSubgraphRelation(r, tx, project, workspace, change.After.Child)
if err != nil {
return err
}
if actual != change.After {
reject(w, 412, "version_conflict")
return nil
}
if actual.Archived {
_, role, err := lockMaterial(r.Context(), tx, project, workspace, actual.Parent, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
archivedParticipants[actual.Child] = true
}
}
var recorded struct {
Parent canvasSnapshot `json:"parent"`
Child *canvasSnapshot `json:"child"`
Children []canvasSnapshot `json:"children"`
Snapshots []canvasSnapshot `json:"snapshots"`
}
if err = json.Unmarshal(afterResponse, &recorded); err != nil {
return err
}
afterStates := map[string]json.RawMessage{}
if recorded.Parent.BoardID != "" {
afterStates[recorded.Parent.BoardID] = recorded.Parent.Document
}
if recorded.Child != nil {
afterStates[recorded.Child.BoardID] = recorded.Child.Document
}
for _, snapshot := range append(recorded.Children, recorded.Snapshots...) {
afterStates[snapshot.BoardID] = snapshot.Document
}
current := map[string]canvasSnapshot{}
for id := range versions {
if !archivedParticipants[id] {
_, role, err := lockMaterial(r.Context(), tx, project, workspace, id, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
}
snapshot, err := loadCanvas(r, tx, project, workspace, id)
if err != nil {
return err
}
expected, exists := input.ExpectedVersions[id]
if !exists {
reject(w, 428, "version_required")
return nil
}
if expected != snapshot.Version || !sameCanvasDocument(snapshot.Document, afterStates[id]) {
reject(w, 412, "version_conflict")
return nil
}
current[id] = snapshot
}
for _, change := range changes {
if err = writeSubgraphRelation(r, tx, project, workspace, change.Before); err != nil {
return err
}
}
beforeIDs := map[string]bool{}
updated := []canvasSnapshot{}
appliedVersions := map[string]int64{}
for _, snapshot := range before {
beforeIDs[snapshot.BoardID] = true
snapshot.Version = current[snapshot.BoardID].Version + 1
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
updated = append(updated, snapshot)
appliedVersions[snapshot.BoardID] = snapshot.Version
}
archived := []string{}
for id, snapshot := range current {
if beforeIDs[id] {
continue
}
_, err = tx.ExecContext(r.Context(), `UPDATE canvas_subgraphs SET archived_at=now() WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND child_board_id=$4 AND archived_at IS NULL`, project, workspace, board, id)
if err != nil {
return err
}
// Keep the complete child for recovery, but invalidate its old version.
snapshot.Version++
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
archived = append(archived, id)
appliedVersions[id] = snapshot.Version
}
_, err = tx.ExecContext(r.Context(), `UPDATE canvas_transactions SET undone_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id)
if err != nil {
return err
}
var newID string
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&newID)
if err != nil {
return err
}
result := response{"transaction_id": newID, "undone_transaction_id": id, "snapshots": updated, "archived_board_ids": archived, "applied_versions": appliedVersions}
for _, snapshot := range updated {
if snapshot.BoardID == board {
result["parent"] = snapshot
}
}
encoded, _ := json.Marshal(result)
prior, _ := json.Marshal(current)
after, _ := json.Marshal(appliedVersions)
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions) VALUES($1,$2,$3,$4,$5,$6,$7,$8,200,$9,$10,$11)`, project, workspace, board, newID, account, action, key, hash, encoded, prior, after)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, appliedVersions[board])
reply(w, 200, result)
return nil
}
func sameCanvasDocument(a, b json.RawMessage) bool {
var first, second any
if json.Unmarshal(a, &first) != nil || json.Unmarshal(b, &second) != nil {
return false
}
return payloadDigest(first) == payloadDigest(second)
}