GraphQL (Apollo) Layer
A complete GraphQL integration layer for Horizon Framework, built on GraphQL client. It provides:
- Generic and resource-based hooks for React components
- Service functions for non-React contexts
- Centralized error emission integrated with the Horizon event bus
- UID normalization for cache correctness
All examples use gql DocumentNodes. Passing raw strings is not supported by the typed API.
yarn add @skedulo/horizon-core
Hooks
useGraphQLFetch
Generic hook using Apollo's useQuery. Returns { data, loading, error, refetch }.
import { gql } from '@apollo/client'
import { useGraphQLFetch } from '@skedulo/horizon-core'
const GET_JOB = gql`
query fetchJob($id: ID!, $includeJobAllocations: Boolean!) {
jobsById(UID: $id) {
Description
Address
JobAllocations @include(if: $includeJobAllocations) {
Status
Resource {
Name
}
}
}
}
`
export function GetJob({ jobId }: { jobId: string }) {
const { data, loading, error, refetch } = useGraphQLFetch<{ jobsById?: any }>(
{
query: GET_JOB,
variables: { id: jobId, includeJobAllocations: true },
},
)
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{data?.jobsById?.Description}</h1>
<button onClick={() => refetch({ includeJobAllocations: false })}>
Refresh without allocations
</button>
</div>
)
}
Notes
- UID normalization: The hook injects
UIDinto selections automatically to ensure cache normalization. - Error policy:
errorPolicy: 'all'ensures partial data is returned alongside GraphQL errors. - Errors are both returned in
errorand emitted centrally (see Error Handling).
useGraphQLResourceFetch
Resource-based convenience hook. Provide resourceName, objectUid, and optional fields (dot notation) or fragment.
import { useGraphQLResourceFetch } from '@skedulo/horizon-core'
export function JobDetails({ jobId }: { jobId: string }) {
const { data, loading, error, refetch } = useGraphQLResourceFetch({
resourceName: 'jobs',
objectUid: jobId,
fields: [
'Description',
'JobAllocations.Status',
'JobAllocations.Resource.Name',
],
})
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{data?.Description}</h1>
<button onClick={() => refetch()}>Refresh</button>
</div>
)
}
Using a GraphQL fragment
You can provide a fragment (DocumentNode) instead of fields. When using a fragment, omit fields.
import { gql } from '@apollo/client'
import { useGraphQLResourceFetch } from '@skedulo/horizon-core'
// Define a fragment on the resource type
const JOB_FIELDS = `
UID
Description
JobAllocations {
Status
Resource {
Name
}
}
`
export function JobDetailsWithFragment({ jobId }: { jobId: string }) {
const { data, loading, error } = useGraphQLResourceFetch({
resourceName: 'jobs',
objectUid: jobId,
fragment: JOB_FIELDS,
// no fields when using fragment
})
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{data?.Description}</h1>
<p>Allocations: {data?.JobAllocations?.length ?? 0}</p>
</div>
)
}
Notes
- UID normalization: The generated query always includes
UIDin the selection set. - Error policy:
errorPolicy: 'all'by default.
useGraphQLMutation
Mutation hook that can automatically aggregate a follow-up "get" operation in a single network request, then returns the standard result fields { mutate, data, loading, error, reset }.
import { gql } from '@apollo/client'
import { useGraphQLMutation } from '@skedulo/horizon-core'
const UPDATE_JOB = gql`
mutation UpdateJob($input: UpdateJobs!) {
schema {
updateJobs(input: $input)
}
}
`
export function JobUpdater({ jobId }: { jobId: string }) {
const {
mutate: updateJob,
loading,
error,
} = useGraphQLMutation({
mutation: UPDATE_JOB,
resourceName: 'jobs',
})
const handleUpdate = async () => {
await updateJob({
input: { UID: jobId, Description: 'Updated description!' },
})
}
if (error) return <div>Error: {error.message}</div>
return (
<button onClick={handleUpdate} disabled={loading}>
{loading ? 'Updating...' : 'Update Job'}
</button>
)
}
Notes
- Aggregation: If variables include an entity id (e.g.,
input.UID), the hook augments the mutation with aget<Resource>in the same request and returns fresh data. Otherwise, it runs the original mutation. - Error policy:
errorPolicy: 'all'by default. - Errors are returned and also emitted centrally.
useGraphQLSubscription
Real-time subscriptions via WebSocket using Apollo's useSubscription, enhanced with centralized error emission and subscription helpers.
Return shape
data: TData | nullloading: booleanerror: ApolloError | nullsubscribe(cb) => { unsubscribe }streams updates to a listenergetCurrentResult() => { data, loading, error }
import { gql } from '@apollo/client'
import { useGraphQLSubscription } from '@skedulo/horizon-core'
import { useState } from 'react'
// Helper to merge subscription payload with cached job
function mergeWithCachedJob(prev: any, next: any) {
return { ...prev, ...next }
}
interface JobData {
UID: string
Description: string
}
interface SchemaJobsSubscription {
operation: 'INSERT' | 'UPDATE' | 'DELETE'
timestamp: string
data: JobData
previous?: Partial<JobData>
}
export function JobStatusMonitor({ jobId }: { jobId: string }) {
const [job, setJob] = useState<JobData | null>(null)
const JOB_STATUS_QUERY = gql`
subscription schemaJobsSubscription($filter: EQLRecordFilterJobs!) {
schemaJobs(operation: UPDATE, filter: $filter) {
operation
timestamp
data {
UID
Description
}
previous {
Description
}
}
}
`
// Subscribe to job updates
useGraphQLSubscription<{
schemaJobs: SchemaJobsSubscription
}>({
query: JOB_STATUS_QUERY,
variables: { filter: `UID == "${jobId}"` },
onData: (result) => {
const newJob = result?.schemaJobs?.data
if (newJob) {
// Merge with previous cached job if needed
setJob((prev) => mergeWithCachedJob(prev, newJob))
}
}
})
if (!job) return <div>Waiting for updates…</div>
return (
<div>
<div>
<strong>Operation:</strong> {jobId ? 'UPDATE' : 'UNKNOWN'}
</div>
<div>
<strong>Description:</strong> {job.Description}
</div>
</div>
)
}
Notes
- Error policy:
errorPolicy: 'all'so data is delivered even when errors are present. - Centralized error emission is triggered on subscription errors.
In case of race conditions between the subscription hook and the cache (when the user mutates the data while the subscription is running)
When using the subscription hook, you may encounter race conditions between the subscription hook and the cache. This can happen if the user mutates the data while and old subscription arrives after the mutation.
To avoid this, you can use a check of LastModifiedDate to merge the cached data with the subscription data. NOTE: For this to work, you need to include LastModifiedDate in the subscription data.
import { gql } from '@apollo/client'
import { useGraphQLSubscription } from '@skedulo/horizon-core'
import { useState } from 'react'
// Helper to merge subscription payload with cached job
function mergeWithCachedJob(prev: any, next: any) {
if (!prev || !next) return next ?? prev
// If both have LastModifiedDate, only update if incoming is newer
if (prev.LastModifiedDate && next.LastModifiedDate) {
const prevDate = new Date(prev.LastModifiedDate).getTime()
const nextDate = new Date(next.LastModifiedDate).getTime()
if (nextDate <= prevDate) {
// Incoming data is older or same; keep previous
return prev
}
}
// Otherwise, merge normally
return { ...prev, ...next }
}
interface JobData {
UID: string
Description: string
LastModifiedDate?: string
}
interface SchemaJobsSubscription {
operation: 'INSERT' | 'UPDATE' | 'DELETE'
timestamp: string
data: JobData
previous?: Partial<JobData>
}
export function JobStatusMonitor({ jobId }: { jobId: string }) {
const [job, setJob] = useState<JobData | null>(null)
const JOB_STATUS_QUERY = gql`
subscription schemaJobsSubscription($filter: EQLRecordFilterJobs!) {
schemaJobs(operation: UPDATE, filter: $filter) {
operation
timestamp
data {
UID
Description
LastModifiedDate
}
previous {
Description
LastModifiedDate
}
}
}
`
// Subscribe to job updates
useGraphQLSubscription<{
schemaJobs: SchemaJobsSubscription
}>({
query: JOB_STATUS_QUERY,
variables: { filter: `UID == "${jobId}"` },
onData: (result) => {
const newJob = result?.schemaJobs?.data
if (newJob) {
setJob((prev) => mergeWithCachedJob(prev, newJob))
}
}
})
if (!job) return <div>Waiting for updates…</div>
return (
<div>
<div>
<strong>Operation:</strong> {jobId ? 'UPDATE' : 'UNKNOWN'}
</div>
<div>
<strong>Description:</strong> {job.Description}
</div>
</div>
)
}
Advanced: Using watchQuery (ObservableQuery)
In complex scenarios you may want to react to cache updates over time and get an immediate cached result followed by a network result. Apollo's watchQuery returns an ObservableQuery that you can subscribe to, which is ideal for advanced flows like dynamic query documents, custom cache reads/writes, or merging subscription events into a list.
We do not ship a dedicated useGraphQLWatch hook in core because real-world usages are usually highly application-specific (custom query construction, cache transforms, ordering, deduping, etc.). Instead, use useApolloClient and watchQuery directly when needed.
Notes
- Default behavior: Our GraphQL client sets
watchQuery.fetchPolicy: 'cache-and-network'anderrorPolicy: 'all'by default, so you can omit these unless you need to override. - Always unsubscribe: Clean up the subscription in
useEffectcleanup to avoid memory leaks. - UID normalization: Include
UIDin selections to ensure stable cache keys.
Example: reactively watch a jobs list for an account
import { useEffect, useState } from 'react'
import { gql, useApolloClient } from '@apollo/client'
type JobsQuery = {
jobs: {
totalCount: number
edges: Array<{
node: { UID: string; Name: string; Start?: string; End?: string }
}>
}
}
const GET_ACCOUNT_JOBS = gql`
query Jobs($first: PositiveIntMax200, $filter: EQLQueryFilterJobs) {
jobs(first: $first, filter: $filter) {
totalCount
edges {
node {
UID
Name
Start
End
}
}
}
}
`
export function AccountJobs({ accountId }: { accountId: string }) {
const client = useApolloClient()
const [data, setData] = useState<JobsQuery['jobs'] | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (!accountId) return
const observable = client.watchQuery<JobsQuery>({
query: GET_ACCOUNT_JOBS,
variables: {
first: 100,
filter: `JobStatus != "Cancelled" AND AccountId == "${accountId}"`,
},
// fetchPolicy: 'cache-and-network', // optional; default is set in the client
})
setLoading(true)
const sub = observable.subscribe({
next: (result) => {
setData(result.data?.jobs ?? null)
setLoading(result.loading)
setError(null)
},
error: (err) => {
setError(err as Error)
setLoading(false)
},
})
return () => sub.unsubscribe()
}, [client, accountId])
if (error) return <div>Error: {error.message}</div>
if (loading && !data) return <div>Loading…</div>
return (
<ul>
{(data?.edges ?? []).map((e) => (
<li key={e.node.UID}>{e.node.Name}</li>
))}
</ul>
)
}
This approach is useful when you need tighter control than useGraphQLFetch provides, for example:
- Dynamically altering the query document (e.g., adding fields at runtime)
- Writing to/reading from the cache manually in response to external events
- Merging real-time updates (subscriptions) into a list while preserving sort order and deduplicating
Service functions (non-React)
There are two ways to use the services:
- Using an GraphQL client explicitly (from the Horizon API client)
import { gql } from '@apollo/client'
import {
executeGraphQLFetch,
refetchGraphQLFetch,
executeGraphQLResourceFetch,
refetchGraphQLResourceFetch,
} from '@skedulo/horizon-core'
// Get an GraphQL client instance
const graphQLClient = apiClient.graphQL.getClient()
const GET_JOBS = gql`
query fetchJobs($first: PositiveIntMax200, $filter: EQLQueryFilterJobs) {
jobs(first: $first, filter: $filter) {
edges {
node {
UID
Name
Description
JobStatus
}
}
}
}
`
// Direct query
await executeGraphQLFetch(graphQLClient, {
query: GET_JOBS,
variables: { first: 20 },
})
// Refetch with overrides
await refetchGraphQLFetch(graphQLClient, { query: GET_JOBS }, { first: 10 })
// Resource-based
await executeGraphQLResourceFetch(graphQLClient, {
resourceName: 'jobs',
objectUid: 'job-uid',
fields: ['Description'],
})
await refetchGraphQLResourceFetch(
apollo,
{
resourceName: 'jobs',
objectUid: 'job-uid',
},
{ includeJobAllocations: true },
)
- Using the client-wrapped convenience methods (no GraphQL client argument)
// Direct query
await apiClient.graphQL.fetch({ query: GET_JOBS, variables: { first: 20 } })
// Refetch with overrides
await apiClient.graphQL.refetch({ query: GET_JOBS }, { first: 10 })
// Resource-based
await apiClient.graphQL.fetchResource({
resourceName: 'jobs',
objectUid: 'job-uid',
fields: ['Description'],
})
await apiClient.graphQL.refetchResource(
{
resourceName: 'jobs',
objectUid: 'job-uid',
},
{ includeJobAllocations: true },
)
Notes
- The explicit form is useful for testing and advanced composition where you manage the GraphQL client lifecycle.
- The convenience methods are simpler for most use cases and automatically use the authenticated client created by
ApiClientV1.
Error handling
- All operations default to
errorPolicy: 'all'so partial data is surfaced with GraphQL errors. - Errors are both returned (e.g.,
errorin hook results) and emitted centrally viaemitGraphQLError, which constructs and emits aPlatformErrorthrough the Horizon event bus. - This enables local handling and global observability/analytics simultaneously.
UID normalization
For cache normalization to work reliably, the UID field must be selected.
- Generic queries:
useGraphQLFetchautomatically injectsUIDwhere missing. - Resource-based queries:
useGraphQLResourceFetchgenerates the query and always includesUIDin selections.
This ensures consistent cache keys without requiring callers to remember UID.