From 7c0c9d0267bbf5581182f87b177cc6e03f9519cc Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 29 Oct 2025 15:28:52 -0600 Subject: [PATCH] fix(db): clean up db helpers Refine the Badger helpers. Signed-off-by: Crush --- go.mod | 18 +- go.sum | 53 +++++- internal/db/db.go | 368 ++++++++++++++++++++++++++++++++++++++++ internal/db/encoding.go | 28 +++ internal/db/keys.go | 122 +++++++++++++ internal/db/options.go | 116 +++++++++++++ internal/db/path.go | 94 ++++++++++ 7 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 internal/db/db.go create mode 100644 internal/db/encoding.go create mode 100644 internal/db/keys.go create mode 100644 internal/db/options.go create mode 100644 internal/db/path.go diff --git a/go.mod b/go.mod index 2bb58b07aa5986225b010e2d76642c2d1a3e42cc..cdb3f941d7998d56fd47e6c0298612777684cba6 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e72ecd17adf88fafdb3d675c186d5ed5eea89849..86eff6ab060a3d0a057b649bc4d7599f94f4c691 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000000000000000000000000000000000000..52d13a07fe0927e2b7b449fe67b173dc30094c9b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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< 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 +} diff --git a/internal/db/encoding.go b/internal/db/encoding.go new file mode 100644 index 0000000000000000000000000000000000000000..10d0b8fe81eaff57d34047f5889e75a3e0ff1426 --- /dev/null +++ b/internal/db/encoding.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/internal/db/keys.go b/internal/db/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..a3ab10cb7f12a5430b853e0b9749714be2beba1f --- /dev/null +++ b/internal/db/keys.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/db/options.go b/internal/db/options.go new file mode 100644 index 0000000000000000000000000000000000000000..23151ea408652a5afa106017e10b309f3c6b1e53 --- /dev/null +++ b/internal/db/options.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/db/path.go b/internal/db/path.go new file mode 100644 index 0000000000000000000000000000000000000000..cefe0d6f035e80faf0deb814059e84cd3237838f --- /dev/null +++ b/internal/db/path.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}