Build image / build-and-push (push) Successful in 19s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
726 lines
27 KiB
Go
726 lines
27 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"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 agent run that produced it,
|
|
// so a change in the history list can be correlated with the run's log lines.
|
|
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)
|
|
// Detached from cancellation on purpose. The commonest reason fn failed is
|
|
// that ctx was cancelled or timed out — and using that same dead context
|
|
// to record what committed would fail too, losing the history for changes
|
|
// that really happened. That is precisely the case this path exists for.
|
|
if _, cerr := s.commitScope(context.WithoutCancel(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. Detached
|
|
// from cancellation for the same reason as WithChangeSet's path: a
|
|
// timed-out context can't be used to write the record of what it did.
|
|
if _, cerr := s.commitScope(context.WithoutCancel(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
|
|
}
|
|
// Fill in the per-op counts. commitScope returns the freshly inserted row,
|
|
// which carries no tally — and a caller receiving a change set with an empty
|
|
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
|
|
// That ambiguity is not theoretical: it made the chat panel report a
|
|
// successful undo as "nothing left to undo".
|
|
if cs != nil {
|
|
cs.Counts = countRevisions(sc.taken())
|
|
}
|
|
return cs, conflicts, nil
|
|
}
|
|
|
|
// countRevisions tallies revisions by (entity type, op).
|
|
//
|
|
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
|
|
// change set that has just been written has no rows to GROUP BY yet — the
|
|
// alternative is a second round trip to count what we are already holding.
|
|
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
|
|
// compares the two breakdowns row for row so it can't drift silently.
|
|
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
|
|
type key struct{ entityType, op string }
|
|
seen := map[key]int{}
|
|
order := []key{}
|
|
for _, r := range revs {
|
|
k := key{r.EntityType, r.Op}
|
|
if _, ok := seen[k]; !ok {
|
|
order = append(order, k)
|
|
}
|
|
seen[k]++
|
|
}
|
|
sort.Slice(order, func(i, j int) bool {
|
|
if order[i].entityType != order[j].entityType {
|
|
return order[i].entityType < order[j].entityType
|
|
}
|
|
return order[i].op < order[j].op
|
|
})
|
|
counts := make([]domain.ChangeCount, 0, len(order))
|
|
for _, k := range order {
|
|
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// 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)
|
|
// beforeRestore fixes up a snapshot whose references may have gone stale
|
|
// while it sat in history.
|
|
beforeRestore func(ctx context.Context, row *T) 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
|
|
}
|
|
if ops.beforeRestore != nil {
|
|
if err := ops.beforeRestore(ctx, &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,
|
|
// A snapshot can name a seed lot that has since been deleted. The live
|
|
// rows got their link nulled by ON DELETE SET NULL, but the snapshot
|
|
// still holds the old id, and restoring it would violate the foreign key
|
|
// and abort the whole revert. Drop the dangling link instead: the plant
|
|
// really was in the ground, which is the part worth restoring.
|
|
beforeRestore: func(ctx context.Context, p *domain.Planting) error {
|
|
if p.SeedLotID == nil {
|
|
return nil
|
|
}
|
|
if _, err := s.store.GetSeedLot(ctx, *p.SeedLotID); errors.Is(err, domain.ErrNotFound) {
|
|
p.SeedLotID = nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
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
|
|
}
|