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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user