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 <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-15 19:09:39 +01:00
parent 0ca6b6775d
commit 7147cf189a
3 changed files with 351 additions and 288 deletions

View File

@@ -26,8 +26,8 @@ GraphQL and MCP codegen is triggered by `go generate`:
## Reference Documentation ## Reference Documentation
Detailed guides for specific subsystems live in `contrib/claude/`: 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/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
- [`contrib/claude/graphql.md`](contrib/claude/graphql.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/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/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) - [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)

View File

@@ -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 | ```graphql
|-------------|----------|---------| type VendorConnection
| `coreEnvironment` | `/api/console/v1/graphql` | Main application data | @goModel(
| `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity | model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
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<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
),
}
// In the component
export default function VendorsPage(props: Props) {
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// ...
}
```
- `loaderFromQueryLoader` — converts a query loader into a React Router loader, returns `{ queryRef, dispose }`
- `withQueryRef` — extracts `queryRef` from loader data and handles cleanup on unmount
For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`.
## Fragments
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);
// ...
}
```
### Refetchable fragments
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
```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( totalCount: Int! @goField(forceResolver: true)
first: $first edges: [VendorEdge!]!
after: $after pageInfo: PageInfo!
last: $last }
before: $before
orderBy: $order
) @connection(key: "VendorContactsTabFragment_contacts") {
__id
edges {
node {
...VendorContactsTabFragment_contact
}
}
}
}
`;
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor); type VendorEdge {
const connectionId = data.contacts.__id; cursor: CursorKey!
node: Vendor!
}
``` ```
## Pagination Without `@goModel`, gqlgen generates a default struct that lacks the custom fields (`ParentID`, `Resolver`, `Filter`) needed by the pagination resolvers.
Use `usePaginationFragment` for cursor-based Relay pagination: ## Enums and `@goModel` / `@goEnum`
```tsx Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value:
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node); ```graphql
const connectionId = pagination.data.vendors.__id; 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")
}
``` ```
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. ## Schema directives
`SortableTable` is the standard component for rendering paginated, sortable lists — it receives `pagination` (with `loadNext`, `hasNext`, `isLoadingNext`) and a `refetch` callback for sorting. | 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 |
## Mutations ## Cursor pagination schema types
### `useMutation` Every paginated field uses shared base types plus entity-specific types:
Direct Relay hook for simple cases: ```graphql
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
```tsx enum OrderDirection
const [mutate] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation); @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")
}
``` ```
### `useMutationWithToasts` Each entity defines: `enum XxxOrderField`, `input XxxOrder`, `type XxxConnection` (with `@goModel`), `type XxxEdge`.
Custom wrapper that adds toast notifications on success/error: Connection fields on parent types use standard Relay arguments:
```tsx ```graphql
const [createContact, isLoading] = useMutationWithToasts( type Organization {
createContactMutation, vendors(
{ first: Int
successMessage: __("Contact created successfully."), after: CursorKey
errorMessage: __("Failed to create contact"), last: Int
}, before: CursorKey
); orderBy: VendorOrder
filter: VendorFilter
await createContact({ ): VendorConnection!
variables: { }
input: { vendorId, ...cleanData },
connections: [connectionId],
},
onSuccess: () => {
dialogRef.current?.close();
reset();
},
});
``` ```
### Store update directives ## Go connection type pattern
Relay directives handle connection updates automatically — no manual store manipulation needed: Each connection type lives in `types/*_connection.go` and follows this structure:
```tsx ```go
// Add new edge to the beginning of a connection type (
const createMutation = graphql` VendorOrderBy OrderBy[coredata.VendorOrderField]
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
}
}
}
}
`;
// Remove an edge from a connection VendorConnection struct {
const deleteMutation = graphql` TotalCount int
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) { Edges []*VendorEdge
deleteVendor(input: $input) { PageInfo PageInfo
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
// Update in-place via fragment spread (no directive needed) Resolver any
const updateMutation = graphql` ParentID gid.GID
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
...VendorContactsTabFragment_contact
} }
)
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)
} }
return &VendorConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
} }
`; }
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 Cursors are opaque `CursorKey` scalars. Internally they encode as base64url(JSON):
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/ ["<entity_global_id>", <sort_field_value>]
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

View File

@@ -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 | Environment | Endpoint | Purpose |
type PageInfo { |-------------|----------|---------|
hasNextPage: Boolean! | `coreEnvironment` | `/api/console/v1/graphql` | Main application data |
hasPreviousPage: Boolean! | `iamEnvironment` | `/api/connect/v1/graphql` | Authentication / identity |
startCursor: CursorKey
endCursor: CursorKey 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<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
),
} }
enum OrderDirection { // In the component
ASC export default function VendorsPage(props: Props) {
DESC 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 For queries that need to run after render (e.g. select dropdowns), use `useLazyLoadQuery` with `fetchPolicy: "network-only"`.
enum VendorOrderField {
CREATED_AT
NAME
}
input VendorOrder { ## Fragments
direction: OrderDirection!
field: VendorOrderField!
}
type VendorConnection { Fragments colocate data requirements with the component that reads them:
totalCount: Int!
edges: [VendorEdge!]!
pageInfo: PageInfo!
}
type VendorEdge { ```tsx
cursor: CursorKey! const contactFragment = graphql`
node: Vendor! 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 ```tsx
type Organization { const vendorContactsFragment = graphql`
vendors( fragment VendorContactsTabFragment on Vendor
first: Int @refetchable(queryName: "VendorContactsListQuery")
after: CursorKey @argumentDefinitions(
last: Int first: { type: "Int", defaultValue: 50 }
before: CursorKey order: { type: "VendorContactOrder", defaultValue: null }
orderBy: VendorOrder after: { type: "CursorKey", defaultValue: null }
filter: VendorFilter before: { type: "CursorKey", defaultValue: null }
): VendorConnection! 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) ## Pagination
- `last` / `before` — backward pagination (returns `Tail` position)
- `orderBy` — optional, defaults to `CREATED_AT` / `DESC`
- `filter` — optional, entity-specific filtering
## 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);
["<entity_global_id>", <sort_field_value>] 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)` ### `useMutation`
- For `ASC` ordering: rows where `(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)`
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<VendorGraphDeleteMutation>(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 await createContact({
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()` variables: {
3. **API types** — add `*_connection.go` with `OrderBy` alias, connection struct, `NewXxxConnection`, `NewXxxEdge` input: { vendorId, ...cleanData },
4. **Resolver** — implement the resolver (authorize, build order, build cursor, call service, build connection) connections: [connectionId],
5. **Codegen** — run `go generate` for the relevant API package },
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.