Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ go run ./main/gui

All entrypoints use `data/mixology.db` by default. Override it with `--db` or `MIXOLOGY_DB`.
CLI, TUI, and GUI processes on the same machine may use that local file concurrently; SQLite
serializes writes and waits up to 10 seconds for a busy writer.
serializes writes and waits up to 10 seconds for a busy writer. The GUI and TUI automatically
re-query after another connection commits; stale edits are rejected with an optimistic-concurrency
conflict rather than overwriting newer data.
Database files created by the former bstore backend are incompatible; reseed them or export/import
their data with the previous application version before opening this version.
They also share actor, logging, and metrics options; run any entrypoint with `--help` for the full
Expand Down
3 changes: 2 additions & 1 deletion app/domains/audit/internal/dao/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import (
)

type AuditEntryRow struct {
ID string
ID string
Revision uint64 `json:"-" store:"revision"`

Action string `store:"index"`

Expand Down
8 changes: 4 additions & 4 deletions app/domains/audit/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func TestAudit_TouchesIncludeIngredientUpdateDrinks(t *testing.T) {
testutil.Ok(t, err)

_, err = f.Ingredients.Update(ctx, &ingredientsmodels.Ingredient{
ID: ingredient.ID,
ID: ingredient.ID, Revision: ingredient.Revision,
Name: "Gin (Updated)",
})
testutil.Ok(t, err)
Expand Down Expand Up @@ -216,7 +216,7 @@ func TestAudit_TouchesIncludeIngredientUpdateMenus(t *testing.T) {
testutil.Ok(t, err)

_, err = f.Ingredients.Update(ctx, &ingredientsmodels.Ingredient{
ID: ingredient.ID,
ID: ingredient.ID, Revision: ingredient.Revision,
Name: "Fresh Lime Juice",
})
testutil.Ok(t, err)
Expand Down Expand Up @@ -249,7 +249,7 @@ func TestAudit_ListFilters(t *testing.T) {
testutil.Ok(t, err)

_, err = f.Ingredients.Update(ctx, &ingredientsmodels.Ingredient{
ID: ing1.ID,
ID: ing1.ID, Revision: ing1.Revision,
Name: "Bourbon (Updated)",
})
testutil.Ok(t, err)
Expand Down Expand Up @@ -315,7 +315,7 @@ func TestAudit_ListExpressionFilters(t *testing.T) {

missingID := entity.IngredientID(cedar.NewEntityUID(entity.TypeIngredient, cedar.String("missing-filter-target")))
_, err = f.Ingredients.Update(f.OwnerContext(), &ingredientsmodels.Ingredient{
ID: missingID, Name: "Missing",
ID: missingID, Revision: 1, Name: "Missing",
})
testutil.ErrorIsNotFound(t, err)

Expand Down
2 changes: 1 addition & 1 deletion app/domains/audit/surfaces/gui/presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func TestPresenterSuppressesStaleOutOfOrderReads(t *testing.T) {
func TestPresenterSnapshotsAreDefensiveAndTouchesSorted(t *testing.T) {
fixture := testutil.NewFixture(t)
ingredient := createAuditedIngredient(t, fixture, "Snapshot")
_, err := fixture.Ingredients.Update(fixture.OwnerContext(), &models.Ingredient{ID: ingredient.ID, Name: "Snapshot updated", Category: models.CategoryOther, Unit: measurement.UnitOz})
_, err := fixture.Ingredients.Update(fixture.OwnerContext(), &models.Ingredient{ID: ingredient.ID, Revision: ingredient.Revision, Name: "Snapshot updated", Category: models.CategoryOther, Unit: measurement.UnitOz})
testutil.Ok(t, err)
presenter := auditPresenter(fixture)
presenter.Refresh()
Expand Down
5 changes: 5 additions & 0 deletions app/domains/audit/surfaces/tui/list_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ func (m *ListViewModel) Interaction() tui.Interaction {
}
func (m *ListViewModel) Update(message tea.Msg) (tui.ViewModel, tea.Cmd) {
switch msg := message.(type) {
case tui.DataInvalidatedMsg:
if !m.actionEnabled(audit.ControlList) {
return m, nil
}
return m, tea.Batch(m.shell.BeginLoading(), m.loadEntries())
case tea.WindowSizeMsg:
m.setSize(msg.Width, msg.Height)
if m.filter != nil {
Expand Down
1 change: 1 addition & 0 deletions app/domains/drinks/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func TestDrinks_CreateGetUpdateDelete(t *testing.T) {

updated, err := f.Drinks.Update(ctx, &models.Drink{
ID: created.ID,
Revision: created.Revision,
Name: "Margarita",
Category: models.DrinkCategoryCocktail,
Glass: models.GlassTypeCoupe,
Expand Down
2 changes: 1 addition & 1 deletion app/domains/drinks/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func TestIngredientUpdatedHandlersAuditEveryDependentWithoutMutatingThem(t *test
menuB := testutil.CreateMenu(t, f, "Menu B", testutil.WithDrink(affectedA))
unrelatedMenu := testutil.CreateMenu(t, f, "Unrelated", testutil.WithDrink(survivor))

_, err := f.Ingredients.Update(ctx, &ingredientsmodels.Ingredient{ID: target.ID, Name: "Renamed Target"})
_, err := f.Ingredients.Update(ctx, &ingredientsmodels.Ingredient{ID: target.ID, Revision: target.Revision, Name: "Renamed Target"})
testutil.Ok(t, err)
for _, want := range []*drinksmodels.Drink{affectedA, affectedB, survivor} {
got, err := f.Drinks.Get(ctx, want.ID)
Expand Down
2 changes: 1 addition & 1 deletion app/domains/drinks/handlers/ingredient-deleted.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func (h *IngredientDeleted) Handle(ctx *middleware.HandlerContext, _e ingredient
if requiresReview {
review.Status = drinksmodels.StatusReviewRequired
}
if err := h.drinkDAO.Update(ctx, review); err != nil {
if err := h.drinkDAO.Update(ctx, &review); err != nil {
return err
}
ctx.TouchEntity(review.ID.EntityUID())
Expand Down
2 changes: 1 addition & 1 deletion app/domains/drinks/internal/commands/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (c *Commands) Create(ctx *middleware.Context, drink *models.Drink) (*models
created.ID = entity.NewDrinkID()
created.Status = models.StatusActive

if err := c.dao.Insert(ctx, created); err != nil {
if err := c.dao.Insert(ctx, &created); err != nil {
return nil, err
}

Expand Down
2 changes: 1 addition & 1 deletion app/domains/drinks/internal/commands/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (c *Commands) Delete(ctx *middleware.Context, drink *models.Drink) (*models
deleted := *drink
deleted.DeletedAt = optional.Some(now)

if err := c.dao.Update(ctx, deleted); err != nil {
if err := c.dao.Update(ctx, &deleted); err != nil {
return nil, err
}

Expand Down
2 changes: 1 addition & 1 deletion app/domains/drinks/internal/commands/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (c *Commands) Update(ctx *middleware.Context, drink *models.Drink) (*models
updated.Status = models.StatusActive
updated.Description = strings.TrimSpace(updated.Description)

if err := c.dao.Update(ctx, updated); err != nil {
if err := c.dao.Update(ctx, &updated); err != nil {
return nil, err
}

Expand Down
2 changes: 2 additions & 0 deletions app/domains/drinks/internal/dao/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func toRow(d drinksmodels.Drink) DrinkRow {
}
return DrinkRow{
ID: d.ID.String(),
Revision: d.Revision,
Name: d.Name,
Category: string(d.Category),
Glass: string(d.Glass),
Expand All @@ -41,6 +42,7 @@ func toModel(r DrinkRow) (drinksmodels.Drink, error) {
}
return drinksmodels.Drink{
ID: drinksmodels.NewDrinkID(r.ID),
Revision: r.Revision,
Name: r.Name,
Category: drinksmodels.DrinkCategory(r.Category),
Glass: drinksmodels.GlassType(r.Glass),
Expand Down
10 changes: 7 additions & 3 deletions app/domains/drinks/internal/dao/insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"github.com/TheFellow/go-modular-monolith/pkg/store"
)

func (d *DAO) Insert(ctx store.Context, drink models.Drink) error {
func (d *DAO) Insert(ctx store.Context, drink *models.Drink) error {
return store.Write(ctx, func(tx *store.Tx) error {
row := toRow(drink)
return store.MapError(tx.Insert(&row), "insert drink %q", drink.Name)
row := toRow(*drink)
if err := store.MapError(tx.Insert(&row), "insert drink %q", drink.Name); err != nil {
return err
}
drink.Revision = row.Revision
return nil
})
}
1 change: 1 addition & 0 deletions app/domains/drinks/internal/dao/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

type DrinkRow struct {
ID string
Revision uint64 `json:"-" store:"revision"`
Name string `store:"unique"`
Category string `store:"index"`
Glass string `store:"index"`
Expand Down
10 changes: 7 additions & 3 deletions app/domains/drinks/internal/dao/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"github.com/TheFellow/go-modular-monolith/pkg/store"
)

func (d *DAO) Update(ctx store.Context, drink models.Drink) error {
func (d *DAO) Update(ctx store.Context, drink *models.Drink) error {
return store.Write(ctx, func(tx *store.Tx) error {
row := toRow(drink)
return store.MapError(tx.Update(&row), "update drink %s", drink.ID.String())
row := toRow(*drink)
if err := store.MapError(tx.Update(&row), "update drink %s", drink.ID.String()); err != nil {
return err
}
drink.Revision = row.Revision
return nil
})
}
1 change: 1 addition & 0 deletions app/domains/drinks/models/drink.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func NewDrinkID(id string) entity.DrinkID {

type Drink struct {
ID entity.DrinkID
Revision uint64 `json:"revision"`
Name string
Category DrinkCategory
Glass GlassType
Expand Down
4 changes: 4 additions & 0 deletions app/domains/drinks/surfaces/cli/drink.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

type Drink struct {
ID string `json:"id"`
Revision uint64 `json:"revision"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Glass string `json:"glass,omitempty"`
Expand All @@ -25,6 +26,7 @@ type Drink struct {
func FromDomainDrink(d models.Drink) Drink {
return Drink{
ID: d.ID.String(),
Revision: d.Revision,
Name: d.Name,
Category: string(d.Category),
Glass: string(d.Glass),
Expand All @@ -38,6 +40,7 @@ func FromDomainDrink(d models.Drink) Drink {
func TemplateUpdateDrink() Drink {
return Drink{
ID: "drk-abc123",
Revision: 1,
Name: "Margarita",
Category: string(models.DrinkCategoryCocktail),
Glass: string(models.GlassTypeCoupe),
Expand Down Expand Up @@ -71,6 +74,7 @@ func (d Drink) ToDomainForUpdate() (models.Drink, error) {

out := models.Drink{
ID: parsedID,
Revision: d.Revision,
Name: d.Name,
Category: models.DrinkCategory(d.Category),
Glass: models.GlassType(d.Glass),
Expand Down
1 change: 1 addition & 0 deletions app/domains/drinks/surfaces/gui/presenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ func (p *Presenter) formDrink() (*models.Drink, error) {
d := &models.Drink{Name: name, Category: category, Glass: glass, Description: description, Recipe: recipe}
if p.state.Mode == Editing && p.state.Selected != nil {
d.ID = p.state.Selected.ID
d.Revision = p.state.Selected.Revision
}
return d, nil
}
Expand Down
6 changes: 6 additions & 0 deletions app/domains/drinks/surfaces/tui/list_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ func (m *ListViewModel) Interaction() tui.Interaction {

func (m *ListViewModel) Update(msg tea.Msg) (tui.ViewModel, tea.Cmd) {
switch msg := msg.(type) {
case tui.DataInvalidatedMsg:
if m.mode != listModeBrowsing || !m.actionEnabled(drinks.ControlList) {
return m, nil
}
m.loading, m.err = true, nil
return m, tea.Batch(m.spinner.Init(), m.loadDrinks(m.request.Cursor))
case tea.WindowSizeMsg:
m.setSize(msg.Width, msg.Height)
switch m.mode {
Expand Down
26 changes: 26 additions & 0 deletions app/domains/drinks/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func TestDrinks_ABAC_SommelierCannotChangeWineToCocktail(t *testing.T) {

updated := drinkForPolicy(created.Name, models.DrinkCategoryCocktail, base.ID)
updated.ID = created.ID
updated.Revision = created.Revision
_, err = f.Drinks.Update(sommelier, &updated)
testutil.ErrorIsPermission(t, err)

Expand All @@ -51,9 +52,34 @@ func TestDrinks_ABAC_BartenderCanUpdateCocktail(t *testing.T) {

updated := drinkForPolicy(created.Name, models.DrinkCategoryCocktail, base.ID)
updated.ID = created.ID
updated.Revision = created.Revision
updated.Description = "Stirred, not shaken"

out, err := f.Drinks.Update(bartender, &updated)
testutil.Ok(t, err)
testutil.ErrorIf(t, out.Category != models.DrinkCategoryCocktail, "expected cocktail category")
}

func TestDrinks_UpdateRejectsStaleRevision(t *testing.T) {
t.Parallel()
f := testutil.NewFixture(t)
ctx := f.OwnerContext()
base := testutil.CreateIngredient(t, f, ingredientsmodels.Ingredient{
Name: "Revision Base", Category: ingredientsmodels.CategoryOther, Unit: measurement.UnitOz,
})

created, err := f.Drinks.Create(ctx, new(drinkForPolicy("Original", models.DrinkCategoryCocktail, base.ID)))
testutil.Ok(t, err)
winner, stale := *created, *created
winner.Description = "winner"
committed, err := f.Drinks.Update(ctx, &winner)
testutil.Ok(t, err)
testutil.Equals(t, committed.Revision, created.Revision+1)

stale.Description = "stale"
_, err = f.Drinks.Update(ctx, &stale)
testutil.ErrorIsConflict(t, err)
current, err := f.Drinks.Get(ctx, created.ID)
testutil.Ok(t, err)
testutil.Equals(t, current.Description, "winner")
}
3 changes: 2 additions & 1 deletion app/domains/ingredients/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ func TestIngredients_CreateGetUpdateDelete(t *testing.T) {
testutil.Equals(t, got, created)

updated, err := f.Ingredients.Update(ctx, &models.Ingredient{
ID: created.ID, Name: "Fresh Lime Juice", Unit: measurement.UnitMl,
ID: created.ID, Revision: created.Revision, Name: "Fresh Lime Juice", Unit: measurement.UnitMl,
})
testutil.Ok(t, err)
wantUpdated := *created
wantUpdated.Name = "Fresh Lime Juice"
wantUpdated.Unit = measurement.UnitMl
wantUpdated.Revision++
testutil.Equals(t, updated, &wantUpdated)

got, err = f.Ingredients.Get(ctx, created.ID)
Expand Down
2 changes: 1 addition & 1 deletion app/domains/ingredients/internal/commands/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func (c *Commands) Create(ctx *middleware.Context, ingredient *models.Ingredient
created.Name = name
created.Description = strings.TrimSpace(created.Description)

if err := c.dao.Insert(ctx, created); err != nil {
if err := c.dao.Insert(ctx, &created); err != nil {
return nil, err
}

Expand Down
2 changes: 1 addition & 1 deletion app/domains/ingredients/internal/commands/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (c *Commands) Retire(ctx *middleware.Context, target RetirementTarget) (*mo
deleted := *ingredient
deleted.DeletedAt = optional.Some(now)

if err := c.dao.Update(ctx, deleted); err != nil {
if err := c.dao.Update(ctx, &deleted); err != nil {
return nil, err
}

Expand Down
4 changes: 2 additions & 2 deletions app/domains/ingredients/internal/commands/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ func (c *Commands) Update(ctx *middleware.Context, ingredient *models.Ingredient
if ingredient.ID.IsZero() {
return nil, errors.Invalidf("id is required")
}

existing, err := c.dao.Get(ctx, ingredient.ID)
if err != nil {
return nil, err
}

updated := *existing
updated.Revision = ingredient.Revision
if name := strings.TrimSpace(ingredient.Name); name != "" {
updated.Name = name
}
Expand All @@ -47,7 +47,7 @@ func (c *Commands) Update(ctx *middleware.Context, ingredient *models.Ingredient
}
updated.Description = strings.TrimSpace(updated.Description)

if err := c.dao.Update(ctx, updated); err != nil {
if err := c.dao.Update(ctx, &updated); err != nil {
return nil, err
}

Expand Down
2 changes: 2 additions & 0 deletions app/domains/ingredients/internal/dao/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func toRow(i models.Ingredient) IngredientRow {
}
return IngredientRow{
ID: i.ID.String(),
Revision: i.Revision,
Name: i.Name,
Category: string(i.Category),
Unit: string(i.Unit),
Expand All @@ -34,6 +35,7 @@ func toModel(r IngredientRow) models.Ingredient {
}
return models.Ingredient{
ID: entity.IngredientID(cedar.NewEntityUID(entity.TypeIngredient, cedar.String(r.ID))),
Revision: r.Revision,
Name: r.Name,
Category: models.Category(r.Category),
Unit: measurement.Unit(r.Unit),
Expand Down
10 changes: 7 additions & 3 deletions app/domains/ingredients/internal/dao/insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"github.com/TheFellow/go-modular-monolith/pkg/store"
)

func (d *DAO) Insert(ctx store.Context, ingredient models.Ingredient) error {
func (d *DAO) Insert(ctx store.Context, ingredient *models.Ingredient) error {
return store.Write(ctx, func(tx *store.Tx) error {
row := toRow(ingredient)
return store.MapError(tx.Insert(&row), "insert ingredient %q", ingredient.Name)
row := toRow(*ingredient)
if err := store.MapError(tx.Insert(&row), "insert ingredient %q", ingredient.Name); err != nil {
return err
}
ingredient.Revision = row.Revision
return nil
})
}
1 change: 1 addition & 0 deletions app/domains/ingredients/internal/dao/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "time"

type IngredientRow struct {
ID string
Revision uint64 `json:"-" store:"revision"`
Name string `store:"unique"`
Category string `store:"index"`
Unit string
Expand Down
Loading