Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/graphql/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

- When adding a new resolver, always add it to `/workspaces/steadystart/apps/graphql/src/schema.ts`
- Follow the existing pattern for organizing resolvers by model
- Use `paginatedField` helper for all queries and nested fields where a list is returned
- Write tests for all resolvers
- Use auth scopes to handle permissions
- Use auth scopes to handle permissions
4 changes: 2 additions & 2 deletions apps/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,19 @@
"zod": "3.22.4"
},
"devDependencies": {
"@genql/runtime": "2.6.0",
"@eslint/eslintrc": "^3.3.0",
"@genql/cli": "2.6.0",
"@genql/runtime": "2.6.0",
"@jest/types": "^29.6.3",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"cross-env": "^7.0.3",
"dotenv-cli": "^8.0.0",
"eslint": "^9.1.0",
"jest": "^29.7.0",
"nodemon": "^3.1.4",
"ts-jest": "^29.3.1",
"dotenv-cli": "^8.0.0",
"ts-node": "^10.9.2",
"typescript": "^5"
}
Expand Down
6 changes: 4 additions & 2 deletions apps/graphql/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { forbiddenAuthScope, ForbiddenAuthScopeArgs } from './authScopes/forbidd
import { modelItemsBelongToWorkspaceScope, ModelItemsBelongToWorkspaceScopeArgs } from './authScopes/modelItemsBelongToWorkspace';
import { userHasAccessOnWorkspaceScope, UserHasAccessOnWorkspaceScopeArgs } from './authScopes/userHasAccessOnWorkspaceScope';
import { Context } from './context';
import PaginationPlugin from './plugins/pagination';
import { Date } from './schema/Date';
import { DateTime } from './schema/DateTime';
import { ID } from './schema/ID';
Expand All @@ -33,7 +34,8 @@ export const builder = new SchemaBuilder<{
}>({
defaultFieldNullability: false,
defaultInputFieldRequiredness: true,
plugins: [ZodPlugin, ScopeAuthPlugin, SimpleObjectsPlugin, DataloaderPlugin],
plugins: [ZodPlugin, ScopeAuthPlugin, SimpleObjectsPlugin, DataloaderPlugin, PaginationPlugin],
pagination: { defaultPageSize: 100 },
scopeAuth: {
defaultStrategy: 'all',
authScopes: async (ctx) => {
Expand All @@ -49,4 +51,4 @@ export const builder = new SchemaBuilder<{

builder.queryType({});

builder.mutationType({});
builder.mutationType({});
3 changes: 1 addition & 2 deletions apps/graphql/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createClerkClient } from '@clerk/backend';
import { PrismaClient } from '@steadystart/prisma';
import { parseSecrets } from '@steadystart/secrets';
import { prisma as productionPrisma } from './prisma';
import { PrismaClient, prisma as productionPrisma } from './prisma';
import { Request } from './types/Request';
import { resolveUserFromContext } from './utils/context/resolveUserFromContext';
import { resolveWorkspaceFromContext } from './utils/context/resolveWorkspaceFromContext';
Expand Down
25 changes: 25 additions & 0 deletions apps/graphql/src/plugins/pagination/global-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FieldKind, FieldNullability, FieldRef, SchemaTypes, ShapeFromTypeParam, TypeParam } from '@pothos/core';
import { PaginationPlugin } from './index';
import { PaginatedField, PaginationFieldOptions, PaginationPluginOptions } from './types';

declare global {
export namespace PothosSchemaTypes {
export interface Plugins<Types extends SchemaTypes> {
pagination: PaginationPlugin<Types>;
}

export interface SchemaBuilderOptions<Types extends SchemaTypes> {
pagination?: PaginationPluginOptions;
}

export interface FieldOptions {
pagination?: PaginationFieldOptions;
}

export interface RootFieldBuilder<Types extends SchemaTypes, ParentShape, Kind extends FieldKind = FieldKind> {
paginatedField: <Type extends TypeParam<Types>, ResolveReturnShape, Nullable extends FieldNullability<Type> = Types['DefaultFieldNullability']>(
options: PaginatedField<Types, ParentShape, Kind, Type, Nullable, ResolveReturnShape>,
) => FieldRef<Types, ShapeFromTypeParam<Types, Type, Nullable>>;
}
}
}
44 changes: 44 additions & 0 deletions apps/graphql/src/plugins/pagination/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import './global-types';
import './schemaBuilder';

import SchemaBuilder, { BasePlugin, PothosOutputFieldConfig, SchemaTypes } from '@pothos/core';
import { PaginationArgs, PaginationResult, PrismaPaginationFn } from '@steadystart/prisma';
import { GraphQLFieldResolver } from 'graphql';

const pluginName = 'pagination' as const;

function isPaginationResult(result: unknown): result is (args: PaginationArgs<Record<string, unknown>>) => Promise<PaginationResult<unknown>> {
return typeof result === 'function';
}

export class PaginationPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
wrapResolve(
resolver: GraphQLFieldResolver<unknown, Types['Context'], object, Promise<PrismaPaginationFn<unknown, unknown>>>,
fieldConfig: PothosOutputFieldConfig<Types>,
): GraphQLFieldResolver<
unknown,
Types['Context'],
{
filter?: { size: number; page?: number };
}
> {
return async (parent, args, context, info) => {
const result = await resolver(parent, args, context, info);

if (isPaginationResult(result)) {
return result({
size: args.filter?.size ?? this.builder.options.pagination?.defaultPageSize ?? fieldConfig.pothosOptions.pagination?.defaultPageSize ?? 100,
page: args.filter?.page ?? 1,
cursor: undefined,
});
}

return result;
};
}
}

SchemaBuilder.registerPlugin(pluginName, PaginationPlugin);

// eslint-disable-next-line import/no-default-export
export default pluginName;
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { SchemaTypes } from '@pothos/core';

export type GetOrCreatePaginationInputTypeRefArgs = {
builder: PothosSchemaTypes.SchemaBuilder<SchemaTypes>;
name: string;
};

export const getOrCreatePaginationInputTypeRef = ({ builder, name }: GetOrCreatePaginationInputTypeRefArgs) => {
try {
return builder.configStore.getInputTypeRef(name);
} catch {
return builder.inputType(name, {
fields: (t) => ({
size: t.int({ required: false }),
page: t.int({ required: false }),
}),
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { OutputType, SchemaTypes, TypeParam } from '@pothos/core';

export type GetOrCreateTypeWithAggregationRefArgs = {
builder: PothosSchemaTypes.SchemaBuilder<SchemaTypes>;
name: string;
type: TypeParam<SchemaTypes>;
};

export const getOrCreateTypeWithAggregationRef = ({ builder, name, type }: GetOrCreateTypeWithAggregationRefArgs): OutputType<SchemaTypes> => {
try {
return builder.configStore.getOutputTypeRef(name);
} catch {
return builder.simpleObject(name, {
fields: (t) => ({
page: t.field({ type: 'Int' }),
size: t.field({ type: 'Int' }),
totalSize: t.field({ type: 'Int' }),
rows: t.field({
type,
}),
}),
});
}
};
26 changes: 26 additions & 0 deletions apps/graphql/src/plugins/pagination/schemaBuilder/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { RootFieldBuilder, SchemaTypes } from '@pothos/core';
import { getOrCreatePaginationInputTypeRef } from './getOrCreatePaginationInputTypeRef';
import { getOrCreateTypeWithAggregationRef } from './getOrCreateTypeWithAggregationRef';

const rootBuilderProto = RootFieldBuilder.prototype as unknown as PothosSchemaTypes.RootFieldBuilder<SchemaTypes, unknown, 'Query' | 'Object'>;

const upperCaseFirst = (input: string) => input.charAt(0).toUpperCase() + input.slice(1);

rootBuilderProto.paginatedField = function (fieldOptions) {
if (!Array.isArray(fieldOptions.type) || typeof fieldOptions.type[0] !== 'object' || !('name' in fieldOptions.type[0])) {
// eslint-disable-next-line no-restricted-syntax
throw new Error(`Type of field with pagination must be list e.g. type: [SomeType]`);
}

const paginationInputTypeRef = getOrCreatePaginationInputTypeRef({ builder: this.builder, name: 'PaginationInput' });

const typeWithAggregationRef = getOrCreateTypeWithAggregationRef({
builder: this.builder,
name: upperCaseFirst(`Paginated${fieldOptions.type[0].name}s`),
type: fieldOptions.type,
});

const paginationArgsGroup = { filter: this.arg({ type: paginationInputTypeRef, required: false }) };

return this.field({ ...fieldOptions, type: typeWithAggregationRef, args: { ...fieldOptions.args, ...paginationArgsGroup } } as never);
};
102 changes: 102 additions & 0 deletions apps/graphql/src/plugins/pagination/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
FieldKind,
FieldNullability,
InputFieldMap,
InputShapeFromFields,
MaybePromise,
SchemaTypes,
ShapeFromTypeParam,
TypeParam,
} from '@pothos/core';
import { PrismaPaginationFn } from '@steadystart/prisma';
import { GraphQLResolveInfo } from 'graphql';

export type PaginationPluginOptions = {
defaultPageSize?: number;
};

export type PaginationFieldOptions = {
defaultPageSize?: number;
};

export interface ObjectFieldOptions<
Types extends SchemaTypes,
ParentShape,
Type extends TypeParam<Types>,
Nullable extends FieldNullability<Type>,
Args extends InputFieldMap,
ResolveReturnShape,
> extends PothosSchemaTypes.FieldOptions<Types, ParentShape, Type, Nullable, Args, ParentShape, ResolveReturnShape> {}

export interface QueryFieldOptions<
Types extends SchemaTypes,
Type extends TypeParam<Types>,
Nullable extends FieldNullability<Type>,
Args extends InputFieldMap,
ResolveReturnShape,
> extends PothosSchemaTypes.FieldOptions<Types, Types['Root'], Type, Nullable, Args, Types['Root'], ResolveReturnShape> {}

type Resolver<Parent, Args, Context, Type> = (
parent: Parent,
args: Args,
context: Context,
info: GraphQLResolveInfo,
) => [Type] extends [readonly (infer Item)[] | null | undefined]
? MaybePromise<PrismaPaginationFn<any, Item[]>>
: { __error: 'Pagination needs to be enable for lists only.' };

interface InferredFieldOptions<
Types extends SchemaTypes,
ResolveShape = unknown,
Type extends TypeParam<Types> = TypeParam<Types>,
Nullable extends FieldNullability<Type> = FieldNullability<Type>,
Args extends InputFieldMap = InputFieldMap,
> {
Resolve: {
/**
* Resolver function for this field
* @param parent - The parent object for the current type
* @param {object} args - args object based on the args defined for this field
* @param {object} context - the context object for the current query, based on `Context` type provided to the SchemaBuilder
* @param {GraphQLResolveInfo} info - info about how this field was queried
*/
resolve: Resolver<ResolveShape, InputShapeFromFields<Args>, Types['Context'], ShapeFromTypeParam<Types, Type, Nullable>>;
};
}

export type InferredFieldOptionsKind<Types extends SchemaTypes = SchemaTypes> = keyof InferredFieldOptions<Types>;

export type InferredFieldOptionsByKind<
Types extends SchemaTypes,
Kind extends InferredFieldOptionsKind,
ResolveShape = unknown,
Type extends TypeParam<Types> = TypeParam<Types>,
Nullable extends FieldNullability<Type> = FieldNullability<Type>,
Args extends InputFieldMap = InputFieldMap,
> = InferredFieldOptions<Types, ResolveShape, Type, Nullable, Args>[Kind];

export interface FieldOptionsByKind<
Types extends SchemaTypes,
ParentShape,
Type extends TypeParam<Types>,
Nullable extends FieldNullability<Type>,
Args extends InputFieldMap,
ResolveReturnShape,
> {
Query: QueryFieldOptions<Types, Type, Nullable, Args, ResolveReturnShape> &
InferredFieldOptionsByKind<Types, Types['InferredFieldOptionsKind'], Types['Root'], Type, Nullable, Args>;
Object: ObjectFieldOptions<Types, ParentShape, Type, Nullable, Args, ResolveReturnShape> &
InferredFieldOptionsByKind<Types, Types['InferredFieldOptionsKind'], ParentShape, Type, Nullable, Args>;
}

export type PaginatedField<
Types extends SchemaTypes,
ParentShape,
Kind extends FieldKind,
Type extends TypeParam<Types>,
Nullable extends FieldNullability<Type>,
ResolveReturnShape,
Args extends InputFieldMap = InputFieldMap,
> = Kind extends 'Query' | 'Object'
? FieldOptionsByKind<Types, ParentShape, Type, Nullable, Args, ResolveReturnShape>[Kind]
: { __error: 'Pagination needs to be used with Query or Object.' };
5 changes: 3 additions & 2 deletions apps/graphql/src/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PrismaClient } from '@steadystart/prisma';
import { createPrismaClient } from '@steadystart/prisma';
export type { PrismaClient } from '@steadystart/prisma';

export const prisma = new PrismaClient();
export const prisma = createPrismaClient({});
4 changes: 1 addition & 3 deletions apps/graphql/src/resolvers/post/queries/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ builder.queryField('posts', (t) =>
userHasAccessOnWorkspace: true,
},
resolve: async (_parent, _args, ctx) => {
const posts = await ctx.prisma.post.findMany({
return ctx.prisma.post.findMany({
where: {
workspaceId: ctx.workspace!.id,
},
});

return posts;
},
}),
);
Loading