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 }