# GRAPHQL

> Conventions for the SDL-first GraphQL server — payload types, codegen mappers, dataLoaders, TypeScript safety, database patterns, and migration best practices. Use when writing or reviewing GraphQL mutations, queries, or resolvers.

- Skill: `parabolinc/graphql` (Agent Skill)
- Install (CLI): `npx skillmds@latest add parabolinc/graphql`
- Raw SKILL.md: https://api.skillmd.com/api/skills/parabolinc/graphql/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: parabolinc (https://skillmd.com/u/parabolinc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/parabolinc/graphql

---


# Server GraphQL Migration Notes

### `schema.graphql` is auto-generated — never edit it directly
The file `packages/server/graphql/public/schema.graphql` is generated from `typeDefs/*.graphql`. Always add new types and mutations to files in `typeDefs/` — one `.graphql` file per type or input, plus entries in `Mutation.graphql` or `Query.graphql`. Run `pnpm codegen` to regenerate.

### Type source files (`public/types/`)
Only needed when the payload type requires **custom field resolvers** beyond the default passthrough. Examples:
- Store an ID and load the full object via dataLoader (e.g. `CreateTaskPayload` stores `taskId`, resolves `task` via dataLoader)
- Deleted entities that can't be re-fetched: store the full object in the source directly (e.g. `DeleteTaskPayload` returns `{task}` — the TaskDB — directly; no custom resolver needed because `Task` is already mapped to `TaskDB` in codegen)

### codegen.json mappers
Only add a mapper entry when you create a new source type file. If the payload's fields are all handled by existing mapped types (e.g. `Task → TaskDB`) and the default resolver suffices, no mapper is needed.

**Mapper path for DB-backed types:** When a GraphQL type maps directly to a Postgres table row, derive the type from the select helper and reference it from `postgres/types/index.d.ts`:
1. Add a `selectFoo()` helper in `packages/server/postgres/select.ts` (explicitly list columns — **omit sensitive fields** like `hashedToken`, `password`, etc.)
2. Export `export type Foo = ExtractTypeFromQueryBuilderSelect<typeof selectFoo>` from `packages/server/postgres/types/index.d.ts`
3. Set the mapper in `codegen.json` to `"../../postgres/types/index#Foo"` (not a hand-rolled source type file)

**Always run `pnpm codegen` after modifying `codegen.json`** to regenerate `resolverTypes.ts` and confirm no type errors were introduced. Do not assume the mapper is correct until codegen succeeds.

### Import paths from `public/mutations/`
- postgres utilities: `../../../postgres/...`
- server utils: `../../../utils/...`
- resolverTypes: `../resolverTypes`
- old shared helpers (not yet migrated): `../../mutations/helpers/<helper>`

### Rate limiting / higher-order resolver wrappers
Old mutations sometimes wrap their resolve function with a `rateLimit({perMinute, perHour})` higher-order function from `packages/server/graphql/rateLimit.ts`. In the SDL-first architecture, this pattern is replaced by a graphql-shield rule in `packages/server/graphql/public/permissions.ts`.

### Adding source types to existing GraphQL type resolvers (e.g. RetroDiscussStage)
Some SDL types already have a resolver file in `public/types/` but no exported source type. When a payload field needs to return one of these types (e.g. `stage: RetroDiscussStage`), you must:

1. **Define a source interface** in the existing type file using types from `packages/server/postgres/types/NewMeetingPhase.d.ts` (NOT the deprecated `/database` directory). For stage types, `augmentDBStage` (in `packages/server/graphql/resolvers.ts`) spreads `{...stage, meetingId, phaseType, teamId}`, so the source extends the Postgres stage interface with `meetingId` and `teamId` (`phaseType` is already a literal on the interface):
   ```ts
   // public/types/RetroDiscussStage.ts
   import type {DiscussStage} from '../../../postgres/types/NewMeetingPhase'

   export interface DiscussStageSource extends DiscussStage {
     meetingId: string
     teamId: string
   }
   ```

2. **Add a mapper** in `codegen.json`:
   ```json
   "RetroDiscussStage": "./types/RetroDiscussStage#DiscussStageSource"
   ```

The same pattern applies to other concrete stage types (e.g. `EstimateStage` → extends `EstimateStage` from `NewMeetingPhase.d.ts`). Check the existing `public/types/` file first — a resolver file may already exist without a source type export.

### TypeScript checking
Use `npx tsc -p packages/server/tsconfig.json --noEmit`. As of TypeScript 7, `tsc` *is* the native
(Go) compiler, so `tsgo` / `@typescript/native-preview` are gone — don't reintroduce them.

### Deprecated `/database` directory — always use `postgres/types/`
All classes/types in `packages/server/database/types/` are **deprecated**. When writing source type interfaces for `public/types/` resolvers, always import from `packages/server/postgres/types/NewMeetingPhase.d.ts` (or other files in `postgres/types/`) instead. If an existing file imports from the `database/` directory, flag it and replace the import.

If a codegen mapper references a type from the `database/` directory, replace it with the equivalent interface from `postgres/types/NewMeetingPhase.d.ts`.

### Field resolvers with IDs vs full objects
When a mutation returns IDs (not full objects), the payload type source needs custom field resolvers:
- Store the ID in the source (e.g. `{orgId, teamIds}`)
- In the type source file, use dataLoader to resolve the full object
- Add a mapper entry in `codegen.json`
- Use `loadNonNull` when the SDL field is non-nullable (e.g. `meeting: NewMeeting`), `load` when nullable

The error branch pattern for union-style payloads with inline error:
```ts
export type FooPayloadSource = {orgId: string; teamIds: string[]} | {error: {message: string}}

const FooPayload: FooPayloadResolvers = {
  organization: (source, _args, {dataLoader}) => {
    if ('error' in source) return null
    return dataLoader.get('organizations').loadNonNull(source.orgId)
  },
  // loadMany returns (T | Error)[] — always filter with isValid and make the resolver async
  teams: async (source, _args, {dataLoader}) => {
    if ('error' in source) return null
    return (await dataLoader.get('teams').loadMany(source.teamIds)).filter(isValid)
  }
}
```

### Date arithmetic: use the `ms` package
When computing future/past dates, use `ms` from the `ms` package instead of manual millisecond math:
```ts
import ms from 'ms'
const maxAllowed = new Date(Date.now() + ms('60d'))
```

### Mutation return types: always use `*Success` directly — union Payloads are deprecated
Mutations must return the `*Success` type directly (e.g. `SetCompanyTeamLimitAtSuccess!`). The `union *Payload = ErrorPayload | *Success` pattern is **deprecated** — do not use it for new mutations. On errors, throw a `GraphQLError` instead of returning an error object.

---

## TypeScript & Type Safety

- **Never use `any`** without strong justification. Don't remove type annotations.
- **Use generated types** from the GraphQL schema and database — don't manually define types that come from codegen or `postgres/types/`.
- **Handle null/undefined** for all nullable types. Use optional chaining (`?.`) and nullish coalescing (`??`).
- **Avoid database ORM classes.** Use plain objects (POJOs) for database inserts/updates — classes don't account for differences between inserts, updates, and reads. POJOs give better type checking with Kysely.
- **Create objects in a single call** instead of building them incrementally across multiple statements. Cleaner for the compiler and immediately shows if variables are unused.
- **Kysely: `undefined` skips the field, `null` sets it to NULL.** The DB driver ignores `undefined` values but does _not_ ignore `null`. Use this distinction intentionally.
- **Use `Number(id)`** when passing string IDs to PostgreSQL integer columns. PG may accept strings, but not every dataloader will.

## DataLoader Best Practices

- **Always use DataLoaders** for related data fetching in resolvers — never query the DB directly from a resolver.
  - Helper functions: `packages/server/postgres/select.ts` — **use these select helpers, not `selectAll()`**, so sensitive columns are excluded automatically
  - Type definitions: `packages/server/dataloader/RootDataLoader.ts`
  - Loader implementation: `packages/server/dataloader/*Loader.ts`
- **Call `dispose()` on dataloaders** as soon as they're no longer needed. Don't extend dataloader lifetime unnecessarily.
- **Include `mutatorId` in publish calls.** Without it, the user who triggered the mutation gets the message twice (once from the mutation response and once from the subscription).

## Database Conventions

- **Use `varchar(N)` instead of `text`** — text columns shouldn't accept arbitrarily large data.
  - `varchar(43)`: cryptographically secure 256-bit base64url tokens
  - `varchar(255)`: general short strings (names, emails)
  - `varchar(2000)`: longer content with reasonable limits
- **Always use foreign key constraints.** Use `ON DELETE CASCADE` when children should be deleted with parents, `ON DELETE SET NULL` when orphaned records should remain.
- **Use integer primary keys** (`int` or `bigint`). Only use UUIDs when distributed ID generation is required.
- **Consistent naming**: `orgId` not `organizationId` — follow existing column naming conventions.
- **Let the database manage timestamps** — don't set `updatedAt` or `createdAt` in application code; let triggers/defaults handle them.
- **Migrations must be idempotent.** Use `IF NOT EXISTS` / `IF EXISTS` so they don't fail on re-run or during upgrades.
- **Migrations use `Kysely<any>`** — the `any` is intentional since migrations are frozen in time and must not reference evolving DB types. Export `async function up(db: Kysely<any>)` and `async function down(db: Kysely<any>)`. Name files with ISO timestamps: `2026-03-30T12:08:00.000Z_description.ts`.
- **Use queries for read operations, mutations for writes.** Don't use mutations for operations that only read data.
- **Store data in the database** rather than computing values on-the-fly in code.

## YJS / CRDT Patterns

- **Wrap all YJS writes in `yDoc.transact()`** — an abrupt exit could corrupt data otherwise.
- **Use primitives for simple YJS values.** `Y.Text` is a `Y.Array` of characters; for atomic replacements, plain strings/numbers stored in `Y.Map` are simpler and faster.
- **Design data structures for concurrent editing.** Prefer Maps of row IDs over arrays to avoid position conflicts during concurrent editing.

## Architecture

- **Avoid distributed state in single-replica services.** Don't keep counts or state in-memory — store in Redis or PostgreSQL so you can run multiple replicas.
- **Check permissions with a whitelist approach** (check if user HAS access, default to false). Don't check if they DON'T have access (blacklist approach).
- **Explain non-obvious architectural decisions in comments** — prevents cargo-culting of bad patterns.
- **Avoid O(n) operations in hot paths.** Use UUIDs (`crypto.randomUUID()`) or Snowflake IDs for guaranteed uniqueness instead of collision checks.

## Security

- **Sanitize HTML with DOMPurify** — never render user-provided HTML without sanitization.
- **CSRF protection for OAuth**: generate and validate `state` parameters using `crypto.randomBytes(32).toString('base64url')`.
- **Normalize SAML attribute names** — attribute names vary by provider. Document expected names and normalize to consistent internal naming.
- **Properly escape CSV data** — use `json2csv` with string formatters. Commas, quotes, and newlines in cell values break naive CSV generation.

## Performance

- **Use Recursive Character Level Chunking** for LLM text chunking — retains sentence boundaries, merges when appropriate, results in fewer chunks.
- **Generate large documents server-side** (PDFs, HTML emails). Use lightweight libraries like `pdfjs` rather than heavy client-side solutions.

