Files
CosmicClash/server/domain/state.go
T
2026-08-31 20:29:11 +01:00

150 lines
4.5 KiB
Go

// Package domain contains database-independent matchmaking invariants.
// Adapters may persist these records in PostgreSQL, but must not redefine
// transition, revision, or idempotency behavior.
package domain
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
)
type ResourceKind string
const (
ResourceQueueTicket ResourceKind = "queue_ticket"
ResourceProposal ResourceKind = "proposal"
ResourceMatch ResourceKind = "match"
)
type State string
const (
Queued State = "QUEUED"
Proposed State = "PROPOSED"
Accepted State = "ACCEPTED"
Allocating State = "ALLOCATING"
ProcessReady State = "PROCESS_READY"
AssignmentReady State = "ASSIGNMENT_READY"
Assigned State = "ASSIGNED"
Connecting State = "CONNECTING"
Live State = "LIVE"
ResultPending State = "RESULT_PENDING"
Completed State = "COMPLETED"
Cancelled State = "CANCELLED"
Expired State = "EXPIRED"
Failed State = "FAILED"
Open State = "OPEN"
Declined State = "DECLINED"
)
var (
ErrConflict = errors.New("mutation conflict")
ErrStaleRevision = errors.New("stale revision")
ErrIllegalTransition = errors.New("illegal state transition")
)
type Record struct {
Kind ResourceKind
ID string
State State
Revision uint64
idempotent map[string]appliedMutation
}
type appliedMutation struct {
payloadDigest [32]byte
result Result
}
type Result struct {
Kind ResourceKind
ID string
State State
Revision uint64
}
func NewRecord(kind ResourceKind, id string, state State) *Record {
return &Record{Kind: kind, ID: id, State: state, idempotent: make(map[string]appliedMutation)}
}
// Apply performs all validation before changing the record. Replaying an
// identical idempotency key returns the original result without advancing the
// revision. Reusing a key with a different payload, or presenting a stale
// revision, is inert and returns an error.
func (r *Record) Apply(idempotencyKey string, payload []byte, expectedRevision uint64, target State) (Result, error) {
if idempotencyKey == "" {
return Result{}, fmt.Errorf("%w: empty idempotency key", ErrConflict)
}
digest := sha256.Sum256(payload)
if prior, ok := r.idempotent[idempotencyKey]; ok {
if !bytes.Equal(prior.payloadDigest[:], digest[:]) {
return Result{}, fmt.Errorf("%w: idempotency key reused with different payload", ErrConflict)
}
return prior.result, nil
}
if expectedRevision != r.Revision {
return Result{}, fmt.Errorf("%w: expected %d, current %d", ErrStaleRevision, expectedRevision, r.Revision)
}
if !legalTransition(r.Kind, r.State, target) {
return Result{}, fmt.Errorf("%w: %s %s -> %s", ErrIllegalTransition, r.Kind, r.State, target)
}
r.State = target
r.Revision++
result := Result{Kind: r.Kind, ID: r.ID, State: r.State, Revision: r.Revision}
r.idempotent[idempotencyKey] = appliedMutation{payloadDigest: digest, result: result}
return result, nil
}
func legalTransition(kind ResourceKind, from, to State) bool {
var targets []State
switch kind {
case ResourceQueueTicket:
targets = queueTransitions[from]
case ResourceProposal:
targets = proposalTransitions[from]
case ResourceMatch:
targets = matchTransitions[from]
default:
return false
}
for _, target := range targets {
if target == to {
return true
}
}
return false
}
var queueTransitions = map[State][]State{
Queued: {Proposed, Cancelled, Expired},
Proposed: {Queued, Accepted, Cancelled, Expired},
Accepted: {Queued, Allocating, Cancelled, Failed},
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
AssignmentReady: {Assigned, Failed, Cancelled},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Expired},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Expired: {}, Failed: {},
}
var proposalTransitions = map[State][]State{
Open: {Accepted, Declined, Expired, Cancelled},
Accepted: {}, Declined: {}, Expired: {}, Cancelled: {},
}
var matchTransitions = map[State][]State{
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
AssignmentReady: {Assigned, Failed, Cancelled},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Cancelled},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Failed: {},
}