145 lines
3.3 KiB
Go
145 lines
3.3 KiB
Go
package ledger
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Event is a single ledger entry stored as JSON per-line (JSONL).
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
TS string `json:"ts"`
|
|
StreamID string `json:"stream_id"`
|
|
Type string `json:"type"`
|
|
Body map[string]any `json:"body"`
|
|
PrevHash *string `json:"prev_hash"`
|
|
Hash string `json:"hash"`
|
|
}
|
|
|
|
// Ledger manages append-only JSONL ledger for a single stream.
|
|
// It is intentionally minimal and safe for the demo (not highly concurrent).
|
|
type Ledger struct {
|
|
Path string
|
|
StreamID string
|
|
LastHash *string
|
|
}
|
|
|
|
// New returns a Ledger instance. It does not create the file yet.
|
|
func New(path, streamID string) *Ledger {
|
|
return &Ledger{Path: path, StreamID: streamID, LastHash: nil}
|
|
}
|
|
|
|
func sha256Hex(b []byte) string {
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func computeHash(id, ts, stream, typ string, body map[string]any, prev *string) (string, error) {
|
|
env := map[string]any{
|
|
"id": id,
|
|
"ts": ts,
|
|
"stream_id": stream,
|
|
"type": typ,
|
|
"body": body,
|
|
"prev_hash": prev,
|
|
}
|
|
// Best-effort deterministic ordering for body keys.
|
|
if body != nil {
|
|
keys := make([]string, 0, len(body))
|
|
for k := range body {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
ordered := make(map[string]any, len(body))
|
|
for _, k := range keys {
|
|
ordered[k] = body[k]
|
|
}
|
|
env["body"] = ordered
|
|
}
|
|
b, err := json.Marshal(env)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sha256Hex(b), nil
|
|
}
|
|
|
|
// Append writes a new event to the ledger file and updates LastHash.
|
|
func (l *Ledger) Append(typ string, body map[string]any) (*Event, error) {
|
|
if l == nil {
|
|
return nil, errors.New("nil ledger")
|
|
}
|
|
id := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
ts := time.Now().UTC().Format(time.RFC3339Nano)
|
|
h, err := computeHash(id, ts, l.StreamID, typ, body, l.LastHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ev := &Event{
|
|
ID: id,
|
|
TS: ts,
|
|
StreamID: l.StreamID,
|
|
Type: typ,
|
|
Body: body,
|
|
PrevHash: l.LastHash,
|
|
Hash: h,
|
|
}
|
|
f, err := os.OpenFile(l.Path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
enc := json.NewEncoder(f)
|
|
if err := enc.Encode(ev); err != nil {
|
|
return nil, err
|
|
}
|
|
l.LastHash = &ev.Hash
|
|
return ev, nil
|
|
}
|
|
|
|
// ReadAll reads all events from the ledger file. If the file does not exist, returns empty slice.
|
|
func (l *Ledger) ReadAll() ([]*Event, error) {
|
|
f, err := os.Open(l.Path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []*Event{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
var out []*Event
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
line := sc.Bytes()
|
|
var ev Event
|
|
if err := json.Unmarshal(line, &ev); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &ev)
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LoadLastHash sets the ledger LastHash to the last entry in the file (if any).
|
|
func (l *Ledger) LoadLastHash() error {
|
|
evs, err := l.ReadAll()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(evs) == 0 {
|
|
l.LastHash = nil
|
|
return nil
|
|
}
|
|
l.LastHash = &evs[len(evs)-1].Hash
|
|
return nil
|
|
}
|