···4646 Usage: "DID to fetch (required for carfile/pds modes)",
4747 },
4848 &cli.StringFlag{
4949- Name: "output",
5050- Value: "stats",
5151- Usage: "Output mode: stats, passthrough, tarfiles, or duckdb",
4949+ Name: "store",
5050+ Value: "stdout",
5151+ Usage: "Storage mode: stdout, tarfiles, or duckdb",
5252 },
5353 &cli.StringFlag{
5454- Name: "output-dir",
5454+ Name: "storage-dir",
5555 Value: "./output",
5656 Usage: "Output directory for tarfiles mode",
5757 },
···7575 relayService := c.String("relay")
7676 jetService := c.String("jetstream")
7777 did := c.String("did")
7878- outputMode := c.String("output")
7979- outputDir := c.String("output-dir")
7878+ storeMode := c.String("store")
7979+ storageDir := c.String("storage-dir")
8080 dbPath := c.String("db")
81818282 // Create remote based on input mode
···106106 return fmt.Errorf("unknown input mode: %s", inputMode)
107107 }
108108109109- // Create store based on output mode
109109+ // Create store based on storage mode
110110 var s store.Store
111111- switch outputMode {
112112- case "passthrough":
113113- s = &store.StdoutStore{Mode: store.StdoutStoreModePassthrough}
114114- case "stats":
111111+ switch storeMode {
112112+ case "stdout":
115113 s = &store.StdoutStore{Mode: store.StdoutStoreModeStats}
116114 case "tarfiles":
117117- s = store.NewTarfilesStore(outputDir)
115115+ s = store.NewTarfilesStore(storageDir)
118116 case "duckdb":
119117 s = store.NewDuckdbStore(dbPath)
120118 default:
121121- return fmt.Errorf("unknown output mode: %s", outputMode)
119119+ return fmt.Errorf("unknown output mode: %s", storeMode)
122120 }
123121124122 // Create context
-9
cmd/butterfly/identity.go
···11-// Package main provides identity resolution infrastructure for Butterfly
22-package main
33-44-// TODO: Implement identity resolution functionality
55-// This will likely include:
66-// - DID resolution
77-// - Handle resolution
88-// - Identity caching
99-// - Integration with the atproto identity package
+172
cmd/butterfly/identity/identity.go
···11+package identity
22+33+import (
44+ "context"
55+ "fmt"
66+ "net/http"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/atproto/identity"
1010+ "github.com/bluesky-social/indigo/atproto/syntax"
1111+ "golang.org/x/time/rate"
1212+1313+ "github.com/bluesky-social/indigo/cmd/butterfly/store"
1414+)
1515+1616+// IdentityResolver wraps the atproto identity directory with butterfly-specific configuration
1717+type IdentityResolver struct {
1818+ directory identity.Directory
1919+}
2020+2121+// IdentityResolverConfig contains configuration options for identity resolution
2222+type IdentityResolverConfig struct {
2323+ // URL for PLC directory (defaults to https://plc.directory)
2424+ PLCURL string
2525+ // Store for caching
2626+ Store store.Store
2727+ // Cache TTL for successful lookups
2828+ CacheTTL time.Duration
2929+ // HTTP client timeout
3030+ HTTPTimeout time.Duration
3131+ // User agent string
3232+ UserAgent string
3333+ // Rate limit for PLC requests (requests per second)
3434+ PLCRateLimit float64
3535+ // Enable authoritative DNS fallback
3636+ TryAuthoritativeDNS bool
3737+}
3838+3939+// NewIdentityResolver creates a new identity resolver with custom configuration
4040+func NewIdentityResolver(config IdentityResolverConfig) *IdentityResolver {
4141+ // Set defaults
4242+ if config.PLCURL == "" {
4343+ config.PLCURL = "https://plc.directory"
4444+ }
4545+ if config.HTTPTimeout == 0 {
4646+ config.HTTPTimeout = 30 * time.Second
4747+ }
4848+ if config.UserAgent == "" {
4949+ config.UserAgent = "butterfly/0.0.1"
5050+ }
5151+ if config.PLCRateLimit == 0 {
5252+ config.PLCRateLimit = 10 // 10 requests per second default
5353+ }
5454+5555+ // Create base directory
5656+ baseDir := &identity.BaseDirectory{
5757+ PLCURL: config.PLCURL,
5858+ PLCLimiter: rate.NewLimiter(rate.Limit(config.PLCRateLimit), 1),
5959+ HTTPClient: http.Client{
6060+ Timeout: config.HTTPTimeout,
6161+ },
6262+ UserAgent: config.UserAgent,
6363+ TryAuthoritativeDNS: config.TryAuthoritativeDNS,
6464+ }
6565+6666+ // Wrap with store caching
6767+ if config.CacheTTL == 0 {
6868+ config.CacheTTL = 5 * time.Minute
6969+ }
7070+ cacheDir := NewStoreDirectory(baseDir, config.Store, config.CacheTTL, time.Minute, 5*time.Minute)
7171+7272+ return &IdentityResolver{
7373+ directory: &cacheDir,
7474+ }
7575+}
7676+7777+// ResolveHandle looks up a handle and returns the associated identity
7878+func (r *IdentityResolver) ResolveHandle(ctx context.Context, handle string) (*identity.Identity, error) {
7979+ h, err := syntax.ParseHandle(handle)
8080+ if err != nil {
8181+ return nil, fmt.Errorf("invalid handle %q: %w", handle, err)
8282+ }
8383+8484+ ident, err := r.directory.LookupHandle(ctx, h)
8585+ if err != nil {
8686+ return nil, fmt.Errorf("failed to resolve handle %q: %w", handle, err)
8787+ }
8888+8989+ return ident, nil
9090+}
9191+9292+// ResolveDID looks up a DID and returns the associated identity
9393+func (r *IdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.Identity, error) {
9494+ d, err := syntax.ParseDID(did)
9595+ if err != nil {
9696+ return nil, fmt.Errorf("invalid DID %q: %w", did, err)
9797+ }
9898+9999+ ident, err := r.directory.LookupDID(ctx, d)
100100+ if err != nil {
101101+ return nil, fmt.Errorf("failed to resolve DID %q: %w", did, err)
102102+ }
103103+104104+ return ident, nil
105105+}
106106+107107+// Resolve looks up either a handle or DID and returns the associated identity
108108+func (r *IdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) {
109109+ atid, err := syntax.ParseAtIdentifier(identifier)
110110+ if err != nil {
111111+ return nil, fmt.Errorf("invalid identifier %q: %w", identifier, err)
112112+ }
113113+114114+ ident, err := r.directory.Lookup(ctx, *atid)
115115+ if err != nil {
116116+ return nil, fmt.Errorf("failed to resolve identifier %q: %w", identifier, err)
117117+ }
118118+119119+ return ident, nil
120120+}
121121+122122+// GetPDSEndpoint returns the PDS endpoint for a given identity
123123+func (r *IdentityResolver) GetPDSEndpoint(ident *identity.Identity) (string, error) {
124124+ if ident == nil {
125125+ return "", fmt.Errorf("identity is nil")
126126+ }
127127+128128+ // Look for the atproto_pds service endpoint
129129+ if svc, ok := ident.Services["atproto_pds"]; ok {
130130+ return svc.URL, nil
131131+ }
132132+133133+ return "", fmt.Errorf("no PDS endpoint found for DID %s", ident.DID)
134134+}
135135+136136+// GetPublicKey returns the public key for a given identity and key ID
137137+func (r *IdentityResolver) GetPublicKey(ident *identity.Identity, keyID string) (*identity.VerificationMethod, error) {
138138+ if ident == nil {
139139+ return nil, fmt.Errorf("identity is nil")
140140+ }
141141+142142+ if key, ok := ident.Keys[keyID]; ok {
143143+ return &key, nil
144144+ }
145145+146146+ return nil, fmt.Errorf("key %q not found for DID %s", keyID, ident.DID)
147147+}
148148+149149+// GetSigningKey returns the primary signing key for a given identity
150150+func (r *IdentityResolver) GetSigningKey(ident *identity.Identity) (*identity.VerificationMethod, error) {
151151+ // Try the atproto signing key first
152152+ if key, err := r.GetPublicKey(ident, "atproto"); err == nil {
153153+ return key, nil
154154+ }
155155+156156+ // Fall back to the first available key
157157+ for _, key := range ident.Keys {
158158+ return &key, nil
159159+ }
160160+161161+ return nil, fmt.Errorf("no signing key found for DID %s", ident.DID)
162162+}
163163+164164+// Purge removes an identifier from the cache (if caching is enabled)
165165+func (r *IdentityResolver) Purge(ctx context.Context, identifier string) error {
166166+ atid, err := syntax.ParseAtIdentifier(identifier)
167167+ if err != nil {
168168+ return fmt.Errorf("invalid identifier %q: %w", identifier, err)
169169+ }
170170+171171+ return r.directory.Purge(ctx, *atid)
172172+}
+350
cmd/butterfly/identity/store_directory.go
···11+package identity
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "sync"
88+ "time"
99+1010+ "github.com/bluesky-social/indigo/atproto/identity"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1212+1313+ "github.com/bluesky-social/indigo/cmd/butterfly/store"
1414+)
1515+1616+const identityCache = "identity-cache"
1717+const handleCache = "handle-cache"
1818+1919+// StoreDirectory is an implementation of identity.Directory with cache of Handle and DID in the provided Store
2020+type StoreDirectory struct {
2121+ Inner identity.Directory
2222+ ErrTTL time.Duration
2323+ InvalidHandleTTL time.Duration
2424+ store store.Store
2525+ didLookupChans sync.Map
2626+ handleLookupChans sync.Map
2727+}
2828+2929+type handleEntry struct {
3030+ Updated time.Time
3131+ DID syntax.DID
3232+ Err error
3333+}
3434+3535+type identityEntry struct {
3636+ Updated time.Time
3737+ Identity *identity.Identity
3838+ Err error
3939+}
4040+4141+// var _ identity.Directory = (*StoreDirectory)(nil) TODO is this needed?
4242+4343+// Ttl of zero means unlimited duration.
4444+func NewStoreDirectory(inner identity.Directory, store store.Store, hitTTL, errTTL, invalidHandleTTL time.Duration) StoreDirectory {
4545+ return StoreDirectory{
4646+ ErrTTL: errTTL,
4747+ InvalidHandleTTL: invalidHandleTTL,
4848+ Inner: inner,
4949+ store: store,
5050+ }
5151+}
5252+5353+func (d *StoreDirectory) isHandleStale(e *handleEntry) bool {
5454+ if e.Err != nil && time.Since(e.Updated) > d.ErrTTL {
5555+ return true
5656+ }
5757+ return false
5858+}
5959+6060+func (d *StoreDirectory) isIdentityStale(e *identityEntry) bool {
6161+ if e.Err != nil && time.Since(e.Updated) > d.ErrTTL {
6262+ return true
6363+ }
6464+ if e.Identity != nil && e.Identity.Handle.IsInvalidHandle() && time.Since(e.Updated) > d.InvalidHandleTTL {
6565+ return true
6666+ }
6767+ return false
6868+}
6969+7070+func (d *StoreDirectory) updateHandle(ctx context.Context, h syntax.Handle) handleEntry {
7171+ ident, err := d.Inner.LookupHandle(ctx, h)
7272+ if err != nil {
7373+ he := handleEntry{
7474+ Updated: time.Now(),
7575+ DID: "",
7676+ Err: err,
7777+ }
7878+ putHandle(d.store, h, &he)
7979+ return he
8080+ }
8181+8282+ entry := identityEntry{
8383+ Updated: time.Now(),
8484+ Identity: ident,
8585+ Err: nil,
8686+ }
8787+ he := handleEntry{
8888+ Updated: time.Now(),
8989+ DID: ident.DID,
9090+ Err: nil,
9191+ }
9292+9393+ putIdent(d.store, ident.DID, &entry)
9494+ putHandle(d.store, ident.Handle, &he)
9595+ return he
9696+}
9797+9898+func (d *StoreDirectory) ResolveHandle(ctx context.Context, h syntax.Handle) (syntax.DID, error) {
9999+ h = h.Normalize()
100100+ if h.IsInvalidHandle() {
101101+ return "", fmt.Errorf("can not resolve handle: %w", identity.ErrInvalidHandle)
102102+ }
103103+ // start := time.Now() TODO
104104+ entry, err := getHandle(d.store, h)
105105+ if err == nil && !d.isHandleStale(entry) {
106106+ // TODO
107107+ // handleCacheHits.Inc()
108108+ // handleResolution.WithLabelValues("lru", "cached").Inc()
109109+ // handleResolutionDuration.WithLabelValues("lru", "cached").Observe(time.Since(start).Seconds())
110110+ return entry.DID, entry.Err
111111+ }
112112+ // handleCacheMisses.Inc() TODO
113113+114114+ // Coalesce multiple requests for the same Handle
115115+ res := make(chan struct{})
116116+ val, loaded := d.handleLookupChans.LoadOrStore(h.String(), res)
117117+ if loaded {
118118+ // TODO
119119+ // handleRequestsCoalesced.Inc()
120120+ // handleResolution.WithLabelValues("lru", "coalesced").Inc()
121121+ // handleResolutionDuration.WithLabelValues("lru", "coalesced").Observe(time.Since(start).Seconds())
122122+ // Wait for the result from the pending request
123123+ select {
124124+ case <-val.(chan struct{}):
125125+ // The result should now be in the cache
126126+ entry, err := getHandle(d.store, h)
127127+ if err == nil && !d.isHandleStale(entry) {
128128+ return entry.DID, entry.Err
129129+ }
130130+ return "", fmt.Errorf("identity not found in cache after coalesce returned")
131131+ case <-ctx.Done():
132132+ return "", ctx.Err()
133133+ }
134134+ }
135135+136136+ // Update the Handle Entry from PLC and cache the result
137137+ newEntry := d.updateHandle(ctx, h)
138138+139139+ // Cleanup the coalesce map and close the results channel
140140+ d.handleLookupChans.Delete(h.String())
141141+ // Callers waiting will now get the result from the cache
142142+ close(res)
143143+144144+ if newEntry.Err != nil {
145145+ // TODO
146146+ // handleResolution.WithLabelValues("lru", "error").Inc()
147147+ // handleResolutionDuration.WithLabelValues("lru", "error").Observe(time.Since(start).Seconds())
148148+ return "", newEntry.Err
149149+ }
150150+ if newEntry.DID != "" {
151151+ // TODO
152152+ // handleResolution.WithLabelValues("lru", "success").Inc()
153153+ // handleResolutionDuration.WithLabelValues("lru", "success").Observe(time.Since(start).Seconds())
154154+ return newEntry.DID, nil
155155+ }
156156+ return "", fmt.Errorf("unexpected control-flow error")
157157+}
158158+159159+func (d *StoreDirectory) updateDID(ctx context.Context, did syntax.DID) identityEntry {
160160+ ident, err := d.Inner.LookupDID(ctx, did)
161161+ // persist the identity lookup error, instead of processing it immediately
162162+ entry := identityEntry{
163163+ Updated: time.Now(),
164164+ Identity: ident,
165165+ Err: err,
166166+ }
167167+ var he *handleEntry
168168+ // if *not* an error, then also update the handle cache
169169+ if nil == err && !ident.Handle.IsInvalidHandle() {
170170+ he = &handleEntry{
171171+ Updated: time.Now(),
172172+ DID: did,
173173+ Err: nil,
174174+ }
175175+ }
176176+177177+ putIdent(d.store, did, &entry)
178178+ if he != nil {
179179+ putHandle(d.store, ident.Handle, he)
180180+ }
181181+ return entry
182182+}
183183+184184+func (d *StoreDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
185185+ id, _, err := d.LookupDIDWithCacheState(ctx, did)
186186+ return id, err
187187+}
188188+189189+func (d *StoreDirectory) LookupDIDWithCacheState(ctx context.Context, did syntax.DID) (*identity.Identity, bool, error) {
190190+ // start := time.Now() TODO
191191+ entry, err := getIdent(d.store, did)
192192+ if err == nil && !d.isIdentityStale(entry) {
193193+ // TODO
194194+ // identityCacheHits.Inc()
195195+ // didResolution.WithLabelValues("lru", "cached").Inc()
196196+ // didResolutionDuration.WithLabelValues("lru", "cached").Observe(time.Since(start).Seconds())
197197+ return entry.Identity, true, entry.Err
198198+ }
199199+ // identityCacheMisses.Inc() TODO
200200+201201+ // Coalesce multiple requests for the same DID
202202+ res := make(chan struct{})
203203+ val, loaded := d.didLookupChans.LoadOrStore(did.String(), res)
204204+ if loaded {
205205+ // TODO
206206+ // identityRequestsCoalesced.Inc()
207207+ // didResolution.WithLabelValues("lru", "coalesced").Inc()
208208+ // didResolutionDuration.WithLabelValues("lru", "coalesced").Observe(time.Since(start).Seconds())
209209+ // Wait for the result from the pending request
210210+ select {
211211+ case <-val.(chan struct{}):
212212+ // The result should now be in the cache
213213+ entry, err := getIdent(d.store, did)
214214+ if err == nil && !d.isIdentityStale(entry) {
215215+ return entry.Identity, false, entry.Err
216216+ }
217217+ return nil, false, fmt.Errorf("identity not found in cache after coalesce returned")
218218+ case <-ctx.Done():
219219+ return nil, false, ctx.Err()
220220+ }
221221+ }
222222+223223+ // Update the Identity Entry from PLC and cache the result
224224+ newEntry := d.updateDID(ctx, did)
225225+226226+ // Cleanup the coalesce map and close the results channel
227227+ d.didLookupChans.Delete(did.String())
228228+ // Callers waiting will now get the result from the cache
229229+ close(res)
230230+231231+ if newEntry.Err != nil {
232232+ // TODO
233233+ // didResolution.WithLabelValues("lru", "error").Inc()
234234+ // didResolutionDuration.WithLabelValues("lru", "error").Observe(time.Since(start).Seconds())
235235+ return nil, false, newEntry.Err
236236+ }
237237+ if newEntry.Identity != nil {
238238+ // TODO
239239+ // didResolution.WithLabelValues("lru", "success").Inc()
240240+ // didResolutionDuration.WithLabelValues("lru", "success").Observe(time.Since(start).Seconds())
241241+ return newEntry.Identity, false, nil
242242+ }
243243+ return nil, false, fmt.Errorf("unexpected control-flow error")
244244+}
245245+246246+func (d *StoreDirectory) LookupHandle(ctx context.Context, h syntax.Handle) (*identity.Identity, error) {
247247+ ident, _, err := d.LookupHandleWithCacheState(ctx, h)
248248+ return ident, err
249249+}
250250+251251+func (d *StoreDirectory) LookupHandleWithCacheState(ctx context.Context, h syntax.Handle) (*identity.Identity, bool, error) {
252252+ h = h.Normalize()
253253+ did, err := d.ResolveHandle(ctx, h)
254254+ if err != nil {
255255+ return nil, false, err
256256+ }
257257+ ident, hit, err := d.LookupDIDWithCacheState(ctx, did)
258258+ if err != nil {
259259+ return nil, hit, err
260260+ }
261261+262262+ declared, err := ident.DeclaredHandle()
263263+ if err != nil {
264264+ return nil, hit, fmt.Errorf("could not verify handle/DID mapping: %w", err)
265265+ }
266266+ // NOTE: DeclaredHandle() returns a normalized handle, and we already normalized 'h' above
267267+ if declared != h {
268268+ return nil, hit, fmt.Errorf("%w: %s != %s", identity.ErrHandleMismatch, declared, h)
269269+ }
270270+ return ident, hit, nil
271271+}
272272+273273+func (d *StoreDirectory) Lookup(ctx context.Context, a syntax.AtIdentifier) (*identity.Identity, error) {
274274+ handle, err := a.AsHandle()
275275+ if nil == err { // if not an error, is a handle
276276+ return d.LookupHandle(ctx, handle)
277277+ }
278278+ did, err := a.AsDID()
279279+ if nil == err { // if not an error, is a DID
280280+ return d.LookupDID(ctx, did)
281281+ }
282282+ return nil, fmt.Errorf("at-identifier neither a Handle nor a DID")
283283+}
284284+285285+func (d *StoreDirectory) Purge(ctx context.Context, atid syntax.AtIdentifier) error {
286286+ handle, err := atid.AsHandle()
287287+ if nil == err { // if not an error, is a handle
288288+ handle = handle.Normalize()
289289+ delHandle(d.store, handle)
290290+ return nil
291291+ }
292292+ did, err := atid.AsDID()
293293+ if nil == err { // if not an error, is a DID
294294+ delIdent(d.store, did)
295295+ return nil
296296+ }
297297+ return fmt.Errorf("at-identifier neither a Handle nor a DID")
298298+}
299299+300300+func getHandle(store store.Store, handle syntax.Handle) (*handleEntry, error) {
301301+ entryJSON, err := store.KvGet(handleCache, string(handle))
302302+ if entryJSON != "" {
303303+ // TODO - is this parse safe? do we need to be checking the output or anything?
304304+ var entry handleEntry
305305+ if err := json.Unmarshal([]byte(entryJSON), &entry); err != nil {
306306+ return nil, err
307307+ }
308308+ return &entry, nil
309309+ }
310310+ return nil, err
311311+}
312312+313313+func putHandle(store store.Store, handle syntax.Handle, entry *handleEntry) error {
314314+ // TODO - is this right?
315315+ entryJSON, err := json.Marshal(entry)
316316+ if err != nil {
317317+ return nil
318318+ }
319319+ return store.KvPut(handleCache, string(handle), string(entryJSON))
320320+}
321321+322322+func delHandle(store store.Store, handle syntax.Handle) error {
323323+ return store.KvDel(handleCache, string(handle))
324324+}
325325+326326+func getIdent(store store.Store, did syntax.DID) (*identityEntry, error) {
327327+ entryJSON, err := store.KvGet(identityCache, string(did))
328328+ if entryJSON != "" {
329329+ // TODO - is this parse safe? do we need to be checking the output or anything?
330330+ var entry identityEntry
331331+ if err := json.Unmarshal([]byte(entryJSON), &entry); err != nil {
332332+ return nil, err
333333+ }
334334+ return &entry, nil
335335+ }
336336+ return nil, err
337337+}
338338+339339+func putIdent(store store.Store, did syntax.DID, entry *identityEntry) error {
340340+ // TODO - is this right?
341341+ entryJSON, err := json.Marshal(entry)
342342+ if err != nil {
343343+ return nil
344344+ }
345345+ return store.KvPut(identityCache, string(did), string(entryJSON))
346346+}
347347+348348+func delIdent(store store.Store, did syntax.DID) error {
349349+ return store.KvDel(identityCache, string(did))
350350+}
+15
cmd/butterfly/store/duckdb.go
···477477478478 return stats, nil
479479}
480480+481481+// KvGet retrieves a value from general KV storage (not yet implemented)
482482+func (d *DuckdbStore) KvGet(namespace string, key string) (string, error) {
483483+ return "", fmt.Errorf("KvGet not yet implemented for duckdb store")
484484+}
485485+486486+// KvPut stores a value in general KV storage (not yet implemented)
487487+func (d *DuckdbStore) KvPut(namespace string, key string, value string) error {
488488+ return fmt.Errorf("KvPut not yet implemented for duckdb store")
489489+}
490490+491491+// KvDel deletes a value from general KV storage (not yet implemented)
492492+func (d *DuckdbStore) KvDel(namespace string, key string) error {
493493+ return fmt.Errorf("KvDel not yet implemented for duckdb store")
494494+}
+15
cmd/butterfly/store/stdout.go
···108108 }
109109 }
110110}
111111+112112+// KvGet retrieves a value from general KV storage (not yet implemented)
113113+func (s *StdoutStore) KvGet(namespace string, key string) (string, error) {
114114+ return "", fmt.Errorf("KvGet not yet implemented for stdout store")
115115+}
116116+117117+// KvPut stores a value in general KV storage (not yet implemented)
118118+func (s *StdoutStore) KvPut(namespace string, key string, value string) error {
119119+ return fmt.Errorf("KvPut not yet implemented for stdout store")
120120+}
121121+122122+// KvDel deletes a value from general KV storage (not yet implemented)
123123+func (s *StdoutStore) KvDel(namespace string, key string) error {
124124+ return fmt.Errorf("KvDel not yet implemented for stdout store")
125125+}
+9
cmd/butterfly/store/store.go
···2222 // ActiveSync processes live update events from a remote stream
2323 // The implementation should handle context cancellation appropriately
2424 ActiveSync(ctx context.Context, stream *remote.RemoteStream) error
2525+2626+ // General-purpose KV storage
2727+2828+ // Get from general kv storage
2929+ KvGet(namespace string, key string) (string, error)
3030+ // Put to general kv storage
3131+ KvPut(namespace string, key string, value string) error
3232+ // Delete from general kv storage
3333+ KvDel(namespace string, key string) error
2534}
26352736// StoreType identifies the type of store
+15
cmd/butterfly/store/tarfiles.go
···347347348348 return contents, nil
349349}
350350+351351+// KvGet retrieves a value from general KV storage (not yet implemented)
352352+func (t *TarfilesStore) KvGet(namespace string, key string) (string, error) {
353353+ return "", fmt.Errorf("KvGet not yet implemented for tarfiles store")
354354+}
355355+356356+// KvPut stores a value in general KV storage (not yet implemented)
357357+func (t *TarfilesStore) KvPut(namespace string, key string, value string) error {
358358+ return fmt.Errorf("KvPut not yet implemented for tarfiles store")
359359+}
360360+361361+// KvDel deletes a value from general KV storage (not yet implemented)
362362+func (t *TarfilesStore) KvDel(namespace string, key string) error {
363363+ return fmt.Errorf("KvDel not yet implemented for tarfiles store")
364364+}