fix(db): clean up db helpers

Amolith created

Refine the Badger helpers.

Signed-off-by: Crush <crush@charm.land>

Change summary

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(-)

Detailed changes

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
 )

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=

internal/db/db.go 🔗

@@ -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
+}

internal/db/encoding.go 🔗

@@ -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)
+}

internal/db/keys.go 🔗

@@ -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()
+}

internal/db/options.go 🔗

@@ -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
+}

internal/db/path.go 🔗

@@ -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
+}