Files
zeekay 50a5190bce fix(store): a read-modify-write swaps, so no replica loses another's
Refunds and disputes were recorded under an in-process mutex, and the
renewal guard was a read then a write under another. Two processes over
one store, or a webhook beside the billing cycle, could each read the
invoice and each write it back, the second wiping the first's refund.

A swap writes only while the stored entity's updatedAt is still the one
read (insert-if-absent for a new or deleted entity) and otherwise reads
again: db.Swapper on SQLite and Postgres as one statement,
datastore.Swap, mixin.Model.Swap. Refund and dispute recording swap the
invoice or payment record in, and idempotencykey.Begin swaps its first
sighting and its stale re-claim, so a guarded money move runs once
across replicas. The returns and lockPeriod mutexes are gone.

lockFunds stays: api/transaction mounts only in standalone commerce,
whose store has one writer.

The tests open one store file through two handles, each with its own
write lock: 24 refunds delivered twice to both, with dispute reports
between them, land once each; 32 sightings of one key proceed once. A
blind write in the refund recorder fails the first test every run.
2026-10-09 10:40:59 -07:00

554 lines
13 KiB
Go

package mixin
// Model[T] wraps orm.Model[T] and implements the full mixin.Entity
// interface. Embed this in model structs instead of mixin.BaseModel to use ORM
// generics while remaining compatible with the REST layer.
//
// Usage:
//
// type Note struct {
// mixin.Model[Note]
// Enabled bool `json:"enabled" orm:"default:true"`
// Message string `json:"message"`
// }
// func init() { orm.Register[Note]("note") }
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"time"
"github.com/hanzoai/commerce/datastore"
dskey "github.com/hanzoai/commerce/datastore/key"
"github.com/hanzoai/commerce/datastore/query"
"github.com/hanzoai/commerce/util/hashid"
"github.com/hanzoai/orm"
)
// Model[T] embeds orm.Model[T] and provides mixin.Entity compatibility.
// The orm.Model[T] must be the first field so self() pointer arithmetic works.
type Model[T any] struct {
orm.Model[T]
ds *datastore.Datastore `json:"-" datastore:"-"`
Parent datastore.Key `json:"-" datastore:"-"`
Mock bool `json:"-" datastore:"-"`
UseStringKey bool `json:"-" datastore:"-"`
loaded_ bool `json:"-" datastore:"-"`
}
// self returns a pointer to the outermost struct T that embeds this bridge.
// Works because Model[T] is at offset 0 of T.
func (b *Model[T]) self() *T {
return (*T)(reflect.NewAt(
reflect.TypeOf((*T)(nil)).Elem(),
reflect.ValueOf(b).UnsafePointer(),
).UnsafePointer())
}
// Id returns the hashid-encoded entity ID.
// Overrides orm.Model[T].Id() to ensure hashid encoding even before Put().
func (b *Model[T]) Id() string {
b.ensureKey()
if b.Model.Id_ == "" {
b.Model.Id() // orm fallback for uninitialized entities (no datastore)
}
return b.Model.Id_
}
// --- Initialization ---
// Init wires the bridge to a commerce datastore.
// Creates an ORM adapter, initializes the orm.Model, and applies defaults.
func (b *Model[T]) Init(db *datastore.Datastore) {
b.ds = db
adapter := NewDatastoreAdapter(db)
b.Model.Init(adapter)
orm.ApplyDefaults(b.self())
}
// Rebind points an ALREADY-POPULATED entity at a different datastore, touching
// no entity data. Init cannot be used for this: it ends in orm.ApplyDefaults,
// whose Defaulter tail calls the entity's Defaults() unconditionally, so on a
// loaded entity it overwrites stored values with platform defaults. That is
// harmless in New (defaults run on a zero struct, then the load fills it) and
// destructive afterwards. Use Init to construct, Rebind to re-home.
//
// SetContext is not a substitute: when the entity was shallow-copied it shares
// the ds pointer with its source, and SetContext mutates that shared datastore.
func (b *Model[T]) Rebind(db *datastore.Datastore) {
b.ds = db
b.Model.Init(NewDatastoreAdapter(db))
}
// --- Context / Namespace ---
func (b *Model[T]) Context() context.Context {
if b.ds != nil {
return b.ds.Context
}
return context.Background()
}
func (b *Model[T]) SetContext(ctx context.Context) {
if b.ds == nil {
b.ds = datastore.New(ctx)
adapter := NewDatastoreAdapter(b.ds)
b.Model.Init(adapter)
} else {
b.ds.SetContext(ctx)
}
}
func (b *Model[T]) SetNamespace(namespace string) {
if b.ds != nil {
b.ds.SetNamespace(namespace)
}
b.Model.SetNamespace(namespace)
}
func (b *Model[T]) Namespace() string {
if b.ds != nil {
return b.ds.GetNamespace()
}
return b.Model.Namespace()
}
// --- Key management ---
// These shadow orm.Model[T]'s methods to return datastore.Key instead of orm.Key.
func (b *Model[T]) Key() datastore.Key {
ormKey := b.Model.Key()
return OrmKeyToDS(ormKey)
}
func (b *Model[T]) SetKey(key interface{}) error {
switch v := key.(type) {
case datastore.Key:
b.Model.SetKey(DSKeyToOrm(v))
if parent := v.Parent(); parent != nil {
b.Parent = parent
}
case orm.Key:
b.Model.SetKey(v)
case string:
// Try to decode as a hashid-encoded key (which carries parent info).
// Fall back to storing the raw string if decoding fails.
ctx := b.Context()
if decoded, err := dskey.Decode(ctx, v); err == nil && decoded.IntID() != 0 {
b.Model.SetKey(DSKeyToOrm(decoded))
if parent := decoded.Parent(); parent != nil {
b.Parent = parent
}
} else {
b.Model.SetId(v)
}
case int64:
kind := b.Model.Kind()
ormKey := b.Model.DB().NewKey(kind, "", v, nil)
b.Model.SetKey(ormKey)
case int:
kind := b.Model.Kind()
ormKey := b.Model.DB().NewKey(kind, "", int64(v), nil)
b.Model.SetKey(ormKey)
case nil:
// no-op
default:
return fmt.Errorf("orm bridge: unable to set %v as key", key)
}
b.updateId()
return nil
}
// updateId encodes the ORM key as a hashid string and sets Id_.
// This ensures IDs returned to clients are hashid-encoded (matching old BaseModel behavior).
func (b *Model[T]) updateId() {
k := b.Model.Key()
if k == nil {
return
}
dsKey := OrmKeyToDS(k)
if dsKey == nil {
return
}
if dsKey.Kind() == "" {
return // guard: empty-kind key can't be hashid-encoded
}
encoded := hashid.EncodeKey(b.Context(), dsKey)
if encoded != "" {
b.Model.Id_ = encoded
}
}
func (b *Model[T]) NewKey() datastore.Key {
if b.ds != nil {
kind := b.Model.Kind()
return b.ds.AllocateOrphanKey(kind, b.Parent)
}
return OrmKeyToDS(b.Model.Key())
}
// --- CRUD ---
// Override all CRUD methods to ensure:
// 1. Keys are auto-allocated before persistence (old BaseModel behavior)
// 2. Hashid-encoded Id_ is set after persistence
// 3. PropertyLoadSaver.Save() side effects run before serialization
func (b *Model[T]) ensureKey() {
if b.ds == nil {
return
}
// b.Model.Id_ == "" means no key has been set yet.
// We CANNOT use b.Model.Key() == nil here because Key() always allocates
// a new key (with nil parent) as a side effect, making the check always false.
if b.Model.Id_ == "" {
newKey := b.NewKey()
b.SetKey(newKey)
} else {
b.updateId()
}
}
// callSave invokes PropertyLoadSaver.Save() for side effects before ORM serialization.
// E.g., Coupon.Save() uppercases the code, Order.Save() calculates totals.
// Only runs if the model has been properly initialized (has a datastore).
func (b *Model[T]) callSave() {
if b.ds == nil {
return
}
type propertySaver interface {
Save() ([]datastore.Property, error)
}
if saver, ok := any(b.self()).(propertySaver); ok {
saver.Save()
}
}
func (b *Model[T]) Put() error {
b.callSave()
b.ensureKey()
err := b.Model.Put()
b.updateId()
return err
}
func (b *Model[T]) Create() error {
b.callSave()
b.ensureKey()
err := b.Model.Create()
b.updateId()
return err
}
func (b *Model[T]) Update() error {
b.callSave()
b.ensureKey()
err := b.Model.Update()
b.updateId()
return err
}
// Swaps is how many times a read-modify-write reads again after another writer
// changed its entity under it, before it reports the contention as an error.
const Swaps = 8
// Swap writes the entity only while the stored one is still the one this model
// read: its updatedAt unchanged since, or nothing stored for an entity never
// written (datastore.Swap). It reports whether it wrote; false means another writer
// changed it first, the model is left as read, and the caller reads again. It runs
// no update hooks: it is the write of a read-modify-write, not of a form.
func (b *Model[T]) Swap() (bool, error) {
if b.ds == nil {
return false, errors.New("mixin: swap on a model with no datastore")
}
b.callSave()
b.ensureKey()
prev, created := b.Model.UpdatedAt, b.Model.CreatedAt
now := time.Now()
if !b.Model.Created() {
b.Model.CreatedAt = now
}
b.Model.UpdatedAt = now
if err := orm.SerializeFields(b.self()); err != nil {
return false, err
}
ok, err := b.ds.Swap(b.Key(), prev, b.self())
if err != nil || !ok {
b.Model.UpdatedAt, b.Model.CreatedAt = prev, created
return false, err
}
b.updateId()
return true, nil
}
func (b *Model[T]) Delete() error {
return b.Model.Delete()
}
// Get overrides orm.Model[T].Get to accept datastore.Key.
func (b *Model[T]) Get(key datastore.Key) error {
if key != nil {
b.Model.SetKey(DSKeyToOrm(key))
}
err := b.Model.Get(b.Model.Key())
b.updateId()
return err
}
// GetById uses mixin.ModelQuery for hashid decoding, slug fallbacks, etc.
func (b *Model[T]) GetById(id string) error {
ok, err := b.Query().ById(id)
if err != nil {
return err
}
if !ok {
return datastore.ErrNoSuchEntity
}
b.updateId()
return nil
}
// --- Must variants ---
func (b *Model[T]) MustSetKey(key interface{}) {
if err := b.SetKey(key); err != nil {
panic(err)
}
}
func (b *Model[T]) MustGet(key datastore.Key) {
if err := b.Get(key); err != nil {
panic(err)
}
}
func (b *Model[T]) MustGetById(id string) {
if err := b.GetById(id); err != nil {
panic(err)
}
}
func (b *Model[T]) MustPut() {
if err := b.Put(); err != nil {
panic(err)
}
}
func (b *Model[T]) MustCreate() {
if err := b.Create(); err != nil {
panic(fmt.Sprintf("orm bridge: MustCreate: %v", err))
}
}
func (b *Model[T]) MustUpdate() {
if err := b.Update(); err != nil {
panic(fmt.Sprintf("orm bridge: MustUpdate: %v", err))
}
}
func (b *Model[T]) MustDelete() {
if err := b.Delete(); err != nil {
panic(fmt.Sprintf("orm bridge: MustDelete: %v", err))
}
}
// --- Existence checks ---
func (b *Model[T]) Exists() (bool, error) {
return b.Model.Exists()
}
func (b *Model[T]) IdExists(id string) (datastore.Key, bool, error) {
q := b.queryDS()
return q.IdExists(id)
}
func (b *Model[T]) KeyExists(key datastore.Key) (bool, error) {
q := b.queryDS()
return q.KeyExists(key)
}
// --- Document/Search ---
func (b *Model[T]) PutDocument() error {
entity := b.self()
hook, ok := any(entity).(Searchable)
if !ok {
return nil
}
if doc := hook.Document(); doc != nil {
if searchIndexProvider == nil {
return nil
}
return searchIndexProvider.Put(b.Context(), b.Model.Id(), doc)
}
return nil
}
func (b *Model[T]) DeleteDocument() error {
entity := b.self()
hook, ok := any(entity).(Searchable)
if !ok {
return nil
}
if doc := hook.Document(); doc != nil {
if searchIndexProvider == nil {
return nil
}
return searchIndexProvider.Delete(b.Context(), b.Model.Id())
}
return nil
}
// --- GetOrCreate / GetOrUpdate ---
func (b *Model[T]) GetOrCreate(filterStr string, value interface{}) error {
q := b.Query()
ok, err := q.Filter(filterStr, value).Get()
if err != nil {
return err
}
if !ok {
return b.Create()
}
b.updateId()
return nil
}
func (b *Model[T]) GetOrUpdate(filterStr string, value interface{}) error {
q := b.Query()
ok, err := q.Filter(filterStr, value).Get()
if err != nil {
return err
}
if !ok {
return b.Create()
}
return b.Update()
}
// --- Datastore / Transaction ---
func (b *Model[T]) Datastore() *datastore.Datastore {
return b.ds
}
func (b *Model[T]) RunInTransaction(fn func() error, opts *datastore.TransactionOptions) error {
return datastore.RunInTransaction(b.Context(), func(db *datastore.Datastore) error {
return fn()
}, opts)
}
// --- Query ---
// queryDS returns a raw datastore query bound to THIS model's datastore (b.ds),
// so a per-org (namespaced) model lists/queries/GetById against its OWN store —
// the same store its writes go to via the ORM adapter. Previously this used the
// package-global query.New(...) default DB, so a Model[T] entity's reads went to
// the shared pool while its writes went to b.ds: on the live single-tenant
// Postgres that both leaked cross-tenant AND hid an org's own just-written rows.
// Falls back to the global default only for an uninitialized model (b.ds == nil).
func (b *Model[T]) queryDS() datastore.Query {
if b.ds != nil {
return b.ds.Query(b.Model.Kind())
}
return query.New(b.Context(), b.Model.Kind())
}
// Query returns a mixin.ModelQuery for this entity.
func (b *Model[T]) Query() *ModelQuery {
q := new(ModelQuery)
entity, ok := any(b.self()).(Entity)
if ok {
q.entity = entity
}
q.dsq = b.queryDS()
q.db = b.ds
return q
}
// --- Utility methods ---
func (b *Model[T]) Zero() Entity {
typ := reflect.TypeOf((*T)(nil)).Elem()
entity := reflect.New(typ).Interface()
if e, ok := entity.(Entity); ok {
return e
}
return nil
}
func (b *Model[T]) Clone() Entity {
entity := b.self()
data, err := json.Marshal(entity)
if err != nil {
return b.Zero()
}
clone := new(T)
json.Unmarshal(data, clone)
if e, ok := any(clone).(Entity); ok {
return e
}
return nil
}
func (b *Model[T]) CloneFromJSON() Entity {
data := b.JSON()
clone := new(T)
json.Unmarshal(data, clone)
if e, ok := any(clone).(Entity); ok {
return e
}
return nil
}
func (b *Model[T]) Slice() interface{} {
typ := reflect.TypeOf((*T)(nil))
sliceType := reflect.SliceOf(typ)
slice := reflect.MakeSlice(sliceType, 0, 0)
ptr := reflect.New(slice.Type())
ptr.Elem().Set(slice)
return ptr.Interface()
}
func (b *Model[T]) JSON() []byte {
data, _ := json.Marshal(b.self())
return data
}
func (b *Model[T]) JSONString() string {
return string(b.JSON())
}
// --- Timestamp accessors for compatibility ---
func (b *Model[T]) Created() bool {
return !b.Model.CreatedAt.IsZero()
}
func (b *Model[T]) GetCreatedAt() time.Time {
return b.Model.CreatedAt
}
func (b *Model[T]) GetUpdatedAt() time.Time {
return b.Model.UpdatedAt
}
// --- Load guard ---
// loaded_ prevents duplicate deserialization in Load() methods.
// Matches mixin.BaseModel.Loaded() semantics: returns true if already loaded,
// otherwise marks as loaded and returns false.
func (b *Model[T]) Loaded() bool {
if b.loaded_ {
return true
}
b.loaded_ = true
return false
}
// Compile-time verification that Model satisfies Entity.
// We can't do this generically, but each migrated model will verify via the REST layer.