diff --git a/internal/graph/base.resolvers.go b/internal/graph/base.resolvers.go index 54a770f..1ba4184 100644 --- a/internal/graph/base.resolvers.go +++ b/internal/graph/base.resolvers.go @@ -49,6 +49,12 @@ func (r *queryResolver) LatestIndex(ctx context.Context, did string, filter *mod } idx, err := r.EventService.GetLatestIndexAdvanced(ctx, opts, resolveIncludeDeleted(includeDeleted)) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // No matching index is a normal empty result, not a failure. The + // field is nullable, so return null rather than erroring (which + // would null-propagate and tank sibling aliases in a batched query). + return nil, nil + } return nil, err } return indexToModel(idx), nil @@ -82,6 +88,13 @@ func (r *queryResolver) LatestCloudEvent(ctx context.Context, did string, filter } idx, err := r.EventService.GetLatestIndexAdvanced(ctx, opts, resolveIncludeDeleted(includeDeleted)) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // No matching event is a normal empty result, not a failure. The + // field is nullable, so return null: erroring here would + // null-propagate through the schema and, in an aliased multi-source + // query, tank sibling aliases that did resolve. + return nil, nil + } return nil, err } if idx.Data.DataIndexKey != "" { diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 19b583e..200eec1 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -420,11 +420,13 @@ The root query type for the Fetch API GraphQL schema. ERC721 DID (e.g. did:eth:c """ type Query { """ - Latest cloud event index matching filters. Tombstoned attestations are - hidden from the result by default; pass includeDeleted: true to disable - tombstone suppression. + Latest cloud event index matching filters, or null when none match. + Nullable so an aliased multi-source query with an empty source resolves to + null instead of erroring and null-propagating over its siblings. Tombstoned + attestations are hidden from the result by default; pass includeDeleted: + true to disable tombstone suppression. """ - latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex! + latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex @mcpTool( name: "get_latest_index" description: "Get the latest CloudEvent index entry (header + storage key) for a DID-scoped subject, optionally filtered by event type, source, producer, or time range. Returns metadata only — use fetch_get_latest_cloud_event for the full payload." @@ -444,11 +446,13 @@ type Query { ) """ - Latest full cloud event. Tombstoned attestations are hidden from the - result by default; pass includeDeleted: true to disable tombstone - suppression. + Latest full cloud event, or null when the subject has no matching event. + Nullable so that, in an aliased multi-source query, a source with no event + resolves to null instead of erroring and null-propagating over its siblings. + Tombstoned attestations are hidden from the result by default; pass + includeDeleted: true to disable tombstone suppression. """ - latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent! + latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent @mcpTool( name: "get_latest_cloud_event" description: "Get the latest full CloudEvent (header + JSON payload) for a DID-scoped subject, optionally filtered. Returns dataUrl (presigned S3 link) for large binary payloads instead of inlining them." @@ -1445,9 +1449,9 @@ func (ec *executionContext) _Query_latestIndex(ctx context.Context, field graphq return ec.Resolvers.Query().LatestIndex(ctx, fc.Args["did"].(string), fc.Args["filter"].(*model.CloudEventFilter), fc.Args["includeDeleted"].(*bool)) }, nil, - ec.marshalNCloudEventIndex2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventIndex, - true, + ec.marshalOCloudEventIndex2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventIndex, true, + false, ) } @@ -1539,9 +1543,9 @@ func (ec *executionContext) _Query_latestCloudEvent(ctx context.Context, field g return ec.Resolvers.Query().LatestCloudEvent(ctx, fc.Args["did"].(string), fc.Args["filter"].(*model.CloudEventFilter), fc.Args["includeDeleted"].(*bool)) }, nil, - ec.marshalNCloudEvent2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚐCloudEventWrapper, - true, + ec.marshalOCloudEvent2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚐCloudEventWrapper, true, + false, ) } @@ -3664,16 +3668,13 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr case "latestIndex": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() res = ec._Query_latestIndex(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -3708,16 +3709,13 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr case "latestCloudEvent": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() res = ec._Query_latestCloudEvent(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -4153,10 +4151,6 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } -func (ec *executionContext) marshalNCloudEvent2githubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚐCloudEventWrapper(ctx context.Context, sel ast.SelectionSet, v CloudEventWrapper) graphql.Marshaler { - return ec._CloudEvent(ctx, sel, &v) -} - func (ec *executionContext) marshalNCloudEvent2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚐCloudEventWrapperᚄ(ctx context.Context, sel ast.SelectionSet, v []*CloudEventWrapper) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) @@ -4197,10 +4191,6 @@ func (ec *executionContext) marshalNCloudEventHeader2ᚖgithubᚗcomᚋDIMOᚑNe return ec._CloudEventHeader(ctx, sel, v) } -func (ec *executionContext) marshalNCloudEventIndex2githubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventIndex(ctx context.Context, sel ast.SelectionSet, v model.CloudEventIndex) graphql.Marshaler { - return ec._CloudEventIndex(ctx, sel, &v) -} - func (ec *executionContext) marshalNCloudEventIndex2ᚕᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventIndexᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.CloudEventIndex) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) @@ -4502,6 +4492,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return res } +func (ec *executionContext) marshalOCloudEvent2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚐCloudEventWrapper(ctx context.Context, sel ast.SelectionSet, v *CloudEventWrapper) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._CloudEvent(ctx, sel, v) +} + func (ec *executionContext) unmarshalOCloudEventFilter2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventFilter(ctx context.Context, v any) (*model.CloudEventFilter, error) { if v == nil { return nil, nil @@ -4510,6 +4507,13 @@ func (ec *executionContext) unmarshalOCloudEventFilter2ᚖgithubᚗcomᚋDIMOᚑ return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalOCloudEventIndex2ᚖgithubᚗcomᚋDIMOᚑNetworkᚋfetchᚑapiᚋinternalᚋgraphᚋmodelᚐCloudEventIndex(ctx context.Context, sel ast.SelectionSet, v *model.CloudEventIndex) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._CloudEventIndex(ctx, sel, v) +} + func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v any) (*int, error) { if v == nil { return nil, nil diff --git a/internal/graph/mcp_tools_gen.go b/internal/graph/mcp_tools_gen.go index 4c75ec4..4d28bf0 100644 --- a/internal/graph/mcp_tools_gen.go +++ b/internal/graph/mcp_tools_gen.go @@ -93,4 +93,4 @@ var MCPTools = []mcpserver.ToolDefinition{ }, } -var CondensedSchema = "scalar JSON # Arbitrary JSON value; serialized as raw JSON (object/array), not an escaped string.\nscalar Time # A point in time, encoded per RFC-3339.\n\ntype Query {\n \"Latest cloud event index matching filters. Tombstoned attestations are hidden from the result by default; pass includeDeleted: true to disable tombstone suppression.\"\n latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex!\n\n indexes(did: String!, limit: Int = 10, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEventIndex!]!\n \"Latest full cloud event. Tombstoned attestations are hidden from the result by default; pass includeDeleted: true to disable tombstone suppression.\"\n latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent!\n\n cloudEvents(did: String!, limit: Int = 10, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEvent!]!\n availableCloudEventTypes(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEventTypeSummary!]!\n}\n\ntype CloudEvent { header: CloudEventHeader!, data: JSON, dataBase64: String, dataUrl: String }\n\ninput CloudEventFilter { id: String, type: String, types: [String!], dataversion: String, source: String, producer: String, before: Time, after: Time }\n\ntype CloudEventHeader { specversion: String!, type: String!, source: String!, subject: String!, id: String!, time: Time!, datacontenttype: String, dataschema: String, dataversion: String, producer: String!, signature: String, raweventid: String, tags: [String!]! }\n\ntype CloudEventIndex { header: CloudEventHeader!, indexKey: String! }\n\ntype CloudEventTypeSummary { type: String!, count: Int!, firstSeen: Time!, lastSeen: Time! }\n" +var CondensedSchema = "scalar JSON # Arbitrary JSON value; serialized as raw JSON (object/array), not an escaped string.\nscalar Time # A point in time, encoded per RFC-3339.\n\ntype Query {\n \"\"\"\n Latest cloud event index matching filters, or null when none match. Nullable so\n an aliased multi-source query with an empty source resolves to null instead of\n erroring and null-propagating over its siblings. Tombstoned attestations are\n hidden from the result by default; pass includeDeleted: true to disable\n tombstone suppression.\n \"\"\"\n latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex\n\n indexes(did: String!, limit: Int = 10, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEventIndex!]!\n \"\"\"\n Latest full cloud event, or null when the subject has no matching event.\n Nullable so that, in an aliased multi-source query, a source with no event\n resolves to null instead of erroring and null-propagating over its siblings.\n Tombstoned attestations are hidden from the result by default; pass\n includeDeleted: true to disable tombstone suppression.\n \"\"\"\n latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent\n\n cloudEvents(did: String!, limit: Int = 10, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEvent!]!\n availableCloudEventTypes(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): [CloudEventTypeSummary!]!\n}\n\ntype CloudEvent { header: CloudEventHeader!, data: JSON, dataBase64: String, dataUrl: String }\n\ninput CloudEventFilter { id: String, type: String, types: [String!], dataversion: String, source: String, producer: String, before: Time, after: Time }\n\ntype CloudEventHeader { specversion: String!, type: String!, source: String!, subject: String!, id: String!, time: Time!, datacontenttype: String, dataschema: String, dataversion: String, producer: String!, signature: String, raweventid: String, tags: [String!]! }\n\ntype CloudEventIndex { header: CloudEventHeader!, indexKey: String! }\n\ntype CloudEventTypeSummary { type: String!, count: Int!, firstSeen: Time!, lastSeen: Time! }\n" diff --git a/internal/graph/null_propagation_test.go b/internal/graph/null_propagation_test.go new file mode 100644 index 0000000..c579a2e --- /dev/null +++ b/internal/graph/null_propagation_test.go @@ -0,0 +1,130 @@ +package graph + +import ( + "context" + "math/big" + "net/http" + "testing" + "time" + + "github.com/99designs/gqlgen/client" + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/transport" + chconfig "github.com/DIMO-Network/clickhouse-infra/pkg/connect/config" + "github.com/DIMO-Network/clickhouse-infra/pkg/container" + "github.com/DIMO-Network/cloudevent" + chindexer "github.com/DIMO-Network/cloudevent/clickhouse" + "github.com/DIMO-Network/cloudevent/clickhouse/migrations" + "github.com/DIMO-Network/fetch-api/pkg/eventrepo" + "github.com/DIMO-Network/token-exchange-api/pkg/tokenclaims" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings is the regression +// test for the nickname-summary bug: getName batches per-source `latestCloudEvent` +// lookups as GraphQL aliases in one request. When `latestCloudEvent` (and +// `latestIndex`) were non-nullable, a source with no matching event returned an +// error which GraphQL null-propagated up to the root `data`, wiping every +// sibling alias that DID resolve — so a vehicle whose owner had a nickname but +// whose dev-license source did not returned no name at all. +// +// The fields are now nullable and the resolvers return null (not an error) on +// no-rows, so one empty source must leave its siblings intact. Exercised against +// `latestIndex` because it needs only ClickHouse (no blob buckets), but the fix +// and codepath are identical for `latestCloudEvent`. +func TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings(t *testing.T) { + ctx := context.Background() + + ch, err := container.CreateClickHouseContainer(ctx, chconfig.Settings{User: "default", Database: "dimo"}) + require.NoError(t, err) + t.Cleanup(func() { ch.Terminate(context.Background()) }) + + chDB, err := ch.GetClickhouseAsDB() + require.NoError(t, err) + require.NoError(t, migrations.RunGoose(ctx, []string{"up"}, chDB)) + + conn, err := ch.GetClickHouseAsConn() + require.NoError(t, err) + + did := cloudevent.ERC721DID{ + ChainID: 137, + ContractAddress: common.HexToAddress("0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF"), + TokenID: big.NewInt(22892), + }.String() + + const ( + nickType = "dimo.document.nickname" + ownerSource = "0x3Afb7b82D912743F8Fd15f67a9b2095e9d5AD576" // has an event + devSource = "0x299671D2b32ED62Cc61ce65D8f2b9e4f78486B37" // no event + ) + + // Only the owner source has a nickname event. + ownerEvent := &cloudevent.CloudEventHeader{ + ID: "owner-nickname-evt", + Source: ownerSource, + Subject: did, + Type: nickType, + Time: time.Now(), + DataVersion: "nickname/v1.0.0", + } + require.NoError(t, conn.Exec(ctx, chindexer.InsertStmt, chindexer.CloudEventToSlice(ownerEvent)...)) + + svc := eventrepo.New(conn, nil, nil, "", "") + es := NewExecutableSchema(Config{Resolvers: &Resolver{EventService: svc}}) + srv := handler.New(es) + srv.AddTransport(transport.Options{}) + srv.AddTransport(transport.POST{}) + + gql := client.New(injectClaims(srv, did)) + + const query = ` + query($did: String!, $owner: CloudEventFilter, $dev: CloudEventFilter) { + owner: latestIndex(did: $did, filter: $owner) { header { source type } } + dev: latestIndex(did: $did, filter: $dev) { header { source } } + }` + + var resp struct { + Owner *struct { + Header struct { + Source string + Type string + } + } + Dev *struct { + Header struct{ Source string } + } + } + + err = gql.Post( + query, + &resp, + client.Var("did", did), + client.Var("owner", map[string]any{"source": ownerSource, "type": nickType}), + client.Var("dev", map[string]any{"source": devSource, "type": nickType}), + ) + + // Before the fix: the empty `dev` alias erred on a non-null field, the null + // propagated to root `data`, and this Post returned a GraphQL error with no + // data — the owner nickname was lost. + require.NoError(t, err, "empty source must not tank sibling aliases") + require.NotNil(t, resp.Owner, "owner's event must survive a sibling empty source") + assert.Equal(t, ownerSource, resp.Owner.Header.Source) + assert.Equal(t, nickType, resp.Owner.Header.Type) + assert.Nil(t, resp.Dev, "a source with no event must resolve to null, not error") +} + +// injectClaims wraps a handler with the raw-data token the resolvers require, +// scoped to `asset` (the DID under test) so requireSubjectOptsByDID admits it. +func injectClaims(next http.Handler, asset string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok := &tokenclaims.Token{ + CustomClaims: tokenclaims.CustomClaims{ + Asset: asset, + Permissions: []string{tokenclaims.PermissionGetRawData}, + }, + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ClaimsContextKey{}, tok))) + }) +} diff --git a/schema/base.graphqls b/schema/base.graphqls index 9c9f973..df78e47 100644 --- a/schema/base.graphqls +++ b/schema/base.graphqls @@ -41,11 +41,13 @@ The root query type for the Fetch API GraphQL schema. ERC721 DID (e.g. did:eth:c """ type Query { """ - Latest cloud event index matching filters. Tombstoned attestations are - hidden from the result by default; pass includeDeleted: true to disable - tombstone suppression. + Latest cloud event index matching filters, or null when none match. + Nullable so an aliased multi-source query with an empty source resolves to + null instead of erroring and null-propagating over its siblings. Tombstoned + attestations are hidden from the result by default; pass includeDeleted: + true to disable tombstone suppression. """ - latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex! + latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex @mcpTool( name: "get_latest_index" description: "Get the latest CloudEvent index entry (header + storage key) for a DID-scoped subject, optionally filtered by event type, source, producer, or time range. Returns metadata only — use fetch_get_latest_cloud_event for the full payload." @@ -65,11 +67,13 @@ type Query { ) """ - Latest full cloud event. Tombstoned attestations are hidden from the - result by default; pass includeDeleted: true to disable tombstone - suppression. + Latest full cloud event, or null when the subject has no matching event. + Nullable so that, in an aliased multi-source query, a source with no event + resolves to null instead of erroring and null-propagating over its siblings. + Tombstoned attestations are hidden from the result by default; pass + includeDeleted: true to disable tombstone suppression. """ - latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent! + latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent @mcpTool( name: "get_latest_cloud_event" description: "Get the latest full CloudEvent (header + JSON payload) for a DID-scoped subject, optionally filtered. Returns dataUrl (presigned S3 link) for large binary payloads instead of inlining them."