capsule AI-native Unix-like composition layer

src/internal/quota/store.go

5,706 bytes · 259 lines · capsule://quake0day/[email protected] raw on github

package quota

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"go.etcd.io/bbolt"
)

const (
	DefaultPath = "data/quota.db"
	DailyGrant  = 10
	BalanceCap  = 100
)

var (
	ErrInvalidFingerprint = errors.New("invalid fingerprint")
	ErrNoCredits          = errors.New("no credits")
)

var bucketAccounts = []byte("accounts")

type Store struct {
	db  *bbolt.DB
	now func() time.Time
}

type Request struct {
	Fingerprint string `json:"fingerprint"`
}

type StatusResponse struct {
	Balance     int  `json:"balance"`
	SignedToday bool `json:"signedToday"`
	Cap         int  `json:"cap"`
	DailyGrant  int  `json:"dailyGrant"`
}

type account struct {
	Balance         int    `json:"balance"`
	LastCheckInDate string `json:"lastCheckInDate,omitempty"`
	CreatedAt       string `json:"createdAt"`
	UpdatedAt       string `json:"updatedAt"`
}

type Spend struct {
	store       *Store
	fingerprint string
	refunded    bool
}

func Open(path string) (*Store, error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, fmt.Errorf("creating quota data directory: %w", err)
	}
	db, err := bbolt.Open(path, 0o600, &bbolt.Options{Timeout: time.Second})
	if err != nil {
		return nil, fmt.Errorf("opening quota store: %w", err)
	}
	store := &Store{
		db:  db,
		now: time.Now,
	}
	if err := store.ensureBuckets(); err != nil {
		_ = db.Close()
		return nil, err
	}
	return store, nil
}

func (s *Store) Close() error {
	if s == nil || s.db == nil {
		return nil
	}
	return s.db.Close()
}

func (s *Store) Get(fingerprint string) (StatusResponse, error) {
	fingerprint, err := normalizeFingerprint(fingerprint)
	if err != nil {
		return StatusResponse{}, err
	}

	var resp StatusResponse
	err = s.db.View(func(tx *bbolt.Tx) error {
		b := tx.Bucket(bucketAccounts)
		acct, ok, err := readAccount(b, fingerprint)
		if err != nil {
			return err
		}
		if !ok {
			resp = status(account{}, s.today())
			return nil
		}
		resp = status(acct, s.today())
		return nil
	})
	return resp, err
}

func (s *Store) ApplyCheckIn(fingerprint string) (StatusResponse, error) {
	fingerprint, err := normalizeFingerprint(fingerprint)
	if err != nil {
		return StatusResponse{}, err
	}

	var resp StatusResponse
	err = s.db.Update(func(tx *bbolt.Tx) error {
		b := tx.Bucket(bucketAccounts)
		acct, ok, err := readAccount(b, fingerprint)
		if err != nil {
			return err
		}

		today := s.today()
		if ok && acct.LastCheckInDate == today {
			resp = status(acct, today)
			return nil
		}
		if ok && acct.Balance >= BalanceCap {
			resp = status(acct, today)
			return nil
		}

		now := s.now().Format(time.RFC3339)
		if !ok {
			acct.CreatedAt = now
		}
		acct.Balance = min(acct.Balance+DailyGrant, BalanceCap)
		acct.LastCheckInDate = today
		acct.UpdatedAt = now
		if err := writeAccount(b, fingerprint, acct); err != nil {
			return err
		}
		resp = status(acct, today)
		return nil
	})
	return resp, err
}

func (s *Store) Spend(fingerprint string) (*Spend, StatusResponse, error) {
	fingerprint, err := normalizeFingerprint(fingerprint)
	if err != nil {
		return nil, StatusResponse{}, err
	}

	spend := &Spend{store: s, fingerprint: fingerprint}
	var resp StatusResponse
	err = s.db.Update(func(tx *bbolt.Tx) error {
		b := tx.Bucket(bucketAccounts)
		acct, ok, err := readAccount(b, fingerprint)
		if err != nil {
			return err
		}
		if !ok || acct.Balance <= 0 {
			return ErrNoCredits
		}

		acct.Balance--
		acct.UpdatedAt = s.now().Format(time.RFC3339)
		if err := writeAccount(b, fingerprint, acct); err != nil {
			return err
		}
		resp = status(acct, s.today())
		return nil
	})
	if err != nil {
		return nil, StatusResponse{}, err
	}
	return spend, resp, nil
}

func (s *Spend) Refund() error {
	if s == nil || s.store == nil || s.refunded {
		return nil
	}

	err := s.store.db.Update(func(tx *bbolt.Tx) error {
		b := tx.Bucket(bucketAccounts)
		acct, ok, err := readAccount(b, s.fingerprint)
		if err != nil {
			return err
		}
		if !ok {
			return nil
		}
		acct.Balance = min(acct.Balance+1, BalanceCap)
		acct.UpdatedAt = s.store.now().Format(time.RFC3339)
		return writeAccount(b, s.fingerprint, acct)
	})
	if err != nil {
		return err
	}
	s.refunded = true
	return nil
}

func (s *Store) ensureBuckets() error {
	return s.db.Update(func(tx *bbolt.Tx) error {
		_, err := tx.CreateBucketIfNotExists(bucketAccounts)
		return err
	})
}

func (s *Store) today() string {
	return s.now().Format("2006-01-02")
}

func readAccount(b *bbolt.Bucket, fingerprint string) (account, bool, error) {
	raw := b.Get([]byte(fingerprint))
	if raw == nil {
		return account{}, false, nil
	}
	var acct account
	if err := json.Unmarshal(raw, &acct); err != nil {
		return account{}, false, fmt.Errorf("decoding quota account: %w", err)
	}
	return acct, true, nil
}

func writeAccount(b *bbolt.Bucket, fingerprint string, acct account) error {
	raw, err := json.Marshal(acct)
	if err != nil {
		return fmt.Errorf("encoding quota account: %w", err)
	}
	if err := b.Put([]byte(fingerprint), raw); err != nil {
		return fmt.Errorf("writing quota account: %w", err)
	}
	return nil
}

func status(acct account, today string) StatusResponse {
	return StatusResponse{
		Balance:     acct.Balance,
		SignedToday: acct.LastCheckInDate == today,
		Cap:         BalanceCap,
		DailyGrant:  DailyGrant,
	}
}

func normalizeFingerprint(value string) (string, error) {
	value = strings.TrimSpace(value)
	if len(value) < 8 || len(value) > 256 {
		return "", ErrInvalidFingerprint
	}
	for _, r := range value {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
			continue
		}
		return "", ErrInvalidFingerprint
	}
	return value, nil
}