Skip to content

Commit 97918b3

Browse files
committed
fix(graph): nullable latestCloudEvent/latestIndex, null on no-rows
A source with no matching event made the non-null latestCloudEvent field error ("no cloud events found"), and GraphQL null-propagation bubbled the null to the root `data` — tanking every sibling alias in an aliased multi-source query. dimo-app-backend's getName batches per-source nickname lookups this way, so a vehicle whose owner had a nickname but whose dev-license source did not resolved to no name at all (e.g. token 22892 "Loux Transport"). Make latestCloudEvent and latestIndex nullable and return (nil, nil) on sql.ErrNoRows (mirroring the cloudEvents/indexes list resolvers), so an empty source resolves to null without poisoning its siblings. Regenerate gqlgen. Add a ClickHouse-backed regression test over the aliased path.
1 parent 74387dd commit 97918b3

5 files changed

Lines changed: 188 additions & 37 deletions

File tree

internal/graph/base.resolvers.go

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/graph/generated.go

Lines changed: 32 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/graph/mcp_tools_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package graph
2+
3+
import (
4+
"context"
5+
"math/big"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/99designs/gqlgen/client"
11+
"github.com/99designs/gqlgen/graphql/handler"
12+
"github.com/99designs/gqlgen/graphql/handler/transport"
13+
chconfig "github.com/DIMO-Network/clickhouse-infra/pkg/connect/config"
14+
"github.com/DIMO-Network/clickhouse-infra/pkg/container"
15+
"github.com/DIMO-Network/cloudevent"
16+
chindexer "github.com/DIMO-Network/cloudevent/clickhouse"
17+
"github.com/DIMO-Network/cloudevent/clickhouse/migrations"
18+
"github.com/DIMO-Network/fetch-api/pkg/eventrepo"
19+
"github.com/DIMO-Network/token-exchange-api/pkg/tokenclaims"
20+
"github.com/ethereum/go-ethereum/common"
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
// TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings is the regression
26+
// test for the nickname-summary bug: getName batches per-source `latestCloudEvent`
27+
// lookups as GraphQL aliases in one request. When `latestCloudEvent` (and
28+
// `latestIndex`) were non-nullable, a source with no matching event returned an
29+
// error which GraphQL null-propagated up to the root `data`, wiping every
30+
// sibling alias that DID resolve — so a vehicle whose owner had a nickname but
31+
// whose dev-license source did not returned no name at all.
32+
//
33+
// The fields are now nullable and the resolvers return null (not an error) on
34+
// no-rows, so one empty source must leave its siblings intact. Exercised against
35+
// `latestIndex` because it needs only ClickHouse (no blob buckets), but the fix
36+
// and codepath are identical for `latestCloudEvent`.
37+
func TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings(t *testing.T) {
38+
ctx := context.Background()
39+
40+
ch, err := container.CreateClickHouseContainer(ctx, chconfig.Settings{User: "default", Database: "dimo"})
41+
require.NoError(t, err)
42+
t.Cleanup(func() { ch.Terminate(context.Background()) })
43+
44+
chDB, err := ch.GetClickhouseAsDB()
45+
require.NoError(t, err)
46+
require.NoError(t, migrations.RunGoose(ctx, []string{"up"}, chDB))
47+
48+
conn, err := ch.GetClickHouseAsConn()
49+
require.NoError(t, err)
50+
51+
did := cloudevent.ERC721DID{
52+
ChainID: 137,
53+
ContractAddress: common.HexToAddress("0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF"),
54+
TokenID: big.NewInt(22892),
55+
}.String()
56+
57+
const (
58+
nickType = "dimo.document.nickname"
59+
ownerSource = "0x3Afb7b82D912743F8Fd15f67a9b2095e9d5AD576" // has an event
60+
devSource = "0x299671D2b32ED62Cc61ce65D8f2b9e4f78486B37" // no event
61+
)
62+
63+
// Only the owner source has a nickname event.
64+
ownerEvent := &cloudevent.CloudEventHeader{
65+
ID: "owner-nickname-evt",
66+
Source: ownerSource,
67+
Subject: did,
68+
Type: nickType,
69+
Time: time.Now(),
70+
DataVersion: "nickname/v1.0.0",
71+
}
72+
require.NoError(t, conn.Exec(ctx, chindexer.InsertStmt, chindexer.CloudEventToSlice(ownerEvent)...))
73+
74+
svc := eventrepo.New(conn, nil, nil, "", "")
75+
es := NewExecutableSchema(Config{Resolvers: &Resolver{EventService: svc}})
76+
srv := handler.New(es)
77+
srv.AddTransport(transport.Options{})
78+
srv.AddTransport(transport.POST{})
79+
80+
gql := client.New(injectClaims(srv, did))
81+
82+
const query = `
83+
query($did: String!, $owner: CloudEventFilter, $dev: CloudEventFilter) {
84+
owner: latestIndex(did: $did, filter: $owner) { header { source type } }
85+
dev: latestIndex(did: $did, filter: $dev) { header { source } }
86+
}`
87+
88+
var resp struct {
89+
Owner *struct {
90+
Header struct {
91+
Source string
92+
Type string
93+
}
94+
}
95+
Dev *struct {
96+
Header struct{ Source string }
97+
}
98+
}
99+
100+
err = gql.Post(
101+
query,
102+
&resp,
103+
client.Var("did", did),
104+
client.Var("owner", map[string]any{"source": ownerSource, "type": nickType}),
105+
client.Var("dev", map[string]any{"source": devSource, "type": nickType}),
106+
)
107+
108+
// Before the fix: the empty `dev` alias erred on a non-null field, the null
109+
// propagated to root `data`, and this Post returned a GraphQL error with no
110+
// data — the owner nickname was lost.
111+
require.NoError(t, err, "empty source must not tank sibling aliases")
112+
require.NotNil(t, resp.Owner, "owner's event must survive a sibling empty source")
113+
assert.Equal(t, ownerSource, resp.Owner.Header.Source)
114+
assert.Equal(t, nickType, resp.Owner.Header.Type)
115+
assert.Nil(t, resp.Dev, "a source with no event must resolve to null, not error")
116+
}
117+
118+
// injectClaims wraps a handler with the raw-data token the resolvers require,
119+
// scoped to `asset` (the DID under test) so requireSubjectOptsByDID admits it.
120+
func injectClaims(next http.Handler, asset string) http.Handler {
121+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+
tok := &tokenclaims.Token{
123+
CustomClaims: tokenclaims.CustomClaims{
124+
Asset: asset,
125+
Permissions: []string{tokenclaims.PermissionGetRawData},
126+
},
127+
}
128+
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ClaimsContextKey{}, tok)))
129+
})
130+
}

schema/base.graphqls

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ The root query type for the Fetch API GraphQL schema. ERC721 DID (e.g. did:eth:c
4141
"""
4242
type Query {
4343
"""
44-
Latest cloud event index matching filters. Tombstoned attestations are
45-
hidden from the result by default; pass includeDeleted: true to disable
46-
tombstone suppression.
44+
Latest cloud event index matching filters, or null when none match.
45+
Nullable so an aliased multi-source query with an empty source resolves to
46+
null instead of erroring and null-propagating over its siblings. Tombstoned
47+
attestations are hidden from the result by default; pass includeDeleted:
48+
true to disable tombstone suppression.
4749
"""
48-
latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex!
50+
latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex
4951
@mcpTool(
5052
name: "get_latest_index"
5153
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 {
6567
)
6668

6769
"""
68-
Latest full cloud event. Tombstoned attestations are hidden from the
69-
result by default; pass includeDeleted: true to disable tombstone
70-
suppression.
70+
Latest full cloud event, or null when the subject has no matching event.
71+
Nullable so that, in an aliased multi-source query, a source with no event
72+
resolves to null instead of erroring and null-propagating over its siblings.
73+
Tombstoned attestations are hidden from the result by default; pass
74+
includeDeleted: true to disable tombstone suppression.
7175
"""
72-
latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent!
76+
latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent
7377
@mcpTool(
7478
name: "get_latest_cloud_event"
7579
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."

0 commit comments

Comments
 (0)