feat(gl): add immutable postgres ledger storage
This commit is contained in:
+13
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"gl/application/health"
|
"gl/application/health"
|
||||||
"gl/infrastructure/config"
|
"gl/infrastructure/config"
|
||||||
|
"gl/infrastructure/postgres"
|
||||||
grpcadapter "gl/interface/grpc"
|
grpcadapter "gl/interface/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +27,18 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
handler := grpcadapter.NewHealthHandler(health.NewService(nil))
|
database, err := postgres.Open(ctx, cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("open GL database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
if err := postgres.Migrate(ctx, database); err != nil {
|
||||||
|
slog.Error("migrate GL database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := grpcadapter.NewHealthHandler(health.NewService(database))
|
||||||
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
|
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
|
||||||
serverConfig := grpcadapter.ServerConfig{
|
serverConfig := grpcadapter.ServerConfig{
|
||||||
Host: cfg.GRPC.Host,
|
Host: cfg.GRPC.Host,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package ledger contains GL's framework-independent financial model.
|
||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AmountPrecision = 38
|
||||||
|
AmountScale = 18
|
||||||
|
)
|
||||||
|
|
||||||
|
var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil)
|
||||||
|
|
||||||
|
// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18)
|
||||||
|
// semantics. Its zero value is a valid zero amount.
|
||||||
|
type Amount struct {
|
||||||
|
units big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAmount(value string) (Amount, error) {
|
||||||
|
if value == "" {
|
||||||
|
return Amount{}, fmt.Errorf("amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
negative := value[0] == '-'
|
||||||
|
unsigned := value
|
||||||
|
if negative {
|
||||||
|
unsigned = value[1:]
|
||||||
|
}
|
||||||
|
if unsigned == "" || strings.HasPrefix(unsigned, "+") {
|
||||||
|
return Amount{}, fmt.Errorf("invalid amount %q", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(unsigned, ".")
|
||||||
|
if len(parts) > 2 || parts[0] == "" {
|
||||||
|
return Amount{}, fmt.Errorf("invalid amount %q", value)
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||||
|
return Amount{}, fmt.Errorf("invalid amount %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts[0]) > 1 && parts[0][0] == '0' {
|
||||||
|
return Amount{}, fmt.Errorf("amount must not contain leading zeroes")
|
||||||
|
}
|
||||||
|
if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale {
|
||||||
|
return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fraction := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fraction = parts[1]
|
||||||
|
if len(fraction) > AmountScale {
|
||||||
|
return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits := strings.TrimLeft(parts[0]+fraction, "0")
|
||||||
|
if len(digits) > AmountPrecision {
|
||||||
|
return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
whole := new(big.Int)
|
||||||
|
whole.SetString(parts[0], 10)
|
||||||
|
units := new(big.Int).Mul(whole, amountFactor)
|
||||||
|
if fraction != "" {
|
||||||
|
padded := fraction + strings.Repeat("0", AmountScale-len(fraction))
|
||||||
|
fractional := new(big.Int)
|
||||||
|
fractional.SetString(padded, 10)
|
||||||
|
units.Add(units, fractional)
|
||||||
|
}
|
||||||
|
if negative {
|
||||||
|
units.Neg(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Amount{units: *units}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Amount) IsZero() bool {
|
||||||
|
return a.units.Sign() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Amount) Add(other Amount) Amount {
|
||||||
|
var result big.Int
|
||||||
|
result.Add(&a.units, &other.units)
|
||||||
|
return Amount{units: result}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Amount) Negate() Amount {
|
||||||
|
var result big.Int
|
||||||
|
result.Neg(&a.units)
|
||||||
|
return Amount{units: result}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Amount) String() string {
|
||||||
|
if a.units.Sign() == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
abs := new(big.Int).Abs(new(big.Int).Set(&a.units))
|
||||||
|
whole, fraction := new(big.Int), new(big.Int)
|
||||||
|
whole.QuoRem(abs, amountFactor, fraction)
|
||||||
|
value := whole.String()
|
||||||
|
if fraction.Sign() != 0 {
|
||||||
|
fractionText := fmt.Sprintf("%018s", fraction.String())
|
||||||
|
value += "." + strings.TrimRight(fractionText, "0")
|
||||||
|
}
|
||||||
|
if a.units.Sign() < 0 {
|
||||||
|
return "-" + value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseAmountCanonicalizesExactValues(t *testing.T) {
|
||||||
|
for input, expected := range map[string]string{
|
||||||
|
"0": "0",
|
||||||
|
"1": "1",
|
||||||
|
"1.2300": "1.23",
|
||||||
|
"-0.000000000000000001": "-0.000000000000000001",
|
||||||
|
"99999999999999999999": "99999999999999999999",
|
||||||
|
} {
|
||||||
|
amount, err := ParseAmount(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAmount(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got := amount.String(); got != expected {
|
||||||
|
t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmountRejectsInvalidValues(t *testing.T) {
|
||||||
|
for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} {
|
||||||
|
if _, err := ParseAmount(input); err == nil {
|
||||||
|
t.Fatalf("ParseAmount(%q) succeeded", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAmountAdditionIsExact(t *testing.T) {
|
||||||
|
one, _ := ParseAmount("0.1")
|
||||||
|
two, _ := ParseAmount("0.2")
|
||||||
|
minusThree, _ := ParseAmount("-0.3")
|
||||||
|
if got := one.Add(two).Add(minusThree); !got.IsZero() {
|
||||||
|
t.Fatalf("expected exact zero, got %s", got.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccountClassUserAvailable AccountClass = "USER_AVAILABLE"
|
||||||
|
AccountClassUserFrozen AccountClass = "USER_FROZEN"
|
||||||
|
AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN"
|
||||||
|
AccountClassTreasury AccountClass = "TREASURY"
|
||||||
|
AccountClassMarketClearing AccountClass = "MARKET_CLEARING"
|
||||||
|
AccountClassIPGClearing AccountClass = "IPG_CLEARING"
|
||||||
|
AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c AccountClass) Valid() bool {
|
||||||
|
switch c {
|
||||||
|
case AccountClassUserAvailable,
|
||||||
|
AccountClassUserFrozen,
|
||||||
|
AccountClassExternalBlockchain,
|
||||||
|
AccountClassTreasury,
|
||||||
|
AccountClassMarketClearing,
|
||||||
|
AccountClassIPGClearing,
|
||||||
|
AccountClassCommissionRevenue:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountReference struct {
|
||||||
|
Class AccountClass
|
||||||
|
OwnerType string
|
||||||
|
OwnerID string
|
||||||
|
AssetID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AccountReference) Validate() error {
|
||||||
|
if !a.Class.Valid() {
|
||||||
|
return fmt.Errorf("invalid account class %q", a.Class)
|
||||||
|
}
|
||||||
|
if a.AssetID <= 0 {
|
||||||
|
return fmt.Errorf("asset id must be positive")
|
||||||
|
}
|
||||||
|
if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen {
|
||||||
|
if a.OwnerType == "" || a.OwnerID == "" {
|
||||||
|
return fmt.Errorf("user account requires owner type and id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
LineNumber uint32
|
||||||
|
Account AccountReference
|
||||||
|
Amount Amount
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockchainReference struct {
|
||||||
|
Network string
|
||||||
|
TransactionHash string
|
||||||
|
LedgerSequence string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Journal struct {
|
||||||
|
ID string
|
||||||
|
SourceService string
|
||||||
|
IdempotencyKey string
|
||||||
|
SourceTransactionID string
|
||||||
|
TrackingCode string
|
||||||
|
EffectKind string
|
||||||
|
EventVersion uint32
|
||||||
|
Entries []Entry
|
||||||
|
ReversalOfJournalID string
|
||||||
|
OccurredAt time.Time
|
||||||
|
CorrelationID string
|
||||||
|
ActorID string
|
||||||
|
Blockchain BlockchainReference
|
||||||
|
Metadata map[string]string
|
||||||
|
PayloadHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j Journal) Validate() error {
|
||||||
|
if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" {
|
||||||
|
return fmt.Errorf("journal identity fields are required")
|
||||||
|
}
|
||||||
|
if j.EventVersion == 0 {
|
||||||
|
return fmt.Errorf("event version must be positive")
|
||||||
|
}
|
||||||
|
if j.OccurredAt.IsZero() {
|
||||||
|
return fmt.Errorf("occurred time is required")
|
||||||
|
}
|
||||||
|
if len(j.PayloadHash) != 64 {
|
||||||
|
return fmt.Errorf("payload hash must be a SHA-256 hex string")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(j.PayloadHash); err != nil {
|
||||||
|
return fmt.Errorf("payload hash must be a SHA-256 hex string")
|
||||||
|
}
|
||||||
|
if j.PayloadHash != strings.ToLower(j.PayloadHash) {
|
||||||
|
return fmt.Errorf("payload hash must use lowercase hexadecimal")
|
||||||
|
}
|
||||||
|
if len(j.Entries) < 2 {
|
||||||
|
return fmt.Errorf("journal requires at least two entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(map[uint32]struct{}, len(j.Entries))
|
||||||
|
balances := make(map[int64]Amount)
|
||||||
|
for _, entry := range j.Entries {
|
||||||
|
if entry.LineNumber == 0 {
|
||||||
|
return fmt.Errorf("entry line number must be positive")
|
||||||
|
}
|
||||||
|
if _, exists := lines[entry.LineNumber]; exists {
|
||||||
|
return fmt.Errorf("duplicate entry line number %d", entry.LineNumber)
|
||||||
|
}
|
||||||
|
lines[entry.LineNumber] = struct{}{}
|
||||||
|
if err := entry.Account.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("entry %d: %w", entry.LineNumber, err)
|
||||||
|
}
|
||||||
|
if entry.Amount.IsZero() {
|
||||||
|
return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber)
|
||||||
|
}
|
||||||
|
balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount)
|
||||||
|
}
|
||||||
|
for assetID, balance := range balances {
|
||||||
|
if !balance.IsZero() {
|
||||||
|
return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJournalValidationBalancesEachAsset(t *testing.T) {
|
||||||
|
journal := validJournal(t)
|
||||||
|
if err := journal.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
journal.Entries[1].Amount, _ = ParseAmount("9")
|
||||||
|
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") {
|
||||||
|
t.Fatalf("expected unbalanced error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalValidationRejectsDuplicateLines(t *testing.T) {
|
||||||
|
journal := validJournal(t)
|
||||||
|
journal.Entries[1].LineNumber = journal.Entries[0].LineNumber
|
||||||
|
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||||
|
t.Fatalf("expected duplicate line error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validJournal(t *testing.T) Journal {
|
||||||
|
t.Helper()
|
||||||
|
debit, err := ParseAmount("-10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credit := debit.Negate()
|
||||||
|
return Journal{
|
||||||
|
ID: "11111111-1111-4111-8111-111111111111",
|
||||||
|
SourceService: "wallet",
|
||||||
|
IdempotencyKey: "wallet:1:internal-transfer:v1",
|
||||||
|
SourceTransactionID: "1",
|
||||||
|
EffectKind: "internal-transfer",
|
||||||
|
EventVersion: 1,
|
||||||
|
OccurredAt: time.Unix(1, 0).UTC(),
|
||||||
|
PayloadHash: strings.Repeat("a", 64),
|
||||||
|
Entries: []Entry{
|
||||||
|
{LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit},
|
||||||
|
{LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ module gl
|
|||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.4.3
|
||||||
github.com/knadh/koanf/parsers/toml v0.1.0
|
github.com/knadh/koanf/parsers/toml v0.1.0
|
||||||
github.com/knadh/koanf/providers/file v1.2.1
|
github.com/knadh/koanf/providers/file v1.2.1
|
||||||
github.com/knadh/koanf/v2 v2.3.4
|
github.com/knadh/koanf/v2 v2.3.4
|
||||||
@@ -13,11 +14,16 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
|
golang.org/x/crypto v0.26.0 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/net v0.28.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.17.0 // indirect
|
golang.org/x/text v0.17.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
@@ -6,6 +7,14 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||||
|
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||||
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
|
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
|
||||||
@@ -22,10 +31,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||||
|
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||||
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||||
@@ -36,5 +52,7 @@ google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
|||||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package postgres provides GL's PostgreSQL adapters.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gl/infrastructure/config"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tx interface {
|
||||||
|
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||||
|
QueryRow(context.Context, string, ...any) Row
|
||||||
|
Commit(context.Context) error
|
||||||
|
Rollback(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database interface {
|
||||||
|
Begin(context.Context) (Tx, error)
|
||||||
|
QueryRow(context.Context, string, ...any) Row
|
||||||
|
Ping(context.Context) error
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pool struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) {
|
||||||
|
connectionURL := &url.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
User: url.UserPassword(cfg.User, cfg.Password),
|
||||||
|
Host: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
||||||
|
Path: cfg.Name,
|
||||||
|
}
|
||||||
|
query := connectionURL.Query()
|
||||||
|
query.Set("sslmode", cfg.SSLMode)
|
||||||
|
connectionURL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, connectionURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create postgres pool: %w", err)
|
||||||
|
}
|
||||||
|
return &Pool{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Begin(ctx context.Context) (Tx, error) {
|
||||||
|
tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return txAdapter{Tx: tx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Ping(ctx context.Context) error {
|
||||||
|
return p.pool.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
||||||
|
return p.pool.QueryRow(ctx, sql, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Close() {
|
||||||
|
p.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type txAdapter struct {
|
||||||
|
pgx.Tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
||||||
|
return t.Tx.QueryRow(ctx, sql, args...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload")
|
||||||
|
ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppendResult struct {
|
||||||
|
JournalID string
|
||||||
|
AlreadyExists bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type JournalRepository struct {
|
||||||
|
database Database
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJournalRepository(database Database) *JournalRepository {
|
||||||
|
return &JournalRepository{database: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) {
|
||||||
|
if err := journal.Validate(); err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("validate journal: %w", err)
|
||||||
|
}
|
||||||
|
metadataValues := journal.Metadata
|
||||||
|
if metadataValues == nil {
|
||||||
|
metadataValues = map[string]string{}
|
||||||
|
}
|
||||||
|
metadata, err := json.Marshal(metadataValues)
|
||||||
|
if err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("encode metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.database.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("begin journal append: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var insertedID string
|
||||||
|
err = tx.QueryRow(ctx, insertJournalSQL,
|
||||||
|
journal.ID,
|
||||||
|
journal.SourceService,
|
||||||
|
journal.IdempotencyKey,
|
||||||
|
journal.SourceTransactionID,
|
||||||
|
journal.TrackingCode,
|
||||||
|
journal.EffectKind,
|
||||||
|
journal.EventVersion,
|
||||||
|
nullIfEmpty(journal.ReversalOfJournalID),
|
||||||
|
journal.OccurredAt,
|
||||||
|
journal.CorrelationID,
|
||||||
|
journal.ActorID,
|
||||||
|
journal.Blockchain.Network,
|
||||||
|
journal.Blockchain.TransactionHash,
|
||||||
|
journal.Blockchain.LedgerSequence,
|
||||||
|
metadata,
|
||||||
|
journal.PayloadHash,
|
||||||
|
).Scan(&insertedID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
return r.resolveDuplicate(ctx, journal)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range journal.Entries {
|
||||||
|
var accountID int64
|
||||||
|
err = tx.QueryRow(ctx, insertAccountSQL,
|
||||||
|
entry.Account.Class,
|
||||||
|
entry.Account.OwnerType,
|
||||||
|
entry.Account.OwnerID,
|
||||||
|
entry.Account.AssetID,
|
||||||
|
).Scan(&accountID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
err = tx.QueryRow(ctx, selectAccountSQL,
|
||||||
|
entry.Account.Class,
|
||||||
|
entry.Account.OwnerType,
|
||||||
|
entry.Account.OwnerID,
|
||||||
|
entry.Account.AssetID,
|
||||||
|
).Scan(&accountID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(ctx, insertEntrySQL,
|
||||||
|
journal.ID,
|
||||||
|
entry.LineNumber,
|
||||||
|
accountID,
|
||||||
|
entry.Account.AssetID,
|
||||||
|
entry.Amount.String(),
|
||||||
|
entry.Description,
|
||||||
|
); err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
|
||||||
|
}
|
||||||
|
if err = tx.Commit(ctx); err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("commit journal: %w", err)
|
||||||
|
}
|
||||||
|
return AppendResult{JournalID: insertedID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) {
|
||||||
|
var (
|
||||||
|
journalID string
|
||||||
|
payloadHash string
|
||||||
|
sealed bool
|
||||||
|
)
|
||||||
|
err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed)
|
||||||
|
if err != nil {
|
||||||
|
return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err)
|
||||||
|
}
|
||||||
|
if payloadHash != journal.PayloadHash {
|
||||||
|
return AppendResult{}, ErrIdempotencyConflict
|
||||||
|
}
|
||||||
|
if !sealed {
|
||||||
|
return AppendResult{}, ErrIncompleteJournal
|
||||||
|
}
|
||||||
|
return AppendResult{JournalID: journalID, AlreadyExists: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(value string) any {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertJournalSQL = `
|
||||||
|
INSERT INTO journals (
|
||||||
|
id, source_service, idempotency_key, source_transaction_id, tracking_code,
|
||||||
|
effect_kind, event_version, reversal_of_journal_id, occurred_at,
|
||||||
|
correlation_id, actor_id, blockchain_network, blockchain_transaction_hash,
|
||||||
|
blockchain_ledger_sequence, metadata, payload_hash
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||||
|
)
|
||||||
|
ON CONFLICT (idempotency_key) DO NOTHING
|
||||||
|
RETURNING id`
|
||||||
|
|
||||||
|
const insertAccountSQL = `
|
||||||
|
INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING
|
||||||
|
RETURNING id`
|
||||||
|
|
||||||
|
const selectAccountSQL = `
|
||||||
|
SELECT id FROM ledger_accounts
|
||||||
|
WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4`
|
||||||
|
|
||||||
|
const insertEntrySQL = `
|
||||||
|
INSERT INTO journal_entries (
|
||||||
|
journal_id, line_number, account_id, asset_id, amount, description
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||||
|
|
||||||
|
const selectIdempotencySQL = `
|
||||||
|
SELECT id, payload_hash, sealed_at IS NOT NULL
|
||||||
|
FROM journals
|
||||||
|
WHERE idempotency_key = $1`
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRow struct {
|
||||||
|
values []any
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r fakeRow) Scan(dest ...any) error {
|
||||||
|
if r.err != nil {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
if len(dest) != len(r.values) {
|
||||||
|
return errors.New("unexpected scan width")
|
||||||
|
}
|
||||||
|
for index, value := range r.values {
|
||||||
|
switch target := dest[index].(type) {
|
||||||
|
case *string:
|
||||||
|
*target = value.(string)
|
||||||
|
case *int64:
|
||||||
|
*target = value.(int64)
|
||||||
|
case *bool:
|
||||||
|
*target = value.(bool)
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported scan target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTx struct {
|
||||||
|
rows []Row
|
||||||
|
execCount int
|
||||||
|
execErrAt int
|
||||||
|
committed bool
|
||||||
|
rolledBack bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
|
||||||
|
t.execCount++
|
||||||
|
if t.execCount == t.execErrAt {
|
||||||
|
return pgconn.CommandTag{}, errors.New("exec failed")
|
||||||
|
}
|
||||||
|
return pgconn.NewCommandTag("INSERT 0 1"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fakeTx) QueryRow(context.Context, string, ...any) Row {
|
||||||
|
row := t.rows[0]
|
||||||
|
t.rows = t.rows[1:]
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fakeTx) Commit(context.Context) error {
|
||||||
|
t.committed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fakeTx) Rollback(context.Context) error {
|
||||||
|
t.rolledBack = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeDatabase struct {
|
||||||
|
tx *fakeTx
|
||||||
|
directRow Row
|
||||||
|
beginCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *fakeDatabase) Begin(context.Context) (Tx, error) {
|
||||||
|
d.beginCalled = true
|
||||||
|
return d.tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow }
|
||||||
|
func (d *fakeDatabase) Ping(context.Context) error { return nil }
|
||||||
|
func (d *fakeDatabase) Close() {}
|
||||||
|
|
||||||
|
func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) {
|
||||||
|
journal := repositoryJournal(t)
|
||||||
|
tx := &fakeTx{rows: []Row{
|
||||||
|
fakeRow{values: []any{journal.ID}},
|
||||||
|
fakeRow{values: []any{int64(10)}},
|
||||||
|
fakeRow{values: []any{int64(20)}},
|
||||||
|
}}
|
||||||
|
database := &fakeDatabase{tx: tx}
|
||||||
|
|
||||||
|
result, err := NewJournalRepository(database).Append(context.Background(), journal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.JournalID != journal.ID || result.AlreadyExists {
|
||||||
|
t.Fatalf("unexpected result: %+v", result)
|
||||||
|
}
|
||||||
|
if !tx.committed || tx.rolledBack {
|
||||||
|
t.Fatalf("unexpected transaction state: %+v", tx)
|
||||||
|
}
|
||||||
|
if tx.execCount != 3 {
|
||||||
|
t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) {
|
||||||
|
journal := repositoryJournal(t)
|
||||||
|
tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}
|
||||||
|
database := &fakeDatabase{
|
||||||
|
tx: tx,
|
||||||
|
directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := NewJournalRepository(database).Append(context.Background(), journal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.AlreadyExists || result.JournalID != journal.ID {
|
||||||
|
t.Fatalf("unexpected result: %+v", result)
|
||||||
|
}
|
||||||
|
if !tx.rolledBack || tx.committed {
|
||||||
|
t.Fatalf("duplicate transaction was not rolled back: %+v", tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) {
|
||||||
|
journal := repositoryJournal(t)
|
||||||
|
database := &fakeDatabase{
|
||||||
|
tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}},
|
||||||
|
directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := NewJournalRepository(database).Append(context.Background(), journal)
|
||||||
|
if !errors.Is(err, ErrIdempotencyConflict) {
|
||||||
|
t.Fatalf("expected idempotency conflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) {
|
||||||
|
journal := repositoryJournal(t)
|
||||||
|
tx := &fakeTx{
|
||||||
|
rows: []Row{
|
||||||
|
fakeRow{values: []any{journal.ID}},
|
||||||
|
fakeRow{values: []any{int64(10)}},
|
||||||
|
},
|
||||||
|
execErrAt: 1,
|
||||||
|
}
|
||||||
|
database := &fakeDatabase{tx: tx}
|
||||||
|
|
||||||
|
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
|
||||||
|
t.Fatal("expected entry insert error")
|
||||||
|
}
|
||||||
|
if !tx.rolledBack || tx.committed {
|
||||||
|
t.Fatalf("failed append was not rolled back: %+v", tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) {
|
||||||
|
journal := repositoryJournal(t)
|
||||||
|
journal.Entries[1].Amount, _ = ledger.ParseAmount("9")
|
||||||
|
database := &fakeDatabase{tx: &fakeTx{}}
|
||||||
|
|
||||||
|
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
if database.beginCalled {
|
||||||
|
t.Fatal("invalid journal opened a database transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryJournal(t *testing.T) ledger.Journal {
|
||||||
|
t.Helper()
|
||||||
|
debit, err := ledger.ParseAmount("-10.25")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ledger.Journal{
|
||||||
|
ID: "11111111-1111-4111-8111-111111111111",
|
||||||
|
SourceService: "wallet",
|
||||||
|
IdempotencyKey: "wallet:1:internal-transfer:v1",
|
||||||
|
SourceTransactionID: "1",
|
||||||
|
TrackingCode: "track-1",
|
||||||
|
EffectKind: "internal-transfer",
|
||||||
|
EventVersion: 1,
|
||||||
|
OccurredAt: time.Unix(1, 0).UTC(),
|
||||||
|
PayloadHash: strings.Repeat("a", 64),
|
||||||
|
Metadata: map[string]string{"origin": "test"},
|
||||||
|
Entries: []ledger.Entry{
|
||||||
|
{
|
||||||
|
LineNumber: 1,
|
||||||
|
Account: ledger.AccountReference{
|
||||||
|
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
|
||||||
|
},
|
||||||
|
Amount: debit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LineNumber: 2,
|
||||||
|
Account: ledger.AccountReference{
|
||||||
|
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
|
||||||
|
},
|
||||||
|
Amount: debit.Negate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.up.sql
|
||||||
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
|
const migrationLockID int64 = 674301
|
||||||
|
|
||||||
|
func Migrate(ctx context.Context, database Database) (err error) {
|
||||||
|
tx, err := database.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil {
|
||||||
|
return fmt.Errorf("lock migrations: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
|
||||||
|
version bigint PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create migration ledger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list migrations: %w", err)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
for _, name := range files {
|
||||||
|
versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0]
|
||||||
|
version, parseErr := strconv.ParseInt(versionText, 10, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
return fmt.Errorf("parse migration %s: %w", name, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var applied bool
|
||||||
|
if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, readErr := migrationFiles.ReadFile(name)
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", name, readErr)
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, string(contents)); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) {
|
||||||
|
contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema := string(contents)
|
||||||
|
for _, required := range []string{
|
||||||
|
"amount numeric(38, 18)",
|
||||||
|
"idempotency_key text NOT NULL UNIQUE",
|
||||||
|
"guard_journal_seal",
|
||||||
|
"journal is not balanced per asset",
|
||||||
|
"cannot append to a sealed journal",
|
||||||
|
"reject_ledger_mutation",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(schema, required) {
|
||||||
|
t.Fatalf("migration is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events;
|
||||||
|
DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts;
|
||||||
|
DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries;
|
||||||
|
DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries;
|
||||||
|
DROP TRIGGER IF EXISTS journals_reject_delete ON journals;
|
||||||
|
DROP TRIGGER IF EXISTS journals_guard_update ON journals;
|
||||||
|
DROP FUNCTION IF EXISTS guard_entry_insert();
|
||||||
|
DROP FUNCTION IF EXISTS guard_journal_seal();
|
||||||
|
DROP FUNCTION IF EXISTS reject_ledger_mutation();
|
||||||
|
DROP TABLE IF EXISTS transaction_events;
|
||||||
|
DROP TABLE IF EXISTS journal_entries;
|
||||||
|
DROP TABLE IF EXISTS journals;
|
||||||
|
DROP TABLE IF EXISTS ledger_accounts;
|
||||||
|
DROP TABLE IF EXISTS ledger_schema_migrations;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
|
||||||
|
version bigint PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ledger_accounts (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
class text NOT NULL CHECK (class IN (
|
||||||
|
'USER_AVAILABLE',
|
||||||
|
'USER_FROZEN',
|
||||||
|
'EXTERNAL_BLOCKCHAIN',
|
||||||
|
'TREASURY',
|
||||||
|
'MARKET_CLEARING',
|
||||||
|
'IPG_CLEARING',
|
||||||
|
'COMMISSION_REVENUE'
|
||||||
|
)),
|
||||||
|
owner_type text NOT NULL DEFAULT '',
|
||||||
|
owner_id text NOT NULL DEFAULT '',
|
||||||
|
asset_id bigint NOT NULL CHECK (asset_id > 0),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
UNIQUE (class, owner_type, owner_id, asset_id),
|
||||||
|
UNIQUE (id, asset_id),
|
||||||
|
CHECK (
|
||||||
|
class NOT IN ('USER_AVAILABLE', 'USER_FROZEN')
|
||||||
|
OR (owner_type <> '' AND owner_id <> '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE journals (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
source_service text NOT NULL CHECK (source_service <> ''),
|
||||||
|
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
|
||||||
|
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
|
||||||
|
tracking_code text NOT NULL DEFAULT '',
|
||||||
|
effect_kind text NOT NULL CHECK (effect_kind <> ''),
|
||||||
|
event_version integer NOT NULL CHECK (event_version > 0),
|
||||||
|
reversal_of_journal_id uuid REFERENCES journals (id),
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
sealed_at timestamptz,
|
||||||
|
correlation_id text NOT NULL DEFAULT '',
|
||||||
|
actor_id text NOT NULL DEFAULT '',
|
||||||
|
blockchain_network text NOT NULL DEFAULT '',
|
||||||
|
blockchain_transaction_hash text NOT NULL DEFAULT '',
|
||||||
|
blockchain_ledger_sequence text NOT NULL DEFAULT '',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
|
||||||
|
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
|
||||||
|
CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX journals_source_transaction_idx
|
||||||
|
ON journals (source_service, source_transaction_id);
|
||||||
|
|
||||||
|
CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE journal_entries (
|
||||||
|
journal_id uuid NOT NULL REFERENCES journals (id),
|
||||||
|
line_number integer NOT NULL CHECK (line_number > 0),
|
||||||
|
account_id bigint NOT NULL,
|
||||||
|
asset_id bigint NOT NULL CHECK (asset_id > 0),
|
||||||
|
amount numeric(38, 18) NOT NULL CHECK (amount <> 0),
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (journal_id, line_number),
|
||||||
|
FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX journal_entries_account_idx
|
||||||
|
ON journal_entries (account_id, journal_id, line_number);
|
||||||
|
|
||||||
|
CREATE TABLE transaction_events (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
source_service text NOT NULL CHECK (source_service <> ''),
|
||||||
|
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
|
||||||
|
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
|
||||||
|
tracking_code text NOT NULL DEFAULT '',
|
||||||
|
event_version integer NOT NULL CHECK (event_version > 0),
|
||||||
|
state text NOT NULL CHECK (state IN (
|
||||||
|
'CREATED',
|
||||||
|
'PENDING_TRANSACTION',
|
||||||
|
'PENDING_ADMIN',
|
||||||
|
'SUCCESSFUL',
|
||||||
|
'FAILED',
|
||||||
|
'SUSPENDED'
|
||||||
|
)),
|
||||||
|
error_code text NOT NULL DEFAULT '',
|
||||||
|
error_message text NOT NULL DEFAULT '',
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
correlation_id text NOT NULL DEFAULT '',
|
||||||
|
actor_id text NOT NULL DEFAULT '',
|
||||||
|
blockchain_network text NOT NULL DEFAULT '',
|
||||||
|
blockchain_transaction_hash text NOT NULL DEFAULT '',
|
||||||
|
blockchain_ledger_sequence text NOT NULL DEFAULT '',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
|
||||||
|
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX transaction_events_source_idx
|
||||||
|
ON transaction_events (source_service, source_transaction_id, event_version);
|
||||||
|
|
||||||
|
CREATE FUNCTION reject_ledger_mutation() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME
|
||||||
|
USING ERRCODE = '55000';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION guard_journal_seal() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.sealed_at IS NOT NULL
|
||||||
|
OR NEW.sealed_at IS NULL
|
||||||
|
OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN
|
||||||
|
RAISE EXCEPTION 'journals are immutable except for initial sealing'
|
||||||
|
USING ERRCODE = '55000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN
|
||||||
|
RAISE EXCEPTION 'journal requires at least two entries'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT asset_id
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE journal_id = NEW.id
|
||||||
|
GROUP BY asset_id
|
||||||
|
HAVING sum(amount) <> 0
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'journal is not balanced per asset'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION guard_entry_insert() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN
|
||||||
|
RAISE EXCEPTION 'cannot append to a sealed journal'
|
||||||
|
USING ERRCODE = '55000';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER journals_guard_update
|
||||||
|
BEFORE UPDATE ON journals
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION guard_journal_seal();
|
||||||
|
|
||||||
|
CREATE TRIGGER journals_reject_delete
|
||||||
|
BEFORE DELETE ON journals
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER entries_guard_insert
|
||||||
|
BEFORE INSERT ON journal_entries
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION guard_entry_insert();
|
||||||
|
|
||||||
|
CREATE TRIGGER entries_reject_update_or_delete
|
||||||
|
BEFORE UPDATE OR DELETE ON journal_entries
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER accounts_reject_update_or_delete
|
||||||
|
BEFORE UPDATE OR DELETE ON ledger_accounts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER transaction_events_reject_update_or_delete
|
||||||
|
BEFORE UPDATE OR DELETE ON transaction_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||||
Reference in New Issue
Block a user