Compare commits
5 Commits
2c2ce5fd01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d18e244e | |||
| 8edeb3ac99 | |||
| 432fb9ba72 | |||
| 78f22a8320 | |||
| 4e830bf5d0 |
1
chron-core/errors.go
Normal file
1
chron-core/errors.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package chroncore
|
||||||
8
chron-core/go.mod
Normal file
8
chron-core/go.mod
Normal 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
4
chron-core/go.sum
Normal 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=
|
||||||
@@ -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
302
chron-core/ledger.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
81
chron-note/cmd/chron-note/main.go
Normal file
81
chron-note/cmd/chron-note/main.go
Normal 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
3
chron-note/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.michelsen.id/phill/chron/chron-note
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
1
chron-note/internal/app/app.go
Normal file
1
chron-note/internal/app/app.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package app
|
||||||
1
chron-note/internal/config/config.go
Normal file
1
chron-note/internal/config/config.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package config
|
||||||
1
chron-note/internal/config/defaults.go
Normal file
1
chron-note/internal/config/defaults.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package config
|
||||||
11
chron-note/internal/domain/errors.go
Normal file
11
chron-note/internal/domain/errors.go
Normal 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")
|
||||||
|
)
|
||||||
137
chron-note/internal/domain/event.go
Normal file
137
chron-note/internal/domain/event.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
chron-note/internal/domain/object.go
Normal file
74
chron-note/internal/domain/object.go
Normal 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
|
||||||
|
}
|
||||||
1
chron-note/internal/state/reduce.go
Normal file
1
chron-note/internal/state/reduce.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package state
|
||||||
1
chron-note/internal/state/state.go
Normal file
1
chron-note/internal/state/state.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package state
|
||||||
14
chron-note/internal/stores/cas/cas.go
Normal file
14
chron-note/internal/stores/cas/cas.go
Normal 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
|
||||||
|
}
|
||||||
93
chron-note/internal/stores/cas/fs.go
Normal file
93
chron-note/internal/stores/cas/fs.go
Normal 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)
|
||||||
|
}
|
||||||
133
chron-note/internal/util/encoding.go
Normal file
133
chron-note/internal/util/encoding.go
Normal 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
|
||||||
|
}
|
||||||
8
chron-note/internal/util/hash.go
Normal file
8
chron-note/internal/util/hash.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "lukechampine.com/blake3"
|
||||||
|
|
||||||
|
func Hash256(b []byte) [32]byte {
|
||||||
|
h := blake3.Sum256(b)
|
||||||
|
return h
|
||||||
|
}
|
||||||
69
chron-note/internal/workspace/io.go
Normal file
69
chron-note/internal/workspace/io.go
Normal 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)
|
||||||
|
}
|
||||||
46
chron-note/internal/workspace/list.go
Normal file
46
chron-note/internal/workspace/list.go
Normal 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
|
||||||
|
}
|
||||||
52
chron-note/internal/workspace/snapshot.go
Normal file
52
chron-note/internal/workspace/snapshot.go
Normal 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
|
||||||
|
}
|
||||||
32
chron-note/internal/workspace/workspace.go
Normal file
32
chron-note/internal/workspace/workspace.go
Normal 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())
|
||||||
|
}
|
||||||
@@ -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[:]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package core
|
|
||||||
128
core/ledger.go
128
core/ledger.go
@@ -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
20
go.mod
@@ -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
27
go.sum
@@ -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=
|
|
||||||
Reference in New Issue
Block a user