From 7147cf189aadb8e99ae739fda6eefe6742137535 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Sun, 15 Mar 2026 19:09:39 +0100 Subject: [PATCH] Reorganize GraphQL docs: separate Go backend from frontend Relay client Move frontend Relay client documentation into relay.md and create new graphql.md dedicated to Go backend patterns. Covers gqlgen schema-first approach, @goModel/@goEnum/@goField directives, connection type patterns, and cursor pagination implementation. - relay.md: Frontend Relay client (environments, compiler, queries, fragments, mutations) - graphql.md: Go backend gqlgen (directives, connection types, pagination schema, keyset pagination) - AGENTS.md: Update documentation references Signed-off-by: Bryan Frimin --- AGENTS.md | 4 +- contrib/claude/graphql.md | 351 +++++++++++++++----------------------- contrib/claude/relay.md | 284 ++++++++++++++++++++++-------- 3 files changed, 351 insertions(+), 288 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c77fe7a3..fde2924fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ GraphQL and MCP codegen is triggered by `go generate`: ## Reference Documentation Detailed guides for specific subsystems live in `contrib/claude/`: -- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Relay cursor pagination (cursor format, keyset pagination, schema types) -- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Frontend Relay client (queries, fragments, mutations, pagination) +- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination) +- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination) - [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions - [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) - [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming) diff --git a/contrib/claude/graphql.md b/contrib/claude/graphql.md index 36d932a41..16a692631 100644 --- a/contrib/claude/graphql.md +++ b/contrib/claude/graphql.md @@ -1,249 +1,164 @@ -# GraphQL (Frontend Relay Client) +# GraphQL (Go Backend — gqlgen) -The console app uses [Relay](https://relay.dev/) as its GraphQL client. All GraphQL operations are defined inline with the `graphql` template tag from `relay-runtime` — there are no separate `.graphql` files on the frontend. +Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-written; Go types and resolvers are generated. -## Environments +## Connection types and `@goModel` -Two Relay environments connect to two separate GraphQL APIs: +**Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`. -| Environment | Endpoint | Purpose | -|-------------|----------|---------| -| `coreEnvironment` | `/api/console/v1/graphql` | Main application data | -| `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity | - -Configured in `apps/console/src/environments.ts`. Each has its own store with 1-minute query cache expiration. - -## Relay compiler - -Config lives in `apps/console/relay.config.json` with two projects (`core`, `iam`) mapped to different source directories and schemas. Generated files go into `__generated__/` directories. - -```sh -npm run relay # clean + compile -npm run relay-compile # compile only -``` - -Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → string`, `Duration → string`, `BigInt → number`, `EmailAddr → string`. - -## Colocated queries - -Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders: - -```tsx -// In route definition -{ - path: "vendors", - loader: loaderFromQueryLoader(({ organizationId }) => - loadQuery(coreEnvironment, vendorsQuery, { - organizationId, - snapshotId: null, - }), - ), - Component: withQueryRef( - lazy(() => import("#/pages/organizations/vendors/VendorsPage")), - ), +```graphql +type VendorConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [VendorEdge!]! + pageInfo: PageInfo! } -// In the component -export default function VendorsPage(props: Props) { - const data = usePreloadedQuery(vendorsQuery, props.queryRef); - // ... +type VendorEdge { + cursor: CursorKey! + node: Vendor! } ``` -- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }` -- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount +Without `@goModel`, gqlgen generates a default struct that lacks the custom fields (`ParentID`, `Resolver`, `Filter`) needed by the pagination resolvers. -For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`. +## Enums and `@goModel` / `@goEnum` -## Fragments +Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value: -Fragments colocate data requirements with the component that reads them: - -```tsx -const contactFragment = graphql` - fragment VendorContactsTabFragment_contact on VendorContact { - id - fullName - email - phone - role - createdAt - updatedAt - canUpdate: permission(action: "core:vendor-contact:update") - canDelete: permission(action: "core:vendor-contact:delete") - } -`; - -function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) { - const contact = useFragment(contactFragment, props.contactKey); - // ... +```graphql +enum VendorOrderField + @goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") { + CREATED_AT + @goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt") + NAME + @goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName") } ``` -### Refetchable fragments +## Schema directives -For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`: +| Directive | Target | Purpose | +|-----------|--------|---------| +| `@goModel(model: "...")` | `OBJECT`, `ENUM`, `INPUT_OBJECT`, `SCALAR`, `INTERFACE`, `UNION` | Map GraphQL type to existing Go type | +| `@goEnum(value: "...")` | `ENUM_VALUE` | Map enum value to Go constant | +| `@goField(forceResolver: true)` | `FIELD_DEFINITION` | Force a resolver function instead of struct field | +| `@goField(name: "...")` | `FIELD_DEFINITION`, `INPUT_FIELD_DEFINITION` | Override Go field name | +| `@goField(omittable: true)` | `INPUT_FIELD_DEFINITION` | Use `graphql.Omittable[T]` for distinguishing null vs absent | -```tsx -const vendorContactsFragment = graphql` - fragment VendorContactsTabFragment on Vendor - @refetchable(queryName: "VendorContactsListQuery") - @argumentDefinitions( - first: { type: "Int", defaultValue: 50 } - order: { type: "VendorContactOrder", defaultValue: null } - after: { type: "CursorKey", defaultValue: null } - before: { type: "CursorKey", defaultValue: null } - last: { type: "Int", defaultValue: null } - ) { - contacts( - first: $first - after: $after - last: $last - before: $before - orderBy: $order - ) @connection(key: "VendorContactsTabFragment_contacts") { - __id - edges { - node { - ...VendorContactsTabFragment_contact - } - } +## Cursor pagination schema types + +Every paginated field uses shared base types plus entity-specific types: + +```graphql +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: CursorKey + endCursor: CursorKey +} + +enum OrderDirection + @goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") { + ASC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionAsc") + DESC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionDesc") +} +``` + +Each entity defines: `enum XxxOrderField`, `input XxxOrder`, `type XxxConnection` (with `@goModel`), `type XxxEdge`. + +Connection fields on parent types use standard Relay arguments: + +```graphql +type Organization { + vendors( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: VendorOrder + filter: VendorFilter + ): VendorConnection! +} +``` + +## Go connection type pattern + +Each connection type lives in `types/*_connection.go` and follows this structure: + +```go +type ( + VendorOrderBy OrderBy[coredata.VendorOrderField] + + VendorConnection struct { + TotalCount int + Edges []*VendorEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID } - } -`; +) -const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor); -const connectionId = data.contacts.__id; -``` - -## Pagination - -Use `usePaginationFragment` for cursor-based Relay pagination: - -```tsx -const pagination = usePaginationFragment(paginatedVendorsFragment, data.node); -const vendors = pagination.data.vendors?.edges.map(edge => edge.node); -const connectionId = pagination.data.vendors.__id; -``` - -The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity. - -`SortableTable` is the standard component for rendering paginated, sortable lists — it receives `pagination` (with `loadNext`, `hasNext`, `isLoadingNext`) and a `refetch` callback for sorting. - -## Mutations - -### `useMutation` - -Direct Relay hook for simple cases: - -```tsx -const [mutate] = useMutation(deleteVendorMutation); -``` - -### `useMutationWithToasts` - -Custom wrapper that adds toast notifications on success/error: - -```tsx -const [createContact, isLoading] = useMutationWithToasts( - createContactMutation, - { - successMessage: __("Contact created successfully."), - errorMessage: __("Failed to create contact"), - }, -); - -await createContact({ - variables: { - input: { vendorId, ...cleanData }, - connections: [connectionId], - }, - onSuccess: () => { - dialogRef.current?.close(); - reset(); - }, -}); -``` - -### Store update directives - -Relay directives handle connection updates automatically — no manual store manipulation needed: - -```tsx -// Add new edge to the beginning of a connection -const createMutation = graphql` - mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) { - createVendor(input: $input) { - vendorEdge @prependEdge(connections: $connections) { - node { - id - name - } - } +func NewVendorConnection( + p *page.Page[*coredata.Vendor, coredata.VendorOrderField], + parentType any, + parentID gid.GID, +) *VendorConnection { + edges := make([]*VendorEdge, len(p.Data)) + for i, v := range p.Data { + edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field) } - } -`; -// Remove an edge from a connection -const deleteMutation = graphql` - mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) { - deleteVendor(input: $input) { - deletedVendorId @deleteEdge(connections: $connections) + return &VendorConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, } - } -`; +} -// Update in-place via fragment spread (no directive needed) -const updateMutation = graphql` - mutation UpdateContactMutation($input: UpdateVendorContactInput!) { - updateVendorContact(input: $input) { - vendorContact { - ...VendorContactsTabFragment_contact - } +func NewVendorEdge( + v *coredata.Vendor, + orderBy coredata.VendorOrderField, +) *VendorEdge { + return &VendorEdge{ + Cursor: v.CursorKey(orderBy), + Node: NewVendor(v), } - } -`; +} ``` -The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment. +## Cursor format -### `useConfirm` for destructive actions - -Destructive mutations (delete) are wrapped with a confirmation dialog: - -```tsx -const confirm = useConfirm(); - -return () => { - confirm( - () => - promisifyMutation(mutate)({ - variables: { - input: { vendorId: vendor.id! }, - connections: [connectionId], - }, - }), - { message: "Confirm deletion..." }, - ); -}; -``` - -## File organization - -GraphQL operations are colocated with the components that use them: +Cursors are opaque `CursorKey` scalars. Internally they encode as base64url(JSON): ``` -pages/organizations/vendors/ - VendorsPage.tsx # query + pagination fragment - tabs/ - VendorContactsTab.tsx # refetchable fragment + item fragment - VendorComplianceTab.tsx - dialogs/ - CreateContactDialog.tsx # create mutation - EditContactDialog.tsx # update mutation - -hooks/graph/ - VendorGraph.ts # shared queries, mutations, hooks +["", ] ``` -Shared queries and mutation hooks (used by multiple components) live in `hooks/graph/*.ts`. Component-specific operations are defined inline in the component file. +This enables keyset pagination — the database seeks directly to the right position instead of using OFFSET. + +## Keyset pagination + +The database query uses the cursor to build a WHERE clause: + +- `DESC`: rows where `(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id)` +- `ASC`: rows where `(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)` + +The query fetches `size + 1` (or `size + 2` with a cursor) rows to detect whether more pages exist. `NewPage` trims extra rows and sets `hasNextPage` / `hasPreviousPage`. + +For backward pagination (`last` / `before`), SQL sort direction is reversed, then the result slice is reversed back. + +Default page size is **25** when neither `first` nor `last` is provided. + +## Adding a new paginated field — checklist + +1. **Schema** — add `enum XxxOrderField` (with `@goModel`/`@goEnum`), `input XxxOrder`, `type XxxConnection` (with `@goModel` and `totalCount` using `@goField(forceResolver: true)`), `type XxxEdge`, and the connection field with Relay arguments on the parent type +2. **Coredata** — add `*_order_field.go` (with `Column()`, `IsValid()`, marshaling), `CursorKey(field)` method on the entity, and the `LoadAllBy*` query using cursor SQL fragments + `page.NewPage()` +3. **API types** — add `*_connection.go` with `OrderBy` alias, connection struct, `NewXxxConnection`, `NewXxxEdge` +4. **Resolver** — implement the resolver (authorize, build order, build cursor, call service, build connection) +5. **Codegen** — run `go generate` for the relevant API package diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md index 63b7f9a7a..fa21dc4aa 100644 --- a/contrib/claude/relay.md +++ b/contrib/claude/relay.md @@ -1,101 +1,249 @@ -# Relay Cursor Pagination +# Relay (Frontend GraphQL Client) -This project implements [Relay-style cursor pagination](https://relay.dev/graphql/connections.htm) for all list fields across GraphQL APIs. +The console app uses [Relay](https://relay.dev/) as its GraphQL client. All GraphQL operations are defined inline with the `graphql` template tag from `relay-runtime` — there are no separate `.graphql` files on the frontend. -## Schema types +## Environments -Every paginated field uses the same set of types: +Two Relay environments connect to two separate GraphQL APIs: -```graphql -type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: CursorKey - endCursor: CursorKey +| Environment | Endpoint | Purpose | +|-------------|----------|---------| +| `coreEnvironment` | `/api/console/v1/graphql` | Main application data | +| `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity | + +Configured in `apps/console/src/environments.ts`. Each has its own store with 1-minute query cache expiration. + +## Relay compiler + +Config lives in `apps/console/relay.config.json` with two projects (`core`, `iam`) mapped to different source directories and schemas. Generated files go into `__generated__/` directories. + +```sh +npm run relay # clean + compile +npm run relay-compile # compile only +``` + +Custom scalar mappings: `Datetime → string`, `GID → string`, `CursorKey → string`, `Duration → string`, `BigInt → number`, `EmailAddr → string`. + +## Colocated queries + +Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders: + +```tsx +// In route definition +{ + path: "vendors", + loader: loaderFromQueryLoader(({ organizationId }) => + loadQuery(coreEnvironment, vendorsQuery, { + organizationId, + snapshotId: null, + }), + ), + Component: withQueryRef( + lazy(() => import("#/pages/organizations/vendors/VendorsPage")), + ), } -enum OrderDirection { - ASC - DESC +// In the component +export default function VendorsPage(props: Props) { + const data = usePreloadedQuery(vendorsQuery, props.queryRef); + // ... } ``` -Each entity defines its own order field enum, order input, connection, and edge: +- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }` +- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount -```graphql -enum VendorOrderField { - CREATED_AT - NAME -} +For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`. -input VendorOrder { - direction: OrderDirection! - field: VendorOrderField! -} +## Fragments -type VendorConnection { - totalCount: Int! - edges: [VendorEdge!]! - pageInfo: PageInfo! -} +Fragments colocate data requirements with the component that reads them: -type VendorEdge { - cursor: CursorKey! - node: Vendor! +```tsx +const contactFragment = graphql` + fragment VendorContactsTabFragment_contact on VendorContact { + id + fullName + email + phone + role + createdAt + updatedAt + canUpdate: permission(action: "core:vendor-contact:update") + canDelete: permission(action: "core:vendor-contact:delete") + } +`; + +function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) { + const contact = useFragment(contactFragment, props.contactKey); + // ... } ``` -## Field arguments +### Refetchable fragments -Connection fields on parent types always use the standard Relay arguments: +For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`: -```graphql -type Organization { - vendors( - first: Int - after: CursorKey - last: Int - before: CursorKey - orderBy: VendorOrder - filter: VendorFilter - ): VendorConnection! -} +```tsx +const vendorContactsFragment = graphql` + fragment VendorContactsTabFragment on Vendor + @refetchable(queryName: "VendorContactsListQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { type: "VendorContactOrder", defaultValue: null } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + contacts( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "VendorContactsTabFragment_contacts") { + __id + edges { + node { + ...VendorContactsTabFragment_contact + } + } + } + } +`; + +const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor); +const connectionId = data.contacts.__id; ``` -- `first` / `after` — forward pagination (returns `Head` position) -- `last` / `before` — backward pagination (returns `Tail` position) -- `orderBy` — optional, defaults to `CREATED_AT` / `DESC` -- `filter` — optional, entity-specific filtering +## Pagination -## Cursor format +Use `usePaginationFragment` for cursor-based Relay pagination: -Cursors are opaque `CursorKey` scalars. Internally they encode as base64url(JSON): - -``` -["", ] +```tsx +const pagination = usePaginationFragment(paginatedVendorsFragment, data.node); +const vendors = pagination.data.vendors?.edges.map(edge => edge.node); +const connectionId = pagination.data.vendors.__id; ``` -For example, a cursor sorting by `created_at` encodes the entity ID and its `created_at` timestamp. This enables keyset pagination — the database uses the cursor values to seek directly to the right position instead of using OFFSET. +The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity. -## Keyset pagination +`SortableTable` is the standard component for rendering paginated, sortable lists — it receives `pagination` (with `loadNext`, `hasNext`, `isLoadingNext`) and a `refetch` callback for sorting. -The database query uses the cursor to build a WHERE clause that skips to the correct position: +## Mutations -- For `DESC` ordering: rows where `(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id)` -- For `ASC` ordering: rows where `(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)` +### `useMutation` -The query fetches `size + 1` (or `size + 2` when a cursor is provided) rows to detect whether more pages exist in either direction. `NewPage` trims the extra rows and sets `hasNextPage` / `hasPreviousPage` accordingly. +Direct Relay hook for simple cases: -For backward pagination (`last` / `before`), the SQL sort direction is reversed, and the result slice is reversed back to the correct order before building edges. +```tsx +const [mutate] = useMutation(deleteVendorMutation); +``` -## Default page size +### `useMutationWithToasts` -When neither `first` nor `last` is provided, the default page size is **25**. +Custom wrapper that adds toast notifications on success/error: -## Adding a new paginated field — checklist +```tsx +const [createContact, isLoading] = useMutationWithToasts( + createContactMutation, + { + successMessage: __("Contact created successfully."), + errorMessage: __("Failed to create contact"), + }, +); -1. **Schema** — add `enum XxxOrderField`, `input XxxOrder`, `type XxxConnection`, `type XxxEdge`, and the connection field with Relay arguments on the parent type -2. **Coredata** — add `*_order_field.go` (with `Column()`, `IsValid()`, marshaling), `CursorKey(field)` method on the entity, and the `LoadAllBy*` query using cursor SQL fragments + `page.NewPage()` -3. **API types** — add `*_connection.go` with `OrderBy` alias, connection struct, `NewXxxConnection`, `NewXxxEdge` -4. **Resolver** — implement the resolver (authorize, build order, build cursor, call service, build connection) -5. **Codegen** — run `go generate` for the relevant API package +await createContact({ + variables: { + input: { vendorId, ...cleanData }, + connections: [connectionId], + }, + onSuccess: () => { + dialogRef.current?.close(); + reset(); + }, +}); +``` + +### Store update directives + +Relay directives handle connection updates automatically — no manual store manipulation needed: + +```tsx +// Add new edge to the beginning of a connection +const createMutation = graphql` + mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) { + createVendor(input: $input) { + vendorEdge @prependEdge(connections: $connections) { + node { + id + name + } + } + } + } +`; + +// Remove an edge from a connection +const deleteMutation = graphql` + mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) { + deleteVendor(input: $input) { + deletedVendorId @deleteEdge(connections: $connections) + } + } +`; + +// Update in-place via fragment spread (no directive needed) +const updateMutation = graphql` + mutation UpdateContactMutation($input: UpdateVendorContactInput!) { + updateVendorContact(input: $input) { + vendorContact { + ...VendorContactsTabFragment_contact + } + } + } +`; +``` + +The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment. + +### `useConfirm` for destructive actions + +Destructive mutations (delete) are wrapped with a confirmation dialog: + +```tsx +const confirm = useConfirm(); + +return () => { + confirm( + () => + promisifyMutation(mutate)({ + variables: { + input: { vendorId: vendor.id! }, + connections: [connectionId], + }, + }), + { message: "Confirm deletion..." }, + ); +}; +``` + +## File organization + +GraphQL operations are colocated with the components that use them: + +``` +pages/organizations/vendors/ + VendorsPage.tsx # query + pagination fragment + tabs/ + VendorContactsTab.tsx # refetchable fragment + item fragment + VendorComplianceTab.tsx + dialogs/ + CreateContactDialog.tsx # create mutation + EditContactDialog.tsx # update mutation + +hooks/graph/ + VendorGraph.ts # shared queries, mutations, hooks +``` + +Shared queries and mutation hooks (used by multiple components) live in `hooks/graph/*.ts`. Component-specific operations are defined inline in the component file.