diff --git a/.changeset/auto-ef54ba0af9d06c7b.md b/.changeset/auto-ef54ba0af9d06c7b.md new file mode 100644 index 0000000..90afb32 --- /dev/null +++ b/.changeset/auto-ef54ba0af9d06c7b.md @@ -0,0 +1,5 @@ +--- +"@vyrel/morph": minor +--- + +remove some non necessary params diff --git a/apps/docs/content/docs/public-packages/morph/api-reference.mdx b/apps/docs/content/docs/public-packages/morph/api-reference.mdx index c45dd66..1510600 100644 --- a/apps/docs/content/docs/public-packages/morph/api-reference.mdx +++ b/apps/docs/content/docs/public-packages/morph/api-reference.mdx @@ -10,12 +10,11 @@ Creates a bridge bound to one Pothos builder. | Option | Purpose | | --- | --- | | `defaultIdFields` | Field names exposed as GraphQL `ID`; model options can override them | -| `defaultEnumName` | Naming strategy for enums generated from a model field | -| `scalarTypes` | Additional named input/scalar types available to mappings | -| `unmappedFields` | Default `throw`, `warn`, or `omit` behavior | +| `unmappedFields` | Optional mapping failure policy (`throw` by default, or `warn` / `omit`) | Built-in GraphQL input names and scalar types already registered on the builder -are discovered during initialization. +are discovered during initialization. Generated enum names default to +`` `${objectName}${PascalCase(field)}` ``; override per model with `enumName`. The returned bridge exposes `model`, the equivalent `fields` alias, and a bridge-level `inputsFrom` helper. @@ -44,10 +43,28 @@ bridge-level `inputsFrom` helper. | `argsFrom(schema, options?)` | Typed Pothos query argument map | | `computedEnumField(t, field, options)` | One resolver-backed enum field | | `args` | Named pre-built args when `listArgsSchema` is configured | +| `connection({ type, ... })` | Forward-cursor Connection + PageInfo object refs | + +### `model.connection(options)` + +Call after the node object exists: + +```ts +const TaskConnection = taskGraphql.connection({ type: TaskObject }); +``` + +Defaults: `` `${objectName}Connection` `` / `` `${objectName}PageInfo` `` with +`nodes` and `{ endCursor, hasNextPage }`. Pass `fields` to extend the +Connection. Export types `ConnectionPayload` / `ConnectionPageInfo` for +service return shapes. This is not full Relay (`edges` / bi-directional +PageInfo). Input/argument options support `exclude`, `fieldTypes`, `required`, and `unmappedFields`. Model-derived helpers also receive the model's enum registry. +`z.array(z.string())` and `z.array(z.enum(...))` map to GraphQL lists +automatically when enums are already on the builder. + ## Automatic mapping | Zod field | GraphQL mapping | @@ -58,6 +75,8 @@ Input/argument options support `exclude`, `fieldTypes`, `required`, and | other `z.number()` | `Float` | | `z.date()` | `DateTime` | | `z.enum()` / supported literals | Registered GraphQL enum | +| `z.array(z.string())` | `[String]` | +| `z.array(z.enum(...))` | `[Enum]` when values match a builder enum | | Optional or nullable wrapper | Nullable GraphQL field | | `.describe()` | GraphQL description | diff --git a/apps/docs/content/docs/public-packages/morph/index.mdx b/apps/docs/content/docs/public-packages/morph/index.mdx index 63eea05..bcc00e8 100644 --- a/apps/docs/content/docs/public-packages/morph/index.mdx +++ b/apps/docs/content/docs/public-packages/morph/index.mdx @@ -31,7 +31,6 @@ const builder = new SchemaBuilder({ const bridge = initializeDrizzleGraphqlBridge(builder, { defaultIdFields: ["id"], - unmappedFields: "throw", }); const userSchema = z.object({ diff --git a/apps/extension/schema.graphql b/apps/extension/schema.graphql index 3674fcb..d86d3ac 100644 --- a/apps/extension/schema.graphql +++ b/apps/extension/schema.graphql @@ -5,7 +5,7 @@ input CreateTask { dueDate: LocalDate image: File labelIds: [String!] - organizationId: String! + organizationId: ID! priority: TaskPriority status: TaskStatus title: String! @@ -15,7 +15,7 @@ input CreateTask { input CreateTaskLabel { color: String! name: String! - organizationId: String! + organizationId: ID! } """ @@ -117,7 +117,7 @@ type Task { imageFull: String imageThumb: String labels: [TaskLabel!]! - organizationId: String! + organizationId: ID! priority: TaskPriority! status: TaskStatus! title: String! @@ -135,7 +135,7 @@ type TaskLabel { createdAt: DateTime! id: ID! name: String! - organizationId: String! + organizationId: ID! } type TaskPageInfo { diff --git a/apps/mobile/schema.graphql b/apps/mobile/schema.graphql index 3674fcb..d86d3ac 100644 --- a/apps/mobile/schema.graphql +++ b/apps/mobile/schema.graphql @@ -5,7 +5,7 @@ input CreateTask { dueDate: LocalDate image: File labelIds: [String!] - organizationId: String! + organizationId: ID! priority: TaskPriority status: TaskStatus title: String! @@ -15,7 +15,7 @@ input CreateTask { input CreateTaskLabel { color: String! name: String! - organizationId: String! + organizationId: ID! } """ @@ -117,7 +117,7 @@ type Task { imageFull: String imageThumb: String labels: [TaskLabel!]! - organizationId: String! + organizationId: ID! priority: TaskPriority! status: TaskStatus! title: String! @@ -135,7 +135,7 @@ type TaskLabel { createdAt: DateTime! id: ID! name: String! - organizationId: String! + organizationId: ID! } type TaskPageInfo { diff --git a/apps/web/graphql-env.d.ts b/apps/web/graphql-env.d.ts index db7dd28..b52ddcd 100644 --- a/apps/web/graphql-env.d.ts +++ b/apps/web/graphql-env.d.ts @@ -3,8 +3,8 @@ export type introspection_types = { 'Boolean': unknown; - 'CreateTask': { kind: 'INPUT_OBJECT'; name: 'CreateTask'; isOneOf: false; inputFields: [{ name: 'assigneeId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'dueDate'; type: { kind: 'SCALAR'; name: 'LocalDate'; ofType: null; }; defaultValue: null }, { name: 'image'; type: { kind: 'SCALAR'; name: 'File'; ofType: null; }; defaultValue: null }, { name: 'labelIds'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; defaultValue: null }, { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'priority'; type: { kind: 'ENUM'; name: 'TaskPriority'; ofType: null; }; defaultValue: null }, { name: 'status'; type: { kind: 'ENUM'; name: 'TaskStatus'; ofType: null; }; defaultValue: null }, { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; - 'CreateTaskLabel': { kind: 'INPUT_OBJECT'; name: 'CreateTaskLabel'; isOneOf: false; inputFields: [{ name: 'color'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; + 'CreateTask': { kind: 'INPUT_OBJECT'; name: 'CreateTask'; isOneOf: false; inputFields: [{ name: 'assigneeId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'dueDate'; type: { kind: 'SCALAR'; name: 'LocalDate'; ofType: null; }; defaultValue: null }, { name: 'image'; type: { kind: 'SCALAR'; name: 'File'; ofType: null; }; defaultValue: null }, { name: 'labelIds'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; defaultValue: null }, { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; }; defaultValue: null }, { name: 'priority'; type: { kind: 'ENUM'; name: 'TaskPriority'; ofType: null; }; defaultValue: null }, { name: 'status'; type: { kind: 'ENUM'; name: 'TaskStatus'; ofType: null; }; defaultValue: null }, { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; + 'CreateTaskLabel': { kind: 'INPUT_OBJECT'; name: 'CreateTaskLabel'; isOneOf: false; inputFields: [{ name: 'color'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; }; defaultValue: null }]; }; 'DateTime': unknown; 'DeleteOrganization': { kind: 'INPUT_OBJECT'; name: 'DeleteOrganization'; isOneOf: false; inputFields: [{ name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'DeleteTask': { kind: 'INPUT_OBJECT'; name: 'DeleteTask'; isOneOf: false; inputFields: [{ name: 'taskId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; @@ -19,9 +19,9 @@ export type introspection_types = { 'Organization': { kind: 'OBJECT'; name: 'Organization'; fields: { 'createdAt': { name: 'createdAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'imageFull': { name: 'imageFull'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'imageThumb': { name: 'imageThumb'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'logo': { name: 'logo'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'metadata': { name: 'metadata'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'slug': { name: 'slug'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'health': { name: 'health'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'organization': { name: 'organization'; type: { kind: 'OBJECT'; name: 'Organization'; ofType: null; } }; 'organizations': { name: 'organizations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Organization'; ofType: null; }; }; }; } }; 'task': { name: 'task'; type: { kind: 'OBJECT'; name: 'Task'; ofType: null; } }; 'taskConnection': { name: 'taskConnection'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskConnection'; ofType: null; }; } }; 'taskLabels': { name: 'taskLabels'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskLabel'; ofType: null; }; }; }; } }; 'taskSummary': { name: 'taskSummary'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskSummary'; ofType: null; }; } }; 'tasks': { name: 'tasks'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Task'; ofType: null; }; }; }; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; }; }; 'String': unknown; - 'Task': { kind: 'OBJECT'; name: 'Task'; fields: { 'assignee': { name: 'assignee'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'assigneeId': { name: 'assigneeId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'createdById': { name: 'createdById'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'description': { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'dueDate': { name: 'dueDate'; type: { kind: 'SCALAR'; name: 'LocalDate'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'imageFull': { name: 'imageFull'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'imageThumb': { name: 'imageThumb'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'labels': { name: 'labels'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskLabel'; ofType: null; }; }; }; } }; 'organizationId': { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'priority': { name: 'priority'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TaskPriority'; ofType: null; }; } }; 'status': { name: 'status'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TaskStatus'; ofType: null; }; } }; 'title': { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; }; }; + 'Task': { kind: 'OBJECT'; name: 'Task'; fields: { 'assignee': { name: 'assignee'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'assigneeId': { name: 'assigneeId'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'createdById': { name: 'createdById'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'description': { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'dueDate': { name: 'dueDate'; type: { kind: 'SCALAR'; name: 'LocalDate'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'imageFull': { name: 'imageFull'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'imageThumb': { name: 'imageThumb'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'labels': { name: 'labels'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskLabel'; ofType: null; }; }; }; } }; 'organizationId': { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'priority': { name: 'priority'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TaskPriority'; ofType: null; }; } }; 'status': { name: 'status'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TaskStatus'; ofType: null; }; } }; 'title': { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; }; }; 'TaskConnection': { kind: 'OBJECT'; name: 'TaskConnection'; fields: { 'nodes': { name: 'nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Task'; ofType: null; }; }; }; } }; 'pageInfo': { name: 'pageInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TaskPageInfo'; ofType: null; }; } }; }; }; - 'TaskLabel': { kind: 'OBJECT'; name: 'TaskLabel'; fields: { 'color': { name: 'color'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'organizationId': { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; + 'TaskLabel': { kind: 'OBJECT'; name: 'TaskLabel'; fields: { 'color': { name: 'color'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'organizationId': { name: 'organizationId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; }; }; 'TaskPageInfo': { kind: 'OBJECT'; name: 'TaskPageInfo'; fields: { 'endCursor': { name: 'endCursor'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'hasNextPage': { name: 'hasNextPage'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; }; }; 'TaskPriority': { name: 'TaskPriority'; enumValues: 'HIGH' | 'LOW' | 'MEDIUM' | 'NONE'; }; 'TaskSort': { name: 'TaskSort'; enumValues: 'DUE_DATE' | 'NEWEST' | 'PRIORITY' | 'RECENTLY_UPDATED'; }; diff --git a/apps/web/schema.graphql b/apps/web/schema.graphql index 3674fcb..d86d3ac 100644 --- a/apps/web/schema.graphql +++ b/apps/web/schema.graphql @@ -5,7 +5,7 @@ input CreateTask { dueDate: LocalDate image: File labelIds: [String!] - organizationId: String! + organizationId: ID! priority: TaskPriority status: TaskStatus title: String! @@ -15,7 +15,7 @@ input CreateTask { input CreateTaskLabel { color: String! name: String! - organizationId: String! + organizationId: ID! } """ @@ -117,7 +117,7 @@ type Task { imageFull: String imageThumb: String labels: [TaskLabel!]! - organizationId: String! + organizationId: ID! priority: TaskPriority! status: TaskStatus! title: String! @@ -135,7 +135,7 @@ type TaskLabel { createdAt: DateTime! id: ID! name: String! - organizationId: String! + organizationId: ID! } type TaskPageInfo { diff --git a/apps/web/src/graphql/generated/client-schema.ts b/apps/web/src/graphql/generated/client-schema.ts index bfb30ca..1fb7742 100644 --- a/apps/web/src/graphql/generated/client-schema.ts +++ b/apps/web/src/graphql/generated/client-schema.ts @@ -348,7 +348,7 @@ export const graphqlClientSchema = { "kind": "NON_NULL", "ofType": { "kind": "NAMED", - "name": "String" + "name": "ID" } } }, @@ -465,7 +465,7 @@ export const graphqlClientSchema = { "kind": "NON_NULL", "ofType": { "kind": "NAMED", - "name": "String" + "name": "ID" } } } diff --git a/bun.lock b/bun.lock index 6753acb..d631ccf 100644 --- a/bun.lock +++ b/bun.lock @@ -308,6 +308,7 @@ "@vyrel/env": "workspace:*", "@vyrel/graphql": "workspace:*", "@vyrel/logging": "workspace:*", + "@vyrel/morph": "workspace:*", "@vyrel/storage": "workspace:*", "better-auth": "catalog:", "dotenv": "catalog:", diff --git a/packages/api/package.json b/packages/api/package.json index 424ec36..568bfc7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,6 +23,7 @@ "@vyrel/env": "workspace:*", "@vyrel/graphql": "workspace:*", "@vyrel/logging": "workspace:*", + "@vyrel/morph": "workspace:*", "@vyrel/storage": "workspace:*", "better-auth": "catalog:", "dotenv": "catalog:", diff --git a/packages/api/src/models/task/graphql/mutations/create-label.ts b/packages/api/src/models/task/graphql/mutations/create-label.ts index 52d04e8..2874fef 100644 --- a/packages/api/src/models/task/graphql/mutations/create-label.ts +++ b/packages/api/src/models/task/graphql/mutations/create-label.ts @@ -4,12 +4,12 @@ import { builder } from "@vyrel/graphql/pothos"; import { createTaskLabel } from "../../services/label.service"; import { taskLabelCreateSchema } from "../../types/base.types"; import { runTaskGraphqlEffect } from "../effect"; -import { TaskLabelObject, taskLabelGraphql } from "../task.query"; +import { TaskLabelObject, taskGraphql } from "../task.query"; builder.mutationFields((t) => ({ createTaskLabel: t.fieldWithInput({ input: { - ...taskLabelGraphql.inputsFrom(taskLabelCreateSchema), + ...taskGraphql.label.inputsFrom(taskLabelCreateSchema), }, resolve: (_root, args, context) => runTaskGraphqlEffect( diff --git a/packages/api/src/models/task/graphql/mutations/create.ts b/packages/api/src/models/task/graphql/mutations/create.ts index 7c0f5f2..2fe51a1 100644 --- a/packages/api/src/models/task/graphql/mutations/create.ts +++ b/packages/api/src/models/task/graphql/mutations/create.ts @@ -15,11 +15,7 @@ const typeOptionsMetadata = { builder.mutationFields((t) => ({ createTask: t.fieldWithInput({ input: { - ...taskGraphql.inputsFrom(taskCreateSchema, { - fieldTypes: { - labelIds: ["String"], - }, - }), + ...taskGraphql.task.inputsFrom(taskCreateSchema), }, resolve: (_root, args, context) => runTaskGraphqlEffect( diff --git a/packages/api/src/models/task/graphql/mutations/delete-label.ts b/packages/api/src/models/task/graphql/mutations/delete-label.ts index 337bf83..8c8f890 100644 --- a/packages/api/src/models/task/graphql/mutations/delete-label.ts +++ b/packages/api/src/models/task/graphql/mutations/delete-label.ts @@ -4,12 +4,12 @@ import { builder } from "@vyrel/graphql/pothos"; import { deleteTaskLabel } from "../../services/label.service"; import { taskLabelDeleteSchema } from "../../types/base.types"; import { runTaskGraphqlEffect } from "../effect"; -import { taskLabelGraphql } from "../task.query"; +import { taskGraphql } from "../task.query"; builder.mutationFields((t) => ({ deleteTaskLabel: t.fieldWithInput({ input: { - ...taskLabelGraphql.inputsFrom(taskLabelDeleteSchema), + ...taskGraphql.label.inputsFrom(taskLabelDeleteSchema), }, resolve: (_root, args, context) => runTaskGraphqlEffect( diff --git a/packages/api/src/models/task/graphql/mutations/update-label.ts b/packages/api/src/models/task/graphql/mutations/update-label.ts index a19c924..d2e1dd1 100644 --- a/packages/api/src/models/task/graphql/mutations/update-label.ts +++ b/packages/api/src/models/task/graphql/mutations/update-label.ts @@ -4,12 +4,12 @@ import { builder } from "@vyrel/graphql/pothos"; import { updateTaskLabel } from "../../services/label.service"; import { taskLabelUpdateSchema } from "../../types/base.types"; import { runTaskGraphqlEffect } from "../effect"; -import { TaskLabelObject, taskLabelGraphql } from "../task.query"; +import { TaskLabelObject, taskGraphql } from "../task.query"; builder.mutationFields((t) => ({ updateTaskLabel: t.fieldWithInput({ input: { - ...taskLabelGraphql.inputsFrom(taskLabelUpdateSchema), + ...taskGraphql.label.inputsFrom(taskLabelUpdateSchema), }, resolve: (_root, args, context) => runTaskGraphqlEffect( diff --git a/packages/api/src/models/task/graphql/mutations/update.ts b/packages/api/src/models/task/graphql/mutations/update.ts index 7686a92..7ee7ed7 100644 --- a/packages/api/src/models/task/graphql/mutations/update.ts +++ b/packages/api/src/models/task/graphql/mutations/update.ts @@ -15,11 +15,7 @@ const typeOptionsMetadata = { builder.mutationFields((t) => ({ updateTask: t.fieldWithInput({ input: { - ...taskGraphql.inputsFrom(taskUpdateSchema, { - fieldTypes: { - labelIds: ["String"], - }, - }), + ...taskGraphql.task.inputsFrom(taskUpdateSchema), }, resolve: (_root, args, context) => runTaskGraphqlEffect( diff --git a/packages/api/src/models/task/graphql/task.query.ts b/packages/api/src/models/task/graphql/task.query.ts index 6820aaf..7a05d31 100644 --- a/packages/api/src/models/task/graphql/task.query.ts +++ b/packages/api/src/models/task/graphql/task.query.ts @@ -1,12 +1,5 @@ import { db } from "@vyrel/db"; -import { - TASK_PRIORITIES, - TASK_STATUSES, - type task, - taskLabel, - taskLabelAssignment, - user, -} from "@vyrel/db/schema"; +import { taskLabel, taskLabelAssignment, user } from "@vyrel/db/schema"; import { requireActorUserId } from "@vyrel/graphql/context"; import { graphqlBridge } from "@vyrel/graphql/graphql-bridge"; import { builder } from "@vyrel/graphql/pothos"; @@ -23,10 +16,10 @@ import { } from "../services/read.service"; import { taskLabelQuerySchema, taskQuerySchema } from "../types/base.types"; import { - TASK_SORTS, taskByIdSchema, + taskConnectionArgsSchema, taskConnectionSchema, - taskListGraphqlFiltersSchema, + taskListArgsSchema, tasksByOrganizationSchema, } from "../types/extra.types"; import { runTaskGraphqlEffect } from "./effect"; @@ -42,45 +35,37 @@ const metadata = { }, }; -export const TaskStatusEnum = builder.enumType("TaskStatus", { - values: TASK_STATUSES, -}); - -export const TaskPriorityEnum = builder.enumType("TaskPriority", { - values: TASK_PRIORITIES, -}); - -export const TaskSortEnum = builder.enumType("TaskSort", { - values: TASK_SORTS, -}); - -export const taskGraphql = graphqlBridge.model({ - listArgsSchema: { - filters: taskListGraphqlFiltersSchema, - }, - objectName: metadata.objectName, - rowSchema: taskQuerySchema, -}); - -export const taskLabelGraphql = graphqlBridge.model({ - objectName: "TaskLabel", - rowSchema: taskLabelQuerySchema, -}); +export const taskGraphql = { + task: graphqlBridge.model({ + idFields: ["id", "organizationId"], + listArgsSchema: { + connection: taskConnectionArgsSchema, + list: taskListArgsSchema, + }, + objectName: "Task", + rowSchema: taskQuerySchema, + }), + label: graphqlBridge.model({ + idFields: ["id", "organizationId"], + objectName: "TaskLabel", + rowSchema: taskLabelQuerySchema, + }), +}; export const TaskLabelObject = builder.drizzleObject("taskLabel", { description: "A reusable organization-scoped task label.", fields: (t) => ({ - ...taskLabelGraphql.exposeFields(t, { exclude: [] }), + ...taskGraphql.label.exposeFields(t), }), name: "TaskLabel", }); export const TaskObject = builder.drizzleObject("task", { + name: metadata.objectName, description: metadata.description, + fields: (t) => ({ - ...taskGraphql.exposeFields(t, { - exclude: ["dueDate"], - }), + ...taskGraphql.task.exposeFields(t), assignee: t.field({ nullable: true, resolve: (row) => { @@ -91,11 +76,6 @@ export const TaskObject = builder.drizzleObject("task", { }, type: UserObject, }), - dueDate: t.field({ - nullable: true, - resolve: (row) => row.dueDate, - type: "LocalDate", - }), imageFull: t.field({ nullable: true, resolve: (row) => { @@ -134,42 +114,9 @@ export const TaskObject = builder.drizzleObject("task", { type: [TaskLabelObject], }), }), - name: metadata.objectName, }); -type TaskConnectionShape = { - nodes: (typeof task.$inferSelect)[]; - pageInfo: { - endCursor: string | null; - hasNextPage: boolean; - }; -}; - -const TaskPageInfoObject = builder - .objectRef("TaskPageInfo") - .implement({ - fields: (t) => ({ - endCursor: t.exposeString("endCursor", { nullable: true }), - hasNextPage: t.exposeBoolean("hasNextPage", { nullable: false }), - }), - }); - -const TaskConnectionObject = builder - .objectRef("TaskConnection") - .implement({ - fields: (t) => ({ - nodes: t.field({ - nullable: false, - resolve: (connection) => connection.nodes, - type: [TaskObject], - }), - pageInfo: t.field({ - nullable: false, - resolve: (connection) => connection.pageInfo, - type: TaskPageInfoObject, - }), - }), - }); +const TaskConnectionObject = taskGraphql.task.connection({ type: TaskObject }); type TaskSummaryShape = { done: number; @@ -209,15 +156,7 @@ builder.queryFields((t) => ({ }), tasks: t.field({ args: { - labelIds: t.arg.stringList(), - organizationId: t.arg.id({ required: true }), - priorities: t.arg({ - type: [TaskPriorityEnum], - }), - statuses: t.arg({ - type: [TaskStatusEnum], - }), - ...taskGraphql.args.filters, + ...taskGraphql.task.args.list, }, description: metadata.tasks.description, nullable: false, @@ -235,17 +174,7 @@ builder.queryFields((t) => ({ }), taskConnection: t.field({ args: { - after: t.arg.string(), - first: t.arg.int(), - labelIds: t.arg.stringList(), - organizationId: t.arg.id({ required: true }), - priorities: t.arg({ - type: [TaskPriorityEnum], - }), - statuses: t.arg({ - type: [TaskStatusEnum], - }), - ...taskGraphql.args.filters, + ...taskGraphql.task.args.connection, }, description: "List tasks using an opaque cursor and deterministic ordering.", diff --git a/packages/api/src/models/task/services/read.service.ts b/packages/api/src/models/task/services/read.service.ts index dccc445..cebd602 100644 --- a/packages/api/src/models/task/services/read.service.ts +++ b/packages/api/src/models/task/services/read.service.ts @@ -1,5 +1,6 @@ import { db } from "@vyrel/db"; import { task, taskLabelAssignment } from "@vyrel/db/schema"; +import type { ConnectionPayload } from "@vyrel/morph"; import { and, asc, @@ -24,6 +25,8 @@ import type { import { assertOrgMembership, fetchTaskForUser } from "../utils/auth-api"; import { TaskRepositoryError } from "../utils/errors"; +type TaskConnectionResult = ConnectionPayload<(typeof task)["$inferSelect"]>; + export const getTask = (input: TaskTypeById, actorUserId: string) => fetchTaskForUser(input.id, actorUserId); @@ -283,7 +286,7 @@ export const listTaskConnection = ( }), hasNextPage, }, - }; + } satisfies TaskConnectionResult; }); export const getTaskSummary = (organizationId: string, actorUserId: string) => diff --git a/packages/api/src/models/task/types/base.types.ts b/packages/api/src/models/task/types/base.types.ts index ac69c6b..a8cb572 100644 --- a/packages/api/src/models/task/types/base.types.ts +++ b/packages/api/src/models/task/types/base.types.ts @@ -7,16 +7,6 @@ import { import { createInsertSchema, createSelectSchema } from "drizzle-orm/zod"; import z from "zod/v4"; -export const taskQuerySchema = createSelectSchema(task).omit({ - imageAssetId: true, - imageFull: true, - imageThumb: true, - imagePlaceholder: true, -}); - -const taskInsertSchema = createInsertSchema(task); -const taskLabelInsertSchema = createInsertSchema(taskLabel); - const LOCAL_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export const localDateSchema = z @@ -36,6 +26,20 @@ export const localDateSchema = z }, "Date must be a valid calendar date") .meta({ pothosType: "LocalDate" }); +export const taskQuerySchema = createSelectSchema(task) + .omit({ + imageAssetId: true, + imageFull: true, + imageThumb: true, + imagePlaceholder: true, + }) + .extend({ + dueDate: localDateSchema.nullable(), + }); + +const taskInsertSchema = createInsertSchema(task); +const taskLabelInsertSchema = createInsertSchema(taskLabel); + export const taskStatusSchema = z.enum(TASK_STATUSES); export const taskPrioritySchema = z.enum(TASK_PRIORITIES); @@ -58,7 +62,10 @@ export const taskCreateSchema = taskInsertSchema .optional() .meta({ pothosType: "File" }), labelIds: z.array(z.string().min(1)).max(20).default([]), - organizationId: z.string().min(1, "Organization id is required"), + organizationId: z + .string() + .min(1, "Organization id is required") + .meta({ pothosType: "ID" }), priority: taskPrioritySchema.default("NONE"), status: taskStatusSchema.default("TODO"), title: z.string().trim().min(1, "Title is required"), @@ -108,7 +115,7 @@ export const taskLabelCreateSchema = taskLabelInsertSchema .trim() .regex(/^#[0-9A-Fa-f]{6}$/, "Invalid label color"), name: z.string().trim().min(1).max(32), - organizationId: z.string().min(1), + organizationId: z.string().min(1).meta({ pothosType: "ID" }), }); export const taskLabelUpdateSchema = taskLabelCreateSchema diff --git a/packages/api/src/models/task/types/extra.types.ts b/packages/api/src/models/task/types/extra.types.ts index 3ae18db..2761e34 100644 --- a/packages/api/src/models/task/types/extra.types.ts +++ b/packages/api/src/models/task/types/extra.types.ts @@ -17,7 +17,7 @@ export const taskByIdSchema = z.object({ id: z.string().min(1), }); -/** Plain object shape for GraphQL list args (morph). */ +/** Plain object shape for GraphQL list filters (morph). */ export const taskListFiltersSchema = z.object({ assigneeId: z.string().min(1).optional(), createdFrom: z.coerce.date().optional(), @@ -31,10 +31,17 @@ export const taskListFiltersSchema = z.object({ statuses: z.array(taskStatusSchema).optional(), }); -export const taskListGraphqlFiltersSchema = taskListFiltersSchema.omit({ - labelIds: true, - priorities: true, - statuses: true, +/** GraphQL args for `tasks` (refines applied at parse time). */ +export const taskListArgsSchema = z + .object({ + organizationId: z.string().min(1).meta({ pothosType: "ID" }), + }) + .extend(taskListFiltersSchema.shape); + +/** GraphQL args for `taskConnection` (refines applied at parse time). */ +export const taskConnectionArgsSchema = taskListArgsSchema.extend({ + after: z.string().optional(), + first: z.number().int().min(1).max(100).default(30), }); const createdFromBeforeCreatedTo = (value: { @@ -47,30 +54,33 @@ const createdFromBeforeCreatedTo = (value: { return value.createdFrom.getTime() <= value.createdTo.getTime(); }; -export const tasksByOrganizationSchema = z - .object({ - organizationId: z.string().min(1), - }) - .extend(taskListFiltersSchema.shape) +const dueFromBeforeDueTo = (value: { + dueFrom?: string | undefined; + dueTo?: string | undefined; +}): boolean => + value.dueFrom === undefined || + value.dueTo === undefined || + value.dueFrom <= value.dueTo; + +export const tasksByOrganizationSchema = taskListArgsSchema .refine(createdFromBeforeCreatedTo, { message: "createdFrom must be on or before createdTo", path: ["createdFrom"], }) - .refine( - (value) => - value.dueFrom === undefined || - value.dueTo === undefined || - value.dueFrom <= value.dueTo, - { - message: "dueFrom must be on or before dueTo", - path: ["dueFrom"], - } - ); + .refine(dueFromBeforeDueTo, { + message: "dueFrom must be on or before dueTo", + path: ["dueFrom"], + }); -export const taskConnectionSchema = tasksByOrganizationSchema.extend({ - after: z.string().optional(), - first: z.number().int().min(1).max(100).default(30), -}); +export const taskConnectionSchema = taskConnectionArgsSchema + .refine(createdFromBeforeCreatedTo, { + message: "createdFrom must be on or before createdTo", + path: ["createdFrom"], + }) + .refine(dueFromBeforeDueTo, { + message: "dueFrom must be on or before dueTo", + path: ["dueFrom"], + }); export type TaskTypeById = z.infer; export type TaskListFilters = z.infer; diff --git a/packages/graphql/src/graphql-bridge.ts b/packages/graphql/src/graphql-bridge.ts index 8bbee40..4cb45fa 100644 --- a/packages/graphql/src/graphql-bridge.ts +++ b/packages/graphql/src/graphql-bridge.ts @@ -3,5 +3,4 @@ import { builder } from "./pothos"; export const graphqlBridge = initializeDrizzleGraphqlBridge(builder, { defaultIdFields: ["id", "orgId"], - unmappedFields: "throw", }); diff --git a/packages/public/morph/CHANGELOG.md b/packages/public/morph/CHANGELOG.md index 2ea8fa9..96d3fa1 100644 --- a/packages/public/morph/CHANGELOG.md +++ b/packages/public/morph/CHANGELOG.md @@ -1,5 +1,15 @@ # @vyrel/morph +## 0.0.11 + +### Minor Changes + +- Add `model.connection({ type })` forward-cursor helper and + `ConnectionPayload` / `ConnectionPageInfo` types. +- Map `z.array(z.string())` and `z.array(z.enum(...))` to GraphQL lists + without `fieldTypes`. +- Remove bridge options `defaultEnumName` and `scalarTypes`. + ## 0.0.10 ### Patch Changes diff --git a/packages/public/morph/README.md b/packages/public/morph/README.md index ceec9c3..0100065 100644 --- a/packages/public/morph/README.md +++ b/packages/public/morph/README.md @@ -46,7 +46,6 @@ const builder = new SchemaBuilder({ const bridge = initializeDrizzleGraphqlBridge(builder, { defaultIdFields: ["id", "orgId"], - unmappedFields: "throw", }); const taskRowSchema = z.object({ @@ -98,9 +97,10 @@ Creates a bridge bound to your Pothos builder. | Option | Description | | --- | --- | | `defaultIdFields` | Column names exposed as GraphQL `ID`. When omitted on both bridge and model, defaults to `["id", "orgId"]`. | -| `defaultEnumName` | Naming strategy for generated enums `(field, objectName) => string`. | -| `scalarTypes` | Extra scalar names registered on the builder. | -| `unmappedFields` | `"throw"` (default), `"warn"`, or `"omit"` when a Zod field cannot map to GraphQL. | +| `unmappedFields` | Optional. `"throw"` (default), `"warn"`, or `"omit"` when a Zod field cannot map to GraphQL. | + +Scalars already registered on the Pothos builder are discovered automatically. +Enum GraphQL names default to `` `${objectName}${PascalCase(field)}` ``; override per model with `enumName`. Returns: @@ -131,6 +131,28 @@ Model helpers: | `inputsFrom(schema, options?)` | Map a Zod schema to `withInput` fields | | `argsFrom(schema, options?)` | Map a Zod schema to field args | | `args` | Pre-built args when `listArgsSchema` is configured | +| `connection({ type, name?, pageInfoName?, fields? })` | Forward-cursor `Connection` + `PageInfo` objects | + +### `model.connection(options)` + +Creates GraphQL types for forward-cursor pagination (not full Relay): + +```ts +const TaskConnection = taskGraphql.connection({ type: TaskObject }); +// TaskConnection { nodes, pageInfo } +// TaskPageInfo { endCursor, hasNextPage } +``` + +| Option | Description | +| --- | --- | +| `type` | Pothos node object type for `nodes` | +| `name` | Connection type name (default `` `${objectName}Connection` ``) | +| `pageInfoName` | PageInfo type name (default `` `${objectName}PageInfo` ``) | +| `fields` | Optional extra fields on the Connection object | + +Also exported: `ConnectionPayload` and `ConnectionPageInfo` for typing service return values. + +`z.array(z.string())` maps to `[String]`; `z.array(z.enum(...))` reuses a matching builder enum as a list. Common `inputsFrom` / `argsFrom` options: @@ -148,6 +170,8 @@ Common `inputsFrom` / `argsFrom` options: | `z.number()` | `Float` / `Int` (inferred) | | `z.date()` | `DateTime` when registered | | `z.enum()` / `z.literal()` | Generated GraphQL enums | +| `z.array(z.string())` | `[String]` | +| `z.array(z.enum(...))` | `[ExistingEnum]` when values match a builder enum | | nullable / optional Zod | Nullable GraphQL fields | | `.describe()` | GraphQL field description | diff --git a/packages/public/morph/src/graphql-bridge.test.ts b/packages/public/morph/src/graphql-bridge.test.ts index 7ce559e..3bb3f94 100644 --- a/packages/public/morph/src/graphql-bridge.test.ts +++ b/packages/public/morph/src/graphql-bridge.test.ts @@ -45,7 +45,6 @@ describe("initializeDrizzleGraphqlBridge", () => { }); const bridge = initializeDrizzleGraphqlBridge(builder, { defaultIdFields: ["id"], - unmappedFields: "throw", }); const rowSchema = z.object({ active: z.boolean(), @@ -72,6 +71,41 @@ describe("initializeDrizzleGraphqlBridge", () => { expect(Object.keys(model.args.filters)).toEqual(["role"]); }); + it("creates forward-cursor Connection and PageInfo objects", () => { + const builder = new SchemaBuilder({ + plugins: [ValidationPlugin, WithInputPlugin], + }); + builder.queryType({}); + const bridge = initializeDrizzleGraphqlBridge(builder); + const rowSchema = z.object({ + id: z.string(), + title: z.string(), + }); + + const model = bridge.model({ + objectName: "Task", + rowSchema, + }); + + const TaskObject = builder.objectRef<{ id: string; title: string }>("Task"); + TaskObject.implement({ + fields: (t) => ({ + id: t.exposeString("id", { nullable: false }), + title: t.exposeString("title", { nullable: false }), + }), + }); + + model.connection({ type: TaskObject }); + + expect(builder.configStore.typeConfigs.has("TaskConnection")).toBe(true); + expect(builder.configStore.typeConfigs.has("TaskPageInfo")).toBe(true); + + const connection = builder.configStore.typeConfigs.get("TaskConnection"); + const pageInfo = builder.configStore.typeConfigs.get("TaskPageInfo"); + expect(connection?.kind).toBe("Object"); + expect(pageInfo?.kind).toBe("Object"); + }); + it("does not import internal Vyrel builders from the public package source", async () => { const sourceFiles = await readSourceFiles(sourceRoot); const checkedImports = await Promise.all( diff --git a/packages/public/morph/src/index.ts b/packages/public/morph/src/index.ts index 92a9d8e..e86c48d 100644 --- a/packages/public/morph/src/index.ts +++ b/packages/public/morph/src/index.ts @@ -4,8 +4,11 @@ export const initializeDrizzleGraphqlBridge = initializeDrizzleGraphqlBridgeImpl; export type { + ConnectionPageInfo, + ConnectionPayload, DefineDrizzleGraphqlFieldsConfig, DrizzleGraphqlBridge, DrizzleGraphqlFields, InitializeDrizzleGraphqlBridgeOptions, + MorphConnectionOptions, } from "./lib/define-drizzle-graphql-fields"; diff --git a/packages/public/morph/src/lib/connection.ts b/packages/public/morph/src/lib/connection.ts new file mode 100644 index 0000000..ae00958 --- /dev/null +++ b/packages/public/morph/src/lib/connection.ts @@ -0,0 +1,74 @@ +import type { FieldMap, SchemaTypes } from "@pothos/core"; + +import type { PothosSchemaBuilder } from "./zod-pothos-inputs"; + +export type ConnectionPageInfo = { + endCursor: string | null; + hasNextPage: boolean; +}; + +export type ConnectionPayload = { + nodes: TNode[]; + pageInfo: ConnectionPageInfo; +}; + +export type MorphConnectionOptions = { + readonly fields?: (t: unknown) => FieldMap | undefined; + readonly name?: string; + readonly pageInfoName?: string; + readonly type: TNodeType; +}; + +/** + * Forward-cursor Connection + PageInfo objects for a model. + * Shape: `{ nodes, pageInfo: { endCursor, hasNextPage } }` (not full Relay). + */ +export const createMorphConnection = ( + graphqlBuilder: PothosSchemaBuilder, + objectName: string, + options: MorphConnectionOptions +) => { + const connectionName = options.name ?? `${objectName}Connection`; + const pageInfoName = options.pageInfoName ?? `${objectName}PageInfo`; + + // Field builders are cast loosely: morph is generic over consumer SchemaTypes, + // so expose*/field inference against ConnectionPageInfo is not available here. + const pageInfoRef = graphqlBuilder + .objectRef(pageInfoName) + .implement({ + fields: ((t: { + exposeBoolean: ( + name: string, + options: { nullable: boolean } + ) => unknown; + exposeString: (name: string, options: { nullable: boolean }) => unknown; + }) => ({ + endCursor: t.exposeString("endCursor", { nullable: true }), + hasNextPage: t.exposeBoolean("hasNextPage", { nullable: false }), + })) as never, + }); + + return graphqlBuilder + .objectRef>(connectionName) + .implement({ + fields: ((t: { + field: (config: { + nullable: boolean; + resolve: (parent: ConnectionPayload) => unknown; + type: unknown; + }) => unknown; + }) => ({ + nodes: t.field({ + nullable: false, + resolve: (connection) => connection.nodes, + type: [options.type], + }), + pageInfo: t.field({ + nullable: false, + resolve: (connection) => connection.pageInfo, + type: pageInfoRef, + }), + ...(options.fields?.(t) ?? {}), + })) as never, + }); +}; diff --git a/packages/public/morph/src/lib/define-drizzle-graphql-fields.ts b/packages/public/morph/src/lib/define-drizzle-graphql-fields.ts index fcd7181..6500137 100644 --- a/packages/public/morph/src/lib/define-drizzle-graphql-fields.ts +++ b/packages/public/morph/src/lib/define-drizzle-graphql-fields.ts @@ -1,6 +1,10 @@ import type { FieldMap, GenericFieldRef, SchemaTypes } from "@pothos/core"; import { z } from "zod/v4"; +import { + createMorphConnection, + type MorphConnectionOptions, +} from "./connection"; import { buildDrizzleGraphqlEnumRegistry } from "./drizzle-graphql-enum-registry"; import { morphWarn } from "./warn"; import { @@ -22,6 +26,12 @@ import { type ZodSchemaKeys, } from "./zod-pothos-inputs"; +export type { + ConnectionPageInfo, + ConnectionPayload, + MorphConnectionOptions, +} from "./connection"; + type ExtraEnumSource = readonly (string | number)[] | z.ZodType; type BivariantResolve = { bivarianceHack: (parent: TParent) => unknown; @@ -105,6 +115,11 @@ type DrizzleGraphqlFieldsCore< schema: TSchema, options?: TOptions ) => PothosArgsFieldsFromZodSchema; + readonly connection: ( + options: MorphConnectionOptions + ) => ReturnType< + ReturnType["objectRef"]>["implement"] + >; readonly exposeFields: ( t: TBuilder, options?: ExposeFieldsOptions @@ -141,9 +156,7 @@ export type DrizzleGraphqlFields< : Record); export type InitializeDrizzleGraphqlBridgeOptions = { - readonly defaultEnumName?: (field: string, objectName: string) => string; readonly defaultIdFields?: readonly string[]; - readonly scalarTypes?: readonly PothosInputFieldType[]; readonly unmappedFields?: PothosUnmappedFieldPolicy; }; @@ -221,8 +234,7 @@ const builtInGraphqlInputTypeNames = [ ] as const; const registerBuilderNamedGraphqlTypes = ( - graphqlBuilder: PothosSchemaBuilder, - scalarTypes: readonly PothosInputFieldType[] | undefined + graphqlBuilder: PothosSchemaBuilder ) => { for (const typeName of builtInGraphqlInputTypeNames) { registerNamedPothosGraphqlType(typeName); @@ -233,12 +245,6 @@ const registerBuilderNamedGraphqlTypes = ( registerNamedPothosGraphqlType(config.name); } } - - for (const typeName of scalarTypes ?? []) { - if (typeof typeName === "string") { - registerNamedPothosGraphqlType(typeName); - } - } }; const handleUnmappedExposeField = ( @@ -372,11 +378,7 @@ const createDrizzleGraphqlFields = < TRowSchema, ListArgsSchemaFromConfig > => { - const enumRegistry = buildDrizzleGraphqlEnumRegistry( - graphqlBuilder, - bridgeOptions, - config - ); + const enumRegistry = buildDrizzleGraphqlEnumRegistry(graphqlBuilder, config); const idFields = new Set( config.idFields ?? bridgeOptions.defaultIdFields ?? ["id", "orgId"] ); @@ -477,9 +479,13 @@ const createDrizzleGraphqlFields = < }); }; + const connection = (options: MorphConnectionOptions) => + createMorphConnection(graphqlBuilder, config.objectName, options); + const core: DrizzleGraphqlFieldsCore = { argsFrom, computedEnumField, + connection, exposeFields, inputsFrom, }; @@ -509,7 +515,7 @@ export const initializeDrizzleGraphqlBridge = < graphqlBuilder: PothosSchemaBuilder, options: InitializeDrizzleGraphqlBridgeOptions = {} ): DrizzleGraphqlBridge => { - registerBuilderNamedGraphqlTypes(graphqlBuilder, options.scalarTypes); + registerBuilderNamedGraphqlTypes(graphqlBuilder); const inputsFrom = createPothosInputsFromZodSchema(graphqlBuilder); const argsFromZodSchema = createPothosArgsFromZodSchema(graphqlBuilder); diff --git a/packages/public/morph/src/lib/drizzle-graphql-enum-registry.ts b/packages/public/morph/src/lib/drizzle-graphql-enum-registry.ts index 37523eb..a1cd6d2 100644 --- a/packages/public/morph/src/lib/drizzle-graphql-enum-registry.ts +++ b/packages/public/morph/src/lib/drizzle-graphql-enum-registry.ts @@ -1,10 +1,7 @@ import type { SchemaTypes } from "@pothos/core"; import { z } from "zod/v4"; -import type { - DrizzleGraphqlEnumRegistryConfig, - InitializeDrizzleGraphqlBridgeOptions, -} from "./define-drizzle-graphql-fields"; +import type { DrizzleGraphqlEnumRegistryConfig } from "./define-drizzle-graphql-fields"; import { graphqlEnumRuntimeValues, type PothosInputFieldType, @@ -110,12 +107,10 @@ const registerEnumsFromShape = ( export const buildDrizzleGraphqlEnumRegistry = ( graphqlBuilder: PothosSchemaBuilder, - bridgeOptions: InitializeDrizzleGraphqlBridgeOptions, config: DrizzleGraphqlEnumRegistryConfig ): Record => { const enumRegistry: Record = {}; - const enumName = - config.enumName ?? bridgeOptions.defaultEnumName ?? defaultEnumName; + const enumName = config.enumName ?? defaultEnumName; registerEnumsFromShape( graphqlBuilder, diff --git a/packages/public/morph/src/lib/zod-pothos-inputs.ts b/packages/public/morph/src/lib/zod-pothos-inputs.ts index ad20eee..906eb49 100644 --- a/packages/public/morph/src/lib/zod-pothos-inputs.ts +++ b/packages/public/morph/src/lib/zod-pothos-inputs.ts @@ -310,7 +310,7 @@ const readPothosTypeFromZodMeta = ( if (typeof meta.pothosType === "string") { throw new Error( - `[pothosInputsFromZodSchema] Unknown GraphQL type "${meta.pothosType}" in Zod meta.pothosType. Register it on the shared Pothos builder or pass scalarTypes to initializeDrizzleGraphqlBridge(...).` + `[pothosInputsFromZodSchema] Unknown GraphQL type "${meta.pothosType}" in Zod meta.pothosType. Register it on the shared Pothos builder via builder.addScalarType(...).` ); } } @@ -382,6 +382,23 @@ export const resolveGraphqlTypeForZodField = ( ); } + if (unwrapped instanceof z.ZodArray) { + const element = unwrapZodField(unwrapped.element as z.ZodType); + + if (element instanceof z.ZodEnum) { + const enumType = + enumRegistry?.[fieldKey] ?? + resolveZodEnumGraphqlType(element, graphqlBuilder); + if (enumType !== undefined) { + return [enumType] as PothosInputFieldType; + } + } + + if (element instanceof z.ZodString) { + return ["String"] as PothosInputFieldType; + } + } + if (unwrapped instanceof z.ZodString) { return "string"; }