fix(09): WR-19 expose the write transaction to hooks and scopes through TxFromContext

This commit is contained in:
Jakub Zych
2026-10-01 21:37:13 +02:00
parent bbdc7a6b7e
commit 4ae272e3cc
6 changed files with 80 additions and 0 deletions

View File

@@ -153,6 +153,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. |

View File

@@ -152,6 +152,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
}
var result BulkResult
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err
@@ -199,6 +200,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
}
var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
@@ -307,6 +309,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err

View File

@@ -26,6 +26,9 @@ type hookController struct {
fail string
queries atomic.Int32
allocs atomic.Int32
// txHooks counts the Before hooks that found the write's transaction on
// their context through TxFromContext.
txHooks atomic.Int32
}
func (c *hookController) ID() string { return "acme.demo.records" }
@@ -58,6 +61,7 @@ func (c *hookController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm
}
func (c *hookController) FormBeforeCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_create")
c.noteTx(ctx)
if c.trackScope {
if row, ok := model.(*crudRow); ok {
if scope, ok := ctx.Value(scopeKey{}).(uint); ok {
@@ -73,6 +77,7 @@ func (c *hookController) FormAfterCreate(ctx context.Context, model any) error {
}
func (c *hookController) FormBeforeUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_update")
c.noteTx(ctx)
return c.hookErr("form_before_update")
}
func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
@@ -81,12 +86,18 @@ func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
}
func (c *hookController) FormBeforeDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_before_delete")
c.noteTx(ctx)
return c.hookErr("form_before_delete")
}
func (c *hookController) FormAfterDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_after_delete")
return c.hookErr("form_after_delete")
}
func (c *hookController) noteTx(ctx context.Context) {
if _, ok := TxFromContext(ctx); ok {
c.txHooks.Add(1)
}
}
func (c *hookController) hookErr(name string) error {
if c != nil && c.fail == name {
return errHookBoom
@@ -558,3 +569,31 @@ func TestCRUDOperationsFollowDeclarations(t *testing.T) {
t.Fatalf("declared bulk delete=%d %s", got.Code, got.Body.String())
}
}
// TestHooksReceiveTheWriteTransaction pins WR-19: lifecycle hooks can reach the
// transaction their write runs in through TxFromContext, so a hook's own reads
// share the write's snapshot and connection instead of using the app pool.
func TestHooksReceiveTheWriteTransaction(t *testing.T) {
_, httpSvc, _, _, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
created := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada"}`), ctx)
if created.Code != http.StatusCreated {
t.Fatalf("create=%d %s", created.Code, created.Body.String())
}
id := uintString(decodeData(t, created.Body.Bytes())["id"])
if updated := crudCall(httpSvc, http.MethodPut, id, []byte(`{"name":"Bea"}`), ctx); updated.Code != http.StatusOK {
t.Fatalf("update=%d %s", updated.Code, updated.Body.String())
}
if deleted := crudCall(httpSvc, http.MethodDelete, id, nil, ctx); deleted.Code != http.StatusOK {
t.Fatalf("delete=%d %s", deleted.Code, deleted.Body.String())
}
if got := hooks.txHooks.Load(); got != 3 {
t.Fatalf("Before hooks that saw the transaction = %d, want 3 (create, update, delete)", got)
}
if _, ok := TxFromContext(context.Background()); ok {
t.Fatal("a bare context reported a transaction")
}
if _, ok := TxFromContext(nil); ok { //nolint:staticcheck // a nil context must not panic
t.Fatal("a nil context reported a transaction")
}
}

View File

@@ -633,6 +633,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
if err != nil {
return err
@@ -702,6 +703,7 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
if err != nil {
return err

View File

@@ -0,0 +1,33 @@
package cabana
import (
"context"
"gorm.io/gorm"
)
type txContextKey struct{}
// withTx returns ctx carrying the transaction a write is running in.
func withTx(ctx context.Context, tx *gorm.DB) context.Context {
if tx == nil {
return ctx
}
return context.WithValue(ctx, txContextKey{}, tx)
}
// TxFromContext returns the database transaction a write route is running in,
// for the context handed to a lifecycle hook (pact.FormBeforeCreate and the
// other Form and Relation hooks) or a scope (pact.FormExtendQuery) that runs
// inside it. A hook that has to read the database should use this handle
// instead of the application pool: it then sees the same snapshot as the write
// and does not take a second connection while the transaction holds row locks.
// The handle is valid only until the hook returns. The second result is false
// on routes that do not run in a transaction, such as the list.
func TxFromContext(ctx context.Context) (*gorm.DB, bool) {
if ctx == nil {
return nil, false
}
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
return tx, ok && tx != nil
}