一键导入
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.
// 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.
Reference conventions for React client components — Tailwind CSS, UI primitives (Dialog, Tooltip, Menu), constants, HTML sanitization, and component design patterns. Use when building or reviewing UI components in packages/client.
Guide for moving inline auth checks out of GraphQL resolvers and into permissions.ts using graphql-shield rules. Use when adding, reviewing, or migrating authorization logic for mutations or queries.
Steps to set up the Stripe CLI and forward webhook events to a local dev server for testing Stripe integrations.
| name | graphql |
| description | 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. |
schema.graphql is auto-generated — never edit it directlyThe 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.
public/types/)Only needed when the payload type requires custom field resolvers beyond the default passthrough. Examples:
CreateTaskPayload stores taskId, resolves task via dataLoader)DeleteTaskPayload returns {task} — the TaskDB — directly; no custom resolver needed because Task is already mapped to TaskDB in codegen)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:
selectFoo() helper in packages/server/postgres/select.ts (explicitly list columns — omit sensitive fields like hashedToken, password, etc.)export type Foo = ExtractTypeFromQueryBuilderSelect<typeof selectFoo> from packages/server/postgres/types/index.d.tscodegen.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.
public/mutations/../../../postgres/...../../../utils/...../resolverTypes../../mutations/helpers/<helper>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.
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 /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):
// 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.
tsgo not tscUse npx tsgo -p packages/server/tsconfig.json --noEmit (NOT pnpm tsc or tsc).
/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.
When a mutation returns IDs (not full objects), the payload type source needs custom field resolvers:
{orgId, teamIds})codegen.jsonloadNonNull when the SDL field is non-nullable (e.g. meeting: NewMeeting), load when nullableThe 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)
}
}
ms packageWhen 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'))
*Success directly — union Payloads are deprecatedMutations 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.
any without strong justification. Don't remove type annotations.postgres/types/.?.) and nullish coalescing (??).undefined skips the field, null sets it to NULL. The DB driver ignores undefined values but does not ignore null. Use this distinction intentionally.Number(id) when passing string IDs to PostgreSQL integer columns. PG may accept strings, but not every dataloader will.packages/server/postgres/select.ts — use these select helpers, not selectAll(), so sensitive columns are excluded automaticallypackages/server/dataloader/RootDataLoader.tspackages/server/dataloader/*Loader.tsdispose() on dataloaders as soon as they're no longer needed. Don't extend dataloader lifetime unnecessarily.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).varchar(N) instead of text — 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 limitsON DELETE CASCADE when children should be deleted with parents, ON DELETE SET NULL when orphaned records should remain.int or bigint). Only use UUIDs when distributed ID generation is required.orgId not organizationId — follow existing column naming conventions.updatedAt or createdAt in application code; let triggers/defaults handle them.IF NOT EXISTS / IF EXISTS so they don't fail on re-run or during upgrades.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.yDoc.transact() — an abrupt exit could corrupt data otherwise.Y.Text is a Y.Array of characters; for atomic replacements, plain strings/numbers stored in Y.Map are simpler and faster.crypto.randomUUID()) or Snowflake IDs for guaranteed uniqueness instead of collision checks.state parameters using crypto.randomBytes(32).toString('base64url').json2csv with string formatters. Commas, quotes, and newlines in cell values break naive CSV generation.pdfjs rather than heavy client-side solutions.