this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial relay firehose testing framework

+397 -38
+14 -14
cmd/relayered/testing/consumer.go
··· 8 8 "time" 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/events" 12 - "github.com/bluesky-social/indigo/events/schedulers/sequential" 11 + "github.com/bluesky-social/indigo/cmd/relayered/stream" 12 + "github.com/bluesky-social/indigo/cmd/relayered/stream/schedulers/sequential" 13 13 14 14 "github.com/gorilla/websocket" 15 15 ) ··· 17 17 // testing helper which receives a set of firehose events 18 18 type Consumer struct { 19 19 Host string 20 - Events []*events.XRPCStreamEvent 20 + Events []*stream.XRPCStreamEvent 21 21 LastSeq int64 22 22 Timeout time.Duration 23 23 eventsLk sync.Mutex ··· 32 32 return &c 33 33 } 34 34 35 - func (c *Consumer) eventCallbacks() *events.RepoStreamCallbacks { 36 - rsc := &events.RepoStreamCallbacks{ 35 + func (c *Consumer) eventCallbacks() *stream.RepoStreamCallbacks { 36 + rsc := &stream.RepoStreamCallbacks{ 37 37 RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 38 38 c.eventsLk.Lock() 39 39 defer c.eventsLk.Unlock() 40 - c.Events = append(c.Events, &events.XRPCStreamEvent{RepoCommit: evt}) 40 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoCommit: evt}) 41 41 c.LastSeq = evt.Seq 42 42 return nil 43 43 }, 44 44 RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error { 45 45 c.eventsLk.Lock() 46 46 defer c.eventsLk.Unlock() 47 - c.Events = append(c.Events, &events.XRPCStreamEvent{RepoSync: evt}) 47 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoSync: evt}) 48 48 c.LastSeq = evt.Seq 49 49 return nil 50 50 }, 51 51 RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error { 52 52 c.eventsLk.Lock() 53 53 defer c.eventsLk.Unlock() 54 - c.Events = append(c.Events, &events.XRPCStreamEvent{RepoIdentity: evt}) 54 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoIdentity: evt}) 55 55 c.LastSeq = evt.Seq 56 56 return nil 57 57 }, 58 58 RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error { 59 59 c.eventsLk.Lock() 60 60 defer c.eventsLk.Unlock() 61 - c.Events = append(c.Events, &events.XRPCStreamEvent{RepoAccount: evt}) 61 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoAccount: evt}) 62 62 c.LastSeq = evt.Seq 63 63 return nil 64 64 }, ··· 66 66 RepoHandle: func(evt *comatproto.SyncSubscribeRepos_Handle) error { 67 67 c.eventsLk.Lock() 68 68 defer c.eventsLk.Unlock() 69 - c.Events = append(c.Events, &events.XRPCStreamEvent{RepoHandle: evt}) 69 + c.Events = append(c.Events, &stream.XRPCStreamEvent{RepoHandle: evt}) 70 70 c.LastSeq = evt.Seq 71 71 return nil 72 72 }, ··· 101 101 102 102 seqScheduler := sequential.NewScheduler("test", c.eventCallbacks().EventHandler) 103 103 go func() { 104 - if err := events.HandleRepoStream(ctx, conn, seqScheduler, nil); err != nil { 105 - slog.Error("consumer failed processing event", "err", err) 104 + if err := stream.HandleRepoStream(ctx, conn, seqScheduler, nil); err != nil { 105 + slog.Debug("consumer failed processing event", "err", err) 106 106 cancel() 107 107 } 108 108 }() ··· 119 119 func (c *Consumer) Clear() { 120 120 c.eventsLk.Lock() 121 121 defer c.eventsLk.Unlock() 122 - c.Events = []*events.XRPCStreamEvent{} 122 + c.Events = []*stream.XRPCStreamEvent{} 123 123 } 124 124 125 125 func (c *Consumer) Shutdown() { ··· 131 131 // connects to host and consumes 'count' events, then returns them. will try up to 'c.Timeout', and error if not enough events are seen 132 132 // 133 133 // cursor: pass -1 to consume from current 134 - func (c *Consumer) ConsumeEvents(count int) ([]*events.XRPCStreamEvent, error) { 134 + func (c *Consumer) ConsumeEvents(count int) ([]*stream.XRPCStreamEvent, error) { 135 135 // poll until we have enough events 136 136 start := time.Now() 137 137 for {
+46 -7
cmd/relayered/testing/framework_test.go
··· 1 1 package testing 2 2 3 3 import ( 4 - "context" 4 + "encoding/json" 5 + "fmt" 6 + "os" 5 7 "testing" 6 8 7 9 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 10 "github.com/bluesky-social/indigo/atproto/syntax" 9 - "github.com/bluesky-social/indigo/events" 11 + "github.com/bluesky-social/indigo/cmd/relayered/stream" 10 12 11 13 "github.com/stretchr/testify/assert" 12 14 ) ··· 14 16 // meta test for the testing framework itself. simply connects the consumer to the producer 15 17 func TestFramework(t *testing.T) { 16 18 assert := assert.New(t) 17 - ctx := context.Background() // XXX 19 + ctx := t.Context() 18 20 19 - p := NewProducer(":9900") 20 - p.Listen() 21 + p := NewProducer() 22 + port := p.ListenRandom() 21 23 defer p.Shutdown() 22 24 23 - c := NewConsumer("ws://localhost:9900") 25 + c := NewConsumer(fmt.Sprintf("ws://localhost:%d", port)) 24 26 err := c.Connect(ctx, -1) 25 27 if err != nil { 26 28 t.Fatal(err) ··· 28 30 defer c.Shutdown() 29 31 30 32 h := "example.atbin.dev" 31 - e1 := events.XRPCStreamEvent{ 33 + e1 := stream.XRPCStreamEvent{ 32 34 RepoIdentity: &comatproto.SyncSubscribeRepos_Identity{ 33 35 Did: "did:web:example.atbin.dev", 34 36 Handle: &h, ··· 45 47 assert.Equal(1, len(evts)) 46 48 assert.Equal(e1.RepoIdentity, evts[0].RepoIdentity) 47 49 } 50 + 51 + // simply loads a scenario from JSON and checks data looks right 52 + func TestScenarioLoad(t *testing.T) { 53 + assert := assert.New(t) 54 + 55 + fixBytes, err := os.ReadFile("testdata/basic.json") 56 + if err != nil { 57 + t.Fatal(err) 58 + } 59 + 60 + var s Scenario 61 + if err = json.Unmarshal(fixBytes, &s); err != nil { 62 + t.Fatal(err) 63 + } 64 + assert.Equal(1, len(s.Accounts)) 65 + assert.Equal("active", s.Accounts[0].Status) 66 + assert.Equal("https://morel.us-east.host.bsky.network", s.Accounts[0].Identity.PDSEndpoint()) 67 + _, err = s.Accounts[0].Identity.PublicKey() 68 + assert.NoError(err) 69 + assert.Equal(1, len(s.Messages)) 70 + msg, err := s.Messages[0].Frame.XRPCStreamEvent() 71 + if err != nil { 72 + t.Fatal(err) 73 + } 74 + assert.Equal(int64(7278969010), msg.RepoCommit.Seq) 75 + assert.Equal(4945, len(msg.RepoCommit.Blocks)) 76 + assert.Equal(1, len(msg.RepoCommit.Ops)) 77 + } 78 + 79 + func TestBasicScenario(t *testing.T) { 80 + ctx := t.Context() 81 + 82 + err := LoadAndRunScenario(ctx, "testdata/basic.json") 83 + if err != nil { 84 + t.Fatal(err) 85 + } 86 + }
+20 -17
cmd/relayered/testing/producer.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log/slog" 7 + "net" 7 8 "net/http" 8 9 "sync" 9 10 10 - "github.com/bluesky-social/indigo/events" 11 + "github.com/bluesky-social/indigo/cmd/relayered/stream" 11 12 12 13 "github.com/gorilla/websocket" 13 14 ) 14 15 15 16 // testing helper which outputs a sequence of events over a websocket 16 17 type Producer struct { 17 - Bind string 18 18 BufferSize int 19 19 mux *http.ServeMux 20 20 subs []*Subscriber ··· 22 22 } 23 23 24 24 type Subscriber struct { 25 - outgoing chan *events.XRPCStreamEvent 25 + outgoing chan *stream.XRPCStreamEvent 26 26 done chan struct{} 27 27 } 28 28 29 - func NewProducer(bind string) *Producer { 29 + func NewProducer() *Producer { 30 30 mux := http.NewServeMux() 31 31 p := Producer{ 32 - Bind: bind, 33 32 BufferSize: 1024, 34 33 mux: mux, 35 34 } ··· 38 37 } 39 38 40 39 func (p *Producer) handleSubscribeRepos(resp http.ResponseWriter, req *http.Request) { 41 - slog.Info("XXX: subscribeRepos") 42 40 43 41 ctx, cancel := context.WithCancel(req.Context()) 44 42 defer cancel() ··· 54 52 for { 55 53 _, _, err := conn.ReadMessage() 56 54 if err != nil { 57 - slog.Warn("failed to read message from client", "err", err) 55 + slog.Debug("failed to read message from client", "err", err) 58 56 cancel() 59 57 return 60 58 } ··· 72 70 select { 73 71 case evt, ok := <-evts: 74 72 if !ok { 75 - slog.Error("event stream closed unexpectedly") 73 + slog.Debug("event stream closed unexpectedly") 76 74 return 77 75 } 78 76 ··· 99 97 case <-ctx.Done(): 100 98 return 101 99 } 102 - slog.Info("XXX: emitted event") 103 100 } 104 101 } 105 102 106 - func (p *Producer) Listen() { 103 + func (p *Producer) ListenRandom() int { 104 + listener, err := net.Listen("tcp", ":0") 105 + if err != nil { 106 + panic(err) 107 + } 108 + port := listener.Addr().(*net.TCPAddr).Port 109 + slog.Info("starting test producer", "port", port) 107 110 go func() { 108 - err := http.ListenAndServe(p.Bind, p.mux) 111 + defer listener.Close() 112 + err := http.Serve(listener, p.mux) 109 113 if err != nil { 110 - slog.Error("test producer shutdown", "err", err) 114 + slog.Warn("test producer shutting down", "err", err) 111 115 } 112 116 }() 117 + return port 113 118 } 114 119 115 120 func (p *Producer) Shutdown() { ··· 121 126 } 122 127 } 123 128 124 - func (p *Producer) AddSubscriber(ctx context.Context) (<-chan *events.XRPCStreamEvent, error) { 129 + func (p *Producer) AddSubscriber(ctx context.Context) (<-chan *stream.XRPCStreamEvent, error) { 125 130 126 - slog.Info("XXX: adding subscriber") 127 131 sub := &Subscriber{ 128 - outgoing: make(chan *events.XRPCStreamEvent, p.BufferSize), 132 + outgoing: make(chan *stream.XRPCStreamEvent, p.BufferSize), 129 133 done: make(chan struct{}), 130 134 } 131 135 ··· 136 140 return sub.outgoing, nil 137 141 } 138 142 139 - func (p *Producer) Emit(evt *events.XRPCStreamEvent) error { 143 + func (p *Producer) Emit(evt *stream.XRPCStreamEvent) error { 140 144 if err := evt.Preserialize(); err != nil { 141 145 return err 142 146 } ··· 148 152 slog.Warn("sending event, but no subscribers") 149 153 } 150 154 for _, s := range p.subs { 151 - slog.Info("XXX: outgoing") 152 155 select { 153 156 case s.outgoing <- evt: 154 157 // sent evt on this subscriber's chan! yay!
+171
cmd/relayered/testing/runner.go
··· 1 + package testing 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net" 9 + "net/http" 10 + "os" 11 + "reflect" 12 + 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/cmd/relayered/relay" 15 + "github.com/bluesky-social/indigo/cmd/relayered/relay/validator" 16 + "github.com/bluesky-social/indigo/cmd/relayered/stream" 17 + "github.com/bluesky-social/indigo/cmd/relayered/stream/eventmgr" 18 + "github.com/bluesky-social/indigo/cmd/relayered/stream/persist/diskpersist" 19 + "github.com/bluesky-social/indigo/util/cliutil" 20 + 21 + "github.com/labstack/echo/v4" 22 + ) 23 + 24 + type SimpleRelay struct { 25 + Relay *relay.Relay 26 + Port int 27 + echo *echo.Echo 28 + } 29 + 30 + func MustSimpleRelay(dir identity.Directory, tmpd string) *SimpleRelay { 31 + 32 + relayConfig := relay.DefaultRelayConfig() 33 + relayConfig.SSL = false 34 + 35 + db, err := cliutil.SetupDatabase("sqlite://:memory:", 40) 36 + if err != nil { 37 + panic(err) 38 + } 39 + 40 + pOpts := diskpersist.DefaultDiskPersistOptions() 41 + persister, err := diskpersist.NewDiskPersistence(tmpd, "", db, pOpts) 42 + if err != nil { 43 + panic(err) 44 + } 45 + vldtr := validator.NewValidator(dir) 46 + evtman := eventmgr.NewEventManager(persister) 47 + 48 + r, err := relay.NewRelay(db, vldtr, evtman, dir, relayConfig) 49 + if err != nil { 50 + panic(err) 51 + } 52 + persister.SetUidSource(r) 53 + 54 + listener, err := net.Listen("tcp", ":0") 55 + if err != nil { 56 + panic(err) 57 + } 58 + port := listener.Addr().(*net.TCPAddr).Port 59 + slog.Info("starting test relay", "port", port) 60 + 61 + e := echo.New() 62 + e.HideBanner = true 63 + e.GET("/xrpc/com.atproto.sync.subscribeRepos", r.EventsHandler) 64 + e.Listener = listener 65 + srv := &http.Server{} 66 + 67 + go func() { 68 + defer listener.Close() 69 + err := e.StartServer(srv) 70 + if err != nil { 71 + slog.Warn("test relay shutting down", "err", err) 72 + } 73 + }() 74 + return &SimpleRelay{ 75 + Relay: r, 76 + Port: port, 77 + } 78 + } 79 + 80 + func LoadAndRunScenario(ctx context.Context, fpath string) error { 81 + 82 + fixBytes, err := os.ReadFile(fpath) 83 + if err != nil { 84 + return err 85 + } 86 + 87 + var s Scenario 88 + if err = json.Unmarshal(fixBytes, &s); err != nil { 89 + return err 90 + } 91 + 92 + dir := identity.NewMockDirectory() 93 + for _, acc := range s.Accounts { 94 + dir.Insert(acc.Identity) 95 + } 96 + 97 + tmpd, err := os.MkdirTemp("", "relayered-test-") 98 + if err != nil { 99 + return err 100 + } 101 + defer os.RemoveAll(tmpd) 102 + 103 + p := NewProducer() 104 + hostPort := p.ListenRandom() 105 + defer p.Shutdown() 106 + 107 + sr := MustSimpleRelay(&dir, tmpd) 108 + 109 + err = sr.Relay.Slurper.SubscribeToPds(ctx, fmt.Sprintf("localhost:%d", hostPort), true, true, nil) 110 + if err != nil { 111 + return err 112 + } 113 + 114 + c := NewConsumer(fmt.Sprintf("ws://localhost:%d", sr.Port)) 115 + err = c.Connect(ctx, -1) 116 + if err != nil { 117 + return err 118 + } 119 + defer c.Shutdown() 120 + 121 + for _, msg := range s.Messages { 122 + c.Clear() 123 + evt, err := msg.Frame.XRPCStreamEvent() 124 + if err != nil { 125 + return err 126 + } 127 + p.Emit(evt) 128 + if !msg.Drop { 129 + evts, err := c.ConsumeEvents(1) 130 + if err != nil { 131 + return err 132 + } 133 + if len(evts) != 1 { 134 + return fmt.Errorf("consumed unexpected events") 135 + } 136 + if !EqualEvents(evt, evts[0]) { 137 + fmt.Printf("%+v\n", evt.RepoCommit) 138 + fmt.Printf("%+v\n", evts[0].RepoCommit) 139 + return fmt.Errorf("events didn't match") 140 + } 141 + } else { 142 + // TODO: verify nothing returned? 143 + } 144 + } 145 + return nil 146 + } 147 + 148 + func EqualEvents(a, b *stream.XRPCStreamEvent) bool { 149 + // TODO: these are pretty partial checks (only some messages, not all reflect) 150 + if a.RepoCommit != nil { 151 + a.RepoCommit.Seq = 0 152 + if b.RepoCommit != nil { 153 + b.RepoCommit.Seq = 0 154 + } 155 + return reflect.DeepEqual(a.RepoCommit, b.RepoCommit) 156 + } 157 + // TODO: all these need to check seq 158 + if a.RepoSync != b.RepoSync { 159 + return false 160 + } 161 + if a.RepoIdentity != b.RepoIdentity { 162 + return false 163 + } 164 + if a.RepoAccount != b.RepoAccount { 165 + return false 166 + } 167 + if a.Error != b.Error { 168 + return false 169 + } 170 + return true 171 + }
+82
cmd/relayered/testing/scenario.go
··· 1 + package testing 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/cmd/relayered/stream" 10 + ) 11 + 12 + // represents an entire test case 13 + type Scenario struct { 14 + Accounts []ScenarioAccount `json:"accounts"` 15 + Messages []ScenarioMessage `json:"messages"` 16 + } 17 + 18 + type ScenarioAccount struct { 19 + Identity identity.Identity `json:"identity"` 20 + Status string `json:"status"` 21 + } 22 + 23 + type ScenarioMessage struct { 24 + Frame RepoEventFrame `json:"frame"` 25 + 26 + // whether relay should drop message (instead of passing through) 27 + Drop bool `json:"drop"` 28 + 29 + // if the message is invalid (regardless of whether passed through 30 + Invalid bool `json:"invalid"` 31 + 32 + // whether account state / identity directory be updated 33 + Update bool `json:"update"` 34 + } 35 + 36 + // wrapper type appropriate for JSON encoding of firehose events 37 + type RepoEventFrame struct { 38 + Header stream.EventHeader `json:"header"` 39 + Body json.RawMessage `json:"body,omitempty"` 40 + } 41 + 42 + func (re *RepoEventFrame) XRPCStreamEvent() (*stream.XRPCStreamEvent, error) { 43 + if re.Header.Op == -1 { 44 + var evt stream.ErrorFrame 45 + if err := json.Unmarshal(re.Body, &evt); err != nil { 46 + return nil, err 47 + } 48 + return &stream.XRPCStreamEvent{Error: &evt}, nil 49 + } else if re.Header.Op != 1 { 50 + return nil, fmt.Errorf("unhandled header op: %d", re.Header.Op) 51 + } 52 + 53 + switch re.Header.MsgType { 54 + case "#commit": 55 + var evt comatproto.SyncSubscribeRepos_Commit 56 + if err := json.Unmarshal(re.Body, &evt); err != nil { 57 + return nil, err 58 + } 59 + return &stream.XRPCStreamEvent{RepoCommit: &evt}, nil 60 + case "#sync": 61 + var evt comatproto.SyncSubscribeRepos_Sync 62 + if err := json.Unmarshal(re.Body, &evt); err != nil { 63 + return nil, err 64 + } 65 + return &stream.XRPCStreamEvent{RepoSync: &evt}, nil 66 + case "#identity": 67 + var evt comatproto.SyncSubscribeRepos_Identity 68 + if err := json.Unmarshal(re.Body, &evt); err != nil { 69 + return nil, err 70 + } 71 + return &stream.XRPCStreamEvent{RepoIdentity: &evt}, nil 72 + case "#account": 73 + var evt comatproto.SyncSubscribeRepos_Account 74 + if err := json.Unmarshal(re.Body, &evt); err != nil { 75 + return nil, err 76 + } 77 + return &stream.XRPCStreamEvent{RepoAccount: &evt}, nil 78 + // TODO: add deprecated types, to test drop? 79 + default: 80 + return nil, fmt.Errorf("unhandled message type: %s", re.Header.MsgType) 81 + } 82 + }
+64
cmd/relayered/testing/testdata/basic.json
··· 1 + { 2 + "accounts": [ 3 + { 4 + "identity": { 5 + "did": "did:plc:44ybard66vv44zksje25o7dz", 6 + "handle": "bnewbold.net", 7 + "alsoKnownAs": [ 8 + "at://bnewbold.net" 9 + ], 10 + "services": { 11 + "atproto_pds": { 12 + "type": "AtprotoPersonalDataServer", 13 + "url": "https://morel.us-east.host.bsky.network" 14 + } 15 + }, 16 + "keys": { 17 + "atproto": { 18 + "type": "Multikey", 19 + "publicKeyMultibase": "zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 20 + } 21 + } 22 + }, 23 + "status": "active" 24 + } 25 + ], 26 + "messages": [ 27 + { 28 + "frame": { 29 + "header": { 30 + "op": 1, 31 + "t": "#commit" 32 + }, 33 + "body": { 34 + "blobs": null, 35 + "blocks": { 36 + "$bytes": "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dPZhdtgqWCUAAXESIB03Bk1hTpw+BoKpyiFlPnNQS7+AQjErOhQA8XuIf2AopGFrSTZocnc0eDcyY2FwF2F09mF22CpYJQABcRIgyisSkcenNZnrAxHph9rjQpcyclH8hkolhP9MnfergFmkYWtKYWJjNWlyMm4yb2FwFmF02CpYJQABcRIgRO0IbvfoT5wYLFPj7k6vTqfj9XffLQ39kC5i5C+oYC1hdtgqWCUAAXESIPYDs6pAzcLPMthbv5EYzldB7ADD+78geNw84n6pJbCapGFrSWhvazIycTUyb2FwF2F02CpYJQABcRIgP469vL+nIPBXFWGEo+Vb4SHBR1TCVRrj12MLcQe9ku5hdtgqWCUAAXESIBQEFr7YFS2kZqEU2UAi/KNQ9K6gxejS2BJYJ+7sFmI/YWzYKlglAAFxEiBdq2KEB27B/aWop6BQSlOjLYtNH3HnmKmsLyry4rS/IoAEAXESILegZ6a60NX7Sv/Nwrg9ddJfKCGHIgLcG32iI2YkKY+RomFlh6Rha1ggYXBwLmJza3kuZmVlZC5saWtlLzNsbG51bWlsNHdnMmphcABhdPZhdtgqWCUAAXESIIfsNXUCkj9YmzE8xsllojKB+aT66EiUqesLLXAx3fY0pGFrSnBjNW8yc3VwMnFhcBZhdPZhdtgqWCUAAXESIFKDcY+j3nNd4KG9+Y/LT10ARK5c5a+kbiClW8FFffCJpGFrSXNqbnFjYXoyd2FwF2F09mF22CpYJQABcRIgLDPGv/ygRwJu2yJyrgs6hxHNIjz/cPMgiBKJPuaU9U2kYWtJdzVmemJiYTIyYXAXYXT2YXbYKlglAAFxEiCHMwqUiHjriXux+mQ2fwgi3P8aMeNXPJ52bBkxfHV9g6Rha0l5ZnRjdmloMnJhcBdhdPZhdtgqWCUAAXESIMSw0g7yq5qmzXmol9P5hbam9FBSFMsEuAG1iZJOrzESpGFrUnBvc3QvM2prNm9yZGphcmgyY2FwDmF09mF22CpYJQABcRIgwD/y3ctXuJWQHSa6yFodiOeMblEh1QTEvg9AvMNF0qWkYWtKNzRhdjI2cnAyY2FwFmF09mF22CpYJQABcRIgy3/6I3V+wKOWCH84VpNAB48vVDnexIpN1oCxDvjboJhhbPb4AQFxEiDEsNIO8quaps15qJfT+YW2pvRQUhTLBLgBtYmSTq8xEqNlJHR5cGVyYXBwLmJza3kuZmVlZC5saWtlZ3N1YmplY3SiY2NpZHg7YmFmeXJlaWdqdTNkYnRneWR3cWNtZW42eW9kbDZtenNrbWV3eWc1NWt4NWg3YnRjN2tkZm03dXczZHFjdXJpeEZhdDovL2RpZDpwbGM6YXVnZXd0ZXN6cDZjNG1hd25hc3FkZjJsL2FwcC5ic2t5LmZlZWQucG9zdC8zbGxtbHd0M3RsMjJvaWNyZWF0ZWRBdHgYMjAyNS0wNC0wMVQwNDowMTozMi4zNDNa4AEBcRIgPUyK/JwMnEQ0/Egv/dzW4x9TqxhptK9kl/94YCRFKTGmY2RpZHggZGlkOnBsYzo0NHliYXJkNjZ2djQ0emtzamUyNW83ZHpjcmV2bTNsbHB5ZnRkZjRoMnJjc2lnWECiZDtLaMqhEITv/bfvjvOJ53zp8ef4voK+7dohSpbvOkA8bLSeRhq3tKlthLfagHyFZE7kwtyC/UtfAkaMb/wWZGRhdGHYKlglAAFxEiAbmqNmXS+hdd9dJjuxhEfAsOr5Ss+ekH7wgmsFGZuHcGRwcmV29md2ZXJzaW9uAw" 37 + }, 38 + "commit": { 39 + "$link": "bafyreib5jsfpzhamtrcdj7cif765zvxdd5j2wgdjwsxwjf77pbqcirjjge" 40 + }, 41 + "ops": [ 42 + { 43 + "action": "create", 44 + "cid": { 45 + "$link": "bafyreigewdja54vltktm26nis7j7tbnwu32fauquzmclqanvrgje5lzrci" 46 + }, 47 + "path": "app.bsky.feed.like/3llpyftcvih2r" 48 + } 49 + ], 50 + "rebase": false, 51 + "repo": "did:plc:44ybard66vv44zksje25o7dz", 52 + "rev": "3llpyftdf4h2r", 53 + "seq": 7278969010, 54 + "since": "3llpx3aem5h2j", 55 + "time": "2025-04-01T04:01:32.384Z", 56 + "tooBig": false 57 + } 58 + }, 59 + "drop": false, 60 + "invalid": false, 61 + "update": false 62 + } 63 + ] 64 + }