Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19) #38

Merged
steve merged 2 commits from phase-8-agent-seam into main 2026-07-19 05:16:40 +00:00
4 changed files with 133 additions and 40 deletions
Showing only changes of commit 8b5a91c464 - Show all commits
+27 -8
View File
@@ -10,6 +10,7 @@ import (
"gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store" "gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
) )
@@ -51,9 +52,9 @@ func TestToolboxScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("garden: %v", err) t.Fatalf("garden: %v", err)
} }
Review

🟡 Swallowed CreatePlant errors in test setup

error-handling, maintainability · flagged by 2 models

  • internal/agent/tools_test.go:54-56 — Swallowed errors in test setup CreatePlant returns (*domain.Plant, error) but the test discards the error with _, _ for garlic, basil, and beans. If plant creation fails, the test will later fail with a confusing downstream error (e.g. plantId: 0 rejected) instead of surfacing the root cause. Check the errors like the rest of the test body and like ops_test.go's seedNamedPlant.

🪰 Gadfly · advisory

🟡 **Swallowed CreatePlant errors in test setup** _error-handling, maintainability · flagged by 2 models_ - **`internal/agent/tools_test.go:54-56` — Swallowed errors in test setup** `CreatePlant` returns `(*domain.Plant, error)` but the test discards the error with `_, _` for garlic, basil, and beans. If plant creation fails, the test will later fail with a confusing downstream error (e.g. `plantId: 0` rejected) instead of surfacing the root cause. Check the errors like the rest of the test body and like `ops_test.go`'s `seedNamedPlant`. <sub>🪰 Gadfly · advisory</sub>
garlic, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Garlic", Category: "vegetable", SpacingCM: 15, Color: "#d9d2c5", Icon: "🧄"}) garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
basil, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Basil", Category: "herb", SpacingCM: 25, Color: "#4a7c3f", Icon: "🌿"}) basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
beans, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Beans", Category: "vegetable", SpacingCM: 10, Color: "#6b8e23", Icon: "🫘"}) beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
// create_object → a 400×400 bed. // create_object → a 400×400 bed.
res := call("create_object", map[string]any{ res := call("create_object", map[string]any{
@@ -114,12 +115,15 @@ func TestToolboxScenario(t *testing.T) {
} }
// ACL: a viewer's fill_region is refused (the toolbox runs as that actor). // ACL: a viewer's fill_region is refused (the toolbox runs as that actor).
viewerUser, _ := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"}) viewerUser, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
Outdated
Review

🟡 String literal role instead of domain.RoleViewer constant

maintainability · flagged by 1 model

  • internal/agent/tools_test.go:118 — String literal role "viewer" instead of constant The rest of the service tests (e.g. ops_test.go:171) use domain.RoleViewer. Using a raw string breaks the project's established pattern and is brittle if the canonical value changes. Import internal/domain and use domain.RoleViewer.

🪰 Gadfly · advisory

🟡 **String literal role instead of domain.RoleViewer constant** _maintainability · flagged by 1 model_ - **`internal/agent/tools_test.go:118` — String literal role `"viewer"` instead of constant** The rest of the service tests (e.g. `ops_test.go:171`) use `domain.RoleViewer`. Using a raw string breaks the project's established pattern and is brittle if the canonical value changes. Import `internal/domain` and use `domain.RoleViewer`. <sub>🪰 Gadfly · advisory</sub>
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", "viewer"); err != nil { if err != nil {
t.Fatalf("register viewer: %v", err)
}
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err) t.Fatalf("share: %v", err)
} }
viewerBox := NewToolbox(svc, viewerUser.ID) viewerBox := NewToolbox(svc, viewerUser.ID)
vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(map[string]any{ vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{
"objectId": bed.ID, "region": "all", "plantId": garlic.ID, "objectId": bed.ID, "region": "all", "plantId": garlic.ID,
})}) })})
if !vr.IsError { if !vr.IsError {
1
@@ -127,7 +131,22 @@ func TestToolboxScenario(t *testing.T) {
} }
} }
func mustJSON(v any) json.RawMessage { func mustJSON(t *testing.T, v any) json.RawMessage {
b, _ := json.Marshal(v) t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b return b
} }
func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spacing float64, icon string) *domain.Plant {
t.Helper()
p, err := svc.CreatePlant(context.Background(), owner, service.PlantInput{
Name: name, Category: domain.CategoryVegetable, SpacingCM: spacing, Color: "#4a7c3f", Icon: icon,
})
if err != nil {
t.Fatalf("create plant %s: %v", name, err)
}
return p
}
+57 -32
View File
@@ -15,23 +15,31 @@ import (
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the // requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
// object's center, +x east, +y south, so -y is NORTH). // object's center, +x east, +y south, so -y is NORTH).
// Region is an area in an object's local frame: an axis-aligned rectangle, or a // maxFillPlops bounds a single FillRegion so a huge bed with tiny spacing can't
// circle when Circle is set. NamedRegion produces the common ones. // generate a runaway number of inserts. A bed with thousands of plops is already
// far past any real garden; over the cap we refuse rather than silently truncate.
Outdated
Review

🟡 Region.Circle/CX/CY/Radius fields and contains circle branch are dead/speculative code never produced by NamedRegion and unsupported by hexCenters

maintainability · flagged by 4 models

  • internal/service/ops.go:20-24Region.Circle/CX/CY/Radius are dead/speculative fields. NamedRegion (the only Region constructor in the package) always returns rectangles via rect, which never sets Circle. A repo-wide grep for Circle =/Circle:/Circle: true finds no assignment anywhere — Circle is only ever read in the r.Circle branch of Region.contains (ops.go:27-33), which is therefore unreachable from any production path. hexCenters (ops.go:144-166) also d…

🪰 Gadfly · advisory

🟡 **Region.Circle/CX/CY/Radius fields and contains circle branch are dead/speculative code never produced by NamedRegion and unsupported by hexCenters** _maintainability · flagged by 4 models_ - **`internal/service/ops.go:20-24` — `Region.Circle`/`CX`/`CY`/`Radius` are dead/speculative fields.** `NamedRegion` (the only `Region` constructor in the package) always returns rectangles via `rect`, which never sets `Circle`. A repo-wide grep for `Circle =`/`Circle:`/`Circle: true` finds no assignment anywhere — `Circle` is only ever read in the `r.Circle` branch of `Region.contains` (ops.go:27-33), which is therefore unreachable from any production path. `hexCenters` (ops.go:144-166) also d… <sub>🪰 Gadfly · advisory</sub>
const maxFillPlops = 5000
// Region is an axis-aligned rectangle in an object's local frame. (Circle/polygon
// regions are post-v1, like polygon objects; NamedRegion produces only rects.)
type Region struct { type Region struct {
MinX, MinY, MaxX, MaxY float64 // rectangle (also the circle's bounding box) MinX, MinY, MaxX, MaxY float64
Circle bool
CX, CY, Radius float64 // used when Circle
} }
// contains reports whether a local point lies in the region. // contains reports whether a local point lies in the region.
func (r Region) contains(x, y float64) bool { func (r Region) contains(x, y float64) bool {
if r.Circle {
dx, dy := x-r.CX, y-r.CY
return dx*dx+dy*dy <= r.Radius*r.Radius
}
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
} }
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
// so an oversized caller-supplied region can't make hexCenters loop forever.
func (r Region) clampTo(halfW, halfH float64) Region {
return Region{
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
MaxX: math.Min(r.MaxX, halfW), MaxY: math.Min(r.MaxY, halfH),
}
}
// rect builds a rectangular region. // rect builds a rectangular region.
func rect(minX, minY, maxX, maxY float64) Region { func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY} return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
1
@@ -43,13 +51,16 @@ func rect(minX, minY, maxX, maxY float64) Region {
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North // A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
// is -y (see the file header). Unknown names return ErrInvalidInput. // is -y (see the file header). Unknown names return ErrInvalidInput.
Review

🟡 Empty/blank region name silently maps to "all" instead of returning ErrInvalidInput, contradicting the doc and surprising bad-input handling

error-handling, security · flagged by 2 models

  • internal/service/ops.go:52 — empty/blank region name silently maps to "all" instead of ErrInvalidInput. The doc comment (line 44) states "Unknown names return ErrInvalidInput," but case "all", "": (line 52) accepts an empty string (after trim) as the whole-object rect. Through the agent tool this means fill_region with region: "" (or whitespace) fills the entire object rather than being rejected — a surprising edge case for bad input. Suggested fix: drop "" from the all cas…

🪰 Gadfly · advisory

🟡 **Empty/blank region name silently maps to "all" instead of returning ErrInvalidInput, contradicting the doc and surprising bad-input handling** _error-handling, security · flagged by 2 models_ - **`internal/service/ops.go:52` — empty/blank region name silently maps to `"all"` instead of `ErrInvalidInput`.** The doc comment (line 44) states "Unknown names return ErrInvalidInput," but `case "all", "":` (line 52) accepts an empty string (after trim) as the whole-object rect. Through the agent tool this means `fill_region` with `region: ""` (or whitespace) fills the entire object rather than being rejected — a surprising edge case for bad input. Suggested fix: drop `""` from the `all` cas… <sub>🪰 Gadfly · advisory</sub>
func NamedRegion(o *domain.GardenObject, name string) (Region, error) { func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
if o == nil {
return Region{}, domain.ErrInvalidInput
}
hw, hh := o.WidthCM/2, o.HeightCM/2 hw, hh := o.WidthCM/2, o.HeightCM/2
key := strings.ToLower(strings.TrimSpace(name)) key := strings.ToLower(strings.TrimSpace(name))
key = strings.TrimSpace(strings.TrimSuffix(key, "corner")) key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
key = strings.TrimSpace(strings.TrimSuffix(key, "half")) key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
switch key { switch key {
case "all", "": case "all":
return rect(-hw, -hh, hw, hh), nil return rect(-hw, -hh, hw, hh), nil
case "north", "top": case "north", "top":
return rect(-hw, -hh, hw, 0), nil return rect(-hw, -hh, hw, 0), nil
1
@@ -72,8 +83,8 @@ func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
} }
} }
// defaultPlopRadius is #15's formula for a placed plop's radius: max(1.5×spacing, // defaultPlopRadius is the radius a freshly-placed plop gets from its plant's
// 15cm). Reused here (don't duplicate the constant elsewhere). // spacing: max(1.5×spacing, 15cm) — matching the editor's placement default (#15).
func defaultPlopRadius(spacingCM float64) float64 { func defaultPlopRadius(spacingCM float64) float64 {
return math.Max(1.5*spacingCM, 15) return math.Max(1.5*spacingCM, 15)
} }
@@ -89,6 +100,14 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
}
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
Outdated
Review

🟠 FillRegion silent empty fill when computed radius is NaN/Inf

error-handling · flagged by 2 models

  • internal/service/ops.go:108-134FillRegion's read-then-write is unguarded, so concurrent fills on the same object can create duplicate/overlapping plops. FillRegion loads existing plantings once via ListActivePlantingsForObject (line 108), computes hex-lattice candidates against that snapshot, then inserts one row at a time (line 125) with no transaction, row lock, or version check tying the read to the writes. By contrast, UpdateObject (internal/service/objects.go:136-149)…

🪰 Gadfly · advisory

🟠 **FillRegion silent empty fill when computed radius is NaN/Inf** _error-handling · flagged by 2 models_ - **`internal/service/ops.go:108-134` — `FillRegion`'s read-then-write is unguarded, so concurrent fills on the same object can create duplicate/overlapping plops.** `FillRegion` loads `existing` plantings once via `ListActivePlantingsForObject` (line 108), computes hex-lattice candidates against that snapshot, then inserts one row at a time (line 125) with no transaction, row lock, or version check tying the read to the writes. By contrast, `UpdateObject` (`internal/service/objects.go:136-149`)… <sub>🪰 Gadfly · advisory</sub>
// already loaded and authorized (roleEditor). It clamps the region to the
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
// in one transaction rather than one round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
if !o.Plantable { if !o.Plantable {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
1
@@ -104,33 +123,36 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
spacing = *spacingOverride spacing = *spacingOverride
} }
radius := defaultPlopRadius(spacing) radius := defaultPlopRadius(spacing)
if !isFinite(radius) || radius <= 0 {
return nil, domain.ErrInvalidInput
}
existing, err := s.store.ListActivePlantingsForObject(ctx, objectID) region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers := hexCenters(region, radius)
if len(centers) > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
}
existing, err := s.store.ListActivePlantingsForObject(ctx, o.ID)
if err != nil { if err != nil {
return nil, err return nil, err
Review

localPoint is a one-off struct with no reuse; minor parallel abstraction over plain (x,y) coords

maintainability · flagged by 1 model

  • internal/service/ops.go:138localPoint is a one-off internal struct defined separately from Region and used only by hexCenters/FillRegion. Since Region.contains already takes (x, y float64), localPoint adds a small parallel abstraction with no reuse elsewhere (grep confirms no other references). A plain (x, y float64) return would be one fewer type to track. Trivial; leave if you prefer the named clarity.

🪰 Gadfly · advisory

⚪ **localPoint is a one-off struct with no reuse; minor parallel abstraction over plain (x,y) coords** _maintainability · flagged by 1 model_ - **`internal/service/ops.go:138` — `localPoint` is a one-off internal struct** defined separately from `Region` and used only by `hexCenters`/`FillRegion`. Since `Region.contains` already takes `(x, y float64)`, `localPoint` adds a small parallel abstraction with no reuse elsewhere (grep confirms no other references). A plain `(x, y float64)` return would be one fewer type to track. Trivial; leave if you prefer the named clarity. <sub>🪰 Gadfly · advisory</sub>
} }
today := s.now().UTC().Format(dateLayout) today := s.now().UTC().Format(dateLayout)
halfW, halfH := o.WidthCM/2, o.HeightCM/2 batch := make([]*domain.Planting, 0, len(centers))
created := []domain.Planting{} for _, c := range centers {
for _, c := range hexCenters(region, radius) {
// The center must be inside the object (region ⊆ bounds already, but guard).
if math.Abs(c.x) > halfW || math.Abs(c.y) > halfH {
continue
}
if coveredByExisting(c.x, c.y, radius, existing) { if coveredByExisting(c.x, c.y, radius, existing) {
continue continue
Review

🟡 hexCenters generates the full lattice before the bounds guard filters it; an oversized caller-supplied Region (not clamped to the object) can cause a very long loop / large allocation

error-handling · flagged by 1 model

  • hexCenters can loop a very long time on an oversized caller-supplied Regioninternal/service/ops.go:144-166. FillRegion takes a caller-supplied Region that is never validated to lie within the object or clamped to [-halfW,halfW]×[-halfH,halfH]. hexCenters generates the full lattice before the math.Abs(c.x) > halfW guard at ops.go:118 filters each point, so a region like MinX=-1e9, MaxX=1e9 with a tiny radius would generate a huge lattice only to discard every point.…

🪰 Gadfly · advisory

🟡 **hexCenters generates the full lattice before the bounds guard filters it; an oversized caller-supplied Region (not clamped to the object) can cause a very long loop / large allocation** _error-handling · flagged by 1 model_ - **`hexCenters` can loop a very long time on an oversized caller-supplied `Region`** — `internal/service/ops.go:144-166`. `FillRegion` takes a caller-supplied `Region` that is never validated to lie within the object or clamped to `[-halfW,halfW]×[-halfH,halfH]`. `hexCenters` generates the full lattice *before* the `math.Abs(c.x) > halfW` guard at `ops.go:118` filters each point, so a region like `MinX=-1e9, MaxX=1e9` with a tiny radius would generate a huge lattice only to discard every point.… <sub>🪰 Gadfly · advisory</sub>
} }
p := &domain.Planting{ObjectID: objectID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today} p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
stored, err := s.store.CreatePlanting(ctx, p) batch = append(batch, p)
if err != nil { existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
return nil, err }
} created, err := s.store.CreatePlantings(ctx, batch)
stored.DerivedCount = derivedCount(stored.RadiusCM, spacing) if err != nil {
created = append(created, *stored) return nil, err
// Count what we just placed so later candidates in this same fill don't }
// stack on top of it. for i := range created {
existing = append(existing, *stored) created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing)
} }
return created, nil return created, nil
} }
3
@@ -188,11 +210,14 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.FillRegion(ctx, actorID, objectID, region, plantID, spacingOverride) return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
} }
// ClearObject soft-removes every active plop in an object the actor can edit (one // ClearObject soft-removes every active plop in an object the actor can edit (one
// UPDATE), returning how many were cleared. Distinct from deleting the object. // UPDATE), returning how many were cleared. Distinct from deleting the object.
// Unlike FillRegion it does NOT require the object be plantable: an object toggled
// 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) { func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil { if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
return 0, err return 0, err
2
+19
View File
@@ -41,6 +41,25 @@ func TestNamedRegion(t *testing.T) {
if _, err := NamedRegion(o, "middle-ish"); !errors.Is(err, domain.ErrInvalidInput) { if _, err := NamedRegion(o, "middle-ish"); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown region err = %v, want ErrInvalidInput", err) t.Errorf("unknown region err = %v, want ErrInvalidInput", err)
} }
if _, err := NamedRegion(o, ""); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("empty region err = %v, want ErrInvalidInput", err)
}
if _, err := NamedRegion(nil, "all"); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("nil object err = %v, want ErrInvalidInput", err)
}
}
func TestFillRegionCappedForHugeArea(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Huge", WidthCM: 8000, HeightCM: 8000})
Outdated
Review

🟡 Duplicate seedFillBed helper; seedBed already exists in same package

maintainability · flagged by 1 model

  • internal/service/ops_test.go:56 / internal/service/plantings_test.go:12 — Duplicate test helper seedFillBed vs seedBed. Same package, same purpose (create a plantable bed), only the dimensions/position differ. Parameterize the existing seedBed helper instead of adding a second one.

🪰 Gadfly · advisory

🟡 **Duplicate seedFillBed helper; seedBed already exists in same package** _maintainability · flagged by 1 model_ - **`internal/service/ops_test.go:56` / `internal/service/plantings_test.go:12` — Duplicate test helper `seedFillBed` vs `seedBed`.** Same package, same purpose (create a plantable bed), only the dimensions/position differ. Parameterize the existing `seedBed` helper instead of adding a second one. <sub>🪰 Gadfly · advisory</sub>
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
}
} }
func TestDefaultPlopRadius(t *testing.T) { func TestDefaultPlopRadius(t *testing.T) {
2
+30
View File
1
@@ -132,6 +132,36 @@ func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Pl
return created, nil return created, nil
} }
// CreatePlantings inserts many plops in a single transaction (one commit), for
// bulk fills. Returns the stored rows in order. An empty input is a no-op.
func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting) ([]domain.Planting, error) {
if len(plantings) == 0 {
return []domain.Planting{}, nil
}
tx, err := d.sql.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: begin plantings tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
const stmt = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING ` + plantingColumns
out := make([]domain.Planting, 0, len(plantings))
for _, p := range plantings {
created, err := scanPlanting(tx.QueryRowContext(ctx, stmt,
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt))
if err != nil {
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
}
out = append(out, *created)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("store: commit plantings: %w", err)
}
return out, nil
}
// UpdatePlanting applies a version-guarded update of all mutable columns (the // UpdatePlanting applies a version-guarded update of all mutable columns (the
// service merges partial patches first). Returns the updated row, or // service merges partial patches first). Returns the updated row, or
// (current row, ErrVersionConflict) / ErrNotFound — the same contract as the // (current row, ErrVersionConflict) / ErrNotFound — the same contract as the