Instruction file imported from pravanjang/QuizMaster (
.github/instructions/graphql.instructions.md). Copyright stays with the author.
Copilot instructions — GraphQL schema and operations
Applies to all GraphQL schema files, Strawberry type definitions, and Apollo Client operation documents. The GraphQL schema is the public API contract between mobile and backend — treat changes with the same discipline as a versioned REST API.
Schema design principles
Naming
- Types:
PascalCase—Quiz,QuizSession,GenerateQuizInput - Fields:
camelCase—generatedAt,questionCount,isCorrect - Enums:
PascalCasetype,SCREAMING_SNAKE_CASEvalues —Difficulty { EASY, MEDIUM, HARD } - Mutations: verb + noun —
generateQuiz,submitAnswer,saveQuiz,deleteQuiz - Queries: noun or
get+ noun —quiz,quizHistory,getTopics - Subscriptions:
on+ event —onQuizGenProgress,onQuizComplete
Nullability
- Fields are non-null by default (
!) — only make fields nullable whennullis a meaningful, expected value - Never use nullable to mean "not implemented yet" — add the field when it is ready
- Input fields should be nullable only when they are genuinely optional with a documented default
# Correct — topic and difficulty are always required; language defaults to "en"
input GenerateQuizInput {
topic: String!
difficulty: Difficulty!
questionCount: Int!
language: String
}
# Wrong — making required fields nullable "just in case"
input GenerateQuizInput {
topic: String
difficulty: Difficulty
questionCount: Int
}
IDs
Always use the ID scalar for primary keys and foreign references — never String or Int.
type Quiz {
id: ID!
userId: ID!
...
}
Pagination
Use cursor-based pagination for all list fields — never offset pagination at the API level.
type QuizConnection {
edges: [QuizEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type QuizEdge {
node: Quiz!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
quizHistory(first: Int, after: String, last: Int, before: String): QuizConnection!
}
Full schema reference
# ── Scalars and enums ───────────────────────────────────────────────────────
scalar DateTime
scalar UUID
enum Difficulty {
EASY
MEDIUM
HARD
}
enum QuizStatus {
GENERATING
READY
FAILED
}
# ── Core types ───────────────────────────────────────────────────────────────
type User {
id: ID!
email: String!
createdAt: DateTime!
}
type Answer {
id: ID!
text: String!
}
type Question {
id: ID!
text: String!
options: [Answer!]!
hint: String
}
type Quiz {
id: ID!
topic: String!
difficulty: Difficulty!
status: QuizStatus!
questions: [Question!]!
generatedAt: DateTime!
savedBy: ID
}
type QuizSession {
id: ID!
quizId: ID!
userId: ID!
score: Float!
totalQuestions: Int!
completedAt: DateTime!
}
type AuthPayload {
token: String!
user: User!
}
# ── Progress (subscription payload) ─────────────────────────────────────────
type QuizGenProgress {
quizId: ID!
message: String!
percentComplete: Int!
status: QuizStatus!
}
# ── Input types ──────────────────────────────────────────────────────────────
input GenerateQuizInput {
topic: String!
difficulty: Difficulty!
questionCount: Int!
language: String
}
input SubmitAnswerInput {
sessionId: ID!
questionId: ID!
answerId: ID!
}
input RegisterInput {
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
# ── Pagination ───────────────────────────────────────────────────────────────
type QuizEdge {
node: Quiz!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type QuizConnection {
edges: [QuizEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# ── Root types ───────────────────────────────────────────────────────────────
type Query {
me: User!
quiz(id: ID!): Quiz!
quizHistory(first: Int, after: String): QuizConnection!
topics: [String!]!
}
type Mutation {
register(input: RegisterInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
generateQuiz(input: GenerateQuizInput!): Quiz!
submitAnswer(input: SubmitAnswerInput!): QuizSession!
saveQuiz(id: ID!): Quiz!
deleteQuiz(id: ID!): Boolean!
}
type Subscription {
onQuizGenProgress(quizId: ID!): QuizGenProgress!
onQuizComplete(quizId: ID!): Quiz!
}
Breaking vs non-breaking changes
Safe (non-breaking) — ship directly
- Adding a new type
- Adding a new nullable field to an existing type
- Adding a new optional argument to a query or mutation
- Adding a new enum value
- Adding a new query, mutation, or subscription
Breaking — requires deprecation first
- Removing a field or type
- Renaming a field or type
- Changing a field from nullable to non-null
- Changing an argument type
- Removing an enum value
Deprecation pattern
type Quiz {
# Old field — kept for 2 release cycles
name: String @deprecated(reason: "Use `topic` instead. Removed in v3.")
# New field
topic: String!
}
Client-side operation rules (mobile src/graphql/)
// ALWAYS: one file per operation — getQuiz.ts, generateQuiz.ts, quizGenProgress.ts
// ALWAYS: name every operation — anonymous operations are rejected by the backend
// Wrong
export const GET_QUIZ = gql`query { quiz(id: $id) { id topic } }`;
// Correct
export const GET_QUIZ = gql`
query GetQuiz($id: ID!) {
quiz(id: $id) {
id
topic
difficulty
status
questions {
id
text
options { id text }
hint
}
generatedAt
}
}
`;
// ALWAYS: request only the fields you use — never use __typename alone as a fragment
// Wrong — over-fetching
export const GET_QUIZ = gql`query GetQuiz($id: ID!) { quiz(id: $id) { id topic difficulty status questions { id text options { id text } hint } generatedAt savedBy } }`;
// ALWAYS: use fragments to share field sets across operations
export const QUESTION_FIELDS = gql`
fragment QuestionFields on Question {
id
text
options { id text }
hint
}
`;
export const GET_QUIZ = gql`
${QUESTION_FIELDS}
query GetQuiz($id: ID!) {
quiz(id: $id) {
id
topic
difficulty
questions { ...QuestionFields }
}
}
`;
// ALWAYS: subscription documents live in src/graphql/subscriptions/
export const ON_QUIZ_GEN_PROGRESS = gql`
subscription OnQuizGenProgress($quizId: ID!) {
onQuizGenProgress(quizId: $quizId) {
quizId
message
percentComplete
status
}
}
`;
Apollo Client cache rules
// ALWAYS: configure type policies in apollo/cache.ts — never ad-hoc cache writes in components
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
quizHistory: relayStylePagination(),
},
},
Quiz: {
keyFields: ['id'],
},
Question: {
keyFields: ['id'],
},
},
});
// ALWAYS: update the cache after mutations instead of refetching the entire list
const [saveQuiz] = useMutation(SAVE_QUIZ, {
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: 'Quiz', id: data.saveQuiz.id }),
fields: { savedBy: () => data.saveQuiz.savedBy },
});
},
});
// NEVER: call client.resetStore() on every mutation — only on logout
Error handling in operations
# Backend: always return domain errors as GraphQL errors with an extensions.code
# Never use HTTP 200 with an `errors` wrapper field for business errors
# Correct error response shape (backend enforces this):
{
"errors": [{
"message": "Quiz not found",
"extensions": { "code": "NOT_FOUND", "quizId": "abc-123" }
}]
}
// Mobile: always check for both networkError and graphQLErrors
const { error } = useQuery(GET_QUIZ, { variables: { id } });
if (error?.networkError) { /* offline or server down */ }
if (error?.graphQLErrors?.[0]?.extensions?.code === 'NOT_FOUND') { /* handle gracefully */ }