Compare commits

...

5 Commits

30 changed files with 1107 additions and 326 deletions

1
chron-core/errors.go Normal file
View File

@@ -0,0 +1 @@
package chroncore

8
chron-core/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module git.michelsen.id/phill/chron/chron-core
go 1.25.7
require (
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)

4
chron-core/go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=

View File

@@ -1,18 +1,24 @@
package core package chroncore
import ( import "context"
"context"
)
type EntryStore interface { type EntryStore interface {
Store(ctx context.Context, entry Entry) error Store(ctx context.Context, entry Entry) error
Load(ctx context.Context, id EntryID) (Entry, error) Load(ctx context.Context, id EntryID) (Entry, error)
Exists(ctx context.Context, id EntryID) (bool, error) Exists(ctx context.Context, id EntryID) (bool, error)
Delete(ctx context.Context, id EntryID) error Delete(ctx context.Context, id EntryID) error
StoreBatch(ctx context.Context, entries []Entry) error
LoadBatch(ctx context.Context, ids []EntryID) ([]Entry, error)
} }
type ReferenceStore interface { type ReferenceStore interface {
Set(ctx context.Context, name string, entryID EntryID) error Set(ctx context.Context, name string, entryID EntryID) error
Get(ctx context.Context, name string) (EntryID, bool, error) Get(ctx context.Context, name string) (EntryID, bool, error)
Delete(ctx context.Context, name string) error Delete(ctx context.Context, name string) error
List(ctx context.Context, prefix string) (map[string]EntryID, error)
SetBatch(ctx context.Context, refs map[string]EntryID) error
GetBatch(ctx context.Context, names []string) (map[string]EntryID, error)
} }

302
chron-core/ledger.go Normal file
View File

@@ -0,0 +1,302 @@
package chroncore
import (
"bytes"
"context"
"errors"
"slices"
"sort"
"time"
)
var (
ErrHeadNotSet = errors.New("HEAD not set")
ErrEntryIDMismatch = errors.New("entry id mismatch")
ErrBatchLoadSize = errors.New("LoadBatch returned unexpected number of entries")
)
type Ledger struct {
entryStore EntryStore
referenceStore ReferenceStore
}
func NewLedger(entryStore EntryStore, referenceStore ReferenceStore) (*Ledger, error) {
return &Ledger{
entryStore: entryStore,
referenceStore: referenceStore,
}, nil
}
func (l *Ledger) Add(ctx context.Context, parents []EntryID, payload []byte) (EntryID, error) {
ps := normalizeParents(parents)
ts := time.Now()
id := ComputeEntryID(ps, ts, payload)
e := Entry{
EntryID: id,
Parents: ps,
Timestamp: ts,
Payload: payload,
}
if err := l.VerifyEntry(e); err != nil {
return EntryID{}, err
}
if err := l.entryStore.Store(ctx, e); err != nil {
return EntryID{}, err
}
return id, nil
}
func (l *Ledger) Append(ctx context.Context, payload []byte) (EntryID, error) {
head, ok, err := l.referenceStore.Get(ctx, "HEAD")
if err != nil {
return EntryID{}, err
}
var parents []EntryID
if ok {
parents = []EntryID{head}
} else {
parents = nil
}
id, err := l.Add(ctx, parents, payload)
if err != nil {
return EntryID{}, err
}
if err := l.referenceStore.Set(ctx, "HEAD", id); err != nil {
return EntryID{}, err
}
return id, nil
}
func (l *Ledger) AppendTo(ctx context.Context, parent EntryID, payload []byte) (EntryID, error) {
return l.Add(ctx, []EntryID{parent}, payload)
}
func (l *Ledger) Get(ctx context.Context, id EntryID) (Entry, error) {
return l.entryStore.Load(ctx, id)
}
func (l *Ledger) Exists(ctx context.Context, id EntryID) (bool, error) {
return l.entryStore.Exists(ctx, id)
}
func (l *Ledger) Verify(ctx context.Context, id EntryID) error {
e, err := l.entryStore.Load(ctx, id)
if err != nil {
return err
}
return l.VerifyEntry(e)
}
func (l *Ledger) VerifyEntry(e Entry) error {
want := ComputeEntryID(e.Parents, e.Timestamp, e.Payload)
if want != e.EntryID {
return ErrEntryIDMismatch
}
return nil
}
func (l *Ledger) GetRef(ctx context.Context, name string) (EntryID, bool, error) {
return l.referenceStore.Get(ctx, name)
}
func (l *Ledger) SetRef(ctx context.Context, name string, id EntryID) error {
return l.referenceStore.Set(ctx, name, id)
}
func (l *Ledger) DeleteRef(ctx context.Context, name string) error {
return l.referenceStore.Delete(ctx, name)
}
func (l *Ledger) ListRefs(ctx context.Context, prefix string) (map[string]EntryID, error) {
return l.referenceStore.List(ctx, prefix)
}
func (l *Ledger) SetRefs(ctx context.Context, refs map[string]EntryID) error {
return l.referenceStore.SetBatch(ctx, refs)
}
func (l *Ledger) GetRefs(ctx context.Context, names []string) (map[string]EntryID, error) {
return l.referenceStore.GetBatch(ctx, names)
}
func (l *Ledger) GetHead(ctx context.Context) (EntryID, bool, error) {
return l.referenceStore.Get(ctx, "HEAD")
}
func (l *Ledger) SetHead(ctx context.Context, id EntryID) error {
return l.referenceStore.Set(ctx, "HEAD", id)
}
func (l *Ledger) GetHeads(ctx context.Context, prefix string) ([]EntryID, error) {
m, err := l.referenceStore.List(ctx, prefix)
if err != nil {
return nil, err
}
seen := make(map[EntryID]struct{}, len(m))
for _, id := range m {
seen[id] = struct{}{}
}
out := make([]EntryID, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool {
return bytes.Compare(out[i][:], out[j][:]) < 0
})
return out, nil
}
func (l *Ledger) WalkAncestors(ctx context.Context, start []EntryID, fn func(Entry) bool) error {
frontier := dedupeIDs(start)
visited := make(map[EntryID]struct{}, 1024)
for len(frontier) > 0 {
batchIDs := make([]EntryID, 0, len(frontier))
for _, id := range frontier {
if _, ok := visited[id]; ok {
continue
}
visited[id] = struct{}{}
batchIDs = append(batchIDs, id)
}
if len(batchIDs) == 0 {
return nil
}
entries, err := l.entryStore.LoadBatch(ctx, batchIDs)
if err != nil {
return err
}
if len(entries) != len(batchIDs) {
return ErrBatchLoadSize
}
next := make([]EntryID, 0, len(batchIDs)*2)
for _, e := range entries {
if err := l.VerifyEntry(e); err != nil {
return err
}
if !fn(e) {
return nil
}
for _, p := range e.Parents {
next = append(next, p)
}
}
frontier = dedupeIDs(next)
}
return nil
}
func (l *Ledger) IsAncestor(ctx context.Context, ancestor, descendant EntryID) (bool, error) {
if ancestor == descendant {
return true, nil
}
found := false
err := l.WalkAncestors(ctx, []EntryID{descendant}, func(e Entry) bool {
if slices.Contains(e.Parents, ancestor) {
found = true
return false
}
return true
})
if err != nil {
return false, err
}
return found, nil
}
func (l *Ledger) CommonAncestors(ctx context.Context, a, b []EntryID, limit int) ([]EntryID, error) {
aSet := make(map[EntryID]struct{}, 1024)
if err := l.WalkAncestors(ctx, a, func(e Entry) bool {
aSet[e.EntryID] = struct{}{}
return true
}); err != nil {
return nil, err
}
var out []EntryID
seen := make(map[EntryID]struct{}, 64)
err := l.WalkAncestors(ctx, b, func(e Entry) bool {
if _, ok := aSet[e.EntryID]; ok {
if _, dup := seen[e.EntryID]; !dup {
seen[e.EntryID] = struct{}{}
out = append(out, e.EntryID)
if limit > 0 && len(out) >= limit {
return false
}
}
}
return true
})
if err != nil {
return nil, err
}
return out, nil
}
func normalizeParents(parents []EntryID) []EntryID {
if len(parents) == 0 {
return nil
}
ps := make([]EntryID, len(parents))
copy(ps, parents)
sort.Slice(ps, func(i, j int) bool {
return bytes.Compare(ps[i][:], ps[j][:]) < 0
})
out := ps[:0]
var last EntryID
for i, p := range ps {
if i == 0 || p != last {
out = append(out, p)
last = p
}
}
return out
}
func dedupeIDs(ids []EntryID) []EntryID {
if len(ids) == 0 {
return nil
}
tmp := make([]EntryID, len(ids))
copy(tmp, ids)
sort.Slice(tmp, func(i, j int) bool {
return bytes.Compare(tmp[i][:], tmp[j][:]) < 0
})
out := tmp[:0]
var last EntryID
for i, id := range tmp {
if i == 0 || id != last {
out = append(out, id)
last = id
}
}
return out
}

View File

@@ -1,7 +1,9 @@
package core package chroncore
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"sort"
"time" "time"
"lukechampine.com/blake3" "lukechampine.com/blake3"
@@ -12,25 +14,35 @@ type EntryID [32]byte
type Entry struct { type Entry struct {
EntryID EntryID EntryID EntryID
Previous EntryID Parents []EntryID
Timestamp time.Time Timestamp time.Time
Payload []byte Payload []byte
} }
func ComputeEntryID(prev EntryID, ts time.Time, payload []byte) EntryID { func ComputeEntryID(parents []EntryID, ts time.Time, payload []byte) EntryID {
h := blake3.New(32, nil) h := blake3.New(32, nil)
h.Write(prev[:]) ps := make([]EntryID, len(parents))
copy(ps, parents)
sort.Slice(ps, func(i, j int) bool {
return bytes.Compare(ps[i][:], ps[j][:]) < 0
})
var u32 [4]byte
binary.LittleEndian.PutUint32(u32[:], uint32(len(ps)))
h.Write(u32[:])
for _, p := range ps {
h.Write(p[:])
}
var i64 [8]byte var i64 [8]byte
binary.LittleEndian.PutUint64(i64[:], uint64(ts.UTC().UnixNano())) binary.LittleEndian.PutUint64(i64[:], uint64(ts.UTC().UnixNano()))
h.Write(i64[:]) h.Write(i64[:])
var u32 [4]byte
binary.LittleEndian.PutUint32(u32[:], uint32(len(payload))) binary.LittleEndian.PutUint32(u32[:], uint32(len(payload)))
h.Write(u32[:]) h.Write(u32[:])
h.Write(payload) h.Write(payload)
var id EntryID var id EntryID

View File

@@ -0,0 +1,81 @@
package main
import (
"fmt"
"os"
"path/filepath"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
"git.michelsen.id/phill/chron/chron-note/internal/workspace"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
ws, err := workspace.Open(root)
must(err)
fmt.Println("Workspace directory:")
fmt.Println(ws.Dir)
fmt.Println()
fmt.Println("Creating objects...")
var ids []domain.ObjectID
for i := range 3 {
id, err := domain.NewObjectID()
must(err)
content := fmt.Sprintf("object %d\nid: %s\n", i, id.String())
must(ws.Write(id, []byte(content)))
ids = append(ids, id)
fmt.Printf("Created object %s\n", id)
fmt.Printf("Path: %s\n\n", ws.ObjectPath(id))
}
fmt.Println("Listing workspace objects:")
fmt.Println()
list, err := ws.ListObjectIDs()
must(err)
for _, id := range list {
fi, err := ws.Stat(id)
must(err)
fmt.Printf("Object: %s\n", id)
fmt.Printf("Size: %d bytes\n", fi.Size())
fmt.Printf("Path: %s\n", ws.ObjectPath(id))
fmt.Println()
}
fmt.Println("Reading objects back:")
fmt.Println()
for _, id := range ids {
data, err := ws.Read(id)
must(err)
fmt.Printf("Object %s contents:\n", id)
fmt.Println(string(data))
fmt.Println("---")
}
fmt.Println()
fmt.Println("Filesystem view:")
fmt.Println(filepath.Join(ws.Dir, "..."))
}

3
chron-note/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.michelsen.id/phill/chron/chron-note
go 1.25.7

View File

@@ -0,0 +1 @@
package app

View File

@@ -0,0 +1 @@
package config

View File

@@ -0,0 +1 @@
package config

View File

@@ -0,0 +1,11 @@
package domain
import "errors"
var (
ErrInvalidObjectID = errors.New("invalid object id")
ErrInvalidBlobID = errors.New("invalid blob id")
ErrUnknownEventType = errors.New("unknown event type")
ErrInvalidEvent = errors.New("invalid event")
ErrDecode = errors.New("decode error")
)

View File

@@ -0,0 +1,137 @@
package domain
import (
"fmt"
"git.michelsen.id/phill/chron/chron-note/internal/util"
)
type EventType uint8
const (
EventObjectUpsert EventType = 1
EventObjectDelete EventType = 2
)
const encodingVersion uint8 = 1
type ObjectUpsert struct {
ObjectID ObjectID
Name string
Tags []string
HasBlob bool
Blob BlobID
}
type ObjectDelete struct {
ObjectID ObjectID
}
type Event struct {
Type EventType
Upsert *ObjectUpsert
Delete *ObjectDelete
}
func EncodeEvent(e Event) ([]byte, error) {
enc := util.NewEncoder(nil)
enc.U8(encodingVersion)
enc.U8(uint8(e.Type))
switch e.Type {
case EventObjectUpsert:
if e.Upsert == nil {
return nil, ErrInvalidEvent
}
u := e.Upsert
var flags uint8
if u.HasBlob {
flags |= 0x01
}
enc.U8(flags)
enc.BytesFixed(u.ObjectID[:])
enc.String(u.Name)
enc.StringSlice(u.Tags)
if u.HasBlob {
enc.BytesFixed(u.Blob[:])
}
case EventObjectDelete:
if e.Delete == nil {
return nil, ErrInvalidEvent
}
enc.BytesFixed(e.Delete.ObjectID[:])
default:
return nil, ErrUnknownEventType
}
if err := enc.Err(); err != nil {
return nil, err
}
return enc.Bytes(), nil
}
func DecodeEvent(b []byte) (Event, error) {
dec := util.NewDecoder(b)
ver := dec.U8()
if dec.Err() != nil {
return Event{}, ErrDecode
}
if ver != encodingVersion {
return Event{}, fmt.Errorf("%w: unsupported encoding version %d", ErrDecode, ver)
}
typ := EventType(dec.U8())
if dec.Err() != nil {
return Event{}, ErrDecode
}
switch typ {
case EventObjectUpsert:
flags := dec.U8()
var objID ObjectID
copy(objID[:], dec.BytesFixed(len(objID)))
name := dec.String()
tags := dec.StringSlice()
hasBlob := (flags & 0x01) != 0
var blob BlobID
if hasBlob {
copy(blob[:], dec.BytesFixed(len(blob)))
}
if dec.Err() != nil {
return Event{}, ErrDecode
}
return Event{
Type: typ,
Upsert: &ObjectUpsert{
ObjectID: objID,
Name: NormalizeName(name),
Tags: NormalizeTags(tags),
HasBlob: hasBlob,
Blob: blob,
},
}, nil
case EventObjectDelete:
var objID ObjectID
copy(objID[:], dec.BytesFixed(len(objID)))
if dec.Err() != nil {
return Event{}, ErrDecode
}
return Event{Type: typ, Delete: &ObjectDelete{ObjectID: objID}}, nil
default:
return Event{}, ErrUnknownEventType
}
}

View File

@@ -0,0 +1,74 @@
package domain
import (
"crypto/rand"
"encoding/hex"
"strings"
)
type ObjectID [16]byte
type BlobID [32]byte
func NewObjectID() (ObjectID, error) {
var id ObjectID
_, err := rand.Read(id[:])
return id, err
}
func ParseObjectID(s string) (ObjectID, error) {
s = strings.TrimSpace(s)
b, err := hex.DecodeString(s)
if err != nil || len(b) != len(ObjectID{}) {
return ObjectID{}, ErrInvalidObjectID
}
var id ObjectID
copy(id[:], b)
return id, nil
}
func (id ObjectID) String() string { return hex.EncodeToString(id[:]) }
func ParseBlobID(s string) (BlobID, error) {
s = strings.TrimSpace(s)
b, err := hex.DecodeString(s)
if err != nil || len(b) != len(BlobID{}) {
return BlobID{}, ErrInvalidBlobID
}
var id BlobID
copy(id[:], b)
return id, nil
}
func (id BlobID) String() string { return hex.EncodeToString(id[:]) }
type ObjectState struct {
ID ObjectID
Name string
Tags []string
Blob BlobID
HasBlob bool
Deleted bool
}
// Keep normalization here so all layers agree.
func NormalizeName(s string) string {
s = strings.TrimSpace(s)
return s
}
func NormalizeTags(tags []string) []string {
out := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))
for _, t := range tags {
t = strings.TrimSpace(strings.ToLower(t))
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}

View File

@@ -0,0 +1 @@
package state

View File

@@ -0,0 +1 @@
package state

View File

@@ -0,0 +1,14 @@
package cas
import (
"context"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
)
type Store interface {
Put(ctx context.Context, data []byte) (domain.BlobID, error) // id = blake3-256(data)
Get(ctx context.Context, id domain.BlobID) ([]byte, error)
Has(ctx context.Context, id domain.BlobID) (bool, error)
Remove(ctx context.Context, id domain.BlobID) error
}

View File

@@ -0,0 +1,93 @@
package cas
import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
"git.michelsen.id/phill/chron/chron-note/internal/util"
)
var ErrBlobNotFound = errors.New("blob not found")
type FS struct {
root string
}
func NewFS(root string) *FS {
return &FS{root: root}
}
func (s *FS) Put(ctx context.Context, data []byte) (domain.BlobID, error) {
_ = ctx
sum := util.Hash256(data)
var id domain.BlobID
copy(id[:], sum[:])
p := s.pathFor(id)
// Fast path: already exists
if _, err := os.Stat(p); err == nil {
return id, nil
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return domain.BlobID{}, err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return domain.BlobID{}, err
}
if err := os.Rename(tmp, p); err != nil {
_ = os.Remove(tmp)
// If another writer won the race, accept it.
if _, statErr := os.Stat(p); statErr == nil {
return id, nil
}
return domain.BlobID{}, err
}
return id, nil
}
func (s *FS) Get(ctx context.Context, id domain.BlobID) ([]byte, error) {
_ = ctx
p := s.pathFor(id)
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrBlobNotFound
}
return nil, err
}
return b, nil
}
func (s *FS) Has(ctx context.Context, id domain.BlobID) (bool, error) {
_ = ctx
p := s.pathFor(id)
_, err := os.Stat(p)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (s *FS) pathFor(id domain.BlobID) string {
hexID := hex.EncodeToString(id[:])
if len(hexID) < 4 {
// should never happen
return filepath.Join(s.root, fmt.Sprintf("bad-%s", hexID))
}
// git-style fanout
return filepath.Join(s.root, hexID[:2], hexID[2:4], hexID)
}

View File

@@ -0,0 +1,133 @@
package util
import (
"encoding/binary"
"errors"
)
var ErrDecode = errors.New("decode")
type Encoder struct {
b []byte
err error
}
func NewEncoder(dst []byte) *Encoder { return &Encoder{b: dst} }
func (e *Encoder) Bytes() []byte { return e.b }
func (e *Encoder) Err() error { return e.err }
func (e *Encoder) U8(v uint8) {
if e.err != nil {
return
}
e.b = append(e.b, v)
}
func (e *Encoder) U32(v uint32) {
if e.err != nil {
return
}
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], v)
e.b = append(e.b, tmp[:]...)
}
func (e *Encoder) BytesFixed(p []byte) {
if e.err != nil {
return
}
e.b = append(e.b, p...)
}
func (e *Encoder) BytesLen(p []byte) {
if e.err != nil {
return
}
e.U32(uint32(len(p)))
e.b = append(e.b, p...)
}
func (e *Encoder) String(s string) {
e.BytesLen([]byte(s))
}
func (e *Encoder) StringSlice(ss []string) {
if e.err != nil {
return
}
e.U32(uint32(len(ss)))
for _, s := range ss {
e.String(s)
}
}
type Decoder struct {
b []byte
off int
err error
}
func NewDecoder(src []byte) *Decoder { return &Decoder{b: src} }
func (d *Decoder) Err() error { return d.err }
func (d *Decoder) need(n int) bool {
if d.err != nil {
return false
}
if n < 0 || d.off+n > len(d.b) {
d.err = ErrDecode
return false
}
return true
}
func (d *Decoder) U8() uint8 {
if !d.need(1) {
return 0
}
v := d.b[d.off]
d.off++
return v
}
func (d *Decoder) U32() uint32 {
if !d.need(4) {
return 0
}
v := binary.LittleEndian.Uint32(d.b[d.off : d.off+4])
d.off += 4
return v
}
func (d *Decoder) BytesFixed(n int) []byte {
if !d.need(n) {
return nil
}
out := d.b[d.off : d.off+n]
d.off += n
return out
}
func (d *Decoder) BytesLen() []byte {
n := int(d.U32())
return d.BytesFixed(n)
}
func (d *Decoder) String() string {
return string(d.BytesLen())
}
func (d *Decoder) StringSlice() []string {
n := int(d.U32())
if d.Err() != nil {
return nil
}
out := make([]string, 0, n)
for range n {
out = append(out, d.String())
if d.Err() != nil {
return nil
}
}
return out
}

View File

@@ -0,0 +1,8 @@
package util
import "lukechampine.com/blake3"
func Hash256(b []byte) [32]byte {
h := blake3.Sum256(b)
return h
}

View File

@@ -0,0 +1,69 @@
package workspace
import (
"fmt"
"os"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
)
func (ws *Workspace) Exists(id domain.ObjectID) (bool, error) {
_, err := os.Stat(ws.ObjectPath(id))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("stat object %s: %w", id.String(), err)
}
func (ws *Workspace) Read(id domain.ObjectID) ([]byte, error) {
b, err := os.ReadFile(ws.ObjectPath(id))
if err != nil {
return nil, fmt.Errorf("read object %s: %w", id.String(), err)
}
return b, nil
}
func (ws *Workspace) Write(id domain.ObjectID, b []byte) error {
if err := os.MkdirAll(ws.Dir, 0o755); err != nil {
return fmt.Errorf("mkdir workspace dir: %w", err)
}
dst := ws.ObjectPath(id)
f, err := os.CreateTemp(ws.Dir, id.String()+".*.tmp")
if err != nil {
return fmt.Errorf("create temp for object %s: %w", id.String(), err)
}
tmp := f.Name()
// Best-effort cleanup on failure.
defer func() { _ = os.Remove(tmp) }()
if _, err := f.Write(b); err != nil {
_ = f.Close()
return fmt.Errorf("write temp for object %s: %w", id.String(), err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close temp for object %s: %w", id.String(), err)
}
if err := os.Rename(tmp, dst); err != nil {
return fmt.Errorf("rename temp for object %s: %w", id.String(), err)
}
// Rename succeeded; prevent deferred cleanup.
_ = os.Remove(tmp)
return nil
}
func (ws *Workspace) Delete(id domain.ObjectID) error {
p := ws.ObjectPath(id)
err := os.Remove(p)
if err == nil || os.IsNotExist(err) {
return nil
}
return fmt.Errorf("delete object %s: %w", id.String(), err)
}

View File

@@ -0,0 +1,46 @@
package workspace
import (
"fmt"
"io/fs"
"os"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
)
func (ws *Workspace) ListObjectIDs() ([]domain.ObjectID, error) {
entries, err := os.ReadDir(ws.Dir)
if err != nil {
return nil, fmt.Errorf("readdir workspace dir: %w", err)
}
out := make([]domain.ObjectID, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
return nil, fmt.Errorf("stat workspace entry: %w", err)
}
if !info.Mode().IsRegular() {
continue
}
id, err := domain.ParseObjectID(e.Name())
if err != nil {
continue
}
out = append(out, id)
}
return out, nil
}
func (ws *Workspace) Stat(id domain.ObjectID) (fs.FileInfo, error) {
fi, err := os.Stat(ws.ObjectPath(id))
if err != nil {
return nil, fmt.Errorf("stat object %s: %w", id.String(), err)
}
return fi, nil
}

View File

@@ -0,0 +1,52 @@
package workspace
import (
"context"
"fmt"
"os"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
)
type SnapshotObject struct {
ID domain.ObjectID
Size int64
ModNS int64
}
func (ws *Workspace) Snapshot(ctx context.Context) ([]SnapshotObject, error) {
entries, err := os.ReadDir(ws.Dir)
if err != nil {
return nil, fmt.Errorf("readdir workspace dir: %w", err)
}
out := make([]SnapshotObject, 0, len(entries))
for _, e := range entries {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
return nil, fmt.Errorf("stat workspace entry: %w", err)
}
if !info.Mode().IsRegular() {
continue
}
id, err := domain.ParseObjectID(e.Name())
if err != nil {
continue
}
out = append(out, SnapshotObject{
ID: id,
Size: info.Size(),
ModNS: info.ModTime().UnixNano(),
})
}
return out, nil
}

View File

@@ -0,0 +1,32 @@
package workspace
import (
"fmt"
"os"
"path/filepath"
"git.michelsen.id/phill/chron/chron-note/internal/domain"
)
type Workspace struct {
Root string
Dir string
}
func Open(root string) (*Workspace, error) {
root = filepath.Clean(root)
dir := filepath.Join(root, ".chron", "workspace")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("mkdir workspace dir: %w", err)
}
return &Workspace{
Root: root,
Dir: dir,
}, nil
}
func (ws *Workspace) ObjectPath(id domain.ObjectID) string {
return filepath.Join(ws.Dir, id.String())
}

View File

@@ -1,140 +0,0 @@
package main
import (
"context"
"encoding/hex"
"errors"
"fmt"
"sort"
"git.michelsen.id/chron/core"
)
type InProcEntryStore struct {
entries map[core.EntryID]core.Entry
}
func (e *InProcEntryStore) Store(ctx context.Context, entry core.Entry) error {
if _, ok := e.entries[entry.EntryID]; ok {
return errors.New("entry already exists")
}
e.entries[entry.EntryID] = entry
return nil
}
func (e *InProcEntryStore) Load(ctx context.Context, id core.EntryID) (core.Entry, error) {
entry, ok := e.entries[id]
if !ok {
return core.Entry{}, errors.New("entry does not exist")
}
return entry, nil
}
func (e *InProcEntryStore) Exists(ctx context.Context, id core.EntryID) (bool, error) {
_, ok := e.entries[id]
return ok, nil
}
func (e *InProcEntryStore) Delete(ctx context.Context, id core.EntryID) error {
if _, ok := e.entries[id]; !ok {
return errors.New("entry does not exist")
}
delete(e.entries, id)
return nil
}
type InProcReferenceStore struct {
references map[string]core.EntryID
}
func (r *InProcReferenceStore) Set(ctx context.Context, name string, entryID core.EntryID) error {
r.references[name] = entryID
return nil
}
func (r *InProcReferenceStore) Get(ctx context.Context, name string) (core.EntryID, bool, error) {
entryID, ok := r.references[name]
if !ok {
return core.EntryID{}, false, nil
}
return entryID, true, nil
}
func (r *InProcReferenceStore) Delete(ctx context.Context, name string) error {
if _, ok := r.references[name]; !ok {
return errors.New("reference does not exist")
}
delete(r.references, name)
return nil
}
func main() {
ctx := context.TODO()
entryStore := InProcEntryStore{
entries: make(map[core.EntryID]core.Entry),
}
referenceStore := InProcReferenceStore{
references: make(map[string]core.EntryID),
}
ledger, err := core.NewLedger(&entryStore, &referenceStore)
if err != nil {
fmt.Println(err)
return
}
for i := range 500 {
data := fmt.Sprintf("test%d", i)
err = ledger.Append(ctx, []byte(data))
if err != nil {
fmt.Println(err)
return
}
}
entries := make([]core.Entry, 0, len(entryStore.entries))
for _, e := range entryStore.entries {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Timestamp.Equal(entries[j].Timestamp) {
return hex.EncodeToString(entries[i].EntryID[:]) <
hex.EncodeToString(entries[j].EntryID[:])
}
return entries[i].Timestamp.Before(entries[j].Timestamp)
})
fmt.Println("Entries:")
if len(entries) == 0 {
fmt.Println(" (none)")
} else {
for _, e := range entries {
fmt.Printf(" ts=%d id=%s prev=%s payload=%q\n",
e.Timestamp.UnixNano(),
hex.EncodeToString(e.EntryID[:]),
hex.EncodeToString(e.Previous[:]),
e.Payload,
)
}
}
// ---- Print references (name -> EntryID hex) ----
names := make([]string, 0, len(referenceStore.references))
for name := range referenceStore.references {
names = append(names, name)
}
sort.Strings(names)
fmt.Println("References:")
if len(names) == 0 {
fmt.Println(" (none)")
} else {
for _, name := range names {
id := referenceStore.references[name]
fmt.Printf(" %s -> %s\n", name, hex.EncodeToString(id[:]))
}
}
}

View File

@@ -1 +0,0 @@
package core

View File

@@ -1,128 +0,0 @@
package core
import (
"context"
"errors"
"time"
)
type Ledger struct {
entryStore EntryStore
referenceStore ReferenceStore
}
func NewLedger(entryStore EntryStore, referenceStore ReferenceStore) (*Ledger, error) {
return &Ledger{
entryStore: entryStore,
referenceStore: referenceStore,
}, nil
}
func (l *Ledger) Append(ctx context.Context, payload []byte) error {
currentHeadEntryID, ok, err := l.referenceStore.Get(ctx, "HEAD")
if err != nil {
return err
}
if !ok {
l.referenceStore.Set(ctx, "HEAD", EntryID{})
currentHeadEntryID = EntryID{}
}
entryTime := time.Now()
entryID := ComputeEntryID(currentHeadEntryID, entryTime, payload)
entry := Entry{
EntryID: entryID,
Previous: currentHeadEntryID,
Timestamp: entryTime,
Payload: payload,
}
if ComputeEntryID(entry.Previous, entry.Timestamp, entry.Payload) != entryID {
panic("EntryID hash mismatch fuckup")
}
err = l.entryStore.Store(ctx, entry)
if err != nil {
return err
}
err = l.referenceStore.Set(ctx, "HEAD", entry.EntryID)
if err != nil {
return err
}
return nil
}
func (l *Ledger) AppendTo(ctx context.Context, prevEntryID EntryID, payload []byte) error {
entryTime := time.Now()
entryID := ComputeEntryID(prevEntryID, entryTime, payload)
entry := Entry{
EntryID: entryID,
Previous: prevEntryID,
Timestamp: entryTime,
Payload: payload,
}
if ComputeEntryID(entry.Previous, entry.Timestamp, entry.Payload) != entryID {
panic("EntryID hash mismatch fuckup")
}
err := l.entryStore.Store(ctx, entry)
if err != nil {
return err
}
return nil
}
func (l *Ledger) Get(ctx context.Context) (Entry, error) {
headEntryID, ok, err := l.referenceStore.Get(ctx, "HEAD")
if err != nil {
return Entry{}, err
}
if !ok {
return Entry{}, errors.New("HEAD not set")
}
entry, err := l.entryStore.Load(ctx, headEntryID)
if err != nil {
return Entry{}, err
}
return entry, nil
}
func (l *Ledger) GetFromReference(ctx context.Context, reference string) (Entry, error) {
referenceEntryID, ok, err := l.referenceStore.Get(ctx, reference)
if err != nil {
return Entry{}, err
}
if !ok {
return Entry{}, errors.New("HEAD not set")
}
entry, err := l.entryStore.Load(ctx, referenceEntryID)
if err != nil {
return Entry{}, err
}
return entry, nil
}
func (l *Ledger) SetHead(ctx context.Context, entryID EntryID) error {
return l.referenceStore.Set(ctx, "HEAD", entryID)
}
func (l *Ledger) SetReference(ctx context.Context, reference string, entryID EntryID) error {
return l.referenceStore.Set(ctx, reference, entryID)
}
func (l *Ledger) RemoveReference(ctx context.Context, reference string) error {
return l.referenceStore.Delete(ctx, reference)
}

20
go.mod
View File

@@ -1,20 +0,0 @@
module git.michelsen.id/chron
go 1.25.5
require github.com/google/uuid v1.6.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.45.0 // indirect
)

27
go.sum
View File

@@ -1,27 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=

6
go.work Normal file
View File

@@ -0,0 +1,6 @@
go 1.25.7
use (
./chron-core
./chron-note
)