Three real bugs, two of which would have quietly corrupted inventory numbers. CopyGarden carried seed_lot_id into the copied plantings, so copying a garden double-counted the original lot's usage — the copy is a plan, not a second planting out of the same packet. It also quietly attached a private lot to a garden that may later be shared, contradicting the "lots are never shared with a garden" invariant this feature was built on. The copy now drops the link. RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the snapshot still holds the old id, so undoing a planting deletion after retiring its lot would violate the foreign key and abort the whole revert. The revert now drops a dangling link before restoring: the plant really was in the ground, which is the part worth restoring. CreateSeedLot returned a lot with Used/Remaining at their zero values, so a packet you just bought reported "0 remaining" — the exact opposite of the truth. The version-conflict path had the same gap, and that row is what the client rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both fill them now. Remaining can go negative and that is deliberate — it means more was planted than the lot recorded buying, which happens (a miscounted packet, a lot entered after the fact). Clamping to zero would hide the discrepancy at exactly the moment it is worth seeing. Now documented and tested rather than incidental. Performance and tidiness: SeedLotUsage is scoped to the lots being filled instead of scanning every planting the owner has, so reading one lot is one lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has; parseNullable moved out of seed_lots.go into the shared request helpers, since every nullable field in the API needs that three-way absent/null/value distinction; finalizePlant validates against its own maxPlantVendorLen rather than borrowing a constant named for seed lots; seedLotForPlanting became checkSeedLotForPlanting, since it validates rather than fetches; and the duplicate intPtr test helper gave way to the existing ptrInt. Declined: recording seed-lot mutations in the revision history. change_sets are anchored to a garden and lots are user-scoped, deliberately — they belong to you, not to any one garden, so there is no garden to record them against. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -90,11 +91,19 @@ func (s *Service) ListSeedLots(ctx context.Context, actorID int64, plantID *int6
|
||||
// when you plant — drifts the moment anything edits or removes a planting behind
|
||||
// its back, and once it has drifted there is no way to tell what the true number
|
||||
// was. Attribution plus arithmetic can always be recomputed and audited.
|
||||
// Remaining may go NEGATIVE, and that is deliberate: it means more was planted
|
||||
// than the lot recorded buying, which is a real thing that happens (a
|
||||
// miscounted packet, a lot entered after the fact). Clamping it to zero would
|
||||
// hide the discrepancy at exactly the moment it is worth seeing.
|
||||
func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {
|
||||
if len(lots) == 0 {
|
||||
return nil
|
||||
}
|
||||
uses, err := s.store.SeedLotUsage(ctx, actorID)
|
||||
ids := make([]int64, 0, len(lots))
|
||||
for i := range lots {
|
||||
ids = append(ids, lots[i].ID)
|
||||
}
|
||||
uses, err := s.store.SeedLotUsage(ctx, actorID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,11 +124,12 @@ func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domai
|
||||
|
||||
// ownSeedLot loads a lot and enforces that the actor owns it. Someone else's lot
|
||||
// is ErrNotFound, not ErrForbidden — existence stays masked, same as gardens and
|
||||
// other users' plants.
|
||||
// other users' plants. A store failure that isn't "no such row" passes through
|
||||
// unchanged; it is not an authorization answer.
|
||||
func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
|
||||
l, err := s.store.GetSeedLot(ctx, lotID)
|
||||
if err != nil {
|
||||
return nil, err // ErrNotFound
|
||||
return nil, err // ErrNotFound for a missing row; anything else is real
|
||||
}
|
||||
if l.OwnerID != actorID {
|
||||
return nil, domain.ErrNotFound
|
||||
@@ -133,11 +143,7 @@ func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
one := []domain.SeedLot{*l}
|
||||
if err := s.fillRemaining(ctx, actorID, one); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &one[0], nil
|
||||
return s.withRemaining(ctx, actorID, l)
|
||||
}
|
||||
|
||||
// CreateSeedLot records a purchase owned by the actor. The plant must be one the
|
||||
@@ -165,7 +171,23 @@ func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotIn
|
||||
if err := finalizeSeedLot(l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.CreateSeedLot(ctx, l)
|
||||
created, err := s.store.CreateSeedLot(ctx, l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A fresh lot has used nothing, but say so explicitly rather than letting the
|
||||
// zero value stand in: an unfilled Remaining reads as "none left" on a packet
|
||||
// you just bought.
|
||||
return s.withRemaining(ctx, actorID, created)
|
||||
}
|
||||
|
||||
// withRemaining fills Used/Remaining on a single lot.
|
||||
func (s *Service) withRemaining(ctx context.Context, actorID int64, l *domain.SeedLot) (*domain.SeedLot, error) {
|
||||
one := []domain.SeedLot{*l}
|
||||
if err := s.fillRemaining(ctx, actorID, one); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &one[0], nil
|
||||
}
|
||||
|
||||
// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.
|
||||
@@ -181,13 +203,24 @@ func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch
|
||||
l.Version = version
|
||||
updated, err := s.store.UpdateSeedLot(ctx, l)
|
||||
if err != nil {
|
||||
// The conflict path carries the CURRENT row back to the client to rebase
|
||||
// on, so it needs its computed fields too — a 409 body reporting
|
||||
// remaining=0 would be worse than no number at all.
|
||||
if updated != nil {
|
||||
if filled, ferr := s.withRemaining(ctx, actorID, updated); ferr == nil {
|
||||
updated = filled
|
||||
}
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
one := []domain.SeedLot{*updated}
|
||||
if ferr := s.fillRemaining(ctx, actorID, one); ferr != nil {
|
||||
return nil, ferr
|
||||
// The write succeeded. If the follow-up read fails, return the row without
|
||||
// its computed fields rather than reporting a failed update that applied.
|
||||
filled, ferr := s.withRemaining(ctx, actorID, updated)
|
||||
if ferr != nil {
|
||||
slog.Error("service: lot updated but remaining could not be computed", "error", ferr, "lot", lotID)
|
||||
return updated, nil
|
||||
}
|
||||
return &one[0], nil
|
||||
return filled, nil
|
||||
}
|
||||
|
||||
// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive
|
||||
@@ -200,11 +233,11 @@ func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error
|
||||
return s.store.DeleteSeedLot(ctx, lotID)
|
||||
}
|
||||
|
||||
// seedLotForPlanting validates a planting's lot attribution: the lot must be the
|
||||
// actor's, and must be a lot OF the plant being planted. Attributing a garlic
|
||||
// checkSeedLotForPlanting validates a planting's lot attribution: the lot must be
|
||||
// the actor's, and must be a lot OF the plant being planted. Attributing a garlic
|
||||
// planting to a tomato lot is a data error that would silently corrupt every
|
||||
// remaining count downstream, so it's refused rather than stored.
|
||||
func (s *Service) seedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
|
||||
func (s *Service) checkSeedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
|
||||
if lotID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user