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.
CreateTaskPayloadstorestaskId, resolvestaskvia dataLoader) - Deleted entities that can't be re-fetched: store the full object in the source directly (e.g.
DeleteTaskPayloadreturns{task}— the TaskDB — directly; no custom resolver needed becauseTaskis already mapped toTaskDBin 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:
- Add a
selectFoo()helper inpackages/server/postgres/select.ts(explicitly list columns — omit sensitive fields likehashedToken,password, etc.) - Export
export type Foo = ExtractTypeFromQueryBuilderSelect<typeof selectFoo>frompackages/server/postgres/types/index.d.ts - Set the mapper in
codegen.jsonto"../../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:
Define a source interface in the existing type file using types from
packages/server/postgres/types/NewMeetingPhase.d.ts(NOT the deprecated/databasedirectory). For stage types,augmentDBStage(inpackages/server/graphql/resolvers.ts) spreads{...stage, meetingId, phaseType, teamId}, so the source extends the Postgres stage interface withmeetingIdandteamId(phaseTypeis already a literal on the interface):// public/types/RetroDiscussStage.ts import type {DiscussStage} from '../../../postgres/types/NewMeetingPhase' export interface DiscussStageSource extends DiscussStage { meetingId: string teamId: string }Add a mapper in
codegen.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
loadNonNullwhen the SDL field is non-nullable (e.g.meeting: NewMeeting),loadwhen nullable
The error branch pattern for union-style payloads with inline error:
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:
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
anywithout 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:
undefinedskips the field,nullsets it to NULL. The DB driver ignoresundefinedvalues but does not ignorenull. 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, notselectAll(), so sensitive columns are excluded automatically - Type definitions:
packages/server/dataloader/RootDataLoader.ts - Loader implementation:
packages/server/dataloader/*Loader.ts
- Helper functions:
- Call
dispose()on dataloaders as soon as they're no longer needed. Don't extend dataloader lifetime unnecessarily. - Include
mutatorIdin 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 oftext— text columns shouldn't accept arbitrarily large data.varchar(43): cryptographically secure 256-bit base64url tokensvarchar(255): general short strings (names, emails)varchar(2000): longer content with reasonable limits
- Always use foreign key constraints. Use
ON DELETE CASCADEwhen children should be deleted with parents,ON DELETE SET NULLwhen orphaned records should remain. - Use integer primary keys (
intorbigint). Only use UUIDs when distributed ID generation is required. - Consistent naming:
orgIdnotorganizationId— follow existing column naming conventions. - Let the database manage timestamps — don't set
updatedAtorcreatedAtin application code; let triggers/defaults handle them. - Migrations must be idempotent. Use
IF NOT EXISTS/IF EXISTSso they don't fail on re-run or during upgrades. - Migrations use
Kysely<any>— theanyis intentional since migrations are frozen in time and must not reference evolving DB types. Exportasync function up(db: Kysely<any>)andasync 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.Textis aY.Arrayof characters; for atomic replacements, plain strings/numbers stored inY.Mapare 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
stateparameters usingcrypto.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
json2csvwith 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
pdfjsrather than heavy client-side solutions.