diff --git a/appview/ingester.go b/appview/ingester.go index 48e7cc1d8..ed6a5002c 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -18,7 +18,6 @@ import ( "github.com/avast/retry-go/v4" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "github.com/go-git/go-git/v5/plumbing" "github.com/ipfs/go-cid" "golang.org/x/sync/errgroup" @@ -33,6 +32,7 @@ import ( "tangled.org/core/appview/serververify" "tangled.org/core/consts" "tangled.org/core/idresolver" + "tangled.org/core/jetstream" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/repoverify" @@ -56,15 +56,15 @@ type Ingester struct { Verifier repoverify.Verifier } -type processFunc func(ctx context.Context, e *jmodels.Event) error +type processFunc func(ctx context.Context, e *jetstream.Event) error func (i *Ingester) Ingest() processFunc { - return func(ctx context.Context, e *jmodels.Event) error { + return func(ctx context.Context, e *jetstream.Event) error { var err error l := i.Logger.With("kind", e.Kind) switch e.Kind { - case jmodels.EventKindAccount: + case jetstream.EventKindAccount: // TODO: sync account state to db if e.Account.Active { break @@ -73,9 +73,9 @@ func (i *Ingester) Ingest() processFunc { if *e.Account.Status == "deactivated" { err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) } - case jmodels.EventKindIdentity: + case jetstream.EventKindIdentity: err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) - case jmodels.EventKindCommit: + case jetstream.EventKindCommit: l = l.With( "nsid", e.Commit.Collection, "did", e.Did, @@ -191,14 +191,14 @@ func (i *Ingester) resolveOldFormatStar(raw json.RawMessage, star *models.Star, } } -func (i *Ingester) ingestStar(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestStar(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { var err error did := e.Did l = l.With("handler", "ingestStar") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.FeedStar{} unmarshalErr := json.Unmarshal(raw, &record) @@ -245,7 +245,7 @@ func (i *Ingester) ingestStar(ctx context.Context, e *jmodels.Event, l *slog.Log } err = db.UpsertStar(i.Db, e.Commit.RKey, star) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) } @@ -258,14 +258,14 @@ func (i *Ingester) ingestStar(ctx context.Context, e *jmodels.Event, l *slog.Log return nil } -func (i *Ingester) ingestFollow(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestFollow(e *jetstream.Event, l *slog.Logger) error { var err error did := e.Did l = l.With("handler", "ingestFollow") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.GraphFollow{} err = json.Unmarshal(raw, &record) @@ -291,7 +291,7 @@ func (i *Ingester) ingestFollow(e *jmodels.Event, l *slog.Logger) error { SubjectDid: record.Subject, FollowedAt: followedAt, }) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) } @@ -304,14 +304,14 @@ func (i *Ingester) ingestFollow(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestVouch(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestVouch(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { var err error did := e.Did l = l.With("handler", "ingestVouch") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.GraphVouch{} err = json.Unmarshal(raw, &record) @@ -385,7 +385,7 @@ func (i *Ingester) ingestVouch(ctx context.Context, e *jmodels.Event, l *slog.Lo err = tx.Commit() } - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: err = db.DeleteVouchByRkey(i.Db, did, e.Commit.RKey) } @@ -397,14 +397,14 @@ func (i *Ingester) ingestVouch(ctx context.Context, e *jmodels.Event, l *slog.Lo return nil } -func (i *Ingester) ingestPublicKey(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestPublicKey(e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestPublicKey") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: l.Debug("processing add of pubkey") raw := json.RawMessage(e.Commit.Record) record := tangled.PublicKey{} @@ -424,7 +424,7 @@ func (i *Ingester) ingestPublicKey(e *jmodels.Event, l *slog.Logger) error { } err = db.UpsertPublicKey(i.Db, pubKey) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: l.Debug("processing delete of pubkey") err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) } @@ -438,14 +438,14 @@ func (i *Ingester) ingestPublicKey(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestArtifact(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestArtifact(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestArtifact") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.RepoArtifact{} err = json.Unmarshal(raw, &record) @@ -511,7 +511,7 @@ func (i *Ingester) ingestArtifact(ctx context.Context, e *jmodels.Event, l *slog } err = db.AddArtifact(i.Db, artifact) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) } @@ -523,7 +523,7 @@ func (i *Ingester) ingestArtifact(ctx context.Context, e *jmodels.Event, l *slog return nil } -func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestProfile(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error @@ -534,7 +534,7 @@ func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event, l *slog. } switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.ActorProfile{} err = json.Unmarshal(raw, &record) @@ -632,7 +632,7 @@ func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event, l *slog. l.Warn("failed to update preferred handle cache", "err", execErr) } } - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: tx, beginErr := i.Db.Begin() if beginErr != nil { return fmt.Errorf("failed to start transaction: %w", beginErr) @@ -664,14 +664,14 @@ func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event, l *slog. return nil } -func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestSpindleMember") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.SpindleMember{} err = json.Unmarshal(raw, &record) @@ -769,7 +769,7 @@ func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event, l committed = true l.Info("upserted spindle member") - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: rkey := e.Commit.RKey // get record from db first @@ -826,14 +826,14 @@ func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event, l return nil } -func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestSpindle(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestSpindle") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.Spindle{} err = json.Unmarshal(raw, &record) @@ -860,7 +860,7 @@ func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event, l *slog. l.Info("ingested record", "instance", instance) return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: instance := e.Commit.RKey // get record from db first @@ -926,7 +926,7 @@ func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event, l *slog. return nil } -func (i *Ingester) ingestString(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestString(e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey @@ -935,7 +935,7 @@ func (i *Ingester) ingestString(e *jmodels.Event, l *slog.Logger) error { l = l.With("handler", "ingestString") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.String{} err = json.Unmarshal(raw, &record) @@ -959,7 +959,7 @@ func (i *Ingester) ingestString(e *jmodels.Event, l *slog.Logger) error { l.Info("ingested record") return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.DeleteString( i.Db, orm.FilterEq("did", did), @@ -976,14 +976,14 @@ func (i *Ingester) ingestString(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestKnotMember(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestKnotMember(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestKnotMember") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.KnotMember{} err = json.Unmarshal(raw, &record) @@ -1081,7 +1081,7 @@ func (i *Ingester) ingestKnotMember(ctx context.Context, e *jmodels.Event, l *sl committed = true l.Info("upserted knot member") - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: rkey := e.Commit.RKey members, err := db.GetKnotMembers( @@ -1141,14 +1141,14 @@ func (i *Ingester) ingestKnotMember(ctx context.Context, e *jmodels.Event, l *sl return nil } -func (i *Ingester) ingestKnot(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestKnot(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did var err error l = l.With("handler", "ingestKnot") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.Knot{} err = json.Unmarshal(raw, &record) @@ -1172,7 +1172,7 @@ func (i *Ingester) ingestKnot(ctx context.Context, e *jmodels.Event, l *slog.Log l.Info("ingested record", "domain", domain) return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: domain := e.Commit.RKey // get record from db first @@ -1351,7 +1351,7 @@ func (i *Ingester) SweepPendingVerifications() { g.Wait() } -func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestIssue(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey @@ -1360,7 +1360,7 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event, l *slog.Lo l = l.With("handler", "ingestIssue") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.RepoIssue{} err = json.Unmarshal(raw, &record) @@ -1420,7 +1420,7 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event, l *slog.Lo l.Info("ingested record") return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: tx, err := i.Db.BeginTx(ctx, nil) if err != nil { l.Error("failed to begin transaction", "err", err) @@ -1448,7 +1448,7 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event, l *slog.Lo return nil } -func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestPull(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey @@ -1457,7 +1457,7 @@ func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Log l = l.With("handler", "ingestPull") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.RepoPull{} err = json.Unmarshal(raw, &record) @@ -1576,7 +1576,7 @@ func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Log l.Info("ingested record") return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: tx, err := i.Db.BeginTx(ctx, nil) if err != nil { l.Error("failed to begin transaction", "err", err) @@ -1683,7 +1683,7 @@ var pullStatusSpec = stateIngestSpec{ del: db.DeletePullStatus, } -func (i *Ingester) ingestState(ctx context.Context, e *jmodels.Event, l *slog.Logger, spec stateIngestSpec) error { +func (i *Ingester) ingestState(ctx context.Context, e *jetstream.Event, l *slog.Logger, spec stateIngestSpec) error { did := e.Did rkey := e.Commit.RKey nsid := e.Commit.Collection @@ -1691,9 +1691,9 @@ func (i *Ingester) ingestState(ctx context.Context, e *jmodels.Event, l *slog.Lo l = l.With("handler", "ingestState", "nsid", nsid) switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: return i.applyStateRecord(ctx, did, rkey, nsid, e.Commit.Record, spec, l) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: return i.deleteStateRecord(ctx, did, rkey, nsid, spec, l) } @@ -1894,14 +1894,14 @@ func (i *Ingester) ReconcilePendingState() { } // ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions -func (i *Ingester) ingestIssueComment(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestIssueComment(e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestIssueComment") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: // no-op. sh.tangled.repo.issue.comment is deprecated - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.PurgeComments( i.Db, orm.FilterEq("did", e.Did), @@ -1917,14 +1917,14 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event, l *slog.Logger) error { } // ingestPullComment ingests legacy sh.tangled.repo.pull.comment deletions -func (i *Ingester) ingestPullComment(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestPullComment(e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestPullComment") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: // no-op. sh.tangled.repo.pull.comment is deprecated - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.PurgeComments( i.Db, orm.FilterEq("did", e.Did), @@ -1939,7 +1939,7 @@ func (i *Ingester) ingestPullComment(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestComment(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestComment(e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey cid := e.Commit.CID @@ -1951,7 +1951,7 @@ func (i *Ingester) ingestComment(e *jmodels.Event, l *slog.Logger) error { ctx := context.Background() switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.FeedComment{} err = json.Unmarshal(raw, &record) @@ -1988,7 +1988,7 @@ func (i *Ingester) ingestComment(e *jmodels.Event, l *slog.Logger) error { return err } - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.DeleteComments( i.Db, orm.FilterEq("did", did), @@ -2003,14 +2003,14 @@ func (i *Ingester) ingestComment(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestReaction(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestReaction(e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey l = l.With("handler", "ingestReaction") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.FeedReaction{} if err := json.Unmarshal(raw, &record); err != nil { @@ -2044,7 +2044,7 @@ func (i *Ingester) ingestReaction(e *jmodels.Event, l *slog.Logger) error { return fmt.Errorf("failed to upsert reaction: %w", err) } - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.DeleteReactionByRkey(i.Db, did, rkey); err != nil { return fmt.Errorf("failed to delete reaction record: %w", err) } @@ -2054,7 +2054,7 @@ func (i *Ingester) ingestReaction(e *jmodels.Event, l *slog.Logger) error { return nil } -func (i *Ingester) ingestLabelDefinition(e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestLabelDefinition(e *jetstream.Event, l *slog.Logger) error { did := e.Did rkey := e.Commit.RKey @@ -2063,7 +2063,7 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event, l *slog.Logger) error l = l.With("handler", "ingestLabelDefinition") switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.LabelDefinition{} err = json.Unmarshal(raw, &record) @@ -2085,14 +2085,14 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event, l *slog.Logger) error return fmt.Errorf("failed to create labeldef: %w", err) } - if e.Commit.Operation == jmodels.CommitOperationCreate { + if e.Commit.Operation == jetstream.CommitOperationCreate { i.drainPendingLabelOps(l) } l.Info("ingested record") return nil - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := db.DeleteLabelDefinition( i.Db, orm.FilterEq("did", did), @@ -2108,15 +2108,15 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event, l *slog.Logger) error return nil } -func (i *Ingester) ingestLabelOp(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestLabelOp(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestLabelOp") did := e.Did rkey := e.Commit.RKey switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: return i.applyLabelOpRecord(ctx, did, rkey, e.Commit.Record, l) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: return i.deleteLabelOpRecord(ctx, did, rkey, l) } diff --git a/appview/ingester_label_test.go b/appview/ingester_label_test.go index dc14892c2..a7d059318 100644 --- a/appview/ingester_label_test.go +++ b/appview/ingester_label_test.go @@ -8,10 +8,10 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" + "tangled.org/core/jetstream" "tangled.org/core/orm" ) @@ -46,7 +46,7 @@ func seedLabelDef(t *testing.T, d *db.DB, repoDid, defDid, defRkey, name string, return def.AtUri() } -func labelOpEvent(t *testing.T, op, did, rkey, subject, performedAt string, add, del [][2]string) *jmodels.Event { +func labelOpEvent(t *testing.T, op, did, rkey, subject, performedAt string, add, del [][2]string) *jetstream.Event { t.Helper() mk := func(pairs [][2]string) []*tangled.LabelOp_Operand { out := make([]*tangled.LabelOp_Operand, 0, len(pairs)) @@ -59,22 +59,22 @@ func labelOpEvent(t *testing.T, op, did, rkey, subject, performedAt string, add, if err != nil { t.Fatalf("marshal: %v", err) } - return &jmodels.Event{Did: did, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ + return &jetstream.Event{Did: did, Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ Operation: op, Collection: tangled.LabelOpNSID, RKey: rkey, Record: raw}} } -func labelDefEvent(t *testing.T, did, rkey, name string, scope []string, multiple bool) *jmodels.Event { +func labelDefEvent(t *testing.T, did, rkey, name string, scope []string, multiple bool) *jetstream.Event { t.Helper() vt := tangled.LabelDefinition_ValueType{Type: string(models.ConcreteTypeString), Format: string(models.ValueTypeFormatAny)} raw, err := json.Marshal(tangled.LabelDefinition{Name: name, Scope: scope, Multiple: &multiple, CreatedAt: labelPerform, ValueType: &vt}) if err != nil { t.Fatalf("marshal: %v", err) } - return &jmodels.Event{Did: did, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationCreate, Collection: tangled.LabelDefinitionNSID, RKey: rkey, Record: raw}} + return &jetstream.Event{Did: did, Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: tangled.LabelDefinitionNSID, RKey: rkey, Record: raw}} } -func mustIngestOp(t *testing.T, ing *Ingester, e *jmodels.Event) { +func mustIngestOp(t *testing.T, ing *Ingester, e *jetstream.Event) { t.Helper() if err := ing.ingestLabelOp(context.Background(), e, ing.Logger); err != nil { t.Fatalf("ingestLabelOp: %v", err) @@ -158,7 +158,7 @@ func runFold(t *testing.T, tc foldCase, steps []labelStep) map[string][]string { } return out } - kind := map[string]string{"create": jmodels.CommitOperationCreate, "update": jmodels.CommitOperationUpdate, "delete": jmodels.CommitOperationDelete} + kind := map[string]string{"create": jetstream.CommitOperationCreate, "update": jetstream.CommitOperationUpdate, "delete": jetstream.CommitOperationDelete} for _, s := range steps { perform := s.perform if perform == "" { @@ -232,7 +232,7 @@ func TestIngestLabelOp_ReingestIsIdempotent(t *testing.T) { ing := newLabelIngester(t) subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) - e := labelOpEvent(t, jmodels.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, + e := labelOpEvent(t, jetstream.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, [][2]string{{def.String(), "high"}, {def.String(), "low"}}, nil) mustIngestOp(t, ing, e) @@ -284,7 +284,7 @@ func TestIngestLabelOp_Parking(t *testing.T) { seedDef(ing) } - mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, tt.author, "op1", string(subject), labelPerform, + mustIngestOp(t, ing, labelOpEvent(t, jetstream.CommitOperationCreate, tt.author, "op1", string(subject), labelPerform, [][2]string{{defUri, "high"}}, nil)) if n := pendingCount(t, ing.Db, subject); n != 1 { t.Fatalf("op must park while its %s is missing, pending=%d", tt.missing, n) @@ -302,7 +302,7 @@ func TestIngestLabelOp_Parking(t *testing.T) { t.Fatalf("ingest def: %v", err) } case "delete-then-def": - mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationDelete, tt.author, "op1", string(subject), "", nil, nil)) + mustIngestOp(t, ing, labelOpEvent(t, jetstream.CommitOperationDelete, tt.author, "op1", string(subject), "", nil, nil)) if n := pendingCount(t, ing.Db, subject); n != 0 { t.Fatalf("deleting a parked op must unpark it, pending=%d", n) } @@ -332,14 +332,14 @@ func TestIngestLabelOp_DeauthorizedUpdatePreservesPriorOps(t *testing.T) { subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) - mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, author, "op1", string(subject), labelPerform, + mustIngestOp(t, ing, labelOpEvent(t, jetstream.CommitOperationCreate, author, "op1", string(subject), labelPerform, [][2]string{{def.String(), "high"}}, nil)) if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { t.Fatalf("authorized create: got %v", got) } ing.Acl = stubAcl{} - mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationUpdate, author, "op1", string(subject), labelPerform, + mustIngestOp(t, ing, labelOpEvent(t, jetstream.CommitOperationUpdate, author, "op1", string(subject), labelPerform, [][2]string{{def.String(), "low"}}, nil)) if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { t.Fatalf("a now-unauthorized update must neither apply nor erase the prior authorized op, got %v", got) @@ -352,7 +352,7 @@ func TestIngestLabelOp_DeauthorizedRedeliveryPreservesPriorOps(t *testing.T) { subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) - create := labelOpEvent(t, jmodels.CommitOperationCreate, author, "op1", string(subject), labelPerform, + create := labelOpEvent(t, jetstream.CommitOperationCreate, author, "op1", string(subject), labelPerform, [][2]string{{def.String(), "high"}}, nil) mustIngestOp(t, ing, create) if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { @@ -371,14 +371,14 @@ func TestIngestLabelOp_DefDeleteDropsOps(t *testing.T) { subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) - mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, + mustIngestOp(t, ing, labelOpEvent(t, jetstream.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, [][2]string{{def.String(), "high"}}, nil)) if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { t.Fatalf("after create: got %v", got) } - del := &jmodels.Event{Did: labelOwner, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationDelete, Collection: tangled.LabelDefinitionNSID, RKey: "prio"}} + del := &jetstream.Event{Did: labelOwner, Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationDelete, Collection: tangled.LabelDefinitionNSID, RKey: "prio"}} if err := ing.ingestLabelDefinition(del, ing.Logger); err != nil { t.Fatalf("ingest def delete: %v", err) } diff --git a/appview/ingester_repo.go b/appview/ingester_repo.go index ce0907692..f2fef5b32 100644 --- a/appview/ingester_repo.go +++ b/appview/ingester_repo.go @@ -13,24 +13,24 @@ import ( "github.com/avast/retry-go/v4" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" + "tangled.org/core/jetstream" "tangled.org/core/orm" "tangled.org/core/repoident" "tangled.org/core/repoverify" ) -func (i *Ingester) ingestRepo(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestRepo(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestRepo") switch e.Commit.Operation { - case jmodels.CommitOperationCreate: + case jetstream.CommitOperationCreate: return i.ingestRepoCreate(ctx, e, l) - case jmodels.CommitOperationUpdate: + case jetstream.CommitOperationUpdate: return i.ingestRepoUpdate(ctx, e, l) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: return i.ingestRepoDelete(ctx, e, l) default: l.Info("unknown repo operation") @@ -38,7 +38,7 @@ func (i *Ingester) ingestRepo(ctx context.Context, e *jmodels.Event, l *slog.Log } } -func (i *Ingester) ingestRepoCreate(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestRepoCreate(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestRepoCreate") record := tangled.Repo{} @@ -191,7 +191,7 @@ func (i *Ingester) ensureRepoOwnerPermissions(ownerDid, knot, repo string) error return i.Enforcer.E.SavePolicy() } -func (i *Ingester) ingestRepoUpdate(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestRepoUpdate(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestRepoUpdate") record := tangled.Repo{} @@ -260,7 +260,7 @@ func (i *Ingester) ingestRepoUpdate(ctx context.Context, e *jmodels.Event, l *sl return nil } -func (i *Ingester) ingestRepoDelete(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { +func (i *Ingester) ingestRepoDelete(ctx context.Context, e *jetstream.Event, l *slog.Logger) error { l = l.With("handler", "ingestRepoDelete") repo, err := db.GetRepo(i.Db, diff --git a/appview/ingester_repo_test.go b/appview/ingester_repo_test.go index 73d979361..9072b3666 100644 --- a/appview/ingester_repo_test.go +++ b/appview/ingester_repo_test.go @@ -10,11 +10,11 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" + "tangled.org/core/jetstream" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/repoident" @@ -30,7 +30,7 @@ func mustKnotURL(t *testing.T, raw string) repoident.KnotURL { return u } -func acceptOwner(t *testing.T, e *jmodels.Event) repoverify.Verifier { +func acceptOwner(t *testing.T, e *jetstream.Event) repoverify.Verifier { t.Helper() knot := mustKnotURL(t, "https://knot.example") return func(_ context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) { @@ -103,7 +103,7 @@ func withVerifier(ing *Ingester, v repoverify.Verifier) *Ingester { return ing } -func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jmodels.Event) error { +func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jetstream.Event) error { t.Helper() ing.Verifier = acceptOwner(t, e) return ing.ingestRepo(context.Background(), e, ing.Logger) @@ -133,16 +133,16 @@ func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid str func ptr[T any](v T) *T { return &v } -func makeEvent(t *testing.T, op string, did, rkey string, record tangled.Repo) *jmodels.Event { +func makeEvent(t *testing.T, op string, did, rkey string, record tangled.Repo) *jetstream.Event { t.Helper() raw, err := json.Marshal(record) if err != nil { t.Fatalf("marshal record: %v", err) } - return &jmodels.Event{ + return &jetstream.Event{ Did: did, - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: op, Collection: tangled.RepoNSID, RKey: rkey, @@ -151,12 +151,12 @@ func makeEvent(t *testing.T, op string, did, rkey string, record tangled.Repo) * } } -func makeDeleteEvent(did, rkey string) *jmodels.Event { - return &jmodels.Event{ +func makeDeleteEvent(did, rkey string) *jetstream.Event { + return &jetstream.Event{ Did: did, - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationDelete, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationDelete, Collection: tangled.RepoNSID, RKey: rkey, }, @@ -204,7 +204,7 @@ func assertNoRepoPolicies(t *testing.T, ing *Ingester, knot, repo string) { func TestIngestRepo_CreateInsertsNewRow(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", Name: ptr("MyRepo"), Description: ptr("a test repo"), @@ -235,7 +235,7 @@ func TestIngestRepo_CreateSkipsIfRowExists(t *testing.T) { ing, spy := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "myrepo", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", Name: ptr("myrepo"), RepoDid: ptr("did:plc:repo1"), @@ -254,7 +254,7 @@ func TestIngestRepo_CreateCascadesRename(t *testing.T) { ing, spy := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldname", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ Knot: "knot.example", Name: ptr("NewName"), RepoDid: ptr("did:plc:repo1"), @@ -299,7 +299,7 @@ func TestIngestRepo_CreateCascadesRename(t *testing.T) { func TestIngestRepo_CreateNoRepoDidSkipped(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", Name: ptr("myrepo"), }) @@ -316,7 +316,7 @@ func TestIngestRepo_UpdateMetadata(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ Knot: "knot.example", Name: ptr("foo"), Description: ptr("updated description"), @@ -345,7 +345,7 @@ func TestIngestRepo_UpdateDisplayName(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ Knot: "knot.example", Name: ptr("Foo"), RepoDid: ptr("did:plc:repo1"), @@ -368,7 +368,7 @@ func TestIngestRepo_UpdateNothingChangedNoOp(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ Knot: "knot.example", Name: ptr("foo"), RepoDid: ptr("did:plc:repo1"), @@ -385,20 +385,20 @@ func TestIngestRepo_UpdateNothingChangedNoOp(t *testing.T) { } func TestIngestRepo_UnknownRowSkipped(t *testing.T) { - ops := []string{jmodels.CommitOperationUpdate, jmodels.CommitOperationDelete} + ops := []string{jetstream.CommitOperationUpdate, jetstream.CommitOperationDelete} for _, op := range ops { t.Run(op, func(t *testing.T) { ing, _ := newTestIngester(t) - var e *jmodels.Event + var e *jetstream.Event switch op { - case jmodels.CommitOperationUpdate: + case jetstream.CommitOperationUpdate: e = makeEvent(t, op, "did:plc:nobody", "ghost", tangled.Repo{ Knot: "knot.example", Name: ptr("ghost"), RepoDid: ptr("did:plc:nope"), }) - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: e = makeDeleteEvent("did:plc:nobody", "ghost") } @@ -413,7 +413,7 @@ func TestIngestRepo_UpdateNoRepoDidSkipped(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ Knot: "knot.example", Name: ptr("bar"), }) @@ -471,11 +471,11 @@ func TestIngestRepo_DeleteWipesRbac(t *testing.T) { func TestIngestRepo_MalformedRecord(t *testing.T) { ing, _ := newTestIngester(t) - e := &jmodels.Event{ + e := &jetstream.Event{ Did: "did:plc:akshay", - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationUpdate, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationUpdate, Collection: tangled.RepoNSID, RKey: "rkey1", Record: json.RawMessage("{not json"), @@ -498,7 +498,7 @@ func TestIngestRepo_RenameDeleteSequenceNoTornState(t *testing.T) { t.Fatalf("seed star: %v", err) } - createEvt := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ + createEvt := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ Knot: "knot.example", Name: ptr("NewName"), RepoDid: ptr("did:plc:repo1"), @@ -550,7 +550,7 @@ func TestIngestRepo_RenameDeleteSequenceNoTornState(t *testing.T) { func TestIngestRepo_CreateFallsBackToRkeyForName(t *testing.T) { ing, _ := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:repo1"), }) @@ -568,7 +568,7 @@ func TestIngestRepo_CreateFallsBackToRkeyForName(t *testing.T) { func TestIngestRepo_CreateSquatRejected(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:akshays-repo"), }) @@ -594,7 +594,7 @@ func TestIngestRepo_CreateHijackExistingRepoRejected(t *testing.T) { ing, spy := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:akshays-repo"), }) @@ -618,7 +618,7 @@ func TestIngestRepo_CreateRenameIgnoresRkeyDrift(t *testing.T) { ing, spy := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldrkey", "did:plc:akshays-repo") - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{ Knot: "knot.example", Name: ptr("newname"), RepoDid: ptr("did:plc:akshays-repo"), @@ -660,7 +660,7 @@ func TestIngestRepo_CreateTakesOnlyAnIdentifiedOwnerAndRetriesOnlyTheUndecided(t for name, tc := range cases { t.Run(name, func(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", RepoDid: ptr(repoDid.String()), }) @@ -689,7 +689,7 @@ func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{ Knot: "knot.example", Description: ptr("boltless hijacks metadata"), RepoDid: ptr("did:plc:akshays-repo"), @@ -710,7 +710,7 @@ func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) { func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:"), }) @@ -735,7 +735,7 @@ func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) { func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:akshays-repo"), }) @@ -752,7 +752,7 @@ func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) { func TestIngestRepo_CreateRejectsKnotMismatch(t *testing.T) { ing, spy := newTestIngester(t) - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ Knot: "evil.example", RepoDid: ptr("did:plc:akshays-repo"), }) @@ -777,7 +777,7 @@ func TestIngestRepo_UpdateRejectsKnotMismatch(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ Knot: "evil.example", Description: ptr("redirected clone target"), RepoDid: ptr("did:plc:akshays-repo"), @@ -801,7 +801,7 @@ func TestIngestRepo_UpdateRejectsRepoDidMutation(t *testing.T) { ing, _ := newTestIngester(t) seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo") - e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{ Knot: "knot.example", Description: ptr("sneaky repoDid swap"), RepoDid: ptr("did:plc:other-repo"), @@ -840,7 +840,7 @@ func TestIngestRepo_RenameClearsCollidingAlias(t *testing.T) { t.Fatalf("RecordRepoRename: %v", err) } - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "3mpxmsvicr2zn", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "3mpxmsvicr2zn", tangled.Repo{ Knot: "knot.example", Name: ptr("anemone"), RepoDid: ptr("did:plc:anemone"), }) if err := ingestAcceptingOwner(t, ing, e); err != nil { @@ -861,7 +861,7 @@ func TestIngestRepo_InsertClearsCollidingAlias(t *testing.T) { t.Fatalf("RecordRepoRename: %v", err) } - e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "3mpxmfgowwck3", tangled.Repo{ + e := makeEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "3mpxmfgowwck3", tangled.Repo{ Knot: "knot.example", Name: ptr("clam"), RepoDid: ptr("did:plc:clam-repo"), }) if err := ingestAcceptingOwner(t, ing, e); err != nil { diff --git a/appview/ingester_state_test.go b/appview/ingester_state_test.go index d5d04f964..9cc8e99c4 100644 --- a/appview/ingester_state_test.go +++ b/appview/ingester_state_test.go @@ -10,11 +10,11 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/knotacl" "tangled.org/core/appview/models" + "tangled.org/core/jetstream" "tangled.org/core/orm" ) @@ -92,7 +92,7 @@ func seedRepoAndIssue(t *testing.T, d *db.DB, ownerDid, repoDid, issueRkey strin return seedIssue(t, d, ownerDid, repoDid, issueRkey) } -func issueStateEvent(t *testing.T, op, did, rkey, subject, state, createdAt string) *jmodels.Event { +func issueStateEvent(t *testing.T, op, did, rkey, subject, state, createdAt string) *jetstream.Event { t.Helper() raw, err := json.Marshal(tangled.RepoIssueState{ Issue: subject, @@ -102,10 +102,10 @@ func issueStateEvent(t *testing.T, op, did, rkey, subject, state, createdAt stri if err != nil { t.Fatalf("marshal: %v", err) } - return &jmodels.Event{ + return &jetstream.Event{ Did: did, - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: op, Collection: tangled.RepoIssueStateNSID, RKey: rkey, @@ -142,7 +142,7 @@ func TestIngestState_Authorization(t *testing.T) { ing.Acl = tc.acl at := seedRepoAndIssue(t, ing.Db, owner, "did:plc:anemone", "issue1") - ev := issueStateEvent(t, jmodels.CommitOperationCreate, tc.author, "s1", string(at), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") + ev := issueStateEvent(t, jetstream.CommitOperationCreate, tc.author, "s1", string(at), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") if err := ing.ingestState(context.Background(), ev, ing.Logger, issueStateSpec); err != nil { t.Fatalf("ingestState: %v", err) } @@ -162,7 +162,7 @@ func TestIngestState_ParkThenReconcileDrains(t *testing.T) { owner := "did:plc:boltless" subject := syntax.ATURI("at://" + owner + "/" + tangled.RepoIssueNSID + "/issue1") - ev := issueStateEvent(t, jmodels.CommitOperationCreate, owner, "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") + ev := issueStateEvent(t, jetstream.CommitOperationCreate, owner, "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") if err := ing.ingestState(ing.Ctx, ev, ing.Logger, issueStateSpec); err != nil { t.Fatalf("park: %v", err) } @@ -190,16 +190,16 @@ func TestIngestState_DeleteUnparksBeforeSubjectArrives(t *testing.T) { owner := "did:plc:boltless" subject := syntax.ATURI("at://" + owner + "/" + tangled.RepoIssueNSID + "/issue1") - create := issueStateEvent(t, jmodels.CommitOperationCreate, owner, "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") + create := issueStateEvent(t, jetstream.CommitOperationCreate, owner, "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") if err := ing.ingestState(ctx, create, ing.Logger, issueStateSpec); err != nil { t.Fatalf("park: %v", err) } - del := &jmodels.Event{ + del := &jetstream.Event{ Did: owner, - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationDelete, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationDelete, Collection: tangled.RepoIssueStateNSID, RKey: "s1", }, @@ -226,7 +226,7 @@ func TestIngestState_ParkedUnauthorizedDroppedOnDrain(t *testing.T) { owner := "did:plc:boltless" subject := syntax.ATURI("at://" + owner + "/" + tangled.RepoIssueNSID + "/issue1") - ev := issueStateEvent(t, jmodels.CommitOperationCreate, "did:plc:squid", "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") + ev := issueStateEvent(t, jetstream.CommitOperationCreate, "did:plc:squid", "s1", string(subject), tangled.RepoIssueStateClosed, "2026-06-01T00:00:00Z") if err := ing.ingestState(ctx, ev, ing.Logger, issueStateSpec); err != nil { t.Fatalf("park: %v", err) } diff --git a/appview/ingester_string_test.go b/appview/ingester_string_test.go index fac4c626a..e86c89a06 100644 --- a/appview/ingester_string_test.go +++ b/appview/ingester_string_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" + "tangled.org/core/jetstream" "tangled.org/core/orm" ) @@ -29,16 +29,16 @@ func newStringIngester(t *testing.T) *Ingester { } } -func makeStringEvent(t *testing.T, op, did, rkey string, record tangled.String) *jmodels.Event { +func makeStringEvent(t *testing.T, op, did, rkey string, record tangled.String) *jetstream.Event { t.Helper() raw, err := json.Marshal(record) if err != nil { t.Fatalf("marshal record: %v", err) } - return &jmodels.Event{ + return &jetstream.Event{ Did: did, - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: op, Collection: tangled.StringNSID, RKey: rkey, @@ -69,7 +69,7 @@ func TestIngestString_CreateRoundTrip(t *testing.T) { ing := newStringIngester(t) created := time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC) - e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", tangled.String{ + e := makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "rk1", tangled.String{ Filename: "hello.txt", Description: "a greeting", Contents: "hello world\n", @@ -111,13 +111,13 @@ func TestIngestString_UpdateBumpsEditedOnContentChange(t *testing.T) { CreatedAt: created.Format(time.RFC3339), } - if err := ing.ingestString(makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", base), ing.Logger); err != nil { + if err := ing.ingestString(makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "rk1", base), ing.Logger); err != nil { t.Fatalf("ingestString create: %v", err) } updated := base updated.Contents = "hello, world!\n" - if err := ing.ingestString(makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "rk1", updated), ing.Logger); err != nil { + if err := ing.ingestString(makeStringEvent(t, jetstream.CommitOperationUpdate, "did:plc:boltless", "rk1", updated), ing.Logger); err != nil { t.Fatalf("ingestString update: %v", err) } @@ -145,10 +145,10 @@ func TestIngestString_UpdateNoChangeKeepsEditedNil(t *testing.T) { CreatedAt: time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC).Format(time.RFC3339), } - if err := ing.ingestString(makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil { + if err := ing.ingestString(makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil { t.Fatalf("create: %v", err) } - if err := ing.ingestString(makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil { + if err := ing.ingestString(makeStringEvent(t, jetstream.CommitOperationUpdate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil { t.Fatalf("update: %v", err) } @@ -165,15 +165,15 @@ func TestIngestString_DeleteRemovesRow(t *testing.T) { Contents: "x", CreatedAt: time.Now().UTC().Format(time.RFC3339), } - if err := ing.ingestString(makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", rec), ing.Logger); err != nil { + if err := ing.ingestString(makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "rk1", rec), ing.Logger); err != nil { t.Fatalf("create: %v", err) } - del := &jmodels.Event{ + del := &jetstream.Event{ Did: "did:plc:boltless", - Kind: jmodels.EventKindCommit, - Commit: &jmodels.Commit{ - Operation: jmodels.CommitOperationDelete, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationDelete, Collection: tangled.StringNSID, RKey: "rk1", }, @@ -202,7 +202,7 @@ func TestIngestString_ValidatorRejects(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "bad", tc.rec) + e := makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:akshay", "bad", tc.rec) if err := ing.ingestString(e, ing.Logger); err == nil { t.Fatal("expected validator error, got nil") } @@ -221,7 +221,7 @@ func TestIngestString_ColdReplayPreservesCreated(t *testing.T) { Contents: "package main\n", CreatedAt: created.Format(time.RFC3339), } - event := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rkcold", rec) + event := makeStringEvent(t, jetstream.CommitOperationCreate, "did:plc:boltless", "rkcold", rec) first := newStringIngester(t) if err := first.ingestString(event, first.Logger); err != nil { diff --git a/appview/state/state.go b/appview/state/state.go index bec06585c..1772ace5b 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -124,7 +124,6 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { jc, err := jetstream.NewJetstreamClient( config.Jetstream.Endpoint, - "appview", []string{ tangled.ActorProfileNSID, tangled.FeedStarNSID, diff --git a/deliberi/ingest.go b/deliberi/ingest.go index f87d5b2cf..47b02c811 100644 --- a/deliberi/ingest.go +++ b/deliberi/ingest.go @@ -19,14 +19,7 @@ import ( ) const ( - kindCommit = "commit" - - opCreate = "create" - opUpdate = "update" - opDelete = "delete" - - maxMentions = 8 - // an idle stream still moves the cursor, but not on every frame + maxMentions = 8 idleCursorEvery = time.Minute // rewinding replays events, the rev check in plan drops the repeats cursorRewind = 5 * time.Minute @@ -94,7 +87,7 @@ type writer func(x deldb.Execer) error func (i *Ingester) process(ctx context.Context, e *jetstream.Event) error { var write writer - if e.Kind == kindCommit && e.Commit != nil { + if e.Kind == jetstream.EventKindCommit && e.Commit != nil { write = i.plan(ctx, e) } if write == nil { @@ -170,7 +163,7 @@ func (i *Ingester) notifying(e *jetstream.Event) bool { } func (i *Ingester) planManifest(e *jetstream.Event, uri string) writer { - if e.Commit.Operation == opDelete || len(e.Commit.Record) == 0 { + if e.Commit.Operation == jetstream.CommitOperationDelete || len(e.Commit.Record) == 0 { return nil } var rec repoManifest @@ -188,7 +181,7 @@ func (i *Ingester) planManifest(e *jetstream.Event, uri string) writer { func (i *Ingester) planTicket(ctx context.Context, e *jetstream.Event, uri string, notify bool) writer { c := e.Commit - if c.Operation == opDelete { + if c.Operation == jetstream.CommitOperationDelete { return func(x deldb.Execer) error { if err := deldb.DeleteEntity(x, uri); err != nil { return err @@ -217,7 +210,7 @@ func (i *Ingester) planTicket(ctx context.Context, e *jetstream.Event, uri strin } // a backfill sends the latest version as a create, and a maintainer may have // written that one, so only a live create names who opened the ticket - opened := c.IsLive() && c.Operation == opCreate && known.Kind == "" + opened := c.IsLive() && c.Operation == jetstream.CommitOperationCreate && known.Kind == "" if opened { next.Author = actor } @@ -304,7 +297,7 @@ func stateNotification(kind models.EntityKind, from, to string) models.Notificat func (i *Ingester) planComment(ctx context.Context, e *jetstream.Event, uri string, notify bool) writer { c := e.Commit - if c.Operation == opDelete { + if c.Operation == jetstream.CommitOperationDelete { return func(x deldb.Execer) error { if err := deldb.DeleteComment(x, uri); err != nil { return err @@ -312,8 +305,8 @@ func (i *Ingester) planComment(ctx context.Context, e *jetstream.Event, uri stri return deldb.DeleteNotificationsFrom(x, uri) } } - // edits never change who a comment is by or what it replies to - if c.Operation != opCreate || len(c.Record) == 0 { + // an edit can't change the author or reply target, so only creates count + if c.Operation != jetstream.CommitOperationCreate || len(c.Record) == 0 { return nil } var rec commentRecord @@ -402,7 +395,7 @@ func (i *Ingester) planComment(ctx context.Context, e *jetstream.Event, uri stri } func (i *Ingester) planPatch(e *jetstream.Event, uri string) writer { - if e.Commit.Operation == opDelete { + if e.Commit.Operation == jetstream.CommitOperationDelete { return func(x deldb.Execer) error { return deldb.DeletePatch(x, uri) } } var rec patchRecord @@ -419,7 +412,7 @@ func (i *Ingester) planCollaborator(e *jetstream.Event) writer { if err != nil { return nil } - if e.Commit.Operation == opDelete { + if e.Commit.Operation == jetstream.CommitOperationDelete { return func(x deldb.Execer) error { return deldb.DeleteCollaborator(x, e.Did, did.String()) } } return func(x deldb.Execer) error { return deldb.PutCollaborator(x, e.Did, did.String()) } @@ -427,7 +420,7 @@ func (i *Ingester) planCollaborator(e *jetstream.Event) writer { func (i *Ingester) planSubscription(e *jetstream.Event, uri string) writer { c := e.Commit - if c.Operation == opDelete { + if c.Operation == jetstream.CommitOperationDelete { return func(x deldb.Execer) error { return deldb.DeleteSubscription(x, e.Did, c.RKey) } } var rec subscriptionRecord @@ -441,7 +434,7 @@ func (i *Ingester) planSubscription(e *jetstream.Event, uri string) writer { } func (i *Ingester) planStar(ctx context.Context, e *jetstream.Event, uri string, notify bool) writer { - if e.Commit.Operation != opCreate || !notify { + if e.Commit.Operation != jetstream.CommitOperationCreate || !notify { return nil } var rec starRecord @@ -470,7 +463,7 @@ func (i *Ingester) planStar(ctx context.Context, e *jetstream.Event, uri string, } func (i *Ingester) planFollow(e *jetstream.Event, uri string, notify bool) writer { - if e.Commit.Operation != opCreate || !notify { + if e.Commit.Operation != jetstream.CommitOperationCreate || !notify { return nil } subject, err := syntax.ParseDID(e.Commit.RKey) diff --git a/deliberi/ingest_test.go b/deliberi/ingest_test.go index a953a38e5..60fb1dc9b 100644 --- a/deliberi/ingest_test.go +++ b/deliberi/ingest_test.go @@ -66,7 +66,7 @@ func frame(did, op, collection, rkey, rev, record string) *jetstream.Event { return &jetstream.Event{ Did: did, TimeUS: frameClock, - Kind: kindCommit, + Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ Rev: rev, Operation: op, @@ -186,7 +186,7 @@ func repoSubject(did string) string { } func subscribe(user, rkey, subject string, ignore bool) *jetstream.Event { - return frame(user, opCreate, subscriptionNSID, rkey, "rev-"+rkey, subscriptionJSON(subject, ignore)) + return frame(user, jetstream.CommitOperationCreate, subscriptionNSID, rkey, "rev-"+rkey, subscriptionJSON(subject, ignore)) } func collaboratorFrame(op, rev, did string) *jetstream.Event { @@ -208,18 +208,18 @@ func commentUri(rkey string) string { } func commentFrame(rkey, rev, subjectUri, replyTo, editor string) *jetstream.Event { - return frame(repoDid, opCreate, commentNSID, rkey, rev, commentJSON(subjectUri, replyTo, editor)) + return frame(repoDid, jetstream.CommitOperationCreate, commentNSID, rkey, rev, commentJSON(subjectUri, replyTo, editor)) } func TestLiveCreateNotifiesOwnerCollaboratorsAndSubscribers(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - collaboratorFrame(opCreate, "r1", collabDid), + collaboratorFrame(jetstream.CommitOperationCreate, "r1", collabDid), subscribe("did:plc:sub", "s1", recordSubject(ticketUri), false), subscribe(editorDid, "s2", repoSubject(repoDid), false), subscribe("did:plc:ignorer", "s3", repoSubject(repoDid), true), subscribe(ownerDid, "s4", recordSubject(ticketUri), true), - ticketFrame(opCreate, "r2", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r2", "open", editorDid, ""), ) wantTypes(t, i, map[string]models.NotificationType{ @@ -241,7 +241,7 @@ func TestLiveCreateNotifiesOwnerCollaboratorsAndSubscribers(t *testing.T) { func TestLiveCreateOfPullNotifiesOwner(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - mustProcess(t, i, ticketFrame(opCreate, "r1", "open", editorDid, pullExtra)) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, pullExtra)) n := onlyFor(t, i, ownerDid) if n.Type != models.NotificationTypePullCreated || n.EntityKind != models.EntityKindPull { @@ -252,8 +252,8 @@ func TestLiveCreateOfPullNotifiesOwner(t *testing.T) { func TestBackfillStoresStateWithoutNotifying(t *testing.T) { i := newTestIngester(t, fakeBobbin{authors: map[string]string{ticketUri: editorDid}}) mustProcess(t, i, - backfill(ticketFrame(opCreate, "r1", "open", otherDid, "")), - backfill(ticketFrame(opUpdate, "r2", "closed", otherDid, "")), + backfill(ticketFrame(jetstream.CommitOperationCreate, "r1", "open", otherDid, "")), + backfill(ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", otherDid, "")), ) if got := countFor(t, i, ownerDid); got != 0 { @@ -267,8 +267,8 @@ func TestBackfillStoresStateWithoutNotifying(t *testing.T) { t.Errorf("author = %q, want none yet: a snapshot's editor isn't the author and a replay doesn't ask bobbin", got.Author) } - // the first time it matters, bobbin names the opener - mustProcess(t, i, ticketFrame(opUpdate, "r3", "open", otherDid, "")) + // bobbin supplies the opener on a live update, not the frame's editor + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r3", "open", otherDid, "")) if got := deldb.GetEntity(i.db, ticketUri).Author; got != editorDid { t.Errorf("author = %q, want bobbin's original author once a live update notifies", got) } @@ -280,8 +280,8 @@ func TestBackfillStoresStateWithoutNotifying(t *testing.T) { func TestBackfillSetsBaselineForTheNextLiveUpdate(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - backfill(ticketFrame(opCreate, "r1", "closed", editorDid, "")), - ticketFrame(opUpdate, "r2", "open", otherDid, ""), + backfill(ticketFrame(jetstream.CommitOperationCreate, "r1", "closed", editorDid, "")), + ticketFrame(jetstream.CommitOperationUpdate, "r2", "open", otherDid, ""), ) if n := onlyFor(t, i, ownerDid); n.Type != models.NotificationTypeIssueReopen { @@ -291,7 +291,7 @@ func TestBackfillSetsBaselineForTheNextLiveUpdate(t *testing.T) { func TestLiveUpdateWithoutRowOnlyAdoptsTheTicket(t *testing.T) { i := newTestIngester(t, fakeBobbin{authors: map[string]string{ticketUri: editorDid}}) - mustProcess(t, i, ticketFrame(opUpdate, "r2", "closed", otherDid, "")) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", otherDid, "")) if got := countFor(t, i, ownerDid); got != 0 { t.Fatalf("owner rows = %d, want 0 (no baseline to diff)", got) @@ -304,10 +304,10 @@ func TestLiveUpdateWithoutRowOnlyAdoptsTheTicket(t *testing.T) { func TestCloseByMaintainerNotifiesEveryone(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - collaboratorFrame(opCreate, "r1", collabDid), + collaboratorFrame(jetstream.CommitOperationCreate, "r1", collabDid), subscribe("did:plc:sub", "s1", repoSubject(repoDid), false), subscribe("did:plc:ignorer", "s2", recordSubject(ticketUri), true), - ticketFrame(opCreate, "r2", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r2", "open", editorDid, ""), commentFrame("c1", "r3", ticketUri, "", otherDid), ) for _, did := range []string{ownerDid, editorDid, collabDid, otherDid, "did:plc:sub"} { @@ -319,7 +319,7 @@ func TestCloseByMaintainerNotifiesEveryone(t *testing.T) { } } - mustProcess(t, i, ticketFrame(opUpdate, "r4", "closed", collabDid, "")) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r4", "closed", collabDid, "")) wantTypes(t, i, map[string]models.NotificationType{ ownerDid: models.NotificationTypeIssueClosed, @@ -336,10 +336,10 @@ func TestCloseByMaintainerNotifiesEveryone(t *testing.T) { func TestIgnoreBeatsTheOwnerOnStateChange(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), subscribe(ownerDid, "s1", recordSubject(ticketUri), true), ) - mustProcess(t, i, ticketFrame(opUpdate, "r2", "closed", editorDid, "")) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, "")) if got := notifsFor(t, i, ownerDid); len(got) != 1 || got[0].Type != models.NotificationTypeIssueCreated { t.Errorf("owner notifications = %+v, want only the earlier created", got) @@ -348,7 +348,7 @@ func TestIgnoreBeatsTheOwnerOnStateChange(t *testing.T) { func TestPullStateChanges(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - mustProcess(t, i, ticketFrame(opCreate, "r1", "open", editorDid, pullExtra)) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, pullExtra)) for n, step := range []struct { state string @@ -358,7 +358,7 @@ func TestPullStateChanges(t *testing.T) { {"open", models.NotificationTypePullReopen}, {"completed", models.NotificationTypePullMerged}, } { - mustProcess(t, i, ticketFrame(opUpdate, "r"+strconv.Itoa(n+2), step.state, otherDid, pullExtra)) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r"+strconv.Itoa(n+2), step.state, otherDid, pullExtra)) n := byType(t, i, ownerDid, step.want) if n.EntityKind != models.EntityKindPull || n.ActorDid != otherDid { t.Errorf("after %s: %q is %q by %q", step.state, step.want, n.EntityKind, n.ActorDid) @@ -372,9 +372,9 @@ func TestPullStateChanges(t *testing.T) { func TestIssueReopen(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - ticketFrame(opUpdate, "r2", "closed", editorDid, ""), - ticketFrame(opUpdate, "r3", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r3", "open", editorDid, ""), ) if got := countFor(t, i, ownerDid); got != 3 { t.Fatalf("owner rows = %d, want created, closed and reopen", got) @@ -385,8 +385,8 @@ func TestIssueReopen(t *testing.T) { func TestEditsThatKeepTheStateDoNotNotify(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - ticketFrame(opUpdate, "r2", "open", otherDid, `"tangledLabels":[{"key":"bug"}],`), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r2", "open", otherDid, `"tangledLabels":[{"key":"bug"}],`), ) if got := countFor(t, i, ownerDid); got != 1 { t.Fatalf("owner rows = %d, want only the created", got) @@ -399,14 +399,14 @@ func TestEditsThatKeepTheStateDoNotNotify(t *testing.T) { func TestReplayDoesNotNotifyTwiceOrReverseTheState(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) frames := []*jetstream.Event{ - ticketFrame(opCreate, "r1", "open", editorDid, ""), - ticketFrame(opUpdate, "r2", "closed", editorDid, ""), - ticketFrame(opUpdate, "r3", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r3", "open", editorDid, ""), } mustProcess(t, i, frames...) mustProcess(t, i, frames...) - // and a different hydrant can hand over an old one late - mustProcess(t, i, ticketFrame(opUpdate, "r2", "closed", editorDid, "")) + // a stale r2 close can show up late from another hydrant + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, "")) if got := countFor(t, i, ownerDid); got != 3 { t.Errorf("owner rows = %d, want 3", got) @@ -419,14 +419,14 @@ func TestReplayDoesNotNotifyTwiceOrReverseTheState(t *testing.T) { func TestDeleteTombstoneBlocksAnOlderCreate(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - frame(repoDid, opDelete, ticketNSID, "1", "r2", ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + frame(repoDid, jetstream.CommitOperationDelete, ticketNSID, "1", "r2", ""), ) if got := deldb.GetEntity(i.db, ticketUri); got.Kind != "" { t.Fatalf("ticket after delete = %+v, want gone", got) } - mustProcess(t, i, ticketFrame(opCreate, "r1", "open", editorDid, "")) + mustProcess(t, i, ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "")) if got := deldb.GetEntity(i.db, ticketUri); got.Kind != "" { t.Errorf("an older create revived the ticket: %+v", got) @@ -440,8 +440,7 @@ func TestDeleteTombstoneBlocksAnOlderCreate(t *testing.T) { func TestCommentRecipientsFollowLegacyRules(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - // a thread of otherDid and collabDid, and a thread nobody else is in + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), commentFrame("top", "r2", ticketUri, "", otherDid), commentFrame("reply", "r3", ticketUri, commentUri("top"), collabDid), commentFrame("lonely", "r4", ticketUri, "", "did:plc:sub"), @@ -476,7 +475,7 @@ func TestCommentRecipientsFollowLegacyRules(t *testing.T) { func TestPullCommentTellsEveryParticipant(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, pullExtra), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, pullExtra), commentFrame("c1", "r2", ticketUri, "", otherDid), commentFrame("c2", "r3", ticketUri, "", "did:plc:sub"), ) @@ -497,8 +496,8 @@ func TestCommentOnPatchResolvesToTheTicket(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) patchUri := "at://" + repoDid + "/org.tangled.review.patch/bafypatch" mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, pullExtra), - frame(repoDid, opCreate, patchNSID, "bafypatch", "r2", `{"ticket":"1","base":"a","head":"b","createdAt":"2026-01-01T00:00:00Z"}`), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, pullExtra), + frame(repoDid, jetstream.CommitOperationCreate, patchNSID, "bafypatch", "r2", `{"ticket":"1","base":"a","head":"b","createdAt":"2026-01-01T00:00:00Z"}`), commentFrame("c1", "r3", patchUri, "", otherDid), ) @@ -525,15 +524,15 @@ func TestCommentOnAnotherReposTicketIgnored(t *testing.T) { func TestCommentEditAndBackfillDoNotNotify(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - backfill(ticketFrame(opCreate, "r1", "open", editorDid, "")), + backfill(ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "")), backfill(commentFrame("c1", "r2", ticketUri, "", otherDid)), - frame(repoDid, opUpdate, commentNSID, "c1", "r3", commentJSON(ticketUri, "", otherDid)), + frame(repoDid, jetstream.CommitOperationUpdate, commentNSID, "c1", "r3", commentJSON(ticketUri, "", otherDid)), ) if got := countFor(t, i, ownerDid); got != 0 { t.Fatalf("rows = %d, want 0", got) } - // but the backfilled comment is a participant from now on - mustProcess(t, i, ticketFrame(opUpdate, "r4", "closed", editorDid, "")) + // a backfilled comment still makes its author a participant + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r4", "closed", editorDid, "")) if got := countFor(t, i, otherDid); got != 1 { t.Errorf("backfilled commenter rows = %d, want the close", got) } @@ -542,8 +541,8 @@ func TestCommentEditAndBackfillDoNotNotify(t *testing.T) { func TestEditorlessCommentAttributedToRepo(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - frame(repoDid, opCreate, commentNSID, "c1", "r2", + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + frame(repoDid, jetstream.CommitOperationCreate, commentNSID, "c1", "r2", `{"$type":"org.tangled.feed.comment","createdAt":"2026-01-01T00:00:00Z",`+ `"body":{"$type":"org.tangled.markup.markdown","text":"hi"},"subject":{"uri":"`+ticketUri+`","cid":"bafyfake"}}`), ) @@ -595,15 +594,14 @@ func TestSubscriptionResolution(t *testing.T) { t.Fatalf("stale duplicate: subs=%v ignored=%v", subs, ign) } - // dropping the ignore record falls back to the older subscriptions - mustProcess(t, i, frame(sub, opDelete, subscriptionNSID, "3b", "rev-3b-del", "")) + mustProcess(t, i, frame(sub, jetstream.CommitOperationDelete, subscriptionNSID, "3b", "rev-3b-del", "")) if subs, ign := resolve(); !slices.Equal(subs, []string{sub}) || len(ign) != 0 { t.Fatalf("after delete: subs=%v ignored=%v", subs, ign) } mustProcess(t, i, - frame(sub, opDelete, subscriptionNSID, "3a", "rev-3a-del", ""), - frame(sub, opDelete, subscriptionNSID, "3a2", "rev-3a2-del", ""), + frame(sub, jetstream.CommitOperationDelete, subscriptionNSID, "3a", "rev-3a-del", ""), + frame(sub, jetstream.CommitOperationDelete, subscriptionNSID, "3a2", "rev-3a2-del", ""), ) if subs, ign := resolve(); len(subs) != 0 || len(ign) != 0 { t.Fatalf("all deleted: subs=%v ignored=%v", subs, ign) @@ -617,18 +615,18 @@ func TestSubscriptionResolution(t *testing.T) { func TestCollaboratorCreateAndDelete(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - mustProcess(t, i, collaboratorFrame(opCreate, "r1", collabDid)) + mustProcess(t, i, collaboratorFrame(jetstream.CommitOperationCreate, "r1", collabDid)) got, err := deldb.ListCollaborators(i.db, repoDid) if err != nil || !slices.Equal(got, []string{collabDid}) { t.Fatalf("collaborators = %v, %v", got, err) } - mustProcess(t, i, collaboratorFrame(opDelete, "r2", collabDid), collaboratorFrame(opCreate, "r1", collabDid)) + mustProcess(t, i, collaboratorFrame(jetstream.CommitOperationDelete, "r2", collabDid), collaboratorFrame(jetstream.CommitOperationCreate, "r1", collabDid)) if got, _ := deldb.ListCollaborators(i.db, repoDid); len(got) != 0 { t.Fatalf("collaborators after revoke = %v, want none even with the old create replayed", got) } - mustProcess(t, i, collaboratorFrame(opCreate, "r3", collabDid)) + mustProcess(t, i, collaboratorFrame(jetstream.CommitOperationCreate, "r3", collabDid)) if got, _ := deldb.ListCollaborators(i.db, repoDid); !slices.Equal(got, []string{collabDid}) { t.Fatalf("collaborators after re-add = %v", got) } @@ -641,15 +639,15 @@ func TestBootstrapHistoryStaysSilent(t *testing.T) { t.Fatal("a fresh db must record when it was created") } - old := ticketFrame(opCreate, "r1", "open", editorDid, "") + old := ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "") old.TimeUS = bootstrap - 1_000_000 mustProcess(t, i, old) if got := countFor(t, i, ownerDid); got != 0 { t.Fatalf("owner rows = %d, want 0 for history from before the db existed", got) } - // the close is real, and it diffs against the silently built state - mustProcess(t, i, ticketFrame(opUpdate, "r2", "closed", editorDid, "")) + // history built the state silently, so this close still notifies + mustProcess(t, i, ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, "")) if n := onlyFor(t, i, ownerDid); n.Type != models.NotificationTypeIssueClosed { t.Errorf("type = %q, want issue_closed", n.Type) } @@ -684,7 +682,7 @@ func TestUpgradedDatabaseIsNotSilenced(t *testing.T) { func TestEventAndCursorCommitTogether(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - e := ticketFrame(opCreate, "r1", "open", editorDid, "") + e := ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "") mustProcess(t, i, e) if us, ok, _ := deldb.GetCursor(i.db); !ok || us != e.TimeUS { t.Fatalf("cursor = %d/%v, want %d", us, ok, e.TimeUS) @@ -694,7 +692,7 @@ func TestEventAndCursorCommitTogether(t *testing.T) { if _, err := i.db.Exec(`drop table repo_collaborators`); err != nil { t.Fatal(err) } - failing := collaboratorFrame(opCreate, "r2", collabDid) + failing := collaboratorFrame(jetstream.CommitOperationCreate, "r2", collabDid) if err := i.process(context.Background(), failing); err == nil { t.Fatal("process succeeded without its table") } @@ -724,7 +722,7 @@ func TestCursorNeverMovesBack(t *testing.T) { } func TestRunIngestsFromTheStreamAndMovesTheCursor(t *testing.T) { - e := ticketFrame(opCreate, "r1", "open", editorDid, "") + e := ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "") frameJSON, err := json.Marshal(e) if err != nil { t.Fatal(err) @@ -776,10 +774,10 @@ func TestDisabledPrefsSuppressStateNotifications(t *testing.T) { t.Fatal(err) } mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), - ticketFrame(opUpdate, "r2", "closed", editorDid, ""), - frame(repoDid, opCreate, ticketNSID, "2", "r3", ticketJSON("open", editorDid, pullExtra)), - frame(repoDid, opUpdate, ticketNSID, "2", "r4", ticketJSON("completed", editorDid, pullExtra)), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r2", "closed", editorDid, ""), + frame(repoDid, jetstream.CommitOperationCreate, ticketNSID, "2", "r3", ticketJSON("open", editorDid, pullExtra)), + frame(repoDid, jetstream.CommitOperationUpdate, ticketNSID, "2", "r4", ticketJSON("completed", editorDid, pullExtra)), ) for _, n := range notifsFor(t, i, ownerDid) { if n.Type == models.NotificationTypeIssueClosed || n.Type == models.NotificationTypePullMerged { @@ -847,7 +845,7 @@ func TestManifestSeedsOwnerAndName(t *testing.T) { const other = "did:plc:other" mustProcess(t, i, - frame(other, opCreate, repoManifestNSID, "self", "r1", + frame(other, jetstream.CommitOperationCreate, repoManifestNSID, "self", "r1", `{"$type":"org.tangled.repo.manifest","createdAt":"2026-01-01T00:00:00Z","declaration":{"owner":"`+ownerDid+`","name":"my-repo"}}`), starFrame(editorDid, other, "star1"), ) @@ -864,7 +862,7 @@ func TestManifestSeedsOwnerAndName(t *testing.T) { } func starFrame(actorDid, repoDid, rkey string) *jetstream.Event { - return frame(actorDid, opCreate, starNSID, rkey, "rev-"+rkey, + return frame(actorDid, jetstream.CommitOperationCreate, starNSID, rkey, "rev-"+rkey, `{"$type":"org.tangled.feed.star","createdAt":"2026-01-01T00:00:00Z","subject":{"$type":"org.tangled.feed.star#repo","identity":"`+repoDid+`"}}`) } @@ -911,7 +909,7 @@ func TestBackfilledStarAndFollowAreSilent(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, backfill(starFrame(editorDid, repoDid, "star1")), - backfill(frame(editorDid, opCreate, followNSID, ownerDid, "r1", `{"$type":"org.tangled.graph.follow","createdAt":"2026-01-01T00:00:00Z"}`)), + backfill(frame(editorDid, jetstream.CommitOperationCreate, followNSID, ownerDid, "r1", `{"$type":"org.tangled.graph.follow","createdAt":"2026-01-01T00:00:00Z"}`)), ) if got := countFor(t, i, ownerDid); got != 0 { t.Fatalf("owner rows = %d, want 0", got) @@ -921,7 +919,7 @@ func TestBackfilledStarAndFollowAreSilent(t *testing.T) { func TestFollowNotifiesRkeySubject(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - mustProcess(t, i, frame(editorDid, opCreate, followNSID, ownerDid, "r1", `{"$type":"org.tangled.graph.follow","createdAt":"2026-01-01T00:00:00Z"}`)) + mustProcess(t, i, frame(editorDid, jetstream.CommitOperationCreate, followNSID, ownerDid, "r1", `{"$type":"org.tangled.graph.follow","createdAt":"2026-01-01T00:00:00Z"}`)) if n := onlyFor(t, i, ownerDid); n.Type != models.NotificationTypeFollowed || n.ActorDid != editorDid { t.Errorf("notification = %+v", n) @@ -946,7 +944,7 @@ func TestPutEntityKeepsKnownFields(t *testing.T) { func TestCreatedNotificationUsesEventTime(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) - e := ticketFrame(opCreate, "r1", "open", editorDid, "") + e := ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, "") mustProcess(t, i, e) if got := onlyFor(t, i, ownerDid).Created; got.UnixMicro()/1_000_000 != e.TimeUS/1_000_000 { t.Errorf("created = %v, want the event time", got) @@ -956,15 +954,15 @@ func TestCreatedNotificationUsesEventTime(t *testing.T) { func TestErasedTicketTakesItsNoticesWithIt(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), commentFrame("c1", "r2", ticketUri, "", otherDid), - ticketFrame(opUpdate, "r3", "closed", collabDid, ""), + ticketFrame(jetstream.CommitOperationUpdate, "r3", "closed", collabDid, ""), ) if got := countFor(t, i, ownerDid); got != 3 { t.Fatalf("owner rows before the erase = %d, want created, commented and closed", got) } - mustProcess(t, i, frame(repoDid, opDelete, ticketNSID, "1", "r4", "")) + mustProcess(t, i, frame(repoDid, jetstream.CommitOperationDelete, ticketNSID, "1", "r4", "")) for _, did := range []string{ownerDid, editorDid} { if got := countFor(t, i, did); got != 0 { @@ -976,12 +974,12 @@ func TestErasedTicketTakesItsNoticesWithIt(t *testing.T) { func TestErasedCommentTakesOnlyItsOwnNoticesWithIt(t *testing.T) { i := newTestIngester(t, fakeBobbin{}) mustProcess(t, i, - ticketFrame(opCreate, "r1", "open", editorDid, ""), + ticketFrame(jetstream.CommitOperationCreate, "r1", "open", editorDid, ""), commentFrame("c1", "r2", ticketUri, "", otherDid), commentFrame("c2", "r3", ticketUri, "", otherDid), ) - mustProcess(t, i, frame(repoDid, opDelete, commentNSID, "c1", "r4", "")) + mustProcess(t, i, frame(repoDid, jetstream.CommitOperationDelete, commentNSID, "c1", "r4", "")) got := map[models.NotificationType]int{} for _, n := range notifsFor(t, i, ownerDid) { diff --git a/go.mod b/go.mod index 69875ed5b..d7f2fb003 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,6 @@ require ( github.com/blevesearch/bleve/v2 v2.5.3 github.com/bluekeyes/go-gitdiff v0.8.1 github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab - github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/buildkite/terminal-to-html/v3 v3.16.8 github.com/carlmjohnson/versioninfo v0.22.5 @@ -197,7 +196,6 @@ require ( github.com/go-redis/cache/v9 v9.0.0 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.11.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 @@ -315,7 +313,7 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.16.0 // indirect + golang.org/x/time v0.16.0 golang.org/x/tools v0.51.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 // indirect diff --git a/go.sum b/go.sum index b2debf963..26b0bb483 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,6 @@ github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFx github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= -github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 h1:OK76FcHhZp8ohjRB0OMWgti0oYAWFlt3KDQcIkH1pfI= -github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654/go.mod h1:vt8kVRKtvrBspt9G38wDD8+BotjIMO8u8IYoVnyE4zY= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= @@ -349,8 +347,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA= -github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= diff --git a/jetstream/jetstream.go b/jetstream/jetstream.go index 95fc60fb4..13bed06ab 100644 --- a/jetstream/jetstream.go +++ b/jetstream/jetstream.go @@ -8,15 +8,11 @@ import ( "sync/atomic" "time" - "github.com/bluesky-social/jetstream/pkg/client" - "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" - "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/log" ) const ( - gapRewind = time.Second - reconnectDelay = time.Second + gapRewind = time.Second // shutdown saves the cursor after this even if events are still held heldGrace = 30 * time.Second ) @@ -29,10 +25,8 @@ type DB interface { type Set[T comparable] map[T]struct{} type JetstreamClient struct { - cfg *client.ClientConfig - client *client.Client - ident string - l *slog.Logger + cfg StreamConfig + l *slog.Logger logDids bool wantedDids Set[string] @@ -50,6 +44,8 @@ type JetstreamClient struct { onGap func(context.Context) done chan struct{} + + saveEvery time.Duration } func (j *JetstreamClient) AddDid(did string) { @@ -89,12 +85,12 @@ func (j *JetstreamClient) RemoveDid(did string) { j.mu.Unlock() } -type processor func(context.Context, *models.Event) error +type processor func(context.Context, *Event) error func (j *JetstreamClient) withDidFilter(processFunc processor) processor { // since this closure references j.WantedDids; it should auto-update // existing instances of the closure when j.WantedDids is mutated - return func(ctx context.Context, evt *models.Event) error { + return func(ctx context.Context, evt *Event) error { if anchor := j.anchorUs.Swap(0); anchor != 0 && evt.TimeUS > anchor && j.onGap != nil { j.l.Warn("jetstream history no longer reaches the last event seen", "last", anchor, "first", evt.TimeUS) j.onGap(ctx) @@ -135,16 +131,14 @@ func (j *JetstreamClient) withDidFilter(processFunc processor) processor { } } -func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid, logDids bool) (*JetstreamClient, error) { +// StartJetstream replaces cfg.Cursor with the db one, so don't set it +func NewJetstreamClient(endpoint string, collections []string, cfg *StreamConfig, logger *slog.Logger, db DB, waitForDid, logDids bool) (*JetstreamClient, error) { if cfg == nil { - cfg = client.DefaultClientConfig() - cfg.WebsocketURL = endpoint - cfg.WantedCollections = collections + cfg = &StreamConfig{Endpoint: endpoint, Collections: collections, Compress: true} } return &JetstreamClient{ - cfg: cfg, - ident: ident, + cfg: *cfg, db: db, l: logger, wantedDids: make(map[string]struct{}), @@ -157,28 +151,29 @@ func NewJetstreamClient(endpoint, ident string, collections []string, cfg *clien waitForDid: waitForDid, done: make(chan struct{}), + + saveEvery: time.Minute, }, nil } // StartJetstream starts the jetstream client and processes events using the provided processFunc. // The client persists the last time_us cursor itself via the DB it was constructed with. -func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { - logger := j.l - - sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) - - client, err := client.NewClient(j.cfg, logger, sched) +func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *Event) error) error { + cfg := j.cfg + cfg.TailWhenNoCursor = true + cfg.Cursor = func() (int64, bool, error) { return j.cursor(ctx) } + stream, err := NewStream(cfg, j.l) if err != nil { return fmt.Errorf("failed to create jetstream client: %w", err) } - j.client = client + handle := j.withDidFilter(processFunc) go func() { defer close(j.done) if j.waitForDid && !j.waitForWantedDid(ctx) { return } - logger.Info("done waiting for did") + j.l.Info("done waiting for did") ticked := make(chan struct{}) go func() { @@ -186,7 +181,16 @@ func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(c j.periodicLastTimeSave(ctx) }() - j.connectAndRead(ctx) + // returning the error would replay it forever, so log and skip + stream.Run(ctx, func(ctx context.Context, e *Event) error { + if err := handle(ctx, e); err != nil { + if ctx.Err() != nil { + return err + } + j.l.Error("error processing event", "error", err, "time_us", e.TimeUS) + } + return nil + }) <-ticked j.waitForHeld() // save last, nothing may move the cursor once it runs @@ -229,37 +233,9 @@ func (j *JetstreamClient) waitForWantedDid(ctx context.Context) bool { } } -func (j *JetstreamClient) connectAndRead(ctx context.Context) { - l := log.FromContext(ctx) - for { - cursor := j.resume(ctx) - - // the read only sees ctx between messages, so waiting on it hangs on a quiet stream - read := make(chan error, 1) - go func() { read <- j.client.ConnectAndRead(ctx, cursor) }() - - select { - case <-ctx.Done(): - l.Info("context done, stopping jetstream") - return - case err := <-read: - if err != nil { - l.Error("error reading jetstream", "error", err) - } - } - - // a refused connect fails instantly, so wait or this spins - select { - case <-ctx.Done(): - return - case <-time.After(reconnectDelay): - } - } -} - // save cursor periodically func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) { - ticker := time.NewTicker(time.Minute) + ticker := time.NewTicker(j.saveEvery) defer ticker.Stop() for { @@ -284,6 +260,14 @@ func (j *JetstreamClient) resume(ctx context.Context) *int64 { return &rewound } +func (j *JetstreamClient) cursor(ctx context.Context) (int64, bool, error) { + cursor := j.resume(ctx) + if cursor == nil { + return 0, false, nil + } + return *cursor, true, nil +} + func (j *JetstreamClient) resumeCursor(ctx context.Context) *int64 { if seen := j.lastSeenUs.Load(); seen != 0 { return &seen diff --git a/jetstream/jetstream_test.go b/jetstream/jetstream_test.go index 6e5cb77f8..eefe160bb 100644 --- a/jetstream/jetstream_test.go +++ b/jetstream/jetstream_test.go @@ -3,16 +3,19 @@ package jetstream import ( "context" "errors" + "fmt" "log/slog" "net/http" "net/http/httptest" + "slices" "strings" "sync" + "sync/atomic" "testing" "time" - "github.com/bluesky-social/jetstream/pkg/models" "github.com/gorilla/websocket" + "github.com/klauspost/compress/zstd" ) type fakeCursorDB struct { @@ -63,7 +66,7 @@ func TestMissingCursorConnectsLive(t *testing.T) { if got := j.resume(context.Background()); got != nil { t.Fatalf("missing cursor must connect live, got %d", *got) } - _ = j.withDidFilter(func(context.Context, *models.Event) error { return nil })(context.Background(), &models.Event{Did: "did:plc:a", TimeUS: 5}) + _ = j.withDidFilter(func(context.Context, *Event) error { return nil })(context.Background(), &Event{Did: "did:plc:a", TimeUS: 5}) if gaps != 0 { t.Fatal("a first boot has nothing to catch up on") } @@ -89,9 +92,9 @@ func firstEventAfterResume(t *testing.T, saved, first int64) int { var gaps int j := newGapClient(&fakeCursorDB{saved: saved}, &gaps) j.resume(context.Background()) - wrapped := j.withDidFilter(func(context.Context, *models.Event) error { return nil }) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:a", TimeUS: first}) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:a", TimeUS: first + 1_000_000}) + wrapped := j.withDidFilter(func(context.Context, *Event) error { return nil }) + _ = wrapped(context.Background(), &Event{Did: "did:plc:a", TimeUS: first}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:a", TimeUS: first + 1_000_000}) return gaps } @@ -115,14 +118,14 @@ func TestNoGapWhenTheStreamReplaysTheLastEvent(t *testing.T) { func TestLastSeenTracksEveryEventPreFilter(t *testing.T) { j := &JetstreamClient{wantedDids: Set[string]{"did:plc:boltless": {}}} - wrapped := j.withDidFilter(func(context.Context, *models.Event) error { return nil }) + wrapped := j.withDidFilter(func(context.Context, *Event) error { return nil }) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:akshay", TimeUS: 100}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:akshay", TimeUS: 100}) if got := j.lastSeenUs.Load(); got != 101 { t.Fatalf("filtered-out event must still advance last-seen: got %d, want 101", got) } - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:boltless", TimeUS: 200}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:boltless", TimeUS: 200}) if got := j.lastSeenUs.Load(); got != 201 { t.Fatalf("matching event must advance last-seen: got %d, want 201", got) } @@ -136,22 +139,22 @@ func TestExemptCollectionShouldBypassDidFilter(t *testing.T) { j.ExemptCollection("sh.tangled.repo") var processed int - wrapped := j.withDidFilter(func(context.Context, *models.Event) error { + wrapped := j.withDidFilter(func(context.Context, *Event) error { processed++ return nil }) - _ = wrapped(context.Background(), &models.Event{ + _ = wrapped(context.Background(), &Event{ Did: "did:plc:akshay", TimeUS: 100, - Commit: &models.Commit{Collection: "sh.tangled.repo"}, + Commit: &Commit{Collection: "sh.tangled.repo"}, }) if processed != 1 { t.Fatal("An exempt collection must reach this processor from an unwanted DID; enrollment is decided later, not by this filter.") } - _ = wrapped(context.Background(), &models.Event{ + _ = wrapped(context.Background(), &Event{ Did: "did:plc:akshay", TimeUS: 200, - Commit: &models.Commit{Collection: "sh.tangled.repo.pull"}, + Commit: &Commit{Collection: "sh.tangled.repo.pull"}, }) if processed != 1 { t.Fatal("A non-exempt collection must still be filtered by DID.") @@ -162,12 +165,12 @@ func TestLastSeenAdvancesAfterProcessing(t *testing.T) { j := &JetstreamClient{wantedDids: make(Set[string])} var seenDuringProcess int64 - wrapped := j.withDidFilter(func(context.Context, *models.Event) error { + wrapped := j.withDidFilter(func(context.Context, *Event) error { seenDuringProcess = j.lastSeenUs.Load() return nil }) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:boltless", TimeUS: 500}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:boltless", TimeUS: 500}) if seenDuringProcess == 501 { t.Fatal("cursor advanced before the event finished processing; a crash mid-process would skip it") @@ -180,12 +183,12 @@ func TestLastSeenAdvancesAfterProcessing(t *testing.T) { func TestShutdownLeavesTheCursorBeforeTheCutEvent(t *testing.T) { j := &JetstreamClient{wantedDids: make(Set[string])} ctx, cancel := context.WithCancel(context.Background()) - wrapped := j.withDidFilter(func(ctx context.Context, _ *models.Event) error { + wrapped := j.withDidFilter(func(ctx context.Context, _ *Event) error { cancel() return ctx.Err() }) - _ = wrapped(ctx, &models.Event{Did: "did:plc:boltless", TimeUS: 700}) + _ = wrapped(ctx, &Event{Did: "did:plc:boltless", TimeUS: 700}) if got := j.lastSeenUs.Load(); got != 0 { t.Fatalf("cursor moved past an event shutdown cut short: got %d, want it untouched", got) } @@ -193,11 +196,11 @@ func TestShutdownLeavesTheCursorBeforeTheCutEvent(t *testing.T) { func TestAFailingEventStillAdvancesTheCursor(t *testing.T) { j := &JetstreamClient{wantedDids: make(Set[string])} - wrapped := j.withDidFilter(func(context.Context, *models.Event) error { + wrapped := j.withDidFilter(func(context.Context, *Event) error { return errors.New("bad record") }) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:boltless", TimeUS: 800}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:boltless", TimeUS: 800}) if got := j.lastSeenUs.Load(); got != 801 { t.Fatalf("a record that fails on its own must not stall the cursor: got %d, want 801", got) } @@ -208,13 +211,13 @@ const refusedEndpoint = "ws://127.0.0.1:1/subscribe" func startClient(t *testing.T, endpoint string, db DB, waitForDid bool) (*JetstreamClient, context.CancelFunc) { t.Helper() - j, err := NewJetstreamClient(endpoint, "test", nil, nil, slog.Default(), db, waitForDid, false) + j, err := NewJetstreamClient(endpoint, nil, nil, slog.Default(), db, waitForDid, false) if err != nil { t.Fatal(err) } j.lastSeenUs.Store(42) ctx, cancel := context.WithCancel(context.Background()) - if err := j.StartJetstream(ctx, func(context.Context, *models.Event) error { return nil }); err != nil { + if err := j.StartJetstream(ctx, func(context.Context, *Event) error { return nil }); err != nil { t.Fatal(err) } return j, cancel @@ -274,3 +277,202 @@ func TestDoneClosesEvenWhenTheStreamIsQuiet(t *testing.T) { cancel() requireStopsWithSavedCursor(t, j, db, "a stream with no traffic held shutdown hostage, so the final cursor save never ran") } + +func frameServer(t *testing.T, frames ...string) *streamServer { + t.Helper() + enc, err := zstd.NewWriter(nil, zstd.WithEncoderDict(zstdDictionary)) + if err != nil { + t.Fatal(err) + } + var srv *streamServer + srv = newStreamServer(t, func(n int, con *websocket.Conn) { + srv.mu.Lock() + compressed := srv.headers[n].Get("Socket-Encoding") == "zstd" + srv.mu.Unlock() + for _, f := range frames { + if compressed { + con.WriteMessage(websocket.BinaryMessage, enc.EncodeAll([]byte(f), nil)) + } else { + con.WriteMessage(websocket.TextMessage, []byte(f)) + } + } + drain(con) + }) + return srv +} + +func commitFrame(did string, us int, collection string, live bool) string { + return fmt.Sprintf(`{"did":%q,"time_us":%d,"kind":"commit","commit":{"operation":"create","collection":%q,"rkey":"k","live":%t}}`, did, us, collection, live) +} + +func runClient(t *testing.T, j *JetstreamClient, stop func(*Event) bool) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := j.StartJetstream(ctx, func(_ context.Context, e *Event) error { + if stop(e) { + cancel() + } + return nil + }); err != nil { + t.Fatal(err) + } + <-j.Done() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatal("the client never delivered what the test waited for") + } +} + +func newTestClient(t *testing.T, srv *streamServer, db DB, collections ...string) *JetstreamClient { + t.Helper() + j, err := NewJetstreamClient(srv.endpoint(), collections, nil, slog.Default(), db, false, false) + if err != nil { + t.Fatal(err) + } + return j +} + +func TestClientDeliversInOrderAndOneAtATime(t *testing.T) { + var frames []string + for i := 1; i <= 50; i++ { + frames = append(frames, commitFrame("did:plc:a", i, "c", true)) + } + j := newTestClient(t, frameServer(t, frames...), &fakeCursorDB{savedErr: errors.New("no row")}) + + var inflight atomic.Int32 + var got []int64 + runClient(t, j, func(e *Event) bool { + if inflight.Add(1) != 1 { + t.Error("two events were being handled at once") + } + time.Sleep(time.Millisecond) + inflight.Add(-1) + got = append(got, e.TimeUS) + return e.TimeUS == 50 + }) + for i, us := range got { + if us != int64(i+1) { + t.Fatalf("event %d arrived as time_us %d, want %d", i, us, i+1) + } + } +} + +func TestClientDidFilterChangesAtRuntime(t *testing.T) { + srv := frameServer(t, + commitFrame("did:plc:a", 1, "c", true), + commitFrame("did:plc:b", 2, "c", true), + commitFrame("did:plc:a", 3, "c", true), + commitFrame("did:plc:b", 4, "c", true), + commitFrame("did:plc:a", 5, "c", true), + ) + j := newTestClient(t, srv, &fakeCursorDB{savedErr: errors.New("no row")}) + j.AddDid("did:plc:b") + + var got []int64 + runClient(t, j, func(e *Event) bool { + got = append(got, e.TimeUS) + if e.TimeUS == 2 { + j.AddDid("did:plc:a") + j.RemoveDid("did:plc:b") + } + return e.TimeUS == 5 + }) + if want := []int64{2, 3, 5}; !slices.Equal(got, want) { + t.Fatalf("delivered %v, want %v", got, want) + } +} + +func TestClientUnfilteredCollectionIgnoresTheDidFilter(t *testing.T) { + srv := frameServer(t, + commitFrame("did:plc:a", 1, "other", true), + commitFrame("did:plc:a", 2, "open", true), + commitFrame("did:plc:b", 3, "other", true), + ) + j := newTestClient(t, srv, &fakeCursorDB{savedErr: errors.New("no row")}) + j.AddDid("did:plc:b") + j.ExemptCollection("open") + + var got []int64 + runClient(t, j, func(e *Event) bool { + got = append(got, e.TimeUS) + return e.TimeUS == 3 + }) + if len(got) != 2 || got[0] != 2 || got[1] != 3 { + t.Fatalf("delivered %v, want the exempt event 2 and the wanted did's event 3", got) + } +} + +// only deliberi sends wantedEventTypes, a plain jetstream client must not +func TestClientAsksForNoEventTypes(t *testing.T) { + srv := frameServer(t, commitFrame("did:plc:a", 1, "c", true)) + j := newTestClient(t, srv, &fakeCursorDB{savedErr: errors.New("no row")}, "c") + + runClient(t, j, func(e *Event) bool { return true }) + if q := srv.query(0); q.Has("wantedEventTypes") { + t.Fatalf("query = %v, want no wantedEventTypes", q) + } +} + +func TestClientSavesTheCursorOnTheTimer(t *testing.T) { + srv := frameServer(t, commitFrame("did:plc:a", 10, "c", true)) + db := &fakeCursorDB{savedErr: errors.New("no row")} + j := newTestClient(t, srv, db) + j.saveEvery = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := j.StartJetstream(ctx, func(context.Context, *Event) error { return nil }); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + db.mu.Lock() + got := db.got + db.mu.Unlock() + if got == 11 { + return + } + if time.Now().After(deadline) { + t.Fatalf("saved cursor = %d, want 11 from the timer before any shutdown", got) + } + time.Sleep(5 * time.Millisecond) + } +} + +func TestClientReportsAGapAndResumesARewoundCursor(t *testing.T) { + // cursor is one past 10_000_000, but the server's history starts after it + srv := frameServer(t, commitFrame("did:plc:a", 20_000_000, "c", true)) + j := newTestClient(t, srv, &fakeCursorDB{saved: 10_000_001}) + gaps := make(chan struct{}, 2) + j.SetGapHandler(func(context.Context) { gaps <- struct{}{} }) + + runClient(t, j, func(e *Event) bool { return true }) + if len(gaps) != 1 { + t.Fatalf("gap handler ran %d times, want once", len(gaps)) + } + if got := srv.query(0).Get("cursor"); got != "9000001" { + t.Fatalf("connected with cursor %q, want the stored one rewound a second", got) + } +} + +func TestClientFailingEventsDontDropTheConnection(t *testing.T) { + srv := frameServer(t, + commitFrame("did:plc:a", 1, "c", true), + commitFrame("did:plc:a", 2, "c", true), + ) + j := newTestClient(t, srv, &fakeCursorDB{savedErr: errors.New("no row")}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var seen atomic.Int32 + _ = j.StartJetstream(ctx, func(_ context.Context, e *Event) error { + if seen.Add(1) == 2 { + cancel() + } + return errors.New("bad record") + }) + <-j.Done() + if seen.Load() != 2 || srv.connects() != 1 { + t.Fatalf("saw %d events over %d connections, want 2 over 1", seen.Load(), srv.connects()) + } +} diff --git a/jetstream/stream.go b/jetstream/stream.go index 5eb8fb0c9..2d575a908 100644 --- a/jetstream/stream.go +++ b/jetstream/stream.go @@ -2,6 +2,7 @@ package jetstream import ( "context" + _ "embed" "encoding/json" "fmt" "log/slog" @@ -11,17 +12,32 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/jetstream/pkg/models" "github.com/gorilla/websocket" "github.com/klauspost/compress/zstd" ) -// hydrant pings every 30s, so this much silence means a dead connection -// that would otherwise block the read forever +// bsky jetstream compresses with this dictionary, and hydrant's frames decode with it too +// +//go:embed zstd_dictionary +var zstdDictionary []byte + +const ( + EventKindCommit = "commit" + EventKindAccount = "account" + EventKindIdentity = "identity" + + CommitOperationCreate = "create" + CommitOperationUpdate = "update" + CommitOperationDelete = "delete" +) + +// hydrant pings every 30s, so 90s of silence means the connection is dead const defaultReadTimeout = 90 * time.Second -// what hydrant's /subscribe sends, which is indigo's event plus the `live` -// flag its models drop +// refused dials fail instantly, without the wait this loop spins hot +const reconnectDelay = time.Second + +// indigo's jetstream event plus a live flag on commits, which is what hydrant's /subscribe sends type Event struct { Did string `json:"did"` TimeUS int64 `json:"time_us"` @@ -53,11 +69,10 @@ type StreamConfig struct { EventTypes []string Compress bool - // the handler's own store, read on every (re)connect. ok is false when it - // has no cursor yet, which replays from 0 instead of joining the live tail - Cursor func() (timeUs int64, ok bool, err error) - // resume this far before the cursor. time_us is stamped per instance, so - // after a reconnect to another one the same events can carry other stamps + // read on every connect, ok false replays from 0 unless TailWhenNoCursor is set + Cursor func() (timeUs int64, ok bool, err error) + TailWhenNoCursor bool + // each hydrant stamps time_us its own way, so resume a little before the cursor Rewind time.Duration ReadTimeout time.Duration @@ -84,7 +99,7 @@ func NewStream(cfg StreamConfig, logger *slog.Logger) (*Stream, error) { } s := &Stream{cfg: cfg, l: logger} if cfg.Compress { - dec, err := zstd.NewReader(nil, zstd.WithDecoderDicts(models.ZSTDDictionary)) + dec, err := zstd.NewReader(nil, zstd.WithDecoderDicts(zstdDictionary)) if err != nil { return nil, fmt.Errorf("creating zstd decoder: %w", err) } @@ -107,21 +122,32 @@ func (s *Stream) Run(ctx context.Context, handle func(context.Context, *Event) e } } -func (s *Stream) resumeCursor() (int64, error) { +// nil leaves the cursor param off, which starts at the live tail +func (s *Stream) resumeCursor() (*int64, error) { us, ok, err := s.cfg.Cursor() - if err != nil || !ok { - return 0, err + if err != nil { + return nil, err + } + if !ok { + if s.cfg.TailWhenNoCursor { + return nil, nil + } + us = 0 } - return max(us-s.cfg.Rewind.Microseconds(), 0), nil + cursor := max(us-s.cfg.Rewind.Microseconds(), 0) + return &cursor, nil } -func (s *Stream) url(cursor int64) (string, error) { +func (s *Stream) url(cursor *int64) (string, error) { u, err := url.Parse(s.cfg.Endpoint) if err != nil { return "", fmt.Errorf("parsing endpoint %q: %w", s.cfg.Endpoint, err) } q := u.Query() - q.Set("cursor", strconv.FormatInt(cursor, 10)) + q.Del("cursor") + if cursor != nil { + q.Set("cursor", strconv.FormatInt(*cursor, 10)) + } q.Del("wantedCollections") for _, nsid := range s.cfg.Collections { q.Add("wantedCollections", nsid) @@ -152,7 +178,7 @@ func (s *Stream) readOnce(ctx context.Context, handle func(context.Context, *Eve header.Set("Socket-Encoding", "zstd") } - s.l.Info("connecting to stream", "endpoint", s.cfg.Endpoint, "cursor", cursor) + s.l.Info("connecting to stream", "url", target) con, _, err := websocket.DefaultDialer.DialContext(ctx, target, header) if err != nil { return err diff --git a/jetstream/stream_test.go b/jetstream/stream_test.go index c58524699..362888b47 100644 --- a/jetstream/stream_test.go +++ b/jetstream/stream_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/bluesky-social/jetstream/pkg/models" "github.com/gorilla/websocket" "github.com/klauspost/compress/zstd" ) @@ -56,7 +55,12 @@ func (s *streamServer) query(n int) url.Values { return s.queries[n] } -// holds the connection open until the client hangs up +func (s *streamServer) connects() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.queries) +} + func drain(con *websocket.Conn) { for { if _, _, err := con.ReadMessage(); err != nil { @@ -107,7 +111,7 @@ func TestStreamURLCarriesEachParamOnce(t *testing.T) { Collections: []string{"org.tangled.track.ticket", "org.tangled.feed.comment"}, EventTypes: []string{"live", "historical"}, }) - got, err := s.url(0) + got, err := s.url(new(int64)) if err != nil { t.Fatal(err) } @@ -269,7 +273,7 @@ func TestStreamPingsKeepAConnectionAlive(t *testing.T) { } func TestStreamDecodesZstdWithTheJetstreamDictionary(t *testing.T) { - enc, err := zstd.NewWriter(nil, zstd.WithEncoderDict(models.ZSTDDictionary)) + enc, err := zstd.NewWriter(nil, zstd.WithEncoderDict(zstdDictionary)) if err != nil { t.Fatal(err) } @@ -287,3 +291,31 @@ func TestStreamDecodesZstdWithTheJetstreamDictionary(t *testing.T) { t.Errorf("User-Agent = %q, want test", got) } } + +func TestStreamDecodesZstdWithoutADictionary(t *testing.T) { + enc, err := zstd.NewWriter(nil) + if err != nil { + t.Fatal(err) + } + srv := newStreamServer(t, func(_ int, con *websocket.Conn) { + frame := enc.EncodeAll([]byte(`{"did":"d","time_us":7,"kind":"commit","commit":{"live":false}}`), nil) + con.WriteMessage(websocket.BinaryMessage, frame) + drain(con) + }) + s := testStream(t, StreamConfig{Endpoint: srv.endpoint(), Compress: true}) + run(t, s, func(e *Event) bool { return e.TimeUS == 7 && !e.Commit.IsLive() }) +} + +func TestStreamWithoutACursorTailsWhenAsked(t *testing.T) { + srv := newStreamServer(t, func(_ int, con *websocket.Conn) { drain(con) }) + s := testStream(t, StreamConfig{Endpoint: srv.endpoint(), TailWhenNoCursor: true}) + ctx, cancel := context.WithCancel(context.Background()) + go s.Run(ctx, func(context.Context, *Event) error { return nil }) + for srv.connects() == 0 { + time.Sleep(time.Millisecond) + } + cancel() + if _, ok := srv.query(0)["cursor"]; ok { + t.Fatal("a stream with no cursor should connect without one") + } +} diff --git a/jetstream/watermark_test.go b/jetstream/watermark_test.go index 91f9b401b..d8a581921 100644 --- a/jetstream/watermark_test.go +++ b/jetstream/watermark_test.go @@ -7,8 +7,6 @@ import ( "sync" "testing" "time" - - "github.com/bluesky-social/jetstream/pkg/models" ) func TestTheCursorNeverPassesAnUnfinishedEvent(t *testing.T) { @@ -44,15 +42,15 @@ func TestTheCursorNeverPassesAnUnfinishedEvent(t *testing.T) { func TestAHeldEventKeepsTheCursorBehindIt(t *testing.T) { j := &JetstreamClient{wantedDids: make(Set[string])} var held *Ticket - wrapped := j.withDidFilter(func(ctx context.Context, e *models.Event) error { + wrapped := j.withDidFilter(func(ctx context.Context, e *Event) error { if e.TimeUS == 100 { held = Hold(ctx) } return nil }) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:a", TimeUS: 100}) - _ = wrapped(context.Background(), &models.Event{Did: "did:plc:a", TimeUS: 200}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:a", TimeUS: 100}) + _ = wrapped(context.Background(), &Event{Did: "did:plc:a", TimeUS: 200}) if got := j.lastSeenUs.Load(); got != 0 { t.Fatalf("cursor = %d with an earlier event still held, want it untouched", got) } diff --git a/jetstream/zstd_dictionary b/jetstream/zstd_dictionary new file mode 100644 index 0000000000000000000000000000000000000000..106847e7007b4ac409fd58912fed85d69aedb352 GIT binary patch literal 112640 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 NGcW*;mlJ#98UX)HUhe<^ literal 0 HcmV?d00001 diff --git a/knotserver/ingester.go b/knotserver/ingester.go index d539c709a..411affe87 100644 --- a/knotserver/ingester.go +++ b/knotserver/ingester.go @@ -7,20 +7,20 @@ import ( "strings" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" + "tangled.org/core/jetstream" "tangled.org/core/knotserver/db" knotxrpc "tangled.org/core/knotserver/xrpc" "tangled.org/core/log" ) -func (h *Knot) processPublicKey(ctx context.Context, event *jmodels.Event) error { +func (h *Knot) processPublicKey(ctx context.Context, event *jetstream.Event) error { l := log.FromContext(ctx).With("handler", "processPublicKey", "did", event.Did, "rkey", event.Commit.RKey) did := syntax.DID(event.Did) rkey := syntax.RecordKey(event.Commit.RKey) switch event.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jetstream.CommitOperationCreate, jetstream.CommitOperationUpdate: var record tangled.PublicKey if err := json.Unmarshal(json.RawMessage(event.Commit.Record), &record); err != nil { return fmt.Errorf("failed to unmarshal record: %w", err) @@ -35,7 +35,7 @@ func (h *Knot) processPublicKey(ctx context.Context, event *jmodels.Event) error return fmt.Errorf("failed to upsert public key: %w", err) } l.Info("upserted public key from firehose") - case jmodels.CommitOperationDelete: + case jetstream.CommitOperationDelete: if err := h.db.DeletePublicKeyByRkey(did, rkey); err != nil { return fmt.Errorf("failed to delete public key: %w", err) } @@ -54,7 +54,7 @@ type targetRepo struct { DefaultBranch string // default branch } -func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error { +func (h *Knot) processRepo(ctx context.Context, event *jetstream.Event) error { l := log.FromContext(ctx).With("handler", "processRepo", "did", event.Did, "rkey", event.Commit.RKey) rkey := strings.TrimSuffix(strings.TrimSpace(event.Commit.RKey), ".git") @@ -62,11 +62,11 @@ func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error { return nil } - if event.Commit.Operation == jmodels.CommitOperationDelete { + if event.Commit.Operation == jetstream.CommitOperationDelete { return nil } - if event.Commit.Operation != jmodels.CommitOperationCreate && event.Commit.Operation != jmodels.CommitOperationUpdate { + if event.Commit.Operation != jetstream.CommitOperationCreate && event.Commit.Operation != jetstream.CommitOperationUpdate { return nil } @@ -115,12 +115,12 @@ func (h *Knot) processRepo(ctx context.Context, event *jmodels.Event) error { return nil } -func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error { +func (h *Knot) processMessages(ctx context.Context, event *jetstream.Event) error { var err error switch event.Kind { - case jmodels.EventKindIdentity: + case jetstream.EventKindIdentity: err = h.resolver.InvalidateIdent(ctx, event.Did) - case jmodels.EventKindCommit: + case jetstream.EventKindCommit: switch event.Commit.Collection { case tangled.PublicKeyNSID: err = h.processPublicKey(ctx, event) @@ -133,7 +133,7 @@ func (h *Knot) processMessages(ctx context.Context, event *jmodels.Event) error if err != nil { args := []any{"kind", event.Kind, "err", err} - if event.Kind == jmodels.EventKindCommit { + if event.Kind == jetstream.EventKindCommit { args = append(args, "nsid", event.Commit.Collection, "did", event.Did, "rkey", event.Commit.RKey) } h.l.Warn("failed to process event, skipping", args...) diff --git a/knotserver/ingester_repo_test.go b/knotserver/ingester_repo_test.go index 4b14c2d89..20d844c4e 100644 --- a/knotserver/ingester_repo_test.go +++ b/knotserver/ingester_repo_test.go @@ -9,8 +9,8 @@ import ( "sync" "testing" - jsmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/tangled" + "tangled.org/core/jetstream" "tangled.org/core/knotserver/config" "tangled.org/core/knotserver/db" "tangled.org/core/log" @@ -105,16 +105,16 @@ func newProcessRepoFixture(t *testing.T) (*Knot, context.Context, *capturingHand }, ctx, cap } -func repoEvent(t *testing.T, authorDid, rkey, rev string, record tangled.Repo, op string) *jsmodels.Event { +func repoEvent(t *testing.T, authorDid, rkey, rev string, record tangled.Repo, op string) *jetstream.Event { t.Helper() raw, err := json.Marshal(record) if err != nil { t.Fatalf("marshal record: %v", err) } - return &jsmodels.Event{ + return &jetstream.Event{ Did: authorDid, - Kind: jsmodels.EventKindCommit, - Commit: &jsmodels.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: op, Collection: tangled.RepoNSID, RKey: rkey, @@ -135,7 +135,7 @@ func TestProcessRepo_CreateRegistersAlias(t *testing.T) { ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:repo1"), - }, jsmodels.CommitOperationCreate) + }, jetstream.CommitOperationCreate) if err := h.processRepo(ctx, ev); err != nil { t.Fatalf("processRepo: %v", err) } @@ -172,7 +172,7 @@ func TestProcessRepo_DeleteIsNoOp(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } - ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaac", tangled.Repo{}, jsmodels.CommitOperationDelete) + ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaac", tangled.Repo{}, jetstream.CommitOperationDelete) if err := h.processRepo(ctx, ev); err != nil { t.Fatalf("processRepo: %v", err) } @@ -197,11 +197,11 @@ func TestProcessRepo_DeleteIsNoOp(t *testing.T) { func TestProcessRepo_MalformedJSONReturnsError(t *testing.T) { h, ctx, _ := newProcessRepoFixture(t) - ev := &jsmodels.Event{ + ev := &jetstream.Event{ Did: "did:plc:akshay", - Kind: jsmodels.EventKindCommit, - Commit: &jsmodels.Commit{ - Operation: jsmodels.CommitOperationCreate, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: tangled.RepoNSID, RKey: "rkey1", Record: []byte("{not valid json"), @@ -221,7 +221,7 @@ func TestProcessRepo_NotOwnedRejected(t *testing.T) { ev := repoEvent(t, "did:plc:mallory", "pwned", "3laaaaaaaaaab", tangled.Repo{ Knot: "knot.example", RepoDid: ptr("did:plc:repo1"), - }, jsmodels.CommitOperationCreate) + }, jetstream.CommitOperationCreate) if err := h.processRepo(ctx, ev); err != nil { t.Fatalf("processRepo: %v", err) } @@ -244,7 +244,7 @@ func TestProcessRepo_WrongKnotIgnored(t *testing.T) { ev := repoEvent(t, "did:plc:akshay", "bar", "3laaaaaaaaaab", tangled.Repo{ Knot: "other.example", RepoDid: ptr("did:plc:repo1"), - }, jsmodels.CommitOperationCreate) + }, jetstream.CommitOperationCreate) if err := h.processRepo(ctx, ev); err != nil { t.Fatalf("processRepo: %v", err) } diff --git a/knotserver/server.go b/knotserver/server.go index 4d1b0500f..f61e96cc2 100644 --- a/knotserver/server.go +++ b/knotserver/server.go @@ -93,7 +93,7 @@ func Run(ctx context.Context, cmd *cli.Command) error { e.E.EnableAutoSave(true) - jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ + jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, []string{ tangled.PublicKeyNSID, tangled.RepoNSID, }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index 9bca975aa..5bf671ee7 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -187,9 +187,6 @@ schema = 3 version = "v0.0.0-20261021233448-bf24d47f0126" hash = "sha256-t2n8entmHp3SqbRfQplOXF3p8QbqDmobamwiMeX+z5o=" replaced = "tangled.org/ptr.pet/indigo" - [mod."github.com/bluesky-social/jetstream"] - version = "v0.0.0-20260226214936-e0274250f654" - hash = "sha256-VE93NvI3PreteLHnlv7WT6GgH2vSjtoFjMygCmrznfg=" [mod."github.com/bmatcuk/doublestar"] version = "v1.3.4" hash = "sha256-QcHL9WGVAH3vIs4FZH+w1DJxdCHnXkkzODtOfhKR0X0=" @@ -401,9 +398,6 @@ schema = 3 [mod."github.com/gobwas/glob"] version = "v0.2.3" hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc=" - [mod."github.com/goccy/go-json"] - version = "v0.11.2" - hash = "sha256-MMFJd0ePB1LuLce8Mgpo0WxhJqSQT5Mt/oQfzMneWZM=" [mod."github.com/godbus/dbus/v5"] version = "v5.1.0" hash = "sha256-xOCMJpQK3KTmHTPn/CdqI4j0eENCtMmJDgAIoYqYOEY=" diff --git a/sitesd/sitefeed/feed.go b/sitesd/sitefeed/feed.go index 9e223c768..19c456954 100644 --- a/sitesd/sitefeed/feed.go +++ b/sitesd/sitefeed/feed.go @@ -25,7 +25,6 @@ import ( "tangled.org/core/sitesd/worker" "github.com/bluesky-social/indigo/atproto/syntax" - jsmodels "github.com/bluesky-social/jetstream/pkg/models" "github.com/redis/go-redis/v9" ) @@ -96,7 +95,7 @@ func recordDeploy(ctx context.Context, wrk *worker.Client, deploy *models.SiteDe // Run follows every repo's refs snapshot on the jetstream at endpoint until ctx ends func (f *Feed) Run(ctx context.Context, endpoint string) error { - jc, err := jetstream.NewJetstreamClient(endpoint, "sitesd", []string{refsCollection}, nil, f.logger, cursorDB{f.rdb}, false, false) + jc, err := jetstream.NewJetstreamClient(endpoint, []string{refsCollection}, nil, f.logger, cursorDB{f.rdb}, false, false) if err != nil { return fmt.Errorf("setting up the jetstream client: %w", err) } @@ -132,12 +131,12 @@ func (c cursorDB) SaveLastTimeUs(us int64) error { return c.rdb.Set(ctx, cursorKey, strconv.FormatInt(us, 10), 0).Err() } -func (f *Feed) handle(ctx context.Context, evt *jsmodels.Event) error { +func (f *Feed) handle(ctx context.Context, evt *jetstream.Event) error { c := evt.Commit - if evt.Kind != jsmodels.EventKindCommit || c == nil || c.Collection != refsCollection || c.RKey != refsRkey { + if evt.Kind != jetstream.EventKindCommit || c == nil || c.Collection != refsCollection || c.RKey != refsRkey { return nil } - if c.Operation != jsmodels.CommitOperationCreate && c.Operation != jsmodels.CommitOperationUpdate { + if c.Operation != jetstream.CommitOperationCreate && c.Operation != jetstream.CommitOperationUpdate { return nil } logger := f.logger.With("repo_did", evt.Did) diff --git a/sitesd/sitefeed/feed_test.go b/sitesd/sitefeed/feed_test.go index 02d5e1382..51a3c4dc1 100644 --- a/sitesd/sitefeed/feed_test.go +++ b/sitesd/sitefeed/feed_test.go @@ -11,13 +11,12 @@ import ( "time" "tangled.org/core/appview/models" + "tangled.org/core/jetstream" "tangled.org/core/log" "tangled.org/core/sitesd/cloudflare" "tangled.org/core/sitesd/config" "tangled.org/core/sitesd/reposync" "tangled.org/core/sitesd/worker" - - jsmodels "github.com/bluesky-social/jetstream/pkg/models" ) const ( @@ -108,7 +107,7 @@ func (r *recordRecorder) got() []*models.SiteDeploy { } // snapshot is the org.tangled.git.refs/self record as jetstream hands it over -func snapshot(t *testing.T, op string, lines ...string) *jsmodels.Event { +func snapshot(t *testing.T, op string, lines ...string) *jetstream.Event { t.Helper() record, err := json.Marshal(map[string]any{ "$type": refsCollection, @@ -118,7 +117,7 @@ func snapshot(t *testing.T, op string, lines ...string) *jsmodels.Event { if err != nil { t.Fatal(err) } - return &jsmodels.Event{Did: feedTestRepoDid, Kind: jsmodels.EventKindCommit, Commit: &jsmodels.Commit{ + return &jetstream.Event{Did: feedTestRepoDid, Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ Operation: op, Collection: refsCollection, RKey: refsRkey, @@ -155,7 +154,7 @@ func TestFeedSnapshotDeploysTheSiteBranch(t *testing.T) { return ctxFor(), nil } - evt := snapshot(t, jsmodels.CommitOperationUpdate, + evt := snapshot(t, jetstream.CommitOperationUpdate, feedTestOtherSha+" refs/heads/other", feedTestSha+" refs/heads/main", feedTestOtherSha+" refs/tags/v1") @@ -185,9 +184,9 @@ func TestFeedSnapshotDeploysTheSiteBranch(t *testing.T) { } func TestFeedSkipsSnapshotsThatDontDeploy(t *testing.T) { - foreign := func(mutate func(*jsmodels.Event)) func(*testing.T) *jsmodels.Event { - return func(t *testing.T) *jsmodels.Event { - evt := snapshot(t, jsmodels.CommitOperationCreate, feedTestSha+" refs/heads/main") + foreign := func(mutate func(*jetstream.Event)) func(*testing.T) *jetstream.Event { + return func(t *testing.T) *jetstream.Event { + evt := snapshot(t, jetstream.CommitOperationCreate, feedTestSha+" refs/heads/main") mutate(evt) return evt } @@ -195,58 +194,58 @@ func TestFeedSkipsSnapshotsThatDontDeploy(t *testing.T) { for _, tc := range []struct { name string ctx *worker.DeployContext - evt func(*testing.T) *jsmodels.Event + evt func(*testing.T) *jetstream.Event }{ { name: "the site branch isn't in the snapshot", ctx: ctxFor(), - evt: func(t *testing.T) *jsmodels.Event { - return snapshot(t, jsmodels.CommitOperationUpdate, feedTestSha+" refs/heads/other", feedTestSha+" refs/tags/main") + evt: func(t *testing.T) *jetstream.Event { + return snapshot(t, jetstream.CommitOperationUpdate, feedTestSha+" refs/heads/other", feedTestSha+" refs/tags/main") }, }, { name: "no site config", - evt: func(t *testing.T) *jsmodels.Event { - return snapshot(t, jsmodels.CommitOperationCreate, feedTestSha+" refs/heads/main") + evt: func(t *testing.T) *jetstream.Event { + return snapshot(t, jetstream.CommitOperationCreate, feedTestSha+" refs/heads/main") }, }, { name: "repo on a knot sitesd doesn't deploy from", ctx: ctxFor(func(dc *worker.DeployContext) { dc.Knot = feedTestOtherHost }), - evt: func(t *testing.T) *jsmodels.Event { - return snapshot(t, jsmodels.CommitOperationCreate, feedTestSha+" refs/heads/main") + evt: func(t *testing.T) *jetstream.Event { + return snapshot(t, jetstream.CommitOperationCreate, feedTestSha+" refs/heads/main") }, }, { name: "deleted snapshot", ctx: ctxFor(), - evt: foreign(func(e *jsmodels.Event) { e.Commit.Operation = jsmodels.CommitOperationDelete; e.Commit.Record = nil }), + evt: foreign(func(e *jetstream.Event) { e.Commit.Operation = jetstream.CommitOperationDelete; e.Commit.Record = nil }), }, { name: "another collection", ctx: ctxFor(), - evt: foreign(func(e *jsmodels.Event) { e.Commit.Collection = "org.tangled.repo.manifest" }), + evt: foreign(func(e *jetstream.Event) { e.Commit.Collection = "org.tangled.repo.manifest" }), }, { name: "another rkey", ctx: ctxFor(), - evt: foreign(func(e *jsmodels.Event) { e.Commit.RKey = "other" }), + evt: foreign(func(e *jetstream.Event) { e.Commit.RKey = "other" }), }, { name: "account event", ctx: ctxFor(), - evt: foreign(func(e *jsmodels.Event) { e.Kind = jsmodels.EventKindAccount; e.Commit = nil }), + evt: foreign(func(e *jetstream.Event) { e.Kind = jetstream.EventKindAccount; e.Commit = nil }), }, { name: "garbage record", ctx: ctxFor(), - evt: foreign(func(e *jsmodels.Event) { e.Commit.Record = json.RawMessage(`{"refs":{"$bytes":"!!"}}`) }), + evt: foreign(func(e *jetstream.Event) { e.Commit.Record = json.RawMessage(`{"refs":{"$bytes":"!!"}}`) }), }, { name: "a sha that isn't an object id", ctx: ctxFor(), - evt: func(t *testing.T) *jsmodels.Event { - return snapshot(t, jsmodels.CommitOperationCreate, "nothex refs/heads/main") + evt: func(t *testing.T) *jetstream.Event { + return snapshot(t, jetstream.CommitOperationCreate, "nothex refs/heads/main") }, }, } { @@ -275,7 +274,7 @@ func TestFeedWaitsOutAWorkerOutage(t *testing.T) { } return ctxFor(), nil } - if err := f.handle(context.Background(), snapshot(t, jsmodels.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err != nil { + if err := f.handle(context.Background(), snapshot(t, jetstream.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err != nil { t.Fatalf("handle: %v", err) } pollFor(t, "the deploy after the worker came back", func() bool { return len(d.got()) == 1 }) @@ -288,7 +287,7 @@ func TestFeedOutageEndsWithTheStream(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() - if err := f.handle(ctx, snapshot(t, jsmodels.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err == nil { + if err := f.handle(ctx, snapshot(t, jetstream.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err == nil { t.Fatal("handle returned cleanly while the worker was down, so the cursor would move past the push") } if calls := d.got(); len(calls) != 0 { @@ -303,7 +302,7 @@ func TestFeedSkipsARefusedRepoWithoutStalling(t *testing.T) { asked++ return nil, &worker.Error{StatusCode: 403, Name: "NotOwner", NSID: "org.tangled.temp.repo.getDeployContext"} } - if err := f.handle(context.Background(), snapshot(t, jsmodels.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err != nil { + if err := f.handle(context.Background(), snapshot(t, jetstream.CommitOperationUpdate, feedTestSha+" refs/heads/main")); err != nil { t.Fatalf("one repo's refusal stalled the stream: %v", err) } if asked != 1 { diff --git a/spindle/account_job_test.go b/spindle/account_job_test.go index 05c3f67b4..70922f4d7 100644 --- a/spindle/account_job_test.go +++ b/spindle/account_job_test.go @@ -7,16 +7,16 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" + "tangled.org/core/jetstream" "tangled.org/core/spindle/secrets" ) -func takedownEvent(did syntax.DID) *jmodels.Event { +func takedownEvent(did syntax.DID) *jetstream.Event { status := "takendown" - return &jmodels.Event{ + return &jetstream.Event{ Did: did.String(), - Kind: jmodels.EventKindAccount, + Kind: jetstream.EventKindAccount, Account: &comatproto.SyncSubscribeRepos_Account{Did: did.String(), Active: false, Status: &status}, } } diff --git a/spindle/backfill_later_test.go b/spindle/backfill_later_test.go index 29259ba57..dcb9de72c 100644 --- a/spindle/backfill_later_test.go +++ b/spindle/backfill_later_test.go @@ -6,7 +6,8 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" - jmodels "github.com/bluesky-social/jetstream/pkg/models" + + "tangled.org/core/jetstream" ) func TestCatchUpLaterReturnsAtOnceAndStillSavesTheDids(t *testing.T) { @@ -25,9 +26,9 @@ func TestAnAccountJobFinishesItsTicketOnceTheEventIsDone(t *testing.T) { env := newEnsureEnv(t) status := "takendown" var log ticketLog - e := &jmodels.Event{ + e := &jetstream.Event{ Did: "did:plc:nobody", - Kind: jmodels.EventKindAccount, + Kind: jetstream.EventKindAccount, Account: &comatproto.SyncSubscribeRepos_Account{Did: "did:plc:nobody", Active: false, Status: &status}, } diff --git a/spindle/collaborator_marks_test.go b/spindle/collaborator_marks_test.go index add185dc2..000fb035b 100644 --- a/spindle/collaborator_marks_test.go +++ b/spindle/collaborator_marks_test.go @@ -8,9 +8,9 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" ) func (e *ensureEnv) markCollaboratorEvent(t *testing.T, who syntax.DID, op string) { @@ -36,9 +36,9 @@ func TestAPastTheCapRepoStillTakesAGrantAndARevokeOneRecordAtATime(t *testing.T) } env.revoke(testBob) - env.markCollaboratorEvent(t, testBob, models.CommitOperationDelete) + env.markCollaboratorEvent(t, testBob, jetstream.CommitOperationDelete) env.grant(testCarol) - env.markCollaboratorEvent(t, testCarol, models.CommitOperationCreate) + env.markCollaboratorEvent(t, testCarol, jetstream.CommitOperationCreate) before := env.knot.count() env.s.runRepoJob(context.Background(), testRepoDid) @@ -62,7 +62,7 @@ func TestACollaboratorEventOnASyncableRepoIsCoveredByTheFullSync(t *testing.T) { env.ensure(t) env.grant(testCarol) - env.markCollaboratorEvent(t, testCarol, models.CommitOperationCreate) + env.markCollaboratorEvent(t, testCarol, jetstream.CommitOperationCreate) before := env.knot.count() env.s.runRepoJob(context.Background(), testRepoDid) @@ -80,7 +80,7 @@ func TestACollaboratorMarkAppliedAfterAWipeDoesNotGrantAccessToTheWipedRepo(t *t env.serve("repo") env.ensure(t) env.grant(testBob) - env.markCollaboratorEvent(t, testBob, models.CommitOperationCreate) + env.markCollaboratorEvent(t, testBob, jetstream.CommitOperationCreate) // the wipe lands during the mark's knot lookup, fired by the first collaborator query var wiped sync.Once diff --git a/spindle/fakes_test.go b/spindle/fakes_test.go index 6963d82d2..13963b464 100644 --- a/spindle/fakes_test.go +++ b/spindle/fakes_test.go @@ -20,11 +20,11 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "github.com/gorilla/websocket" "github.com/klauspost/compress/zstd" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" "tangled.org/core/rbac" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" @@ -68,7 +68,7 @@ func (j *fakeJetstream) url() string { } // 5s apart, so the 1s rewind on resume only replays the last event -func (j *fakeJetstream) add(e jmodels.Event) int64 { +func (j *fakeJetstream) add(e jetstream.Event) int64 { j.mu.Lock() defer j.mu.Unlock() j.last += 5_000_000 @@ -83,7 +83,7 @@ func (j *fakeJetstream) add(e jmodels.Event) int64 { } func (j *fakeJetstream) commit(did syntax.DID, collection, rkey, op string, record any) int64 { - commit := &jmodels.Commit{Rev: "rev", Operation: op, Collection: collection, RKey: rkey, CID: "bafy"} + commit := &jetstream.Commit{Rev: "rev", Operation: op, Collection: collection, RKey: rkey, CID: "bafy"} if record != nil { raw, err := json.Marshal(record) if err != nil { @@ -91,12 +91,12 @@ func (j *fakeJetstream) commit(did syntax.DID, collection, rkey, op string, reco } commit.Record = raw } - return j.add(jmodels.Event{Did: did.String(), Kind: jmodels.EventKindCommit, Commit: commit}) + return j.add(jetstream.Event{Did: did.String(), Kind: jetstream.EventKindCommit, Commit: commit}) } // the ingester ignores identity events, but the cursor still moves past them func (j *fakeJetstream) filler() int64 { - return j.add(jmodels.Event{Did: testOwner.String(), Kind: jmodels.EventKindIdentity}) + return j.add(jetstream.Event{Did: testOwner.String(), Kind: jetstream.EventKindIdentity}) } // like retention, a resume can't go back before this point @@ -511,12 +511,12 @@ func (w *world) emitDeclaration(name string, repo syntax.DID) int64 { } func (j *fakeJetstream) manifest(repo syntax.DID, value any) int64 { - return j.commit(repo, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jmodels.CommitOperationUpdate, value) + return j.commit(repo, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jetstream.CommitOperationUpdate, value) } func (j *fakeJetstream) collaborator(repo, who syntax.DID, op string) int64 { var record any - if op != jmodels.CommitOperationDelete { + if op != jetstream.CommitOperationDelete { record = map[string]string{"createdAt": "2026-01-01T00:00:00Z"} } return j.commit(repo, org_tangled.GraphCollaboratorNSID, who.String(), op, record) diff --git a/spindle/faults_test.go b/spindle/faults_test.go index 5aa6ecac0..ea4d566f3 100644 --- a/spindle/faults_test.go +++ b/spindle/faults_test.go @@ -10,17 +10,17 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" "tangled.org/core/rbac" "tangled.org/core/spindle/knotclient" "tangled.org/core/spindle/models" ) var ( - opCreate = jmodels.CommitOperationCreate - opDelete = jmodels.CommitOperationDelete + opCreate = jetstream.CommitOperationCreate + opDelete = jetstream.CommitOperationDelete ) // waits for the owner backfill, or a restart finds hydrant with work left diff --git a/spindle/ingest_shards.go b/spindle/ingest_shards.go index 68762c2d5..8b49c400a 100644 --- a/spindle/ingest_shards.go +++ b/spindle/ingest_shards.go @@ -10,9 +10,8 @@ import ( "sync" "time" - "github.com/bluesky-social/jetstream/pkg/models" - "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" ) const ( @@ -29,13 +28,13 @@ type ingestTicket interface { type ingestItem struct { ctx context.Context - e *models.Event + e *jetstream.Event ticket ingestTicket } // same repo always lands on the same shard, that's where ordering comes from type ingestShards struct { - apply func(context.Context, *models.Event) error + apply func(context.Context, *jetstream.Event) error queues [ingestShardCount]chan ingestItem wg sync.WaitGroup @@ -46,7 +45,7 @@ type ingestShards struct { quitOne sync.Once } -func newIngestShards(ctx context.Context, apply func(context.Context, *models.Event) error) *ingestShards { +func newIngestShards(ctx context.Context, apply func(context.Context, *jetstream.Event) error) *ingestShards { sh := &ingestShards{apply: apply, quit: make(chan struct{})} for i := range sh.queues { sh.queues[i] = make(chan ingestItem, ingestShardQueue) @@ -56,8 +55,8 @@ func newIngestShards(ctx context.Context, apply func(context.Context, *models.Ev } // declarations shard by the repo they name, which puts them behind its other events -func ingestShardKey(e *models.Event) string { - if e.Commit != nil && e.Commit.Collection == org_tangled.RepoDeclarationNSID && e.Commit.Operation != models.CommitOperationDelete { +func ingestShardKey(e *jetstream.Event) string { + if e.Commit != nil && e.Commit.Collection == org_tangled.RepoDeclarationNSID && e.Commit.Operation != jetstream.CommitOperationDelete { var declaration org_tangled.RepoDeclaration if json.Unmarshal(e.Commit.Record, &declaration) == nil && declaration.Repo != "" { return declaration.Repo @@ -70,7 +69,7 @@ func ingestShardOf(key string) int { return int(fnv32a(key) % ingestShardCount) } -func (sh *ingestShards) dispatch(ctx context.Context, e *models.Event, ticket ingestTicket) error { +func (sh *ingestShards) dispatch(ctx context.Context, e *jetstream.Event, ticket ingestTicket) error { sh.mu.RLock() defer sh.mu.RUnlock() if sh.stopped { diff --git a/spindle/ingest_shards_test.go b/spindle/ingest_shards_test.go index 7395c5b5d..bcda4091b 100644 --- a/spindle/ingest_shards_test.go +++ b/spindle/ingest_shards_test.go @@ -12,9 +12,9 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" ) type ticketLog struct { @@ -62,9 +62,9 @@ func ticketDone(log *ticketLog, name string) bool { return slices.Contains(done, name) } -func shardEvent(did, rkey string) *models.Event { - return &models.Event{Did: did, Kind: models.EventKindCommit, Commit: &models.Commit{ - Operation: models.CommitOperationCreate, Collection: org_tangled.GraphCollaboratorNSID, RKey: rkey, +func shardEvent(did, rkey string) *jetstream.Event { + return &jetstream.Event{Did: did, Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: org_tangled.GraphCollaboratorNSID, RKey: rkey, }} } @@ -103,7 +103,7 @@ func TestShardsKeepARepoInOrderWhileOtherReposGoOn(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var applied appliedLog gate := make(chan struct{}) - sh := newIngestShards(ctx, func(_ context.Context, e *models.Event) error { + sh := newIngestShards(ctx, func(_ context.Context, e *jetstream.Event) error { if e.Commit.RKey == "x1" { <-gate } @@ -147,7 +147,7 @@ func TestShardsKeepARepoInOrderWhileOtherReposGoOn(t *testing.T) { func TestShardsDropEveryTicketTheyDidNotFinishAtShutdown(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) started := make(chan struct{}) - sh := newIngestShards(ctx, func(ctx context.Context, e *models.Event) error { + sh := newIngestShards(ctx, func(ctx context.Context, e *jetstream.Event) error { if e.Commit.RKey == "x1" { close(started) <-ctx.Done() @@ -181,18 +181,18 @@ func TestShardsDropEveryTicketTheyDidNotFinishAtShutdown(t *testing.T) { func TestADeclarationQueuesBehindTheRepoItNames(t *testing.T) { repo := syntax.DID("did:plc:repo") record, _ := json.Marshal(org_tangled.RepoDeclaration{Repo: repo.String()}) - declaration := &models.Event{Did: "did:plc:owner", Kind: models.EventKindCommit, Commit: &models.Commit{ - Operation: models.CommitOperationCreate, Collection: org_tangled.RepoDeclarationNSID, RKey: "repo", Record: record, + declaration := &jetstream.Event{Did: "did:plc:owner", Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: org_tangled.RepoDeclarationNSID, RKey: "repo", Record: record, }} - manifest := &models.Event{Did: repo.String(), Kind: models.EventKindCommit, Commit: &models.Commit{ - Operation: models.CommitOperationUpdate, Collection: org_tangled.RepoManifestNSID, RKey: "self", + manifest := &jetstream.Event{Did: repo.String(), Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationUpdate, Collection: org_tangled.RepoManifestNSID, RKey: "self", }} if ingestShardOf(ingestShardKey(declaration)) != ingestShardOf(ingestShardKey(manifest)) { t.Fatal("a declaration and the manifest of the repo it names are on different shards") } - deleted := &models.Event{Did: "did:plc:owner", Kind: models.EventKindCommit, Commit: &models.Commit{ - Operation: models.CommitOperationDelete, Collection: org_tangled.RepoDeclarationNSID, RKey: "repo", + deleted := &jetstream.Event{Did: "did:plc:owner", Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationDelete, Collection: org_tangled.RepoDeclarationNSID, RKey: "repo", }} if got := ingestShardKey(deleted); got != "did:plc:owner" { t.Fatalf("a deleted declaration has no body, key = %q, want its owner", got) @@ -202,7 +202,7 @@ func TestADeclarationQueuesBehindTheRepoItNames(t *testing.T) { func TestAPanickingHandlerSkipsItsEventAndTheShardGoesOn(t *testing.T) { ctx := t.Context() var applied appliedLog - sh := newIngestShards(ctx, func(_ context.Context, e *models.Event) error { + sh := newIngestShards(ctx, func(_ context.Context, e *jetstream.Event) error { if e.Commit.RKey == "poison" { var m map[string]int m["boom"] = 1 @@ -226,7 +226,7 @@ func TestNoTicketIsLeftHeldWhenShutdownRacesABlockedDispatch(t *testing.T) { for round := range 100 { ctx, cancel := context.WithCancel(context.Background()) block := make(chan struct{}) - sh := newIngestShards(ctx, func(ctx context.Context, e *models.Event) error { + sh := newIngestShards(ctx, func(ctx context.Context, e *jetstream.Event) error { select { case <-block: case <-ctx.Done(): @@ -259,7 +259,7 @@ func TestAnEventThatFailsOnTheDbIsTriedAgainBeforeItIsGivenUp(t *testing.T) { ingestRetryDelay = time.Millisecond ctx := t.Context() var calls atomic.Int32 - sh := newIngestShards(ctx, func(context.Context, *models.Event) error { + sh := newIngestShards(ctx, func(context.Context, *jetstream.Event) error { if calls.Add(1) < 3 { return errors.New("database is locked") } @@ -279,7 +279,7 @@ func TestAnEventThatKeepsFailingLeavesTheCursorBehindItAndTheShardGoesOn(t *test ingestRetryDelay = time.Millisecond ctx := t.Context() var applied appliedLog - sh := newIngestShards(ctx, func(_ context.Context, e *models.Event) error { + sh := newIngestShards(ctx, func(_ context.Context, e *jetstream.Event) error { if e.Commit.RKey == "broken" { return errors.New("disk I/O error") } diff --git a/spindle/ingester.go b/spindle/ingester.go index dc418b6ef..73135eb08 100644 --- a/spindle/ingester.go +++ b/spindle/ingester.go @@ -11,7 +11,6 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/jetstream/pkg/models" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "tangled.org/core/api/org_tangled" @@ -22,31 +21,31 @@ import ( "tangled.org/core/tapc" ) -type Ingester func(ctx context.Context, e *models.Event) error +type Ingester func(ctx context.Context, e *jetstream.Event) error func (s *Spindle) ingest() Ingester { var shards *ingestShards var start sync.Once wipes := make(chan struct{}, accountJobSlots) - return func(ctx context.Context, e *models.Event) error { - if e.Kind != models.EventKindAccount && e.Kind != models.EventKindCommit { + return func(ctx context.Context, e *jetstream.Event) error { + if e.Kind != jetstream.EventKindAccount && e.Kind != jetstream.EventKindCommit { return nil } ticket := jetstream.Hold(ctx) if ticket == nil { err := s.ingestEvent(ctx, e) // ingestEvent already logged it and nothing retries out here - if err != nil && e.Kind == models.EventKindCommit && ctx.Err() == nil { + if err != nil && e.Kind == jetstream.EventKindCommit && ctx.Err() == nil { return nil } return err } // wipes do io, they skip the shards and hold the ticket until done - if e.Kind == models.EventKindAccount { + if e.Kind == jetstream.EventKindAccount { return s.startAccountJob(ctx, e, ticket, wipes) } start.Do(func() { - shards = newIngestShards(ctx, func(ctx context.Context, e *models.Event) error { + shards = newIngestShards(ctx, func(ctx context.Context, e *jetstream.Event) error { return s.ingestEvent(withMarkOnly(ctx), e) }) }) @@ -59,7 +58,7 @@ const accountJobSlots = 4 // wipes that run past this budget are left to the repo jobs var accountWipeBudget = 10 * time.Minute -func (s *Spindle) startAccountJob(ctx context.Context, e *models.Event, ticket ingestTicket, slots chan struct{}) error { +func (s *Spindle) startAccountJob(ctx context.Context, e *jetstream.Event, ticket ingestTicket, slots chan struct{}) error { select { case slots <- struct{}{}: case <-ctx.Done(): @@ -73,7 +72,7 @@ func (s *Spindle) startAccountJob(ctx context.Context, e *models.Event, ticket i return nil } -func (s *Spindle) ingestAccountJob(ctx context.Context, e *models.Event, ticket ingestTicket) { +func (s *Spindle) ingestAccountJob(ctx context.Context, e *jetstream.Event, ticket ingestTicket) { jobCtx, cancel := context.WithTimeout(ctx, accountWipeBudget) defer cancel() err := s.ingestEvent(jobCtx, e) @@ -84,11 +83,11 @@ func (s *Spindle) ingestAccountJob(ctx context.Context, e *models.Event, ticket finishTicket(ctx, ticket, err) } -func (s *Spindle) ingestEvent(ctx context.Context, e *models.Event) error { - if e.Kind == models.EventKindAccount { +func (s *Spindle) ingestEvent(ctx context.Context, e *jetstream.Event) error { + if e.Kind == jetstream.EventKindAccount { return s.ingestAccount(ctx, e) } - if e.Kind != models.EventKindCommit { + if e.Kind != jetstream.EventKindCommit { return nil } @@ -253,7 +252,7 @@ func (s *Spindle) onDeclaration(ctx context.Context, rec *tapc.RecordEventData) return s.refreshForEvent(ctx, rec, repoDid) } -func jetstreamToTapEvent(e *models.Event) (tapc.Event, bool) { +func jetstreamToTapEvent(e *jetstream.Event) (tapc.Event, bool) { if e.Commit == nil { return tapc.Event{}, false } @@ -263,11 +262,11 @@ func jetstreamToTapEvent(e *models.Event) (tapc.Event, bool) { } var action tapc.RecordAction switch e.Commit.Operation { - case models.CommitOperationCreate: + case jetstream.CommitOperationCreate: action = tapc.RecordCreateAction - case models.CommitOperationUpdate: + case jetstream.CommitOperationUpdate: action = tapc.RecordUpdateAction - case models.CommitOperationDelete: + case jetstream.CommitOperationDelete: action = tapc.RecordDeleteAction default: return tapc.Event{}, false @@ -287,7 +286,7 @@ func jetstreamToTapEvent(e *models.Event) (tapc.Event, bool) { }, true } -func (s *Spindle) ingestAccount(ctx context.Context, e *models.Event) error { +func (s *Spindle) ingestAccount(ctx context.Context, e *jetstream.Event) error { if e.Account == nil || e.Account.Active { return nil } diff --git a/spindle/ingester_test.go b/spindle/ingester_test.go index b726c7645..b469c2945 100644 --- a/spindle/ingester_test.go +++ b/spindle/ingester_test.go @@ -6,9 +6,9 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/tapc" @@ -181,17 +181,17 @@ func TestJetstreamToTapEventMarksRecordsLive(t *testing.T) { operation string action tapc.RecordAction }{ - {name: "create", operation: models.CommitOperationCreate, action: tapc.RecordCreateAction}, - {name: "update", operation: models.CommitOperationUpdate, action: tapc.RecordUpdateAction}, - {name: "delete", operation: models.CommitOperationDelete, action: tapc.RecordDeleteAction}, + {name: "create", operation: jetstream.CommitOperationCreate, action: tapc.RecordCreateAction}, + {name: "update", operation: jetstream.CommitOperationUpdate, action: tapc.RecordUpdateAction}, + {name: "delete", operation: jetstream.CommitOperationDelete, action: tapc.RecordDeleteAction}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - event, ok := jetstreamToTapEvent(&models.Event{ + event, ok := jetstreamToTapEvent(&jetstream.Event{ Did: "did:plc:jge3zxi7lgrfnvhzcgrimeo7", - Kind: models.EventKindCommit, - Commit: &models.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: tt.operation, Collection: org_tangled.RepoManifestNSID, RKey: "self", @@ -214,13 +214,13 @@ func TestJetstreamToTapEventMarksRecordsLive(t *testing.T) { } } -func manifestCommit(env *ensureEnv) *models.Event { +func manifestCommit(env *ensureEnv) *jetstream.Event { raw, _ := json.Marshal(org_tangled.RepoManifest{Spindle: ptr(env.spindleDid()), Declaration: &org_tangled.RepoManifest_Declaration{Owner: testOwner.String(), Name: "repo"}}) - return &models.Event{ + return &jetstream.Event{ Did: testRepoDid.String(), - Kind: models.EventKindCommit, - Commit: &models.Commit{ - Operation: models.CommitOperationCreate, + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: org_tangled.RepoManifestNSID, RKey: "self", Record: raw, @@ -300,8 +300,8 @@ func TestAMarkThatFailsOnTheDbIsReportedInsteadOfSwallowed(t *testing.T) { if _, err := env.s.db.Exec(`drop table record_retries`); err != nil { t.Fatal(err) } - event := &models.Event{Did: testRepoDid.String(), Kind: models.EventKindCommit, Commit: &models.Commit{ - Operation: models.CommitOperationCreate, Collection: org_tangled.TrackTicketNSID, RKey: "3kticket00001", Record: []byte("{}"), + event := &jetstream.Event{Did: testRepoDid.String(), Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{ + Operation: jetstream.CommitOperationCreate, Collection: org_tangled.TrackTicketNSID, RKey: "3kticket00001", Record: []byte("{}"), }} if err := env.s.ingestEvent(withMarkOnly(context.Background()), event); err == nil { diff --git a/spindle/permissions_test.go b/spindle/permissions_test.go index 7abe92865..a4ee2857e 100644 --- a/spindle/permissions_test.go +++ b/spindle/permissions_test.go @@ -7,9 +7,9 @@ import ( "testing" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" "tangled.org/core/rbac" "tangled.org/core/spindle/db" "tangled.org/core/spindle/knotclient" @@ -65,11 +65,11 @@ func (e *ensureEnv) wantNoCollaboratorRows(t *testing.T) { } } -func collaboratorCommit(repo syntax.DID, rkey syntax.DID, operation string) *models.Event { - return &models.Event{ +func collaboratorCommit(repo syntax.DID, rkey syntax.DID, operation string) *jetstream.Event { + return &jetstream.Event{ Did: repo.String(), - Kind: models.EventKindCommit, - Commit: &models.Commit{ + Kind: jetstream.EventKindCommit, + Commit: &jetstream.Commit{ Operation: operation, Collection: org_tangled.GraphCollaboratorNSID, RKey: rkey.String(), @@ -78,7 +78,7 @@ func collaboratorCommit(repo syntax.DID, rkey syntax.DID, operation string) *mod } } -func (e *ensureEnv) ingest(t *testing.T, event *models.Event) { +func (e *ensureEnv) ingest(t *testing.T, event *jetstream.Event) { t.Helper() if err := e.s.ingest()(context.Background(), event); err != nil { t.Fatal(err) @@ -105,7 +105,7 @@ func TestCollaboratorCreateEventForAGrantTheKnotLacksGrantsNothing(t *testing.T) env.serve("repo") env.ensure(t) - env.ingest(t, collaboratorCommit(testRepoDid, testBob, models.CommitOperationCreate)) + env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate)) env.wantNoPushFor(t, testBob) env.wantPush(t, testOwner, true) @@ -117,7 +117,7 @@ func TestCollaboratorDeleteEventForAGrantTheKnotKeepsRemovesNothing(t *testing.T env.serve("repo", testBob) env.ensure(t) - env.ingest(t, collaboratorCommit(testRepoDid, testBob, models.CommitOperationDelete)) + env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete)) env.wantPush(t, testBob, true) if rows := env.collaboratorRows(t); len(rows) != 1 { @@ -132,11 +132,11 @@ func TestCollaboratorEventsFromNonRepoDidsGrantNothing(t *testing.T) { before := env.knot.count() // anyone can write this record into their own repo, only the served repo's did counts - events := map[string]*models.Event{ - "naming themselves": collaboratorCommit(testBob, testBob, models.CommitOperationCreate), - "naming the served repo": collaboratorCommit(testBob, testRepoDid, models.CommitOperationCreate), - "from the owner's pds": collaboratorCommit(testOwner, testBob, models.CommitOperationCreate), - "naming someone else": collaboratorCommit(testCarol, testBob, models.CommitOperationUpdate), + events := map[string]*jetstream.Event{ + "naming themselves": collaboratorCommit(testBob, testBob, jetstream.CommitOperationCreate), + "naming the served repo": collaboratorCommit(testBob, testRepoDid, jetstream.CommitOperationCreate), + "from the owner's pds": collaboratorCommit(testOwner, testBob, jetstream.CommitOperationCreate), + "naming someone else": collaboratorCommit(testCarol, testBob, jetstream.CommitOperationUpdate), } for name, event := range events { t.Run(name, func(t *testing.T) { @@ -157,12 +157,12 @@ func TestCollaboratorGrantAndRemovalReachPushAccessThroughTheIngester(t *testing env.wantNoPushFor(t, testBob) env.grant(testBob) - env.ingest(t, collaboratorCommit(testRepoDid, testBob, models.CommitOperationCreate)) + env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate)) env.wantPush(t, testBob, true) env.wantNoPushFor(t, testCarol) env.revoke(testBob) - env.ingest(t, collaboratorCommit(testRepoDid, testBob, models.CommitOperationDelete)) + env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete)) env.wantNoPushFor(t, testBob) env.wantNoCollaboratorRows(t) env.wantPush(t, testOwner, true) @@ -263,7 +263,7 @@ func TestCollaboratorEventWithAnOversizedListGoesInTheRetrySet(t *testing.T) { env.grant(syntax.DID(fmt.Sprintf("did:plc:flood%d", i))) } - env.ingest(t, collaboratorCommit(testRepoDid, "did:plc:flood0", models.CommitOperationCreate)) + env.ingest(t, collaboratorCommit(testRepoDid, "did:plc:flood0", jetstream.CommitOperationCreate)) if attempts, err := env.s.db.RepoRetryAttempts(testRepoDid); err != nil || attempts != 1 { t.Fatalf("retry attempts = %d, %v, want the repo in the retry set", attempts, err) diff --git a/spindle/server.go b/spindle/server.go index f4c51f144..9ff71bb0a 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -291,7 +291,7 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m org_tangled.ReviewPatchNSID, } // the trailing false is logDids, on would log a line for every did added - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, false) + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, collections, nil, log.SubLogger(logger, "jetstream"), d, true, false) if err != nil { return nil, fmt.Errorf("failed to setup jetstream client: %w", err) } diff --git a/spindle/torture_ingest_test.go b/spindle/torture_ingest_test.go index d6da114bf..3e1d261fc 100644 --- a/spindle/torture_ingest_test.go +++ b/spindle/torture_ingest_test.go @@ -13,9 +13,9 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" "tangled.org/core/rbac" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" @@ -46,12 +46,12 @@ func ingPutCollaborator(repo *tortureRepo, who syntax.DID) { func (w *tortureWorld) grantCollaborator(repo *tortureRepo, who syntax.DID) int64 { ingPutCollaborator(repo, who) - return w.emitCollaborator(repo, who, jmodels.CommitOperationCreate) + return w.emitCollaborator(repo, who, jetstream.CommitOperationCreate) } func (w *tortureWorld) revokeCollaborator(repo *tortureRepo, who syntax.DID) int64 { repo.knot.erase(repo.did, org_tangled.GraphCollaboratorNSID, who.String()) - return w.emitCollaborator(repo, who, jmodels.CommitOperationDelete) + return w.emitCollaborator(repo, who, jetstream.CommitOperationDelete) } func ingXrpc(knots []*tortureKnot) int64 { @@ -179,7 +179,7 @@ func init() { did := syntax.DID(fmt.Sprintf("did:plc:noknot%05d", i)) owner := syntax.DID(fmt.Sprintf("did:plc:nkowner%05d", i)) w.ids.Insert(identity.Identity{DID: did}) - last = w.js.commit(did, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jmodels.CommitOperationUpdate, manifestRecord(owner, "x", w.spindleDid())) + last = w.js.commit(did, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jetstream.CommitOperationUpdate, manifestRecord(owner, "x", w.spindleDid())) } // the owner is allowlisted, which leaves the missing knot as the only thing stopping these @@ -190,7 +190,7 @@ func init() { r.t.Fatal(err) } w.ids.Insert(identity.Identity{DID: did}) - last = w.js.commit(did, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jmodels.CommitOperationUpdate, manifestRecord(owner, "x", w.spindleDid())) + last = w.js.commit(did, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, jetstream.CommitOperationUpdate, manifestRecord(owner, "x", w.spindleDid())) } within := time.Duration(slow)*2*delay + time.Minute diff --git a/spindle/torture_soak_test.go b/spindle/torture_soak_test.go index c30f38758..77ba87057 100644 --- a/spindle/torture_soak_test.go +++ b/spindle/torture_soak_test.go @@ -9,9 +9,9 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/api/org_tangled" + "tangled.org/core/jetstream" ) func init() { @@ -71,9 +71,9 @@ func init() { } repo := rs[i%len(rs)] who := syntax.DID(fmt.Sprintf("did:plc:collab%02d", i%26)) - op := jmodels.CommitOperationCreate + op := jetstream.CommitOperationCreate if (i/len(rs))%2 == 1 { - op = jmodels.CommitOperationDelete + op = jetstream.CommitOperationDelete repo.knot.erase(repo.did, org_tangled.GraphCollaboratorNSID, who.String()) } else { repo.knot.put(repo.did, org_tangled.GraphCollaboratorNSID, who.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"}) diff --git a/spindle/wipe_test.go b/spindle/wipe_test.go index b82dc296c..f3f3ad0f1 100644 --- a/spindle/wipe_test.go +++ b/spindle/wipe_test.go @@ -11,7 +11,6 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - jmodels "github.com/bluesky-social/jetstream/pkg/models" "tangled.org/core/jetstream" "tangled.org/core/log" "tangled.org/core/notifier" @@ -51,7 +50,7 @@ func newWipeTestSpindle(t *testing.T) *Spindle { t.Fatal(err) } - jc, err := jetstream.NewJetstreamClient("", "test", nil, nil, log.New("test"), d, false, false) + jc, err := jetstream.NewJetstreamClient("", nil, nil, log.New("test"), d, false, false) if err != nil { t.Fatal(err) } @@ -324,8 +323,8 @@ func TestIngestAccountTakedownWipes(t *testing.T) { seedWipeRepo(t, s, "did:plc:repo1", owner) status := "takendown" - err := s.ingest()(context.Background(), &jmodels.Event{ - Kind: jmodels.EventKindAccount, + err := s.ingest()(context.Background(), &jetstream.Event{ + Kind: jetstream.EventKindAccount, Did: owner.String(), Account: &comatproto.SyncSubscribeRepos_Account{Did: owner.String(), Active: false, Status: &status}, }) @@ -342,8 +341,8 @@ func TestIngestAccountTakedownWipes(t *testing.T) { } seedWipeRepo(t, s, "did:plc:repo1", owner) - err = s.ingest()(context.Background(), &jmodels.Event{ - Kind: jmodels.EventKindAccount, + err = s.ingest()(context.Background(), &jetstream.Event{ + Kind: jetstream.EventKindAccount, Did: owner.String(), Account: &comatproto.SyncSubscribeRepos_Account{Did: owner.String(), Active: true}, })