feat(gl): add immutable postgres ledger storage
This commit is contained in:
@@ -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