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
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)

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 |
|-------------|----------|---------|
| `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<VendorGraphListQuery>(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<VendorGraphDeleteMutation>(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
["<entity_global_id>", <sort_field_value>]
```
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
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<VendorGraphListQuery>(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):
```
["<entity_global_id>", <sort_field_value>]
```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<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
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.