Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #61.
This commit is contained in:
+8
-2
@@ -81,8 +81,9 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
gardens.GET("/:id", h.getGarden)
|
||||
gardens.PATCH("/:id", h.updateGarden)
|
||||
gardens.DELETE("/:id", h.deleteGarden)
|
||||
gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
|
||||
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
||||
gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
|
||||
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
||||
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
||||
gardens.POST("/:id/objects", h.createObject)
|
||||
|
||||
// Sharing (owner-managed; a recipient may remove their own share).
|
||||
@@ -111,6 +112,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
plantings.PATCH("/:id", h.updatePlanting)
|
||||
plantings.DELETE("/:id", h.deletePlanting)
|
||||
|
||||
// Undo. A change set is addressed by its own id; the service resolves the
|
||||
// owning garden for the permission check, same as objects and plantings.
|
||||
changeSets := v1.Group("/change-sets", h.requireAuth())
|
||||
changeSets.POST("/:id/revert", h.revertChangeSet)
|
||||
|
||||
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
|
||||
plants := v1.Group("/plants", h.requireAuth())
|
||||
plants.GET("", h.listPlants)
|
||||
|
||||
@@ -69,6 +69,20 @@ func writeVersionConflict(c *gin.Context, current any) {
|
||||
})
|
||||
}
|
||||
|
||||
// intQuery reads a non-negative integer query parameter, falling back to def on
|
||||
// an absent or malformed value.
|
||||
func intQuery(c *gin.Context, name string, def int) int {
|
||||
raw := c.Query(name)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// parseIDParam reads a positive int64 path parameter, writing a 400 and
|
||||
// returning ok=false on a malformed value.
|
||||
func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// Change history and undo (#48). Two endpoints: read a garden's change sets, and
|
||||
// revert one. Both encode domain types directly, like the rest of the package —
|
||||
// domain.ChangeSet already carries the actor name, revert linkage and per-op
|
||||
// counts the history list renders, and its Revisions field is omitempty so the
|
||||
// JSON snapshots never ride along on a list response.
|
||||
|
||||
// historyResponse is the body of GET /gardens/:id/history. hasMore lets the
|
||||
// client page without a separate count query over a table that only grows.
|
||||
type historyResponse struct {
|
||||
ChangeSets []domain.ChangeSet `json:"changeSets"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
// revertResponse is the body of POST /change-sets/:id/revert on every path.
|
||||
// Carrying both fields regardless is what lets the UI say "2 of 3 changes undone;
|
||||
// the north bed was edited since and was left alone" instead of a generic
|
||||
// failure — a partial revert really did change things, and pretending otherwise
|
||||
// would be a lie. ChangeSet is null when nothing needed reverting.
|
||||
type revertResponse struct {
|
||||
ChangeSet *domain.ChangeSet `json:"changeSet"`
|
||||
Conflicts []domain.RevertConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
func (h *handlers) getGardenHistory(c *gin.Context) {
|
||||
gardenID, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 0 means "the service's default"; it clamps the upper bound too.
|
||||
limit := intQuery(c, "limit", 0)
|
||||
offset := intQuery(c, "offset", 0)
|
||||
|
||||
sets, hasMore, err := h.svc.GardenHistory(c.Request.Context(), mustActor(c).ID, gardenID, limit, offset)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, historyResponse{ChangeSets: sets, HasMore: hasMore})
|
||||
}
|
||||
|
||||
func (h *handlers) revertChangeSet(c *gin.Context) {
|
||||
id, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A revert through the REST API is a person clicking undo. The agent reverts
|
||||
// its own work through the service directly and stamps SourceAgent, so the
|
||||
// history badge can tell the two apart.
|
||||
cs, conflicts, err := h.svc.RevertChangeSet(c.Request.Context(), mustActor(c).ID, id, domain.SourceUI)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
if conflicts == nil {
|
||||
conflicts = []domain.RevertConflict{}
|
||||
}
|
||||
body := revertResponse{ChangeSet: cs, Conflicts: conflicts}
|
||||
switch {
|
||||
case len(conflicts) > 0:
|
||||
// 409 even when part of the revert applied: something the caller asked for
|
||||
// did not happen, and the body says exactly what.
|
||||
c.JSON(http.StatusConflict, body)
|
||||
case cs == nil:
|
||||
// Every revision resolved to a no-op (already undone by hand, say). Nothing
|
||||
// was created, so 200 rather than a 201 pointing at nothing.
|
||||
c.JSON(http.StatusOK, body)
|
||||
default:
|
||||
c.JSON(http.StatusCreated, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func historyPath(gardenID int64) string {
|
||||
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/history"
|
||||
}
|
||||
|
||||
func revertPath(changeSetID int64) string {
|
||||
return "/api/v1/change-sets/" + strconv.FormatInt(changeSetID, 10) + "/revert"
|
||||
}
|
||||
|
||||
// TestHistoryAndRevertAPI walks the whole loop over HTTP: a mutation shows up in
|
||||
// history without anyone asking for it, reverting it answers 201 with the new
|
||||
// change set, and the change is actually gone from /full.
|
||||
func TestHistoryAndRevertAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The create landed in history with no explicit change set anywhere.
|
||||
w = doJSON(t, r, http.MethodGet, historyPath(gid), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeMap(t, w.Body.Bytes())
|
||||
sets, _ := body["changeSets"].([]any)
|
||||
if len(sets) != 1 {
|
||||
t.Fatalf("got %d change sets, want 1: %s", len(sets), w.Body.String())
|
||||
}
|
||||
if body["hasMore"].(bool) {
|
||||
t.Error("hasMore should be false for a single-entry history")
|
||||
}
|
||||
entry := sets[0].(map[string]any)
|
||||
if entry["summary"] != "Added North Bed" {
|
||||
t.Errorf("summary = %v", entry["summary"])
|
||||
}
|
||||
if entry["source"] != "ui" || entry["actorName"] == "" {
|
||||
t.Errorf("unexpected entry: %+v", entry)
|
||||
}
|
||||
counts, _ := entry["counts"].([]any)
|
||||
if len(counts) != 1 {
|
||||
t.Fatalf("counts = %+v", entry["counts"])
|
||||
}
|
||||
|
||||
// Revert it: 201, and the new change set points back at the original.
|
||||
csID := int64(entry["id"].(float64))
|
||||
w = doJSON(t, r, http.MethodPost, revertPath(csID), nil, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("revert: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
rev := decodeMap(t, w.Body.Bytes())
|
||||
cs := rev["changeSet"].(map[string]any)
|
||||
if int64(cs["revertsId"].(float64)) != csID {
|
||||
t.Errorf("revertsId = %v, want %d", cs["revertsId"], csID)
|
||||
}
|
||||
if conflicts, _ := rev["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Errorf("unexpected conflicts: %+v", conflicts)
|
||||
}
|
||||
|
||||
// The object is gone from the editor payload.
|
||||
w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie)
|
||||
full := decodeMap(t, w.Body.Bytes())
|
||||
if objects, _ := full["objects"].([]any); len(objects) != 0 {
|
||||
t.Errorf("%d objects survived the revert", len(objects))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertConflictAPI: a partial revert answers 409 and names what it skipped,
|
||||
// because "2 of 3 undone, the north bed was edited since" is the only useful
|
||||
// thing to say — a bare failure would be a lie about what happened.
|
||||
func TestRevertConflictAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "name": "Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
||||
}, cookie)
|
||||
obj := decodeMap(t, w.Body.Bytes())
|
||||
objectID := int64(obj["id"].(float64))
|
||||
version := int64(obj["version"].(float64))
|
||||
|
||||
// Move it (change set #2), then edit it again (change set #3).
|
||||
w = doJSON(t, r, http.MethodPatch, objectPath(objectID), map[string]any{
|
||||
"xCm": 300, "version": version,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("move: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
version = int64(decodeMap(t, w.Body.Bytes())["version"].(float64))
|
||||
w = doJSON(t, r, http.MethodPatch, objectPath(objectID), map[string]any{
|
||||
"name": "Renamed", "version": version,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("rename: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Undoing the move now conflicts: the row changed after it.
|
||||
w = doJSON(t, r, http.MethodGet, historyPath(gid), nil, cookie)
|
||||
sets := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
||||
moveID := int64(sets[1].(map[string]any)["id"].(float64)) // newest first: rename, move, create
|
||||
|
||||
w = doJSON(t, r, http.MethodPost, revertPath(moveID), nil, cookie)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("revert: status %d, want 409, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
conflicts := decodeMap(t, w.Body.Bytes())["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("conflicts = %+v", conflicts)
|
||||
}
|
||||
c := conflicts[0].(map[string]any)
|
||||
if c["reason"] != "changed" || c["name"] != "Renamed" {
|
||||
t.Errorf("conflict = %+v", c)
|
||||
}
|
||||
|
||||
// The object was left exactly alone.
|
||||
w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie)
|
||||
objects := decodeMap(t, w.Body.Bytes())["objects"].([]any)
|
||||
o := objects[0].(map[string]any)
|
||||
if o["xCm"].(float64) != 300 || o["name"] != "Renamed" {
|
||||
t.Errorf("conflicted object was modified: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryRequiresAccess — an unauthenticated caller can't read history, and a
|
||||
// stranger gets 404 rather than a hint that the garden exists.
|
||||
func TestHistoryRequiresAccess(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
owner := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, owner, "G")
|
||||
stranger := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, historyPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous history status = %d, want 401", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodGet, historyPath(gid), nil, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger history status = %d, want 404", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, revertPath(1), nil, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger revert status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,97 @@ const (
|
||||
CategoryFruit = "fruit"
|
||||
CategoryTreeShrub = "tree_shrub"
|
||||
CategoryCover = "cover"
|
||||
|
||||
// Where a change set came from. SourceAgent is what the history UI badges
|
||||
// differently, so an autonomous edit is never mistaken for a hand edit.
|
||||
SourceUI = "ui"
|
||||
SourceAgent = "agent"
|
||||
SourceAPI = "api"
|
||||
|
||||
// The entity types a revision can snapshot. Garden covers metadata edits
|
||||
// only — garden creation and deletion are deliberately not revertible.
|
||||
EntityGarden = "garden"
|
||||
EntityObject = "object"
|
||||
EntityPlanting = "planting"
|
||||
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
)
|
||||
|
||||
// ChangeSet groups the revisions produced by one logical operation — the unit of
|
||||
// undo. A revert is itself a change set (RevertsID names its target), so history
|
||||
// is append-only and an undo can be undone.
|
||||
type ChangeSet struct {
|
||||
ID int64 `json:"id"`
|
||||
GardenID int64 `json:"gardenId"`
|
||||
ActorID int64 `json:"actorId"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
AgentRunID *string `json:"agentRunId,omitempty"`
|
||||
RevertsID *int64 `json:"revertsId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
|
||||
// Computed by the service for the history list, never persisted.
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
// RevertedByID is the change set that reverted this one, if any — what lets
|
||||
// the list mark an entry as already undone.
|
||||
RevertedByID *int64 `json:"revertedById,omitempty"`
|
||||
// Counts tallies the revisions by (entity type, op) so the list can say
|
||||
// "3 plops added, 1 bed moved" without loading every revision.
|
||||
Counts []ChangeCount `json:"counts,omitempty"`
|
||||
// Revisions is populated only when a single change set is loaded in full.
|
||||
Revisions []Revision `json:"revisions,omitempty"`
|
||||
}
|
||||
|
||||
// ChangeCount is one (entity type, op) tally within a change set.
|
||||
type ChangeCount struct {
|
||||
EntityType string `json:"entityType"`
|
||||
Op string `json:"op"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
|
||||
// Revision is one row-level change within a change set. Before/After are JSON
|
||||
// row snapshots (nil for create/delete respectively) and include the row's
|
||||
// version, which the revert guard compares against.
|
||||
type Revision struct {
|
||||
ID int64 `json:"id"`
|
||||
ChangeSetID int64 `json:"changeSetId"`
|
||||
Seq int64 `json:"seq"`
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID int64 `json:"entityId"`
|
||||
Op string `json:"op"`
|
||||
Before *string `json:"before,omitempty"`
|
||||
After *string `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// RevertConflict reports one entity a revert deliberately left alone, because
|
||||
// reverting it would have discarded a change made after the change set being
|
||||
// reverted. The rest of the change set still reverts.
|
||||
type RevertConflict struct {
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID int64 `json:"entityId"`
|
||||
// Reason is one of the ConflictReason* values below.
|
||||
Reason string `json:"reason"`
|
||||
// Name is a human label for the entity where one exists (an object's name),
|
||||
// so the UI can say "the north bed" rather than "object 41".
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Why a revert skipped an entity.
|
||||
const (
|
||||
// ConflictChanged: the row was edited after the change set being reverted, so
|
||||
// restoring the old snapshot would silently discard that edit.
|
||||
ConflictChanged = "changed"
|
||||
// ConflictMissing: the row is gone, so there is nothing to revert to.
|
||||
ConflictMissing = "missing"
|
||||
// ConflictExists: a row with that id exists again, so restoring the deleted
|
||||
// snapshot would overwrite it.
|
||||
ConflictExists = "exists"
|
||||
// ConflictUnsupported: this kind of change has no inverse the revert knows how
|
||||
// to apply. Reported rather than silently skipped, so a change recorded by a
|
||||
// later feature that revert wasn't taught about is visible instead of quiet.
|
||||
ConflictUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||
|
||||
@@ -140,7 +140,8 @@ func (s *Service) ListGardens(ctx context.Context, actorID int64) ([]domain.Gard
|
||||
// version mismatch it returns (current garden, ErrVersionConflict) so the handler
|
||||
// can return the fresh row for the client to rebase.
|
||||
func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in GardenInput, version int64) (*domain.Garden, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
before, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, err := gardenFromInput(in, false) // no defaults: an update states every field
|
||||
@@ -153,7 +154,18 @@ func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in
|
||||
if updated != nil {
|
||||
updated.MyRole = roleOwner.String() // only the owner reaches here
|
||||
}
|
||||
return updated, err
|
||||
if err != nil {
|
||||
return updated, err
|
||||
}
|
||||
// MyRole is computed, not stored; blank it in the snapshot so a revert can't
|
||||
// write a role into a row that has no such column.
|
||||
snap := *before
|
||||
snap.MyRole = ""
|
||||
after := *updated
|
||||
after.MyRole = ""
|
||||
s.record(ctx, gardenID, actorID, "Edited garden settings",
|
||||
changeUpdate(domain.EntityGarden, gardenID, &snap, &after))
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// CopyGarden duplicates a garden into a new one owned by the actor, carrying
|
||||
|
||||
@@ -122,7 +122,23 @@ func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in
|
||||
if err := finalizeObject(o, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.CreateObject(ctx, o)
|
||||
created, err := s.store.CreateObject(ctx, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.record(ctx, gardenID, actorID, "Added "+objectLabel(created),
|
||||
changeCreate(domain.EntityObject, created.ID, created))
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// objectLabel names an object for a change-set summary: its own name when it has
|
||||
// one, else its kind ("bed", "grow_bag"), so history reads as "Added bed" rather
|
||||
// than "Added ".
|
||||
func objectLabel(o *domain.GardenObject) string {
|
||||
if o.Name != "" {
|
||||
return o.Name
|
||||
}
|
||||
return o.Kind
|
||||
}
|
||||
|
||||
// objectForRole loads an object and enforces that the actor holds at least min
|
||||
@@ -148,21 +164,36 @@ func (s *Service) UpdateObject(ctx context.Context, actorID, objectID int64, pat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before := *o // objectForRole returns the current row, and the patch mutates it
|
||||
applyObjectPatch(o, patch)
|
||||
if err := finalizeObject(o, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Version = version
|
||||
return s.store.UpdateObject(ctx, o)
|
||||
updated, err := s.store.UpdateObject(ctx, o)
|
||||
if err != nil {
|
||||
return updated, err // may carry the current row on a version conflict
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, "Edited "+objectLabel(updated),
|
||||
changeUpdate(domain.EntityObject, updated.ID, &before, updated))
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeleteObject removes an object (its plantings cascade) from a garden the actor
|
||||
// can edit.
|
||||
func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) error {
|
||||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
|
||||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteObject(ctx, objectID)
|
||||
// deleteObjectRecording snapshots the plantings the FK is about to cascade
|
||||
// away, so the delete is actually revertible rather than merely listed.
|
||||
changes, err := s.deleteObjectRecording(ctx, o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, "Deleted "+objectLabel(o), changes...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GardenFull returns the whole editor payload for a garden the actor can view.
|
||||
|
||||
+54
-2
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -151,6 +153,14 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// One record call with every plop, so a fill auto-scopes into ONE change set
|
||||
// with N revisions — undoing a fill is one click, not N.
|
||||
changes := make([]change, 0, len(created))
|
||||
for i := range created {
|
||||
changes = append(changes, changeCreate(domain.EntityPlanting, created[i].ID, &created[i]))
|
||||
}
|
||||
s.record(ctx, o.GardenID, actorID,
|
||||
fmt.Sprintf("Planted %d %s in %s", len(created), plant.Name, objectLabel(o)), changes...)
|
||||
for i := range created {
|
||||
created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing)
|
||||
}
|
||||
@@ -219,11 +229,53 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
|
||||
// non-plantable after it was planted must still be clearable (you can always
|
||||
// remove existing plops, only not add new ones).
|
||||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
|
||||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
|
||||
// count — then clear exactly those ids. Clearing "every active plop" instead
|
||||
// would let a plop created between this read and the UPDATE be removed with no
|
||||
// revision recorded: cleared, with no way to undo it.
|
||||
before, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := make([]int64, 0, len(before))
|
||||
for i := range before {
|
||||
ids = append(ids, before[i].ID)
|
||||
}
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
return s.store.ClearObjectPlantings(ctx, objectID, today)
|
||||
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids)
|
||||
if err != nil || n == 0 {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// From here the clear HAS happened. A failure to build the history entry must
|
||||
// not be reported as a failed clear — the caller would retry an operation that
|
||||
// already applied. Log the gap and report success, matching how record()
|
||||
// treats its own write failures.
|
||||
after, err := s.store.ListPlantingsForObject(ctx, objectID)
|
||||
if err != nil {
|
||||
slog.Error("service: clear succeeded but history could not be recorded",
|
||||
"error", err, "object", objectID, "cleared", n)
|
||||
return n, nil
|
||||
}
|
||||
afterByID := make(map[int64]*domain.Planting, len(after))
|
||||
for i := range after {
|
||||
afterByID[after[i].ID] = &after[i]
|
||||
}
|
||||
changes := make([]change, 0, len(before))
|
||||
for i := range before {
|
||||
b := before[i]
|
||||
a, ok := afterByID[b.ID]
|
||||
if !ok {
|
||||
continue // deleted outright between the two reads; nothing coherent to record
|
||||
}
|
||||
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||||
|
||||
@@ -72,7 +72,7 @@ type PlantingPatch struct {
|
||||
|
||||
// CreatePlanting places a plop in a plantable object the actor can edit.
|
||||
func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, in PlantingInput) (*domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,6 +105,8 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, "Planted "+plant.Name+" in "+objectLabel(o),
|
||||
changeCreate(domain.EntityPlanting, created.ID, created))
|
||||
created.DerivedCount = derivedCount(created.RadiusCM, plant.SpacingCM)
|
||||
return created, nil
|
||||
}
|
||||
@@ -116,11 +118,12 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
if err != nil {
|
||||
return nil, err // ErrNotFound
|
||||
}
|
||||
o, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||||
o, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before := *pl // GetPlanting returns the current row, and the patch mutates it
|
||||
originalPlantID := pl.PlantID
|
||||
applyPlantingPatch(pl, patch)
|
||||
// Fetch the plop's plant for the derived count. Only when the actor is
|
||||
@@ -155,10 +158,26 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, plantingEditSummary(&before, updated, plant, o),
|
||||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||||
// as a removal to the person who did it, not as an edit.
|
||||
func plantingEditSummary(before, after *domain.Planting, plant *domain.Plant, o *domain.GardenObject) string {
|
||||
switch {
|
||||
case before.RemovedAt == nil && after.RemovedAt != nil:
|
||||
return "Removed " + plant.Name + " from " + objectLabel(o)
|
||||
case before.RemovedAt != nil && after.RemovedAt == nil:
|
||||
return "Restored " + plant.Name + " in " + objectLabel(o)
|
||||
default:
|
||||
return "Edited " + plant.Name + " in " + objectLabel(o)
|
||||
}
|
||||
}
|
||||
|
||||
// DeletePlanting hard-deletes a plop in an object the actor can edit. (Soft
|
||||
// removal — "clear bed" / harvested — sets removed_at via UpdatePlanting.)
|
||||
func (s *Service) DeletePlanting(ctx context.Context, actorID, plantingID int64) error {
|
||||
@@ -166,10 +185,16 @@ func (s *Service) DeletePlanting(ctx context.Context, actorID, plantingID int64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor); err != nil {
|
||||
o, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.DeletePlanting(ctx, plantingID)
|
||||
if err := s.store.DeletePlanting(ctx, plantingID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, "Deleted a planting from "+objectLabel(o),
|
||||
changeDelete(domain.EntityPlanting, pl.ID, pl))
|
||||
return nil
|
||||
}
|
||||
|
||||
// visiblePlant loads a plant the actor may reference in a planting: a built-in or
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// This file is pansy's undo substrate (#48). The agent acts freely — "empty the
|
||||
// garlic bed and plant cucumbers" runs without a confirmation prompt — which is
|
||||
// only defensible because the result is easy to roll back.
|
||||
//
|
||||
// The unit of undo is the OPERATION, not the row: a change set groups the
|
||||
// row-level revisions it produced, and revert replays their inverses. Two
|
||||
// properties shape everything here:
|
||||
//
|
||||
// - Revert is itself a change set (reverts_id names its target). History is
|
||||
// append-only, so an undo can be undone. git revert, not git reset.
|
||||
// - Revert is version-guarded per entity. If a row changed after the change set
|
||||
// being reverted, restoring the old snapshot would silently discard that
|
||||
// edit, so it is reported as a conflict and left alone — the rest of the
|
||||
// change set still reverts.
|
||||
|
||||
// maxHistoryPageSize caps a history page so a season of edits can't be pulled in
|
||||
// one request.
|
||||
const maxHistoryPageSize = 100
|
||||
|
||||
// defaultHistoryPageSize is the page size when a caller doesn't ask for one.
|
||||
const defaultHistoryPageSize = 50
|
||||
|
||||
// changeSetKey is the context key carrying the ambient change scope.
|
||||
type changeSetKey struct{}
|
||||
|
||||
// changeScope accumulates the revisions of one logical operation. Revisions are
|
||||
// buffered rather than written as they happen, so an operation that fails partway
|
||||
// leaves no half-recorded change set behind — the whole thing lands, or none
|
||||
// of it does.
|
||||
type changeScope struct {
|
||||
gardenID int64
|
||||
actorID int64
|
||||
source string
|
||||
summary string
|
||||
agentRunID *string
|
||||
|
||||
mu sync.Mutex
|
||||
revs []domain.Revision
|
||||
}
|
||||
|
||||
func (sc *changeScope) append(revs []domain.Revision) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.revs = append(sc.revs, revs...)
|
||||
}
|
||||
|
||||
func (sc *changeScope) taken() []domain.Revision {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
return sc.revs
|
||||
}
|
||||
|
||||
// scopeFrom returns the change scope on ctx, or nil when the caller opened none.
|
||||
func scopeFrom(ctx context.Context) *changeScope {
|
||||
sc, _ := ctx.Value(changeSetKey{}).(*changeScope)
|
||||
return sc
|
||||
}
|
||||
|
||||
// change is one row-level mutation waiting to be recorded. before/after are the
|
||||
// row structs themselves; they are marshalled to JSON snapshots at record time.
|
||||
type change struct {
|
||||
entityType string
|
||||
entityID int64
|
||||
op string
|
||||
before any
|
||||
after any
|
||||
}
|
||||
|
||||
func changeCreate(entityType string, id int64, after any) change {
|
||||
return change{entityType: entityType, entityID: id, op: domain.OpCreate, after: after}
|
||||
}
|
||||
|
||||
func changeUpdate(entityType string, id int64, before, after any) change {
|
||||
return change{entityType: entityType, entityID: id, op: domain.OpUpdate, before: before, after: after}
|
||||
}
|
||||
|
||||
func changeDelete(entityType string, id int64, before any) change {
|
||||
return change{entityType: entityType, entityID: id, op: domain.OpDelete, before: before}
|
||||
}
|
||||
|
||||
// ChangeSetOptions describes the change set WithChangeSet opens.
|
||||
type ChangeSetOptions struct {
|
||||
// Source is one of domain.SourceUI / SourceAgent / SourceAPI. The history UI
|
||||
// badges agent changes differently, so an autonomous edit is never mistaken
|
||||
// for a hand edit.
|
||||
Source string
|
||||
// Summary is the one-line description shown in the history list.
|
||||
Summary string
|
||||
// AgentRunID joins this change set back to the executus run that produced it.
|
||||
AgentRunID *string
|
||||
}
|
||||
|
||||
// WithChangeSet runs fn with a change scope on the context, so every mutation fn
|
||||
// performs lands in ONE change set — the agent's "a whole turn is one undo"
|
||||
// guarantee. The change set is written only if fn succeeds; if fn changed
|
||||
// nothing, none is written and (nil, nil) is returned.
|
||||
//
|
||||
// Requires editor role on the garden, checked up front so a scope is never opened
|
||||
// for an actor who couldn't have mutated anything anyway.
|
||||
func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, opts ChangeSetOptions, fn func(context.Context) error) (*domain.ChangeSet, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validChangeSource(opts.Source) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
sc := &changeScope{
|
||||
gardenID: gardenID, actorID: actorID,
|
||||
source: opts.Source, summary: opts.Summary, agentRunID: opts.AgentRunID,
|
||||
}
|
||||
if err := fn(context.WithValue(ctx, changeSetKey{}, sc)); err != nil {
|
||||
// fn failed, but the mutations it completed before failing are already
|
||||
// committed — this scope buffers history, not data. Dropping the buffer
|
||||
// would leave those changes with no history and no way to undo them,
|
||||
// which is exactly the situation undo exists for. Record what happened,
|
||||
// mark the summary, and still report the failure.
|
||||
sc.summary = partialSummary(sc.summary)
|
||||
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
|
||||
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.commitScope(ctx, sc, nil)
|
||||
}
|
||||
|
||||
// partialSummary marks a change set whose operation failed partway, so the
|
||||
// history list can say so rather than presenting it as a completed action.
|
||||
func partialSummary(summary string) string {
|
||||
if summary == "" {
|
||||
return "Partly applied (the operation failed partway)"
|
||||
}
|
||||
return summary + " (failed partway)"
|
||||
}
|
||||
|
||||
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
||||
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
||||
// operation that changed nothing doesn't belong in history.
|
||||
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
||||
revs := sc.taken()
|
||||
if len(revs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: sc.gardenID,
|
||||
ActorID: sc.actorID,
|
||||
Source: sc.source,
|
||||
Summary: sc.summary,
|
||||
AgentRunID: sc.agentRunID,
|
||||
RevertsID: revertsID,
|
||||
}, revs)
|
||||
}
|
||||
|
||||
func validChangeSource(s string) bool {
|
||||
return s == domain.SourceUI || s == domain.SourceAgent || s == domain.SourceAPI
|
||||
}
|
||||
|
||||
// record files the changes a mutation just made. When the caller opened a scope
|
||||
// (WithChangeSet) they join it; otherwise this operation becomes its own
|
||||
// single-op change set on the spot — the AUTO-SCOPE path, which is what keeps
|
||||
// this feature invasive but shallow: REST handlers need no edits at all and every
|
||||
// UI mutation lands in history for free. Only the agent wraps a whole turn.
|
||||
//
|
||||
// Note a multi-row operation (FillRegion, ClearObject) passes all of its changes
|
||||
// in ONE call, so it auto-scopes into one change set with N revisions rather than
|
||||
// N change sets.
|
||||
//
|
||||
// Recording is best-effort by design: the row is already written, so failing the
|
||||
// caller here would report a failure that didn't happen and invite a duplicate
|
||||
// retry. A history gap is logged loudly instead.
|
||||
func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary string, changes ...change) {
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
revs, err := toRevisions(changes)
|
||||
if err != nil {
|
||||
slog.Error("service: snapshot revisions", "error", err, "garden", gardenID, "summary", summary)
|
||||
return
|
||||
}
|
||||
if sc := scopeFrom(ctx); sc != nil {
|
||||
sc.append(revs)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
|
||||
}, revs); err != nil {
|
||||
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// toRevisions marshals each change's before/after rows to JSON snapshots.
|
||||
func toRevisions(changes []change) ([]domain.Revision, error) {
|
||||
revs := make([]domain.Revision, 0, len(changes))
|
||||
for _, c := range changes {
|
||||
before, err := snapshot(c.before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := snapshot(c.after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revs = append(revs, domain.Revision{
|
||||
EntityType: c.entityType, EntityID: c.entityID, Op: c.op,
|
||||
Before: before, After: after,
|
||||
})
|
||||
}
|
||||
return revs, nil
|
||||
}
|
||||
|
||||
// snapshot marshals a row to a JSON string, preserving nil as a NULL column.
|
||||
func snapshot(v any) (*string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service: marshal snapshot: %w", err)
|
||||
}
|
||||
s := string(b)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GardenHistory returns a page of a garden's change sets, newest first, for an
|
||||
// actor who can at least view it, plus whether more pages follow. limit is
|
||||
// clamped to [1, maxHistoryPageSize].
|
||||
//
|
||||
// hasMore is answered by reading one row beyond the page and discarding it,
|
||||
// rather than by a second COUNT over a table that only grows. Keeping that here
|
||||
// (not in the handler) means the clamp and the probe can't disagree.
|
||||
func (s *Service) GardenHistory(ctx context.Context, actorID, gardenID int64, limit, offset int) ([]domain.ChangeSet, bool, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultHistoryPageSize
|
||||
}
|
||||
if limit > maxHistoryPageSize {
|
||||
limit = maxHistoryPageSize
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
sets, err := s.store.ListChangeSets(ctx, gardenID, limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(sets) > limit {
|
||||
return sets[:limit], true, nil
|
||||
}
|
||||
return sets, false, nil
|
||||
}
|
||||
|
||||
// RevertChangeSet undoes a change set by applying the inverse of each of its
|
||||
// revisions, inside one NEW change set that points back at the target — so the
|
||||
// undo is itself undoable. Requires editor role on the garden. source says who
|
||||
// asked (the agent can undo its own work, and the history badge should show that).
|
||||
//
|
||||
// Entities that changed after the target change set are reported as conflicts and
|
||||
// left untouched; everything else still reverts. Returns the new change set (nil
|
||||
// when nothing needed reverting) alongside those conflicts.
|
||||
//
|
||||
// The inverses are applied one row at a time rather than in a single
|
||||
// transaction, because the store's version-guarded update path is per-row. That
|
||||
// means a failure partway leaves the garden partly reverted — so whatever DID
|
||||
// apply is recorded before the error is returned, and the partial revert is
|
||||
// itself a change set you can undo. Losing the history for changes that really
|
||||
// happened would be strictly worse than the partial state.
|
||||
func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int64, source string) (*domain.ChangeSet, []domain.RevertConflict, error) {
|
||||
target, err := s.store.GetChangeSet(ctx, changeSetID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := s.requireGardenRole(ctx, actorID, target.GardenID, roleEditor); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !validChangeSource(source) {
|
||||
return nil, nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
conflicts := []domain.RevertConflict{}
|
||||
sc := &changeScope{
|
||||
gardenID: target.GardenID, actorID: actorID, source: source,
|
||||
summary: revertSummary(target),
|
||||
}
|
||||
// A change set may hold several revisions for the SAME entity (an agent turn
|
||||
// that moves a bed and then renames it). Each inverse bumps that row's
|
||||
// version, so from the second one on, the version in the snapshot no longer
|
||||
// matches the live row — and a naive guard would call our own work a conflict.
|
||||
// This tracks what we just wrote so the guard compares against reality.
|
||||
applied := map[entityKey]int64{}
|
||||
|
||||
for _, r := range planRevert(target.Revisions) {
|
||||
changes, conflict, err := s.applyInverse(ctx, r, applied)
|
||||
if err != nil {
|
||||
// Record what actually landed before surfacing the failure, so the
|
||||
// partial revert is visible and undoable rather than orphaned.
|
||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if conflict != nil {
|
||||
conflicts = append(conflicts, *conflict)
|
||||
continue
|
||||
}
|
||||
revs, err := toRevisions(changes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sc.append(revs)
|
||||
}
|
||||
|
||||
cs, err := s.commitScope(ctx, sc, &target.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cs, conflicts, nil
|
||||
}
|
||||
|
||||
// entityKey identifies a row across the entity types a revision can name.
|
||||
type entityKey struct {
|
||||
entityType string
|
||||
id int64
|
||||
}
|
||||
|
||||
func keyOf(r domain.Revision) entityKey {
|
||||
return entityKey{entityType: r.EntityType, id: r.EntityID}
|
||||
}
|
||||
|
||||
// planRevert orders a change set's revisions for inverse application, because the
|
||||
// inverses carry foreign-key dependencies the original operations did not. Five
|
||||
// passes, in this order:
|
||||
//
|
||||
// 0. restore deleted OBJECTS — parents first, or a restored plop has no object
|
||||
// to hang off and its FK rejects it;
|
||||
// 1. restore everything else deleted;
|
||||
// 2. undo updates;
|
||||
// 3. delete created non-objects (plantings) — children before parents;
|
||||
// 4. delete created OBJECTS last, so a delete never leans on the cascade to
|
||||
// clean up rows this revert is itself responsible for.
|
||||
//
|
||||
// Reverse seq order within each pass: the last change made is the first undone.
|
||||
// The passes are explicit rather than relying on reverse seq happening to order
|
||||
// parents correctly, which it only does by luck.
|
||||
func planRevert(revs []domain.Revision) []domain.Revision {
|
||||
rank := func(r domain.Revision) int {
|
||||
switch {
|
||||
case r.Op == domain.OpDelete && r.EntityType == domain.EntityObject:
|
||||
return 0
|
||||
case r.Op == domain.OpDelete:
|
||||
return 1
|
||||
case r.Op == domain.OpUpdate:
|
||||
return 2
|
||||
case r.Op == domain.OpCreate && r.EntityType == domain.EntityObject:
|
||||
return 4
|
||||
default: // create, non-object
|
||||
return 3
|
||||
}
|
||||
}
|
||||
planned := make([]domain.Revision, 0, len(revs))
|
||||
for pass := 0; pass <= 4; pass++ {
|
||||
for i := len(revs) - 1; i >= 0; i-- {
|
||||
if rank(revs[i]) == pass {
|
||||
planned = append(planned, revs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
// applyInverse undoes one revision, returning the changes it made (to be recorded
|
||||
// in the revert's own change set), or a conflict describing why it declined. A
|
||||
// returned error is an infrastructure failure and aborts the revert; a conflict
|
||||
// is a normal outcome that skips just this entity.
|
||||
//
|
||||
// applied carries the versions this revert has already written, so a change set
|
||||
// holding several revisions for one entity doesn't mistake its own earlier work
|
||||
// for someone else's edit.
|
||||
func (s *Service) applyInverse(ctx context.Context, r domain.Revision, applied map[entityKey]int64) ([]change, *domain.RevertConflict, error) {
|
||||
switch r.EntityType {
|
||||
case domain.EntityObject:
|
||||
return revertEntity(ctx, s, r, applied, objectRevertOps(s))
|
||||
case domain.EntityPlanting:
|
||||
return revertEntity(ctx, s, r, applied, plantingRevertOps(s))
|
||||
case domain.EntityGarden:
|
||||
return revertEntity(ctx, s, r, applied, gardenRevertOps(s))
|
||||
default:
|
||||
return nil, conflict(r, domain.ConflictUnsupported, ""), nil
|
||||
}
|
||||
}
|
||||
|
||||
// revertOps is everything reverting one entity type needs. The three
|
||||
// implementations differ only in which store methods they call and how a row
|
||||
// names itself; the ordering, guards and conflict reporting are shared below so
|
||||
// they cannot drift apart.
|
||||
type revertOps[T any] struct {
|
||||
entityType string
|
||||
get func(ctx context.Context, id int64) (*T, error)
|
||||
update func(ctx context.Context, row *T) (*T, error)
|
||||
// restore re-inserts a deleted row under its original id. nil means this
|
||||
// entity type is never deleted (gardens), and a delete revision for it is
|
||||
// reported as unsupported rather than silently skipped.
|
||||
restore func(ctx context.Context, row *T) (*T, error)
|
||||
// remove undoes a creation. It returns the changes it made, because deleting
|
||||
// an object also deletes the plantings hanging off it.
|
||||
remove func(ctx context.Context, row *T) ([]change, error)
|
||||
// blockRemoval optionally refuses a removal, e.g. an object that has gained
|
||||
// plantings since. Returns a conflict reason, or "" to allow it.
|
||||
blockRemoval func(ctx context.Context, row *T) (string, error)
|
||||
version func(row *T) int64
|
||||
setVersion func(row *T, v int64)
|
||||
label func(row *T) string
|
||||
}
|
||||
|
||||
// revertEntity applies the inverse of one revision for one entity type.
|
||||
func revertEntity[T any](
|
||||
ctx context.Context, s *Service, r domain.Revision, applied map[entityKey]int64, ops revertOps[T],
|
||||
) ([]change, *domain.RevertConflict, error) {
|
||||
key := keyOf(r)
|
||||
|
||||
// Inverse of a delete is a restore, under the original id.
|
||||
if r.Op == domain.OpDelete {
|
||||
if ops.restore == nil {
|
||||
return nil, conflict(r, domain.ConflictUnsupported, ""), nil
|
||||
}
|
||||
if existing, err := ops.get(ctx, r.EntityID); err == nil {
|
||||
return nil, conflict(r, domain.ConflictExists, ops.label(existing)), nil
|
||||
} else if !errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
var target T
|
||||
if err := unsnapshot(r.Before, &target); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
restored, err := ops.restore(ctx, &target)
|
||||
if err != nil {
|
||||
// The id may have been taken between the check above and this insert.
|
||||
// Re-check rather than surfacing a driver-specific constraint error:
|
||||
// "someone else has that id now" is a conflict, not a failure.
|
||||
if existing, gerr := ops.get(ctx, r.EntityID); gerr == nil {
|
||||
return nil, conflict(r, domain.ConflictExists, ops.label(existing)), nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
applied[key] = ops.version(restored)
|
||||
return []change{changeCreate(ops.entityType, r.EntityID, restored)}, nil, nil
|
||||
}
|
||||
|
||||
cur, err := ops.get(ctx, r.EntityID)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
if r.Op == domain.OpCreate {
|
||||
return nil, nil, nil // already gone: the inverse is a no-op, not a conflict
|
||||
}
|
||||
return nil, conflict(r, domain.ConflictMissing, ""), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if c := versionGuard(r, ops.version(cur), ops.label(cur), applied[key]); c != nil {
|
||||
return nil, c, nil
|
||||
}
|
||||
|
||||
// Inverse of a create is a removal.
|
||||
if r.Op == domain.OpCreate {
|
||||
if ops.remove == nil {
|
||||
return nil, conflict(r, domain.ConflictUnsupported, ""), nil
|
||||
}
|
||||
if ops.blockRemoval != nil {
|
||||
reason, err := ops.blockRemoval(ctx, cur)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, conflict(r, reason, ops.label(cur)), nil
|
||||
}
|
||||
}
|
||||
changes, err := ops.remove(ctx, cur)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
delete(applied, key)
|
||||
return changes, nil, nil
|
||||
}
|
||||
|
||||
if r.Op != domain.OpUpdate {
|
||||
return nil, conflict(r, domain.ConflictUnsupported, ""), nil
|
||||
}
|
||||
var target T
|
||||
if err := unsnapshot(r.Before, &target); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ops.setVersion(&target, ops.version(cur))
|
||||
updated, err := ops.update(ctx, &target)
|
||||
if errors.Is(err, domain.ErrVersionConflict) {
|
||||
return nil, conflict(r, domain.ConflictChanged, ops.label(cur)), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
applied[key] = ops.version(updated)
|
||||
return []change{changeUpdate(ops.entityType, r.EntityID, cur, updated)}, nil, nil
|
||||
}
|
||||
|
||||
func objectRevertOps(s *Service) revertOps[domain.GardenObject] {
|
||||
return revertOps[domain.GardenObject]{
|
||||
entityType: domain.EntityObject,
|
||||
get: s.store.GetObject,
|
||||
update: s.store.UpdateObject,
|
||||
restore: s.store.RestoreObject,
|
||||
remove: s.deleteObjectRecording,
|
||||
// Undoing "added a bed" would cascade away anything planted in it since.
|
||||
// Those plops are snapshotted, so it would be recoverable — but deleting
|
||||
// someone's plants as a side effect of an unrelated undo should be
|
||||
// reported, not performed quietly.
|
||||
blockRemoval: func(ctx context.Context, o *domain.GardenObject) (string, error) {
|
||||
plops, err := s.store.ListPlantingsForObject(ctx, o.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(plops) > 0 {
|
||||
return domain.ConflictChanged, nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
version: func(o *domain.GardenObject) int64 { return o.Version },
|
||||
setVersion: func(o *domain.GardenObject, v int64) { o.Version = v },
|
||||
label: func(o *domain.GardenObject) string { return o.Name },
|
||||
}
|
||||
}
|
||||
|
||||
func plantingRevertOps(s *Service) revertOps[domain.Planting] {
|
||||
return revertOps[domain.Planting]{
|
||||
entityType: domain.EntityPlanting,
|
||||
get: s.store.GetPlanting,
|
||||
update: s.store.UpdatePlanting,
|
||||
restore: s.store.RestorePlanting,
|
||||
remove: func(ctx context.Context, p *domain.Planting) ([]change, error) {
|
||||
if err := s.store.DeletePlanting(ctx, p.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []change{changeDelete(domain.EntityPlanting, p.ID, p)}, nil
|
||||
},
|
||||
version: func(p *domain.Planting) int64 { return p.Version },
|
||||
setVersion: func(p *domain.Planting, v int64) { p.Version = v },
|
||||
label: func(p *domain.Planting) string { return "" },
|
||||
}
|
||||
}
|
||||
|
||||
// gardenRevertOps has no restore or remove: garden creation and deletion are
|
||||
// never recorded (see the migration), so metadata updates are all that can reach
|
||||
// here, and anything else is honestly reported as unsupported.
|
||||
func gardenRevertOps(s *Service) revertOps[domain.Garden] {
|
||||
return revertOps[domain.Garden]{
|
||||
entityType: domain.EntityGarden,
|
||||
get: s.store.GetGarden,
|
||||
update: s.store.UpdateGarden,
|
||||
version: func(g *domain.Garden) int64 { return g.Version },
|
||||
setVersion: func(g *domain.Garden, v int64) { g.Version = v },
|
||||
label: func(g *domain.Garden) string { return g.Name },
|
||||
}
|
||||
}
|
||||
|
||||
// deleteObjectRecording deletes an object and returns every change to record.
|
||||
// Deleting an object cascades its plantings away in SQLite without the service
|
||||
// ever seeing them, so they are snapshotted first — otherwise the delete could be
|
||||
// listed in history but never reverted. Shared by DeleteObject and the revert of
|
||||
// an object creation, so both stay honest about the cascade.
|
||||
func (s *Service) deleteObjectRecording(ctx context.Context, o *domain.GardenObject) ([]change, error) {
|
||||
plops, err := s.store.ListPlantingsForObject(ctx, o.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.DeleteObject(ctx, o.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes := make([]change, 0, len(plops)+1)
|
||||
changes = append(changes, changeDelete(domain.EntityObject, o.ID, o))
|
||||
for i := range plops {
|
||||
p := plops[i]
|
||||
changes = append(changes, changeDelete(domain.EntityPlanting, p.ID, &p))
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// versionGuard reports a conflict when the row's current version differs from the
|
||||
// one the change set left behind — i.e. something edited it since, and reverting
|
||||
// would silently discard that edit. A revision with no after-snapshot (a delete)
|
||||
// has nothing to compare and passes.
|
||||
//
|
||||
// appliedVersion is non-zero when THIS revert already wrote to this row, which
|
||||
// happens whenever a change set holds more than one revision for the same entity.
|
||||
// In that case the live version is our own doing and is the correct thing to
|
||||
// expect — comparing against the snapshot would flag our own work as a conflict.
|
||||
func versionGuard(r domain.Revision, currentVersion int64, name string, appliedVersion int64) *domain.RevertConflict {
|
||||
if r.After == nil {
|
||||
return nil
|
||||
}
|
||||
expected := appliedVersion
|
||||
if expected == 0 {
|
||||
var after struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*r.After), &after); err != nil {
|
||||
return conflict(r, domain.ConflictUnsupported, name)
|
||||
}
|
||||
expected = after.Version
|
||||
}
|
||||
if expected != currentVersion {
|
||||
return conflict(r, domain.ConflictChanged, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func conflict(r domain.Revision, reason, name string) *domain.RevertConflict {
|
||||
return &domain.RevertConflict{EntityType: r.EntityType, EntityID: r.EntityID, Reason: reason, Name: name}
|
||||
}
|
||||
|
||||
// unsnapshot parses a JSON row snapshot back into a row struct. A missing
|
||||
// snapshot where one is required is a corrupt revision, not a normal outcome.
|
||||
func unsnapshot(s *string, into any) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("service: revision is missing the snapshot it needs")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*s), into); err != nil {
|
||||
return fmt.Errorf("service: parse snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertSummary describes the revert in the history list. Reverting a revert
|
||||
// reads as "Redid …" rather than a stack of nested "Undid" prefixes.
|
||||
func revertSummary(target *domain.ChangeSet) string {
|
||||
verb := "Undid"
|
||||
if target.RevertsID != nil {
|
||||
verb = "Redid"
|
||||
}
|
||||
if target.Summary == "" {
|
||||
return verb + " an earlier change"
|
||||
}
|
||||
return verb + ": " + target.Summary
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// history returns a garden's change sets newest-first, failing the test on error.
|
||||
func history(t *testing.T, s *Service, actor, gardenID int64) []domain.ChangeSet {
|
||||
t.Helper()
|
||||
sets, _, err := s.GardenHistory(context.Background(), actor, gardenID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenHistory: %v", err)
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
// revisionsOf loads a change set's revisions.
|
||||
func revisionsOf(t *testing.T, s *Service, id int64) []domain.Revision {
|
||||
t.Helper()
|
||||
cs, err := s.store.GetChangeSet(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangeSet: %v", err)
|
||||
}
|
||||
return cs.Revisions
|
||||
}
|
||||
|
||||
// TestAutoScopeRecordsEveryMutation is the acceptance criterion that keeps this
|
||||
// feature shallow: a plain REST-shaped mutation, with nobody opening a change
|
||||
// set, still lands in history.
|
||||
func TestAutoScopeRecordsEveryMutation(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
|
||||
sets := history(t, s, owner, g.ID)
|
||||
if len(sets) != 2 {
|
||||
t.Fatalf("got %d change sets, want 2 (create + update)", len(sets))
|
||||
}
|
||||
// Newest first.
|
||||
if sets[0].Summary != "Edited North Bed" {
|
||||
t.Errorf("newest summary = %q", sets[0].Summary)
|
||||
}
|
||||
if sets[0].Source != domain.SourceUI {
|
||||
t.Errorf("source = %q, want ui", sets[0].Source)
|
||||
}
|
||||
if sets[0].ActorID != owner || sets[0].ActorName == "" {
|
||||
t.Errorf("actor not resolved: %+v", sets[0])
|
||||
}
|
||||
if len(sets[0].Counts) != 1 || sets[0].Counts[0].Op != domain.OpUpdate || sets[0].Counts[0].N != 1 {
|
||||
t.Errorf("counts = %+v", sets[0].Counts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionIsOneChangeSet: N plops, one undo. The whole reason the unit of
|
||||
// undo is the operation and not the row.
|
||||
func TestFillRegionIsOneChangeSet(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillNamedRegion: %v", err)
|
||||
}
|
||||
if len(created) < 2 {
|
||||
t.Fatalf("expected a multi-plop fill, got %d", len(created))
|
||||
}
|
||||
|
||||
sets := history(t, s, owner, g.ID)
|
||||
fill := sets[0]
|
||||
revs := revisionsOf(t, s, fill.ID)
|
||||
if len(revs) != len(created) {
|
||||
t.Fatalf("fill produced %d revisions for %d plops", len(revs), len(created))
|
||||
}
|
||||
for _, r := range revs {
|
||||
if r.Op != domain.OpCreate || r.EntityType != domain.EntityPlanting {
|
||||
t.Errorf("unexpected revision %+v", r)
|
||||
}
|
||||
if r.Before != nil || r.After == nil {
|
||||
t.Errorf("a create should snapshot only the after state: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Reverting removes all N and leaves the bed as it was.
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Fatalf("unexpected conflicts: %+v", conflicts)
|
||||
}
|
||||
active, err := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(active) != 0 {
|
||||
t.Errorf("%d plops survived the revert", len(active))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertRestoresObjectGeometry covers the everyday case: move a bed, undo it.
|
||||
func TestRevertRestoresObjectGeometry(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
moved, err := s.UpdateObject(ctx, owner, bed.ID,
|
||||
ObjectPatch{XCM: f64Ptr(300), YCM: f64Ptr(400)}, bed.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
|
||||
sets := history(t, s, owner, g.ID)
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
|
||||
back, err := s.store.GetObject(ctx, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetObject: %v", err)
|
||||
}
|
||||
if back.XCM != bed.XCM || back.YCM != bed.YCM {
|
||||
t.Errorf("position = (%v,%v), want the original (%v,%v)", back.XCM, back.YCM, bed.XCM, bed.YCM)
|
||||
}
|
||||
// The revert is a WRITE, not a rewind: the row's version moves forward.
|
||||
if back.Version <= moved.Version {
|
||||
t.Errorf("version = %d, want > %d (revert is an append-only change)", back.Version, moved.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertOfRevertRestoresTheChange — undo is undoable, because a revert is
|
||||
// itself a change set rather than a rewrite of history.
|
||||
func TestRevertOfRevertRestoresTheChange(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
move := history(t, s, owner, g.ID)[0]
|
||||
|
||||
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("revert: %v", err)
|
||||
}
|
||||
if undo.RevertsID == nil || *undo.RevertsID != move.ID {
|
||||
t.Fatalf("revert doesn't point at its target: %+v", undo)
|
||||
}
|
||||
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
|
||||
t.Fatalf("undo didn't restore x: %v", o.XCM)
|
||||
}
|
||||
|
||||
redo, conflicts, err := s.RevertChangeSet(ctx, owner, undo.ID, domain.SourceUI)
|
||||
if err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert of revert: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 300 {
|
||||
t.Errorf("redo didn't reapply the move: x = %v", o.XCM)
|
||||
}
|
||||
if redo.Summary == "" {
|
||||
t.Error("a revert should carry a summary")
|
||||
}
|
||||
|
||||
// The original is marked as reverted, and every step is still in the list.
|
||||
sets := history(t, s, owner, g.ID)
|
||||
if len(sets) != 4 { // create bed, move, undo, redo
|
||||
t.Fatalf("got %d change sets, want 4", len(sets))
|
||||
}
|
||||
var moveEntry *domain.ChangeSet
|
||||
for i := range sets {
|
||||
if sets[i].ID == move.ID {
|
||||
moveEntry = &sets[i]
|
||||
}
|
||||
}
|
||||
if moveEntry == nil || moveEntry.RevertedByID == nil || *moveEntry.RevertedByID != undo.ID {
|
||||
t.Errorf("the move isn't marked as reverted: %+v", moveEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertConflictsOnLaterEdit is the guard that stops undo from silently
|
||||
// discarding someone's newer work — and the promise that the REST of the change
|
||||
// set still reverts.
|
||||
func TestRevertConflictsOnLaterEdit(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
bedA := seedBed(t, s, owner, g.ID)
|
||||
bedB, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, Name: "B", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateObject: %v", err)
|
||||
}
|
||||
|
||||
// One change set touching both beds.
|
||||
_, err = s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Rearranged"},
|
||||
func(ctx context.Context) error {
|
||||
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{XCM: f64Ptr(600)}, bedA.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{XCM: f64Ptr(300)}, bedB.Version)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
turn := history(t, s, owner, g.ID)[0]
|
||||
if turn.Source != domain.SourceAgent || len(revisionsOf(t, s, turn.ID)) != 2 {
|
||||
t.Fatalf("expected one agent change set with 2 revisions: %+v", turn)
|
||||
}
|
||||
|
||||
// Someone edits bed A afterwards.
|
||||
cur, _ := s.store.GetObject(ctx, bedA.ID)
|
||||
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{Name: strPtr("Touched")}, cur.Version); err != nil {
|
||||
t.Fatalf("later edit: %v", err)
|
||||
}
|
||||
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("got %d conflicts, want 1: %+v", len(conflicts), conflicts)
|
||||
}
|
||||
if conflicts[0].EntityID != bedA.ID || conflicts[0].Reason != domain.ConflictChanged {
|
||||
t.Errorf("unexpected conflict %+v", conflicts[0])
|
||||
}
|
||||
if conflicts[0].Name != "Touched" {
|
||||
t.Errorf("conflict should name the entity as it stands now, got %q", conflicts[0].Name)
|
||||
}
|
||||
|
||||
// A was left alone; B still reverted.
|
||||
a, _ := s.store.GetObject(ctx, bedA.ID)
|
||||
if a.XCM != 600 || a.Name != "Touched" {
|
||||
t.Errorf("conflicted object was modified: %+v", a)
|
||||
}
|
||||
b, _ := s.store.GetObject(ctx, bedB.ID)
|
||||
if b.XCM != bedB.XCM {
|
||||
t.Errorf("bed B should have reverted to %v, got %v", bedB.XCM, b.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertRestoresDeletedObjectWithItsPlantings covers the cascade: deleting an
|
||||
// object takes its plops with it at the FK level, where the service never sees
|
||||
// them. If they aren't snapshotted the delete is listed in history but can't
|
||||
// actually be undone.
|
||||
func TestRevertRestoresDeletedObjectWithItsPlantings(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||
PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePlanting: %v", err)
|
||||
}
|
||||
if err := s.DeleteObject(ctx, owner, bed.ID); err != nil {
|
||||
t.Fatalf("DeleteObject: %v", err)
|
||||
}
|
||||
|
||||
del := history(t, s, owner, g.ID)[0]
|
||||
revs := revisionsOf(t, s, del.ID)
|
||||
if len(revs) != 2 {
|
||||
t.Fatalf("delete recorded %d revisions, want 2 (the bed and its plop)", len(revs))
|
||||
}
|
||||
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
|
||||
back, err := s.store.GetObject(ctx, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("bed not restored: %v", err)
|
||||
}
|
||||
if back.ID != bed.ID || back.Name != bed.Name || back.XCM != bed.XCM {
|
||||
t.Errorf("restored bed differs: %+v", back)
|
||||
}
|
||||
restoredPlop, err := s.store.GetPlanting(ctx, plop.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("plop not restored: %v", err)
|
||||
}
|
||||
if restoredPlop.ObjectID != bed.ID || restoredPlop.XCM != plop.XCM {
|
||||
t.Errorf("restored plop differs: %+v", restoredPlop)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertClearObject: "clear bed" is a bulk soft-remove, and undoing it should
|
||||
// bring the whole bed back planted.
|
||||
func TestRevertClearObject(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
|
||||
n, err := s.ClearObject(ctx, owner, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ClearObject: %v", err)
|
||||
}
|
||||
if n != len(before) {
|
||||
t.Fatalf("cleared %d of %d", n, len(before))
|
||||
}
|
||||
if active, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID); len(active) != 0 {
|
||||
t.Fatalf("%d plops still active after clear", len(active))
|
||||
}
|
||||
|
||||
clear := history(t, s, owner, g.ID)[0]
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, clear.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
after, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("%d plops active after undo, want %d", len(after), len(before))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertPermissions: a viewer may read history but not undo; a stranger sees
|
||||
// neither (existence stays masked).
|
||||
func TestRevertPermissions(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
viewer := seedUser(t, s, "[email protected]")
|
||||
stranger := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("AddShare: %v", err)
|
||||
}
|
||||
_ = bed
|
||||
cs := history(t, s, owner, g.ID)[0]
|
||||
|
||||
if _, _, err := s.GardenHistory(ctx, viewer, g.ID, 0, 0); err != nil {
|
||||
t.Errorf("a viewer should be able to read history: %v", err)
|
||||
}
|
||||
if _, _, err := s.RevertChangeSet(ctx, viewer, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("viewer revert err = %v, want ErrForbidden", err)
|
||||
}
|
||||
if _, _, err := s.GardenHistory(ctx, stranger, g.ID, 0, 0); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("stranger history err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, _, err := s.RevertChangeSet(ctx, stranger, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("stranger revert err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithChangeSetRecordsWhatCommittedOnFailure — a turn that fails partway has
|
||||
// still committed the mutations it got through, because this scope buffers
|
||||
// HISTORY, not data. Dropping the buffer would leave those changes with no
|
||||
// history and no way to undo them, which is exactly the situation undo exists
|
||||
// for. So they're recorded, marked as partial, and the failure is still reported.
|
||||
func TestWithChangeSetRecordsWhatCommittedOnFailure(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
sentinel := errors.New("boom")
|
||||
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Half a turn"},
|
||||
func(ctx context.Context) error {
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want the sentinel", err)
|
||||
}
|
||||
|
||||
// The move really happened, so it must be in history and undoable.
|
||||
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 600 {
|
||||
t.Fatalf("the committed move was rolled back? x = %v", o.XCM)
|
||||
}
|
||||
sets := history(t, s, owner, g.ID)
|
||||
partial := sets[0]
|
||||
if !strings.Contains(partial.Summary, "failed partway") {
|
||||
t.Errorf("summary = %q, want it to say the turn failed partway", partial.Summary)
|
||||
}
|
||||
if len(revisionsOf(t, s, partial.ID)) != 1 {
|
||||
t.Errorf("expected the one committed mutation to be recorded")
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, partial.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
|
||||
t.Errorf("undo of the partial turn didn't restore x: %v", o.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangeSetSkippedWhenNothingChanged — a scope that mutates nothing writes no
|
||||
// change set, so history isn't padded with empty entries.
|
||||
func TestChangeSetSkippedWhenNothingChanged(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Looked around"},
|
||||
func(context.Context) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
if cs != nil {
|
||||
t.Errorf("expected no change set, got %+v", cs)
|
||||
}
|
||||
if got := len(history(t, s, owner, g.ID)); got != before {
|
||||
t.Errorf("history grew by %d", got-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGardenMetadataRevert covers the third entity type.
|
||||
func TestGardenMetadataRevert(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
|
||||
Name: "Renamed", WidthCM: 1200, HeightCM: 1000, UnitPref: domain.UnitImperial,
|
||||
}, g.Version); err != nil {
|
||||
t.Fatalf("UpdateGarden: %v", err)
|
||||
}
|
||||
cs := history(t, s, owner, g.ID)[0]
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
back, _ := s.store.GetGarden(ctx, g.ID)
|
||||
if back.Name != g.Name || back.WidthCM != g.WidthCM {
|
||||
t.Errorf("garden not restored: %+v", back)
|
||||
}
|
||||
// MyRole is computed, never stored — the snapshot must not have carried it
|
||||
// back into the row.
|
||||
if back.MyRole != "" {
|
||||
t.Errorf("MyRole leaked into the stored row: %q", back.MyRole)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanRevertOrdersRestoresParentFirst pins the ordering the FKs depend on,
|
||||
// rather than trusting reverse-seq to get it right by luck.
|
||||
func TestPlanRevertOrdersRestoresParentFirst(t *testing.T) {
|
||||
revs := []domain.Revision{
|
||||
{Seq: 1, EntityType: domain.EntityPlanting, EntityID: 10, Op: domain.OpDelete},
|
||||
{Seq: 2, EntityType: domain.EntityObject, EntityID: 1, Op: domain.OpDelete},
|
||||
{Seq: 3, EntityType: domain.EntityObject, EntityID: 2, Op: domain.OpCreate},
|
||||
{Seq: 4, EntityType: domain.EntityPlanting, EntityID: 11, Op: domain.OpCreate},
|
||||
{Seq: 5, EntityType: domain.EntityObject, EntityID: 3, Op: domain.OpUpdate},
|
||||
}
|
||||
got := planRevert(revs)
|
||||
want := []struct {
|
||||
entity string
|
||||
id int64
|
||||
}{
|
||||
{domain.EntityObject, 1}, // restore parents first
|
||||
{domain.EntityPlanting, 10}, // then children
|
||||
{domain.EntityObject, 3}, // then updates
|
||||
{domain.EntityPlanting, 11}, // then delete created children
|
||||
{domain.EntityObject, 2}, // and only then their parents
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("planned %d revisions, want %d", len(got), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].EntityType != w.entity || got[i].EntityID != w.id {
|
||||
t.Errorf("step %d = %s/%d, want %s/%d", i, got[i].EntityType, got[i].EntityID, w.entity, w.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotsCaptureVersion — the revert guard compares against the version in
|
||||
// the after-snapshot, so it has to actually be there.
|
||||
func TestSnapshotsCaptureVersion(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
|
||||
revs := revisionsOf(t, s, history(t, s, owner, g.ID)[0].ID)
|
||||
if len(revs) != 1 || revs[0].After == nil {
|
||||
t.Fatalf("unexpected revisions %+v", revs)
|
||||
}
|
||||
var snap domain.GardenObject
|
||||
if err := json.Unmarshal([]byte(*revs[0].After), &snap); err != nil {
|
||||
t.Fatalf("snapshot isn't a garden object: %v", err)
|
||||
}
|
||||
if snap.Version != bed.Version || snap.ID != bed.ID {
|
||||
t.Errorf("snapshot = %+v, want version %d id %d", snap, bed.Version, bed.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func f64Ptr(v float64) *float64 { return &v }
|
||||
|
||||
// TestRevertChangeSetTouchingOneEntityTwice — an agent turn that moves a bed and
|
||||
// then renames it holds two revisions for the same object. Each inverse bumps
|
||||
// that row's version, so from the second one on the snapshot's version no longer
|
||||
// matches the live row. Without tracking what the revert itself just wrote, the
|
||||
// guard flags our own work as somebody else's edit and the undo half-fails.
|
||||
func TestRevertChangeSetTouchingOneEntityTwice(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Two edits"},
|
||||
func(ctx context.Context) error {
|
||||
moved, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("Renamed")}, moved.Version)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
turn := history(t, s, owner, g.ID)[0]
|
||||
if len(revisionsOf(t, s, turn.ID)) != 2 {
|
||||
t.Fatalf("expected 2 revisions for the same object")
|
||||
}
|
||||
|
||||
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Fatalf("the revert conflicted with its own work: %+v", conflicts)
|
||||
}
|
||||
back, _ := s.store.GetObject(ctx, bed.ID)
|
||||
if back.XCM != bed.XCM || back.Name != bed.Name {
|
||||
t.Errorf("both edits should have been undone, got x=%v name=%q", back.XCM, back.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertOfObjectCreateRefusesWhenPlanted — undoing "added a bed" would
|
||||
// cascade away anything planted in it since. Those plops would be snapshotted and
|
||||
// so recoverable, but deleting someone's plants as a side effect of an unrelated
|
||||
// undo should be reported, not performed quietly.
|
||||
func TestRevertOfObjectCreateRefusesWhenPlanted(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
create := history(t, s, owner, g.ID)[0]
|
||||
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreatePlanting: %v", err)
|
||||
}
|
||||
|
||||
cs, conflicts, err := s.RevertChangeSet(ctx, owner, create.ID, domain.SourceUI)
|
||||
if err != nil {
|
||||
t.Fatalf("RevertChangeSet: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 || conflicts[0].EntityID != bed.ID {
|
||||
t.Fatalf("expected one conflict naming the bed, got %+v", conflicts)
|
||||
}
|
||||
if cs != nil {
|
||||
t.Errorf("nothing should have been reverted, got change set %+v", cs)
|
||||
}
|
||||
if _, err := s.store.GetObject(ctx, bed.ID); err != nil {
|
||||
t.Errorf("the planted bed was deleted anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryDoesNotDuplicateMultiplyRevertedEntries — more than one change set
|
||||
// can point at the same target (undo, redo, undo again). Looking that up with a
|
||||
// LEFT JOIN would emit one duplicate row per revert and silently corrupt the
|
||||
// page, so it is a scalar subquery instead. Written against the store because
|
||||
// getting a target legitimately reverted twice through the service is hard: the
|
||||
// second attempt conflicts, which is itself the correct behaviour.
|
||||
func TestHistoryDoesNotDuplicateMultiplyRevertedEntries(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
ctx := context.Background()
|
||||
|
||||
target := history(t, s, owner, g.ID) // garden create isn't recorded; start empty
|
||||
if len(target) != 0 {
|
||||
t.Fatalf("expected an empty history, got %+v", target)
|
||||
}
|
||||
base, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Base",
|
||||
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteChangeSet: %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Undo", RevertsID: &base.ID,
|
||||
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}}); err != nil {
|
||||
t.Fatalf("WriteChangeSet revert %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
sets := history(t, s, owner, g.ID)
|
||||
if len(sets) != 3 {
|
||||
t.Fatalf("got %d change sets, want 3 — a join would have duplicated the base row: %+v", len(sets), sets)
|
||||
}
|
||||
seen := map[int64]int{}
|
||||
for _, cs := range sets {
|
||||
seen[cs.ID]++
|
||||
}
|
||||
for id, n := range seen {
|
||||
if n != 1 {
|
||||
t.Errorf("change set %d appears %d times", id, n)
|
||||
}
|
||||
}
|
||||
for _, cs := range sets {
|
||||
if cs.ID == base.ID && (cs.RevertedByID == nil || *cs.RevertedByID == 0) {
|
||||
t.Error("the base change set should be marked as reverted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertSourceIsRecorded — an agent undoing its own work must be
|
||||
// distinguishable from a person clicking undo, or the history badge lies.
|
||||
func TestRevertSourceIsRecorded(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
move := history(t, s, owner, g.ID)[0]
|
||||
|
||||
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("revert: %v", err)
|
||||
}
|
||||
if undo.Source != domain.SourceAgent {
|
||||
t.Errorf("source = %q, want agent", undo.Source)
|
||||
}
|
||||
if _, _, err := s.RevertChangeSet(ctx, owner, move.ID, "nonsense"); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("an unknown source should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearObjectOnlyClearsWhatItSnapshotted — the clear targets the exact ids it
|
||||
// read, so a plop created between the read and the UPDATE can't be removed
|
||||
// without a revision to undo it by.
|
||||
func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
|
||||
n, err := s.ClearObject(ctx, owner, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ClearObject: %v", err)
|
||||
}
|
||||
clear := history(t, s, owner, g.ID)[0]
|
||||
revs := revisionsOf(t, s, clear.ID)
|
||||
// Every row the clear touched has a revision; the count and the revisions agree.
|
||||
if n != len(before) || len(revs) != n {
|
||||
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Revision history: the undo substrate (#48).
|
||||
--
|
||||
-- The unit of undo is the OPERATION, not the row. "Empty the garlic bed and
|
||||
-- plant cucumbers" touches one object and many plantings; undoing half of that
|
||||
-- is worse than useless. So a change_set groups the row-level revisions it
|
||||
-- produced, and revert replays their inverses.
|
||||
--
|
||||
-- Two properties this schema is built around:
|
||||
-- * Revert is itself a change set (reverts_id points at its target). History is
|
||||
-- append-only and never rewritten, so an undo can be undone. git revert, not
|
||||
-- git reset.
|
||||
-- * Every revision carries full JSON row snapshots INCLUDING version, which is
|
||||
-- what the revert guard compares against so a later edit is reported as a
|
||||
-- conflict rather than silently clobbered.
|
||||
--
|
||||
-- JSON snapshots rather than a shadow table per entity: the rows are small,
|
||||
-- household scale makes storage a non-issue, and one code path covers all three
|
||||
-- entity types.
|
||||
--
|
||||
-- Deliberate gap: garden DELETION is not revertible. Dropping a garden cascades
|
||||
-- its objects and plantings without the service ever seeing them, and the
|
||||
-- ON DELETE CASCADE below would take the history with it. The history UI says so
|
||||
-- rather than pretending otherwise.
|
||||
CREATE TABLE change_sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||
actor_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL CHECK (source IN ('ui', 'agent', 'api')),
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
agent_run_id TEXT, -- executus run id when source='agent'
|
||||
reverts_id INTEGER REFERENCES change_sets (id), -- set when this change set reverts another
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- The history panel's only query: one garden, newest first.
|
||||
CREATE INDEX idx_change_sets_garden ON change_sets (garden_id, created_at DESC);
|
||||
|
||||
-- "Has this change set been reverted?" — the list marks reverted entries, so the
|
||||
-- lookup by target must not be a scan.
|
||||
CREATE INDEX idx_change_sets_reverts ON change_sets (reverts_id) WHERE reverts_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE revisions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
change_set_id INTEGER NOT NULL REFERENCES change_sets (id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL, -- order within the change set
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('garden', 'object', 'planting')),
|
||||
entity_id INTEGER NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('create', 'update', 'delete')),
|
||||
before TEXT, -- JSON row snapshot; NULL for create
|
||||
after TEXT -- JSON row snapshot; NULL for delete
|
||||
);
|
||||
|
||||
CREATE INDEX idx_revisions_change_set ON revisions (change_set_id, seq);
|
||||
@@ -61,6 +61,33 @@ func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// RestoreObject re-inserts a deleted object under its ORIGINAL id, preserving
|
||||
// version and timestamps — the inverse of a delete, for reverting a change set
|
||||
// (#48). SQLite allows an explicit INTEGER PRIMARY KEY insert once the row is
|
||||
// gone; if the id has been taken again the insert fails on the primary key, which
|
||||
// is the correct outcome (the service checks first and reports a conflict).
|
||||
//
|
||||
// Deliberately not routed through objectInsert: that omits id/version/timestamps
|
||||
// because a normal create must let the database assign them.
|
||||
func (d *DB) RestoreObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) {
|
||||
restored, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes,
|
||||
version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
RETURNING `+objectColumns,
|
||||
o.ID, o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes, o.Version, o.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: restore object: %w", err)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// GetObject returns the object with the given id, or domain.ErrNotFound.
|
||||
func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, error) {
|
||||
o, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
|
||||
@@ -71,15 +71,59 @@ func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) (
|
||||
objectID)
|
||||
}
|
||||
|
||||
// ClearObjectPlantings soft-removes every active plop in an object in one UPDATE
|
||||
// ListPlantingsForObject returns every plop in an object, removed ones included.
|
||||
// Used when an object is deleted: the FK cascades its plantings away without the
|
||||
// service seeing them, so they are snapshotted first or the delete would not be
|
||||
// revertible. Always a non-nil slice.
|
||||
func (d *DB) ListPlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
||||
return queryPlantings(ctx, d.sql,
|
||||
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? ORDER BY id`,
|
||||
objectID)
|
||||
}
|
||||
|
||||
// RestorePlanting re-inserts a deleted plop under its ORIGINAL id, preserving
|
||||
// version and timestamps — the plop counterpart of RestoreObject, and subject to
|
||||
// the same reasoning. Its parent object must exist again first, or the FK
|
||||
// rejects it; the revert orders object restores ahead of planting restores.
|
||||
func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
||||
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO plantings
|
||||
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
|
||||
version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
RETURNING `+plantingColumns,
|
||||
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
|
||||
p.PlantedAt, p.RemovedAt, p.Version, p.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: restore planting: %w", err)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// ClearObjectPlantings soft-removes the given plops of an object in one UPDATE
|
||||
// (sets removed_at=date, bumps version) and returns how many rows it affected.
|
||||
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string) (int, error) {
|
||||
//
|
||||
// It takes explicit ids rather than clearing "every active plop" so the caller
|
||||
// can snapshot exactly the rows it is about to change. Clearing by predicate
|
||||
// would let a plop created between the caller's read and this UPDATE be removed
|
||||
// without a revision — cleared, but with no way to undo it.
|
||||
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string, ids []int64) (int, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
args = append(args, date, objectID)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := d.sql.ExecContext(ctx,
|
||||
`UPDATE plantings
|
||||
SET removed_at = ?, version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE object_id = ? AND removed_at IS NULL`,
|
||||
date, objectID,
|
||||
WHERE object_id = ? AND removed_at IS NULL AND id IN (`+placeholders(len(ids))+`)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: clear object plantings: %w", err)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// changeSetColumns lists change_sets columns in the order scanChangeSet expects.
|
||||
const changeSetColumns = `id, garden_id, actor_id, source, summary, agent_run_id, reverts_id, created_at`
|
||||
|
||||
func scanChangeSet(s scanner) (*domain.ChangeSet, error) {
|
||||
var cs domain.ChangeSet
|
||||
if err := s.Scan(
|
||||
&cs.ID, &cs.GardenID, &cs.ActorID, &cs.Source, &cs.Summary,
|
||||
&cs.AgentRunID, &cs.RevertsID, &cs.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cs, nil
|
||||
}
|
||||
|
||||
// revisionColumns lists revisions columns in the order scanRevision expects.
|
||||
const revisionColumns = `id, change_set_id, seq, entity_type, entity_id, op, before, after`
|
||||
|
||||
func scanRevision(s scanner) (*domain.Revision, error) {
|
||||
var r domain.Revision
|
||||
if err := s.Scan(
|
||||
&r.ID, &r.ChangeSetID, &r.Seq, &r.EntityType, &r.EntityID, &r.Op, &r.Before, &r.After,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// WriteChangeSet inserts a change set and all of its revisions in one
|
||||
// transaction, so history never records half an operation. The service buffers
|
||||
// revisions while the operation runs and calls this once it has succeeded —
|
||||
// which is also why an operation that fails partway leaves no change set behind.
|
||||
// seq is assigned from the slice order. Returns the stored change set.
|
||||
func (d *DB) WriteChangeSet(ctx context.Context, cs *domain.ChangeSet, revs []domain.Revision) (*domain.ChangeSet, error) {
|
||||
tx, err := d.sql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: begin change set: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
created, err := scanChangeSet(tx.QueryRowContext(ctx,
|
||||
`INSERT INTO change_sets (garden_id, actor_id, source, summary, agent_run_id, reverts_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+changeSetColumns,
|
||||
cs.GardenID, cs.ActorID, cs.Source, cs.Summary, cs.AgentRunID, cs.RevertsID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert change set: %w", err)
|
||||
}
|
||||
|
||||
for i := range revs {
|
||||
r := &revs[i]
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO revisions (change_set_id, seq, entity_type, entity_id, op, before, after)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
created.ID, int64(i+1), r.EntityType, r.EntityID, r.Op, r.Before, r.After,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: insert revision: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("store: commit change set: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// ListChangeSets returns a garden's change sets newest-first, one page at a time.
|
||||
// Each carries its actor's display name, the id of the change set that reverted
|
||||
// it (if any), and per-(entity,op) counts — everything the history list renders,
|
||||
// without a second round-trip per row. Always a non-nil slice.
|
||||
func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset int) ([]domain.ChangeSet, error) {
|
||||
// The "was this reverted?" lookup is a scalar subquery, NOT a LEFT JOIN: a
|
||||
// change set can be reverted more than once (undo, redo, undo again), and a
|
||||
// join would then emit one duplicate row per revert and silently corrupt the
|
||||
// page. MIN(id) names the first revert, which is the one worth showing.
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+qualifyColumns("cs", changeSetColumns)+`, u.display_name,
|
||||
(SELECT MIN(r.id) FROM change_sets r WHERE r.reverts_id = cs.id)
|
||||
FROM change_sets cs
|
||||
JOIN users u ON u.id = cs.actor_id
|
||||
WHERE cs.garden_id = ?
|
||||
ORDER BY cs.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
gardenID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list change sets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sets := []domain.ChangeSet{}
|
||||
ids := []any{}
|
||||
byID := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var cs domain.ChangeSet
|
||||
if err := rows.Scan(
|
||||
&cs.ID, &cs.GardenID, &cs.ActorID, &cs.Source, &cs.Summary,
|
||||
&cs.AgentRunID, &cs.RevertsID, &cs.CreatedAt, &cs.ActorName, &cs.RevertedByID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan change set: %w", err)
|
||||
}
|
||||
byID[cs.ID] = len(sets)
|
||||
ids = append(ids, cs.ID)
|
||||
sets = append(sets, cs)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate change sets: %w", err)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// One grouped query for the whole page rather than N per-row counts.
|
||||
countRows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT change_set_id, entity_type, op, COUNT(*)
|
||||
FROM revisions
|
||||
WHERE change_set_id IN (`+placeholders(len(ids))+`)
|
||||
GROUP BY change_set_id, entity_type, op
|
||||
ORDER BY change_set_id, entity_type, op`,
|
||||
ids...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: count revisions: %w", err)
|
||||
}
|
||||
defer countRows.Close()
|
||||
for countRows.Next() {
|
||||
var csID int64
|
||||
var c domain.ChangeCount
|
||||
if err := countRows.Scan(&csID, &c.EntityType, &c.Op, &c.N); err != nil {
|
||||
return nil, fmt.Errorf("store: scan revision count: %w", err)
|
||||
}
|
||||
if i, ok := byID[csID]; ok {
|
||||
sets[i].Counts = append(sets[i].Counts, c)
|
||||
}
|
||||
}
|
||||
if err := countRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate revision counts: %w", err)
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// GetChangeSet returns one change set with its revisions loaded in seq order, or
|
||||
// domain.ErrNotFound.
|
||||
func (d *DB) GetChangeSet(ctx context.Context, id int64) (*domain.ChangeSet, error) {
|
||||
cs, err := scanChangeSet(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+changeSetColumns+` FROM change_sets WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get change set: %w", err)
|
||||
}
|
||||
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT `+revisionColumns+` FROM revisions WHERE change_set_id = ? ORDER BY seq`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list revisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cs.Revisions = []domain.Revision{}
|
||||
for rows.Next() {
|
||||
r, err := scanRevision(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan revision: %w", err)
|
||||
}
|
||||
cs.Revisions = append(cs.Revisions, *r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate revisions: %w", err)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, …" for an IN clause of n values.
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return "NULL"
|
||||
}
|
||||
b := make([]byte, 0, n*3)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
b = append(b, ',', ' ')
|
||||
}
|
||||
b = append(b, '?')
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user