Horizon Framework

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 UID into selections automatically to ensure cache normalization.
  • Error policy: errorPolicy: 'all' ensures partial data is returned alongside GraphQL errors.
  • Errors are both returned in error and 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 UID in 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 a get<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 | null
  • loading: boolean
  • error: ApolloError | null
  • subscribe(cb) => { unsubscribe } streams updates to a listener
  • getCurrentResult() => { 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' and errorPolicy: 'all' by default, so you can omit these unless you need to override.
  • Always unsubscribe: Clean up the subscription in useEffect cleanup to avoid memory leaks.
  • UID normalization: Include UID in 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:

  1. 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 },
)
  1. 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., error in hook results) and emitted centrally via emitGraphQLError, which constructs and emits a PlatformError through 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: useGraphQLFetch automatically injects UID where missing.
  • Resource-based queries: useGraphQLResourceFetch generates the query and always includes UID in selections.

This ensures consistent cache keys without requiring callers to remember UID.