Detailed changes
@@ -8,10 +8,13 @@ go 1.25.3
require (
github.com/charmbracelet/fang v0.4.3
+ github.com/dgraph-io/badger/v4 v4.8.0
github.com/spf13/cobra v1.10.1
+ github.com/zeebo/blake3 v0.2.3
)
require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect
@@ -22,7 +25,14 @@ require (
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -34,8 +44,14 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/otel v1.37.0 // indirect
+ go.opentelemetry.io/otel/metric v1.37.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
- golang.org/x/text v0.24.0 // indirect
+ golang.org/x/text v0.26.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -1,5 +1,7 @@
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
@@ -26,8 +28,33 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
+github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
+github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
+github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
+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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
+github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=
+github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -47,6 +74,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
@@ -57,14 +86,34 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
+github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,368 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/dgraph-io/badger/v4"
+)
+
+// Database wraps a Badger instance with higher-level helpers suitable for the
+// nasin pali domain.
+type Database struct {
+ opts Options
+ badger *badger.DB
+}
+
+// Errors mapped from Badger, providing a consistent surface to callers.
+var (
+ ErrClosed = errors.New("db: database is closed")
+ ErrReadOnly = errors.New("db: database opened read-only")
+ ErrKeyNotFound = errors.New("db: key not found")
+ ErrTxnAborted = errors.New("db: transaction aborted by callback")
+)
+
+// Open instantiates a Database using the provided options.
+func Open(opts Options) (*Database, error) {
+ finalOpts, err := opts.applyDefaults()
+ if err != nil {
+ return nil, err
+ }
+
+ if err := ensureDir(finalOpts.Path); err != nil {
+ return nil, fmt.Errorf("db: preparing data directory: %w", err)
+ }
+
+ badgerOpts := badger.DefaultOptions(finalOpts.Path)
+ badgerOpts.SyncWrites = finalOpts.SyncWrites
+ badgerOpts.ReadOnly = finalOpts.ReadOnly
+ badgerOpts.Logger = badgerLoggerAdapter{logger: finalOpts.Logger}
+ if !finalOpts.ReadOnly {
+ // ValueLogFileSize defaults to 1GB which is excessive for our workloads.
+ // Drop it to 64 MiB to reduce disk footprint without sacrificing much
+ // performance.
+ badgerOpts.ValueLogFileSize = 64 << 20
+ }
+
+ db, err := badger.Open(badgerOpts)
+ if err != nil {
+ return nil, fmt.Errorf("db: open badger: %w", err)
+ }
+
+ return &Database{
+ opts: finalOpts,
+ badger: db,
+ }, nil
+}
+
+// Close releases underlying resources.
+func (db *Database) Close() error {
+ if db.badger == nil {
+ return nil
+ }
+ err := db.badger.Close()
+ db.badger = nil
+ return err
+}
+
+// Path exposes the filesystem directory backing the database.
+func (db *Database) Path() string {
+ return db.opts.Path
+}
+
+// View executes fn within a read-only transaction.
+func (db *Database) View(ctx context.Context, fn func(*Txn) error) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if db.badger == nil {
+ return ErrClosed
+ }
+
+ txn := db.badger.NewTransaction(false)
+ defer txn.Discard()
+
+ if err := fn(&Txn{txn: txn, readonly: true}); err != nil {
+ if errors.Is(err, badger.ErrKeyNotFound) {
+ return ErrKeyNotFound
+ }
+ if errors.Is(err, ErrTxnAborted) {
+ return nil
+ }
+ return err
+ }
+
+ return ctx.Err()
+}
+
+// Update executes fn within a read-write transaction, retrying on conflicts
+// according to Options.MaxTxnRetries.
+func (db *Database) Update(ctx context.Context, fn func(*Txn) error) error {
+ if db.opts.ReadOnly {
+ return ErrReadOnly
+ }
+ if db.badger == nil {
+ return ErrClosed
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ var attempt int
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ txn := db.badger.NewTransaction(true)
+ callbackErr := fn(&Txn{txn: txn})
+ if callbackErr != nil {
+ txn.Discard()
+ if errors.Is(callbackErr, ErrTxnAborted) {
+ return nil
+ }
+ if errors.Is(callbackErr, badger.ErrKeyNotFound) {
+ return ErrKeyNotFound
+ }
+ return callbackErr
+ }
+
+ if err := txn.Commit(); err != nil {
+ txn.Discard()
+ if errors.Is(err, badger.ErrConflict) && attempt < db.opts.MaxTxnRetries {
+ backoff := db.opts.ConflictBackoff * time.Duration(1<<attempt)
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(backoff):
+ attempt++
+ continue
+ }
+ }
+
+ if errors.Is(err, badger.ErrKeyNotFound) {
+ return ErrKeyNotFound
+ }
+ return fmt.Errorf("db: commit transaction: %w", err)
+ }
+
+ txn.Discard()
+ return nil
+ }
+}
+
+// Txn wraps badger.Txn and exposes helper methods.
+type Txn struct {
+ txn *badger.Txn
+ readonly bool
+}
+
+// Abort signals that the transaction should be rolled back without returning an
+// error to callers.
+func (t *Txn) Abort() error {
+ return ErrTxnAborted
+}
+
+// Get retrieves the value for key.
+func (t *Txn) Get(key []byte) ([]byte, error) {
+ item, err := t.txn.Get(key)
+ if err != nil {
+ return nil, err
+ }
+ return item.ValueCopy(nil)
+}
+
+// GetJSON retrieves the value at key and unmarshals it into dst.
+func (t *Txn) GetJSON(key []byte, dst any) error {
+ item, err := t.txn.Get(key)
+ if err != nil {
+ return err
+ }
+ return item.Value(func(val []byte) error {
+ return json.Unmarshal(val, dst)
+ })
+}
+
+// Exists reports whether a key exists.
+func (t *Txn) Exists(key []byte) (bool, error) {
+ _, err := t.txn.Get(key)
+ if err == nil {
+ return true, nil
+ }
+ if errors.Is(err, badger.ErrKeyNotFound) {
+ return false, nil
+ }
+ return false, err
+}
+
+// Set associates key with value.
+func (t *Txn) Set(key, value []byte) error {
+ if t.readonly {
+ return ErrReadOnly
+ }
+ return t.txn.Set(key, value)
+}
+
+// SetJSON marshals v as JSON and stores it at key.
+func (t *Txn) SetJSON(key []byte, v any) error {
+ if t.readonly {
+ return ErrReadOnly
+ }
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return t.txn.Set(key, data)
+}
+
+// Delete removes key.
+func (t *Txn) Delete(key []byte) error {
+ if t.readonly {
+ return ErrReadOnly
+ }
+ return t.txn.Delete(key)
+}
+
+// IncrementUint64 increments a big-endian uint64 value stored at key by delta
+// and returns the resulting value. The stored representation is raw 8-byte big
+// endian, aligning with the schema's counter storage.
+func (t *Txn) IncrementUint64(key []byte, delta uint64) (uint64, error) {
+ if t.readonly {
+ return 0, ErrReadOnly
+ }
+
+ var current uint64
+ item, err := t.txn.Get(key)
+ switch {
+ case err == nil:
+ if err := item.Value(func(val []byte) error {
+ var convErr error
+ current, convErr = decodeUint64(val)
+ return convErr
+ }); err != nil {
+ return 0, err
+ }
+ case errors.Is(err, badger.ErrKeyNotFound):
+ current = 0
+ default:
+ return 0, err
+ }
+
+ next := current + delta
+ if err := t.txn.Set(key, encodeUint64(next)); err != nil {
+ return 0, err
+ }
+ return next, nil
+}
+
+// Iterate walks over keys using prefix iteration.
+func (t *Txn) Iterate(opts IterateOptions, fn func(Item) error) error {
+ iterOpts := badger.DefaultIteratorOptions
+ iterOpts.Reverse = opts.Reverse
+ iterOpts.PrefetchValues = opts.PrefetchValues
+ iterOpts.Prefix = opts.Prefix
+
+ it := t.txn.NewIterator(iterOpts)
+ defer it.Close()
+
+ if len(opts.Prefix) > 0 {
+ for it.Seek(opts.Prefix); it.ValidForPrefix(opts.Prefix); it.Next() {
+ if err := fn(Item{item: it.Item()}); err != nil {
+ if errors.Is(err, ErrTxnAborted) {
+ return nil
+ }
+ return err
+ }
+ }
+ return nil
+ }
+
+ for it.Rewind(); it.Valid(); it.Next() {
+ if err := fn(Item{item: it.Item()}); err != nil {
+ if errors.Is(err, ErrTxnAborted) {
+ return nil
+ }
+ return err
+ }
+ }
+ return nil
+}
+
+// Item wraps a Badger item during iteration.
+type Item struct {
+ item *badger.Item
+}
+
+// Key returns a copy of the item's key.
+func (it Item) Key() []byte {
+ return it.item.KeyCopy(nil)
+}
+
+// KeyString returns the item's key as a string without additional allocation.
+func (it Item) KeyString() string {
+ return string(it.item.KeyCopy(nil))
+}
+
+// Value returns a copy of the item's value.
+func (it Item) Value() ([]byte, error) {
+ return it.item.ValueCopy(nil)
+}
+
+// ValueJSON unmarshals the item's value into dst.
+func (it Item) ValueJSON(dst any) error {
+ return it.item.Value(func(val []byte) error {
+ return json.Unmarshal(val, dst)
+ })
+}
+
+// IterateOptions configures Txn.Iterate.
+type IterateOptions struct {
+ Prefix []byte
+ Reverse bool
+ PrefetchValues bool
+}
+
+func ensureDir(path string) error {
+ return os.MkdirAll(path, 0o755)
+}
+
+type badgerLoggerAdapter struct {
+ logger Logger
+}
+
+func (a badgerLoggerAdapter) Errorf(format string, args ...any) {
+ a.logger.Errorf(format, args...)
+}
+
+func (a badgerLoggerAdapter) Warningf(format string, args ...any) {
+ a.logger.Warningf(format, args...)
+}
+
+func (a badgerLoggerAdapter) Infof(format string, args ...any) {
+ a.logger.Infof(format, args...)
+}
+
+func (a badgerLoggerAdapter) Debugf(format string, args ...any) {
+ a.logger.Debugf(format, args...)
+}
+
+func encodeUint64(v uint64) []byte {
+ var b [8]byte
+ putUint64(b[:], v)
+ return b[:]
+}
+
+func decodeUint64(b []byte) (uint64, error) {
+ if len(b) != 8 {
+ return 0, fmt.Errorf("db: expected 8 bytes, got %d", len(b))
+ }
+ return readUint64(b), nil
+}
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import (
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+)
+
+func putUint64(dst []byte, v uint64) {
+ binary.BigEndian.PutUint64(dst, v)
+}
+
+func readUint64(src []byte) uint64 {
+ return binary.BigEndian.Uint64(src)
+}
+
+// Uint64Hex renders v as a zero-padded lower-case hexadecimal string.
+func Uint64Hex(v uint64) string {
+ return fmt.Sprintf("%016x", v)
+}
+
+func encodeHex(b []byte) string {
+ return hex.EncodeToString(b)
+}
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import "strings"
+
+const (
+ prefixMeta = "meta"
+ prefixDir = "dir"
+ prefixIdx = "idx"
+ prefixSession = "s"
+ suffixActive = "active"
+ suffixArchived = "archived"
+ suffixGoal = "goal"
+ suffixMeta = "meta"
+ suffixTasks = "task"
+ suffixStatusIdx = "idx"
+ suffixStatus = "status"
+ suffixEvents = "evt"
+ suffixEvtSeq = "evt_seq"
+)
+
+// KeySchemaVersion returns the key for the schema version.
+func KeySchemaVersion() []byte {
+ return []byte(join(prefixMeta, "schema_version"))
+}
+
+// KeyDirActive resolves the key storing the active session ID for a directory.
+func KeyDirActive(dirHash string) []byte {
+ return []byte(join(prefixDir, dirHash, suffixActive))
+}
+
+// KeyDirArchived emits the per-directory archived index key.
+func KeyDirArchived(dirHash, tsHex, sid string) []byte {
+ return []byte(join(prefixDir, dirHash, suffixArchived, tsHex, sid))
+}
+
+// KeyIdxActive returns the index key for active sessions.
+func KeyIdxActive(sid string) []byte {
+ return []byte(join(prefixIdx, suffixActive, sid))
+}
+
+// KeyIdxArchived returns the global archive index key.
+func KeyIdxArchived(tsHex, sid string) []byte {
+ return []byte(join(prefixIdx, suffixArchived, tsHex, sid))
+}
+
+// KeySessionMeta returns the session metadata document key.
+func KeySessionMeta(sid string) []byte {
+ return []byte(join(prefixSession, sid, suffixMeta))
+}
+
+// KeySessionGoal returns the session goal document key.
+func KeySessionGoal(sid string) []byte {
+ return []byte(join(prefixSession, sid, suffixGoal))
+}
+
+// KeySessionTask returns the task document key.
+func KeySessionTask(sid, taskID string) []byte {
+ return []byte(join(prefixSession, sid, suffixTasks, taskID))
+}
+
+// KeySessionTaskStatusIndex returns the membership key for the status index.
+func KeySessionTaskStatusIndex(sid, status, taskID string) []byte {
+ return []byte(join(prefixSession, sid, suffixStatusIdx, suffixStatus, status, taskID))
+}
+
+// KeySessionEventSeq returns the counter key for event sequences.
+func KeySessionEventSeq(sid string) []byte {
+ return []byte(join(prefixSession, sid, suffixMeta, suffixEvtSeq))
+}
+
+// KeySessionEvent returns the key for a specific event sequence.
+func KeySessionEvent(sid string, seq uint64) []byte {
+ return []byte(join(prefixSession, sid, suffixEvents, Uint64Hex(seq)))
+}
+
+// PrefixSessionTasks returns the key prefix covering all tasks for sid.
+func PrefixSessionTasks(sid string) []byte {
+ return []byte(join(prefixSession, sid, suffixTasks))
+}
+
+// PrefixSessionStatusIndex returns the prefix for tasks with the given status.
+func PrefixSessionStatusIndex(sid, status string) []byte {
+ return []byte(join(prefixSession, sid, suffixStatusIdx, suffixStatus, status))
+}
+
+// PrefixSessionEvents returns the prefix for all events in a session.
+func PrefixSessionEvents(sid string) []byte {
+ return []byte(join(prefixSession, sid, suffixEvents))
+}
+
+// PrefixDirArchived returns the prefix for archived sessions belonging to dir.
+func PrefixDirArchived(dirHash string) []byte {
+ return []byte(join(prefixDir, dirHash, suffixArchived))
+}
+
+// PrefixIdxActive returns the prefix for scanning active sessions.
+func PrefixIdxActive() []byte {
+ return []byte(join(prefixIdx, suffixActive))
+}
+
+// PrefixIdxArchived returns the prefix for scanning the archive index.
+func PrefixIdxArchived() []byte {
+ return []byte(join(prefixIdx, suffixArchived))
+}
+
+func join(parts ...string) string {
+ var b strings.Builder
+ for _, part := range parts {
+ if part == "" {
+ continue
+ }
+ if b.Len() > 0 {
+ b.WriteByte('/')
+ }
+ b.WriteString(part)
+ }
+ return b.String()
+}
@@ -0,0 +1,116 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+)
+
+const (
+ defaultNamespace = "nasin-pali"
+
+ // defaultTxnMaxRetries is the number of times we retry transactional conflicts.
+ defaultTxnMaxRetries = 5
+
+ // defaultConflictBackoff is the initial delay used when backing off after a conflict.
+ defaultConflictBackoff = 10 * time.Millisecond
+)
+
+// Options captures configuration for opening a database.
+type Options struct {
+ // Path is the filesystem path to the Badger data directory. When empty, the
+ // default path below $XDG_CONFIG_HOME (or platform equivalent) is used.
+ Path string
+
+ // ReadOnly toggles read-only mode. Attempts to perform write transactions in
+ // this mode will fail.
+ ReadOnly bool
+
+ // SyncWrites mirrors badger.Options.SyncWrites. Default is false to favor
+ // throughput while accepting a small durability risk.
+ SyncWrites bool
+
+ // Logger receives Badger log messages. Defaults to a no-op logger, as CLI
+ // workflows prefer explicit output.
+ Logger Logger
+
+ // MaxTxnRetries controls how many times Update() will retry transactions that
+ // fail with a conflict. Must be >= 0.
+ MaxTxnRetries int
+
+ // ConflictBackoff overrides the base backoff duration between retries. When
+ // zero, a sensible default is used.
+ ConflictBackoff time.Duration
+}
+
+// Logger is a simple logging interface that mirrors Badger's expectations while
+// keeping this package decoupled from the concrete implementation.
+type Logger interface {
+ Errorf(format string, args ...any)
+ Warningf(format string, args ...any)
+ Infof(format string, args ...any)
+ Debugf(format string, args ...any)
+}
+
+type noopLogger struct{}
+
+func (noopLogger) Errorf(string, ...any) {}
+func (noopLogger) Warningf(string, ...any) {}
+func (noopLogger) Infof(string, ...any) {}
+func (noopLogger) Debugf(string, ...any) {}
+
+func (o Options) applyDefaults() (Options, error) {
+ if o.Path == "" {
+ path, err := DefaultPath()
+ if err != nil {
+ return Options{}, err
+ }
+ o.Path = path
+ }
+
+ if o.Logger == nil {
+ o.Logger = noopLogger{}
+ }
+
+ if o.MaxTxnRetries < 0 {
+ return Options{}, errors.New("db: MaxTxnRetries must be >= 0")
+ }
+ if o.MaxTxnRetries == 0 {
+ o.MaxTxnRetries = defaultTxnMaxRetries
+ }
+
+ if o.ConflictBackoff <= 0 {
+ o.ConflictBackoff = defaultConflictBackoff
+ }
+
+ return o, nil
+}
+
+// DefaultPath resolves the directory for persistent data. The location follows
+// the XDG Base Directory specification when possible and falls back to a
+// platform-appropriate default.
+func DefaultPath() (string, error) {
+ configRoot := os.Getenv("XDG_CONFIG_HOME")
+ if configRoot == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ switch runtime.GOOS {
+ case "windows":
+ configRoot = filepath.Join(home, "AppData", "Roaming")
+ case "darwin":
+ configRoot = filepath.Join(home, "Library", "Application Support")
+ default:
+ configRoot = filepath.Join(home, ".config")
+ }
+ }
+
+ return filepath.Join(configRoot, defaultNamespace), nil
+}
@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/zeebo/blake3"
+)
+
+// CanonicalizeDir normalizes path according to the repository requirements.
+func CanonicalizeDir(path string) (string, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", err
+ }
+
+ evaluated, err := filepath.EvalSymlinks(abs)
+ if err != nil {
+ return "", err
+ }
+
+ clean := filepath.Clean(evaluated)
+ slashed := filepath.ToSlash(clean)
+ if runtime.GOOS == "windows" {
+ slashed = strings.ToLower(slashed)
+ }
+ return slashed, nil
+}
+
+// DirHash produces the blake3-256 hash for canonicalPath and renders it in
+// lowercase hex (64 characters).
+func DirHash(canonicalPath string) string {
+ sum := blake3.Sum256([]byte(canonicalPath))
+ return encodeHex(sum[:])
+}
+
+// CanonicalizeAndHash resolves path to its canonical representation and
+// computes its hash.
+func CanonicalizeAndHash(path string) (canonical string, hash string, err error) {
+ canonical, err = CanonicalizeDir(path)
+ if err != nil {
+ return "", "", err
+ }
+ return canonical, DirHash(canonical), nil
+}
+
+// ParentWalk returns canonical parent paths (including the input) ascending to
+// the root. The caller can hash each entry as needed.
+func ParentWalk(canonicalPath string) []string {
+ if canonicalPath == "/" {
+ return []string{canonicalPath}
+ }
+ var parents []string
+ seen := make(map[string]struct{})
+ current := canonicalPath
+ for {
+ if _, exists := seen[current]; exists {
+ break
+ }
+ seen[current] = struct{}{}
+ parents = append(parents, current)
+ next := parentDir(current)
+ if next == current {
+ break
+ }
+ current = next
+ }
+ return parents
+}
+
+func parentDir(path string) string {
+ if path == "/" {
+ return path
+ }
+ idx := strings.LastIndex(path, "/")
+ if idx == -1 {
+ return path
+ }
+ parent := path[:idx]
+ if parent == "" {
+ return "/"
+ }
+ if runtime.GOOS == "windows" {
+ if len(parent) == 2 && parent[1] == ':' {
+ parent += "/"
+ }
+ }
+ return parent
+}