diff --git a/.changeset/declarative-data-operations.md b/.changeset/declarative-data-operations.md new file mode 100644 index 000000000..6e8d53b3c --- /dev/null +++ b/.changeset/declarative-data-operations.md @@ -0,0 +1,83 @@ +--- +'@baseplate-dev/fastify-generators': minor +--- + +Replace imperative CRUD service pattern with declarative, type-safe data operations architecture + +## Overview + +This change migrates from manually-written imperative CRUD functions to a declarative, type-safe data operations system featuring composable field definitions and automatic type inference. This represents a fundamental architectural improvement in how Baseplate generates data access code. + +## Key Changes + +### Architecture Shift + +**Before**: Manual, imperative functions with explicit Prisma calls and complex data transformations + +```typescript +// 250+ lines of manual data handling +export async function createUser({ data, query, context }) { + const { roles, customer, userProfile, images, ...rest } = data; + + const customerOutput = await createOneToOneCreateData({ input: customer }); + const imagesOutput = await createOneToManyCreateData({ + /* complex config */ + }); + // ... more manual transformations + + return applyDataPipeOutput( + [rolesOutput, customerOutput, userProfileOutput, imagesOutput], + prisma.user.create({ + /* manually built data object */ + }), + ); +} +``` + +**After**: Declarative operations with composable field definitions + +```typescript +// ~100 lines with clear separation of concerns +export const createUser = defineCreateOperation({ + model: 'user', + fields: userInputFields, + create: ({ tx, data, query }) => + tx.user.create({ + data, + ...query, + }), +}); +``` + +### Composable Field Definitions + +Field definitions are now centralized, reusable components: + +```typescript +export const userInputFields = { + name: scalarField(z.string().nullish()), + email: scalarField(z.string()), + customer: nestedOneToOneField({ + buildData: (data) => data, + fields: { stripeCustomerId: scalarField(z.string()) }, + getWhereUnique: (parentModel) => ({ id: parentModel.id }), + model: 'customer', + parentModel, + relationName: 'user', + }), + images: nestedOneToManyField({ + buildData: (data) => data, + fields: pick(userImageInputFields, ['id', 'caption', 'file']), + getWhereUnique: (input) => (input.id ? { id: input.id } : undefined), + model: 'userImage', + parentModel, + relationName: 'user', + }), +}; +``` + +## Breaking Changes + +- **File naming**: Services now use `*-data-service.ts` instead of `*-crud.ts` +- **Import paths**: New utilities from `@src/utils/data-operations/` +- **Service signatures**: Remain compatible - same inputs and outputs diff --git a/.changeset/eleven-monkeys-marry.md b/.changeset/eleven-monkeys-marry.md new file mode 100644 index 000000000..f8e425b60 --- /dev/null +++ b/.changeset/eleven-monkeys-marry.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/core-generators': patch +--- + +Set maxWorkers to 1 to allow for integration tests to work properly. Note: This is a temporary solution until we implement parallel db tests. diff --git a/.changeset/red-cycles-wave.md b/.changeset/red-cycles-wave.md new file mode 100644 index 000000000..f2bea2323 --- /dev/null +++ b/.changeset/red-cycles-wave.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/utils': patch +--- + +Add case utils to utils package diff --git a/.changeset/rich-jobs-drum.md b/.changeset/rich-jobs-drum.md new file mode 100644 index 000000000..9f742911a --- /dev/null +++ b/.changeset/rich-jobs-drum.md @@ -0,0 +1,6 @@ +--- +'@baseplate-dev/project-builder-server': patch +'@baseplate-dev/fastify-generators': patch +--- + +Remove support for password transformer since it is no longer used. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8f115e575..87ef4150c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -44,7 +44,7 @@ "problemMatcher": { "base": "$tsc-watch", "owner": "typescript", - "applyTo": "allDocuments", + "applyTo": "closedDocuments", "pattern": { "regexp": "^//:watch:tsc:root: (.+):(\\d+):(\\d+) - (error|warning|info) TS(\\d+): (.*)$", "file": 1, diff --git a/examples/blog-with-auth/apps/admin/src/generated/graphql.tsx b/examples/blog-with-auth/apps/admin/src/generated/graphql.tsx index 0d26bb114..c3bf14543 100644 --- a/examples/blog-with-auth/apps/admin/src/generated/graphql.tsx +++ b/examples/blog-with-auth/apps/admin/src/generated/graphql.tsx @@ -54,9 +54,15 @@ export type ChangePasswordPayload = { user: User; }; +export type CreateUserData = { + email?: InputMaybe; + emailVerified?: InputMaybe; + name?: InputMaybe; +}; + /** Input type for createUser mutation */ export type CreateUserInput = { - data: UserCreateData; + data: CreateUserData; }; /** Payload type for createUser mutation */ @@ -192,9 +198,15 @@ export type ResetUserPasswordPayload = { user: User; }; +export type UpdateUserData = { + email?: InputMaybe; + emailVerified?: InputMaybe; + name?: InputMaybe; +}; + /** Input type for updateUser mutation */ export type UpdateUserInput = { - data: UserUpdateData; + data: UpdateUserData; id: Scalars['Uuid']['input']; }; @@ -225,12 +237,6 @@ export type User = { roles: Array; }; -export type UserCreateData = { - email?: InputMaybe; - emailVerified?: InputMaybe; - name?: InputMaybe; -}; - export type UserRole = { __typename?: 'UserRole'; role: Scalars['String']['output']; @@ -245,12 +251,6 @@ export type UserSessionPayload = { userId: Scalars['Uuid']['output']; }; -export type UserUpdateData = { - email?: InputMaybe; - emailVerified?: InputMaybe; - name?: InputMaybe; -}; - export type GetCurrentUserSessionQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json b/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json index 7246b6205..b91ccc8a8 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json +++ b/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json @@ -45,7 +45,7 @@ "@baseplate-dev/fastify-generators#core/request-service-context:request-service-context": "src/utils/request-service-context.ts", "@baseplate-dev/fastify-generators#core/service-context:service-context": "src/utils/service-context.ts", "@baseplate-dev/fastify-generators#core/service-context:test-helper": "src/tests/helpers/service-context.test-helper.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:User": "src/modules/accounts/services/user.crud.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:User": "src/modules/accounts/services/user.data-service.ts", "@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-global-types": "src/plugins/graphql/FieldAuthorizePlugin/global-types.ts", "@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-plugin": "src/plugins/graphql/FieldAuthorizePlugin/index.ts", "@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-types": "src/plugins/graphql/FieldAuthorizePlugin/types.ts", @@ -68,7 +68,12 @@ "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-schema-builder": "src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts", "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-types": "src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts", "@baseplate-dev/fastify-generators#pothos/pothos:strip-query-mutation-plugin": "src/plugins/graphql/strip-query-mutation-plugin.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:crud-service-types": "src/utils/crud-service-types.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:define-operations": "src/utils/data-operations/define-operations.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:field-definitions": "src/utils/data-operations/field-definitions.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-types": "src/utils/data-operations/prisma-types.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-utils": "src/utils/data-operations/prisma-utils.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:relation-helpers": "src/utils/data-operations/relation-helpers.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:types": "src/utils/data-operations/types.ts", "@baseplate-dev/fastify-generators#prisma/prisma:client": "src/generated/prisma/client.ts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-config": "prisma.config.mts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-schema": "prisma/schema.prisma", diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/package.json b/examples/blog-with-auth/apps/backend/baseplate/generated/package.json index cffd4b44c..f83614140 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/package.json +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/package.json @@ -57,7 +57,7 @@ "graphql-scalars": "1.23.0", "graphql-yoga": "5.15.1", "nanoid": "5.1.6", - "pg-boss": "10.3.2", + "pg-boss": "11.1.1", "pino": "9.5.0", "zod": "3.25.76" }, diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts index 166923209..679d3b926 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts @@ -3,21 +3,25 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; import { restrictObjectNulls } from '@src/utils/nulls.js'; -import { createUser, deleteUser, updateUser } from '../services/user.crud.js'; +import { + createUser, + deleteUser, + updateUser, +} from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const userCreateDataInputType = builder.inputType('UserCreateData', { +const createUserDataInputType = builder.inputType('CreateUserData', { fields: (t) => ({ + email: t.string(), name: t.string(), emailVerified: t.boolean(), - email: t.string(), }), }); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ input: { - data: t.input.field({ required: true, type: userCreateDataInputType }), + data: t.input.field({ required: true, type: createUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], @@ -32,11 +36,11 @@ builder.mutationField('createUser', (t) => }), ); -const userUpdateDataInputType = builder.inputType('UserUpdateData', { +const updateUserDataInputType = builder.inputType('UpdateUserData', { fields: (t) => ({ + email: t.string(), name: t.string(), emailVerified: t.boolean(), - email: t.string(), }), }); @@ -44,13 +48,13 @@ builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ input: { id: t.input.field({ required: true, type: 'Uuid' }), - data: t.input.field({ required: true, type: userUpdateDataInputType }), + data: t.input.field({ required: true, type: updateUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ - id, + where: { id }, data: restrictObjectNulls(data, ['emailVerified']), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -67,7 +71,7 @@ builder.mutationField('deleteUser', (t) => authorize: ['admin'], resolve: async (root, { input: { id } }, context, info) => { const user = await deleteUser({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['user'] }), }); diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.crud.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.crud.ts deleted file mode 100644 index 3a1954878..000000000 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.crud.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Prisma, User } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -type UserCreateData = Pick< - Prisma.UserUncheckedCreateInput, - 'name' | 'emailVerified' | 'email' ->; - -export async function createUser({ - data, - query, -}: CreateServiceInput): Promise { - return prisma.user.create({ data, ...query }); -} - -type UserUpdateData = Pick< - Partial, - 'name' | 'emailVerified' | 'email' ->; - -export async function updateUser({ - id, - data, - query, -}: UpdateServiceInput< - string, - UserUpdateData, - Prisma.UserDefaultArgs ->): Promise { - return prisma.user.update({ where: { id }, data, ...query }); -} - -export async function deleteUser({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.user.delete({ where: { id }, ...query }); -} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.data-service.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.data-service.ts new file mode 100644 index 000000000..afe1bec6e --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/services/user.data-service.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +export const userInputFields = { + email: scalarField(z.string().nullish()), + name: scalarField(z.string().nullish()), + emailVerified: scalarField(z.boolean().optional()), +}; + +export const createUser = defineCreateOperation({ + model: 'user', + fields: userInputFields, + create: ({ tx, data, query }) => + tx.user.create({ + data, + ...query, + }), +}); + +export const updateUser = defineUpdateOperation({ + model: 'user', + fields: userInputFields, + update: ({ tx, where, data, query }) => + tx.user.update({ + where, + data, + ...query, + }), +}); + +export const deleteUser = defineDeleteOperation({ + model: 'user', + delete: ({ tx, where, query }) => + tx.user.delete({ + where, + ...query, + }), +}); diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index 59cab4ed4..5e1d36181 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -10,6 +10,7 @@ import type { AuthRole } from '@src/modules/accounts/constants/auth-roles.consta import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosAuthorizeByRolesPlugin } from './FieldAuthorizePlugin/index.js'; @@ -62,6 +63,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/services/pg-boss.service.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/services/pg-boss.service.ts index c76412b55..9b5ab2db3 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/services/pg-boss.service.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/services/pg-boss.service.ts @@ -28,10 +28,8 @@ const DELETE_AFTER_DAYS = /* TPL_DELETE_AFTER_DAYS:START */ 7; /* TPL_DELETE_AFT */ export async function initializePgBoss({ disableMaintenance = false, - pollingIntervalSeconds = 2, }: { disableMaintenance?: boolean; - pollingIntervalSeconds?: number; } = {}): Promise { if (pgBoss) { return; @@ -39,8 +37,6 @@ export async function initializePgBoss({ pgBoss = new PgBoss({ connectionString: config.DATABASE_URL, - pollingIntervalSeconds, - deleteAfterDays: DELETE_AFTER_DAYS, // Disable maintenance in API mode ...(disableMaintenance && { supervise: false, @@ -177,7 +173,9 @@ export class PgBossQueue implements Queue { } const boss = getPgBoss(); - await boss.createQueue(this.name); + await boss.createQueue(this.name, { + deleteAfterSeconds: DELETE_AFTER_DAYS * 24 * 60 * 60, + }); this.hasCreatedQueue = true; } diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/crud-service-types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/crud-service-types.ts deleted file mode 100644 index f97f00610..000000000 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/crud-service-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ServiceContext } from './service-context.js'; - -export interface CreateServiceInput< - CreateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - data: CreateData; - context: Context; - query?: Query; -} - -export interface UpdateServiceInput< - PrimaryKey, - UpdateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - data: UpdateData; - context: Context; - query?: Query; -} - -export interface DeleteServiceInput< - PrimaryKey, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - context: Context; - query?: Query; -} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/vitest.config.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/vitest.config.ts index f34a7ed62..482a4797e 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/vitest.config.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig( test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', }, diff --git a/examples/blog-with-auth/apps/backend/schema.graphql b/examples/blog-with-auth/apps/backend/schema.graphql index e6477f969..38f1e1835 100644 --- a/examples/blog-with-auth/apps/backend/schema.graphql +++ b/examples/blog-with-auth/apps/backend/schema.graphql @@ -29,9 +29,15 @@ type ChangePasswordPayload { user: User! } +input CreateUserData { + email: String + emailVerified: Boolean + name: String +} + """Input type for createUser mutation""" input CreateUserInput { - data: UserCreateData! + data: CreateUserData! } """Payload type for createUser mutation""" @@ -130,9 +136,15 @@ type ResetUserPasswordPayload { user: User! } +input UpdateUserData { + email: String + emailVerified: Boolean + name: String +} + """Input type for updateUser mutation""" input UpdateUserInput { - data: UserUpdateData! + data: UpdateUserData! id: Uuid! } @@ -160,12 +172,6 @@ type User { roles: [UserRole!]! } -input UserCreateData { - email: String - emailVerified: Boolean - name: String -} - type UserRole { role: String! userId: Uuid! @@ -178,12 +184,6 @@ type UserSessionPayload { userId: Uuid! } -input UserUpdateData { - email: String - emailVerified: Boolean - name: String -} - """ A field whose value is a generic Universally Unique Identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier. """ diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts index 166923209..679d3b926 100644 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts @@ -3,21 +3,25 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; import { restrictObjectNulls } from '@src/utils/nulls.js'; -import { createUser, deleteUser, updateUser } from '../services/user.crud.js'; +import { + createUser, + deleteUser, + updateUser, +} from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const userCreateDataInputType = builder.inputType('UserCreateData', { +const createUserDataInputType = builder.inputType('CreateUserData', { fields: (t) => ({ + email: t.string(), name: t.string(), emailVerified: t.boolean(), - email: t.string(), }), }); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ input: { - data: t.input.field({ required: true, type: userCreateDataInputType }), + data: t.input.field({ required: true, type: createUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], @@ -32,11 +36,11 @@ builder.mutationField('createUser', (t) => }), ); -const userUpdateDataInputType = builder.inputType('UserUpdateData', { +const updateUserDataInputType = builder.inputType('UpdateUserData', { fields: (t) => ({ + email: t.string(), name: t.string(), emailVerified: t.boolean(), - email: t.string(), }), }); @@ -44,13 +48,13 @@ builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ input: { id: t.input.field({ required: true, type: 'Uuid' }), - data: t.input.field({ required: true, type: userUpdateDataInputType }), + data: t.input.field({ required: true, type: updateUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ - id, + where: { id }, data: restrictObjectNulls(data, ['emailVerified']), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -67,7 +71,7 @@ builder.mutationField('deleteUser', (t) => authorize: ['admin'], resolve: async (root, { input: { id } }, context, info) => { const user = await deleteUser({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['user'] }), }); diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.crud.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.crud.ts deleted file mode 100644 index 3a1954878..000000000 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.crud.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Prisma, User } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -type UserCreateData = Pick< - Prisma.UserUncheckedCreateInput, - 'name' | 'emailVerified' | 'email' ->; - -export async function createUser({ - data, - query, -}: CreateServiceInput): Promise { - return prisma.user.create({ data, ...query }); -} - -type UserUpdateData = Pick< - Partial, - 'name' | 'emailVerified' | 'email' ->; - -export async function updateUser({ - id, - data, - query, -}: UpdateServiceInput< - string, - UserUpdateData, - Prisma.UserDefaultArgs ->): Promise { - return prisma.user.update({ where: { id }, data, ...query }); -} - -export async function deleteUser({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.user.delete({ where: { id }, ...query }); -} diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.data-service.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.data-service.ts new file mode 100644 index 000000000..afe1bec6e --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/services/user.data-service.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +export const userInputFields = { + email: scalarField(z.string().nullish()), + name: scalarField(z.string().nullish()), + emailVerified: scalarField(z.boolean().optional()), +}; + +export const createUser = defineCreateOperation({ + model: 'user', + fields: userInputFields, + create: ({ tx, data, query }) => + tx.user.create({ + data, + ...query, + }), +}); + +export const updateUser = defineUpdateOperation({ + model: 'user', + fields: userInputFields, + update: ({ tx, where, data, query }) => + tx.user.update({ + where, + data, + ...query, + }), +}); + +export const deleteUser = defineDeleteOperation({ + model: 'user', + delete: ({ tx, where, query }) => + tx.user.delete({ + where, + ...query, + }), +}); diff --git a/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts b/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts index 59cab4ed4..5e1d36181 100644 --- a/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts +++ b/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts @@ -10,6 +10,7 @@ import type { AuthRole } from '@src/modules/accounts/constants/auth-roles.consta import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosAuthorizeByRolesPlugin } from './FieldAuthorizePlugin/index.js'; @@ -62,6 +63,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json b/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json index e83b38159..d1eb02eee 100644 --- a/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json +++ b/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json @@ -4,11 +4,6 @@ "instanceData": {}, "template": "app-modules" }, - "crud-service-types.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "crud-service-types" - }, "http-errors.ts": { "generator": "@baseplate-dev/fastify-generators#core/error-handler-service", "instanceData": {}, diff --git a/examples/blog-with-auth/apps/backend/src/utils/crud-service-types.ts b/examples/blog-with-auth/apps/backend/src/utils/crud-service-types.ts deleted file mode 100644 index f97f00610..000000000 --- a/examples/blog-with-auth/apps/backend/src/utils/crud-service-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ServiceContext } from './service-context.js'; - -export interface CreateServiceInput< - CreateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - data: CreateData; - context: Context; - query?: Query; -} - -export interface UpdateServiceInput< - PrimaryKey, - UpdateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - data: UpdateData; - context: Context; - query?: Query; -} - -export interface DeleteServiceInput< - PrimaryKey, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - context: Context; - query?: Query; -} diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/.templates-info.json b/examples/blog-with-auth/apps/backend/src/utils/data-operations/.templates-info.json new file mode 100644 index 000000000..4ca668da0 --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/.templates-info.json @@ -0,0 +1,32 @@ +{ + "define-operations.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "define-operations" + }, + "field-definitions.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "field-definitions" + }, + "prisma-types.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "prisma-types" + }, + "prisma-utils.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "prisma-utils" + }, + "relation-helpers.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "relation-helpers" + }, + "types.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "types" + } +} diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-types.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-utils.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/relation-helpers.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/examples/blog-with-auth/apps/backend/vitest.config.ts b/examples/blog-with-auth/apps/backend/vitest.config.ts index f34a7ed62..482a4797e 100644 --- a/examples/blog-with-auth/apps/backend/vitest.config.ts +++ b/examples/blog-with-auth/apps/backend/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig( test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', }, diff --git a/examples/todo-with-auth0/apps/admin/src/generated/graphql.tsx b/examples/todo-with-auth0/apps/admin/src/generated/graphql.tsx index 76ea692b9..c834e05c9 100644 --- a/examples/todo-with-auth0/apps/admin/src/generated/graphql.tsx +++ b/examples/todo-with-auth0/apps/admin/src/generated/graphql.tsx @@ -59,9 +59,18 @@ export type CreatePresignedUploadUrlPayload = { url: Scalars['String']['output']; }; +export type CreateTodoItemData = { + assigneeId?: InputMaybe; + attachments?: InputMaybe>; + done: Scalars['Boolean']['input']; + position: Scalars['Int']['input']; + text: Scalars['String']['input']; + todoListId: Scalars['Uuid']['input']; +}; + /** Input type for createTodoItem mutation */ export type CreateTodoItemInput = { - data: TodoItemCreateData; + data: CreateTodoItemData; }; /** Payload type for createTodoItem mutation */ @@ -70,9 +79,18 @@ export type CreateTodoItemPayload = { todoItem: TodoItem; }; +export type CreateTodoListData = { + coverPhoto?: InputMaybe; + createdAt?: InputMaybe; + name: Scalars['String']['input']; + ownerId: Scalars['Uuid']['input']; + position: Scalars['Int']['input']; + status?: InputMaybe; +}; + /** Input type for createTodoList mutation */ export type CreateTodoListInput = { - data: TodoListCreateData; + data: CreateTodoListData; }; /** Payload type for createTodoList mutation */ @@ -81,9 +99,16 @@ export type CreateTodoListPayload = { todoList: TodoList; }; +export type CreateTodoListShareData = { + createdAt?: InputMaybe; + todoListId: Scalars['Uuid']['input']; + updatedAt?: InputMaybe; + userId: Scalars['Uuid']['input']; +}; + /** Input type for createTodoListShare mutation */ export type CreateTodoListShareInput = { - data: TodoListShareCreateData; + data: CreateTodoListShareData; }; /** Payload type for createTodoListShare mutation */ @@ -92,9 +117,18 @@ export type CreateTodoListSharePayload = { todoListShare: TodoListShare; }; +export type CreateUserData = { + customer?: InputMaybe; + email: Scalars['String']['input']; + images?: InputMaybe>; + name?: InputMaybe; + roles?: InputMaybe>; + userProfile?: InputMaybe; +}; + /** Input type for createUser mutation */ export type CreateUserInput = { - data: UserCreateData; + data: CreateUserData; }; /** Payload type for createUser mutation */ @@ -338,10 +372,6 @@ export type TodoItemAttachment = { url: Scalars['String']['output']; }; -export type TodoItemAttachmentEmbeddedTagsData = { - tag: Scalars['String']['input']; -}; - export type TodoItemAttachmentTag = { __typename?: 'TodoItemAttachmentTag'; tag: Scalars['String']['output']; @@ -349,31 +379,17 @@ export type TodoItemAttachmentTag = { todoItemAttachmentId: Scalars['Uuid']['output']; }; -export type TodoItemCreateData = { - assigneeId?: InputMaybe; - attachments?: InputMaybe>; - done: Scalars['Boolean']['input']; - position: Scalars['Int']['input']; - text: Scalars['String']['input']; - todoListId: Scalars['Uuid']['input']; +export type TodoItemAttachmentTagsNestedInput = { + tag: Scalars['String']['input']; }; -export type TodoItemEmbeddedAttachmentsData = { - id?: InputMaybe; +export type TodoItemAttachmentsNestedInput = { + id?: InputMaybe; position: Scalars['Int']['input']; - tags?: InputMaybe>; + tags?: InputMaybe>; url: Scalars['String']['input']; }; -export type TodoItemUpdateData = { - assigneeId?: InputMaybe; - attachments?: InputMaybe>; - done?: InputMaybe; - position?: InputMaybe; - text?: InputMaybe; - todoListId?: InputMaybe; -}; - export type TodoList = { __typename?: 'TodoList'; coverPhoto?: Maybe; @@ -387,15 +403,6 @@ export type TodoList = { updatedAt: Scalars['DateTime']['output']; }; -export type TodoListCreateData = { - coverPhoto?: InputMaybe; - createdAt?: InputMaybe; - name: Scalars['String']['input']; - ownerId: Scalars['Uuid']['input']; - position: Scalars['Int']['input']; - status?: InputMaybe; -}; - export type TodoListShare = { __typename?: 'TodoListShare'; createdAt: Scalars['DateTime']['output']; @@ -406,41 +413,27 @@ export type TodoListShare = { userId: Scalars['Uuid']['output']; }; -export type TodoListShareCreateData = { - createdAt?: InputMaybe; - todoListId: Scalars['Uuid']['input']; - updatedAt?: InputMaybe; - userId: Scalars['Uuid']['input']; -}; - export type TodoListSharePrimaryKey = { todoListId: Scalars['Uuid']['input']; userId: Scalars['Uuid']['input']; }; -export type TodoListShareUpdateData = { - createdAt?: InputMaybe; - todoListId?: InputMaybe; - updatedAt?: InputMaybe; - userId?: InputMaybe; -}; - export type TodoListStatus = | 'ACTIVE' | 'INACTIVE'; -export type TodoListUpdateData = { - coverPhoto?: InputMaybe; - createdAt?: InputMaybe; - name?: InputMaybe; - ownerId?: InputMaybe; +export type UpdateTodoItemData = { + assigneeId?: InputMaybe; + attachments?: InputMaybe>; + done?: InputMaybe; position?: InputMaybe; - status?: InputMaybe; + text?: InputMaybe; + todoListId?: InputMaybe; }; /** Input type for updateTodoItem mutation */ export type UpdateTodoItemInput = { - data: TodoItemUpdateData; + data: UpdateTodoItemData; id: Scalars['Uuid']['input']; }; @@ -450,9 +443,18 @@ export type UpdateTodoItemPayload = { todoItem: TodoItem; }; +export type UpdateTodoListData = { + coverPhoto?: InputMaybe; + createdAt?: InputMaybe; + name?: InputMaybe; + ownerId?: InputMaybe; + position?: InputMaybe; + status?: InputMaybe; +}; + /** Input type for updateTodoList mutation */ export type UpdateTodoListInput = { - data: TodoListUpdateData; + data: UpdateTodoListData; id: Scalars['Uuid']['input']; }; @@ -462,9 +464,16 @@ export type UpdateTodoListPayload = { todoList: TodoList; }; +export type UpdateTodoListShareData = { + createdAt?: InputMaybe; + todoListId?: InputMaybe; + updatedAt?: InputMaybe; + userId?: InputMaybe; +}; + /** Input type for updateTodoListShare mutation */ export type UpdateTodoListShareInput = { - data: TodoListShareUpdateData; + data: UpdateTodoListShareData; id: TodoListSharePrimaryKey; }; @@ -474,9 +483,18 @@ export type UpdateTodoListSharePayload = { todoListShare: TodoListShare; }; +export type UpdateUserData = { + customer?: InputMaybe; + email?: InputMaybe; + images?: InputMaybe>; + name?: InputMaybe; + roles?: InputMaybe>; + userProfile?: InputMaybe; +}; + /** Input type for updateUser mutation */ export type UpdateUserInput = { - data: UserUpdateData; + data: UpdateUserData; id: Scalars['Uuid']['input']; }; @@ -499,36 +517,10 @@ export type User = { userProfile?: Maybe; }; -export type UserCreateData = { - customer?: InputMaybe; - email: Scalars['String']['input']; - images?: InputMaybe>; - name?: InputMaybe; - roles?: InputMaybe>; - userProfile?: InputMaybe; -}; - -export type UserEmbeddedCustomerData = { +export type UserCustomerNestedInput = { stripeCustomerId: Scalars['String']['input']; }; -export type UserEmbeddedImagesData = { - caption: Scalars['String']['input']; - file: FileInput; - id?: InputMaybe; -}; - -export type UserEmbeddedRolesData = { - role: Scalars['String']['input']; -}; - -export type UserEmbeddedUserProfileData = { - avatar?: InputMaybe; - bio?: InputMaybe; - birthDay?: InputMaybe; - id?: InputMaybe; -}; - export type UserImage = { __typename?: 'UserImage'; file: File; @@ -537,6 +529,12 @@ export type UserImage = { userId: Scalars['Uuid']['output']; }; +export type UserImagesNestedInput = { + caption: Scalars['String']['input']; + file: FileInput; + id?: InputMaybe; +}; + export type UserProfile = { __typename?: 'UserProfile'; avatar?: Maybe; @@ -558,13 +556,15 @@ export type UserRole = { userId: Scalars['Uuid']['output']; }; -export type UserUpdateData = { - customer?: InputMaybe; - email?: InputMaybe; - images?: InputMaybe>; - name?: InputMaybe; - roles?: InputMaybe>; - userProfile?: InputMaybe; +export type UserRolesNestedInput = { + role: Scalars['String']['input']; +}; + +export type UserUserProfileNestedInput = { + avatar?: InputMaybe; + bio?: InputMaybe; + birthDay?: InputMaybe; + id?: InputMaybe; }; export type FileInputFragment = { __typename?: 'File', id: string, filename: string, publicUrl?: string | null }; diff --git a/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json b/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json index 2d62a7f09..0d861f62f 100644 --- a/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json +++ b/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json @@ -2,6 +2,7 @@ "files": { "added": [ "src/modules/accounts/users/services/user.crud.int.test.ts", + "src/modules/accounts/users/services/user.data-service.int.test.ts", "src/modules/storage/queues/clean-unused-files.ts", "src/modules/storage/services/clean-unused-files.ts", "src/modules/storage/utils/mime.unit.test.ts", diff --git a/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json b/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json index e236b1a32..47adbf7fa 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json +++ b/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json @@ -5,7 +5,6 @@ "@baseplate-dev/core-generators#node/node:package-json": "package.json", "@baseplate-dev/core-generators#node/prettier:prettier-config": ".prettierrc", "@baseplate-dev/core-generators#node/prettier:prettier-ignore": ".prettierignore", - "@baseplate-dev/core-generators#node/ts-utils:arrays": "src/utils/arrays.ts", "@baseplate-dev/core-generators#node/ts-utils:normalize-types": "src/utils/normalize-types.ts", "@baseplate-dev/core-generators#node/ts-utils:nulls": "src/utils/nulls.ts", "@baseplate-dev/core-generators#node/ts-utils:string": "src/utils/string.ts", @@ -59,12 +58,13 @@ "@baseplate-dev/fastify-generators#core/request-service-context:request-service-context": "src/utils/request-service-context.ts", "@baseplate-dev/fastify-generators#core/service-context:service-context": "src/utils/service-context.ts", "@baseplate-dev/fastify-generators#core/service-context:test-helper": "src/tests/helpers/service-context.test-helper.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:TodoItem": "src/modules/todos/services/todo-item.crud.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:TodoList": "src/modules/todos/services/todo-list.crud.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:TodoListShare": "src/modules/todos/services/todo-list-share.crud.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:User": "src/modules/accounts/users/services/user.crud.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:UserImage": "src/modules/accounts/users/services/user-image.crud.ts", - "@baseplate-dev/fastify-generators#core/service-file:prisma-crud-service:UserProfile": "src/modules/accounts/users/services/user-profile.crud.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:TodoItem": "src/modules/todos/services/todo-item.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:TodoItemAttachment": "src/modules/todos/services/todo-item-attachment.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:TodoList": "src/modules/todos/services/todo-list.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:TodoListShare": "src/modules/todos/services/todo-list-share.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:User": "src/modules/accounts/users/services/user.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:UserImage": "src/modules/accounts/users/services/user-image.data-service.ts", + "@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:UserProfile": "src/modules/accounts/users/services/user-profile.data-service.ts", "@baseplate-dev/fastify-generators#email/fastify-postmark:postmark": "src/services/postmark.ts", "@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-global-types": "src/plugins/graphql/FieldAuthorizePlugin/global-types.ts", "@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-plugin": "src/plugins/graphql/FieldAuthorizePlugin/index.ts", @@ -103,12 +103,12 @@ "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-schema-builder": "src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts", "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-types": "src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts", "@baseplate-dev/fastify-generators#pothos/pothos:strip-query-mutation-plugin": "src/plugins/graphql/strip-query-mutation-plugin.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:crud-service-types": "src/utils/crud-service-types.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:data-pipes": "src/utils/data-pipes.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:embedded-one-to-many": "src/utils/embedded-pipes/embedded-one-to-many.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:embedded-one-to-one": "src/utils/embedded-pipes/embedded-one-to-one.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:embedded-types": "src/utils/embedded-pipes/embedded-types.ts", - "@baseplate-dev/fastify-generators#prisma/prisma-utils:prisma-relations": "src/utils/prisma-relations.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:define-operations": "src/utils/data-operations/define-operations.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:field-definitions": "src/utils/data-operations/field-definitions.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-types": "src/utils/data-operations/prisma-types.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-utils": "src/utils/data-operations/prisma-utils.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:relation-helpers": "src/utils/data-operations/relation-helpers.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:types": "src/utils/data-operations/types.ts", "@baseplate-dev/fastify-generators#prisma/prisma:client": "src/generated/prisma/client.ts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-config": "prisma.config.mts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-schema": "prisma/schema.prisma", @@ -135,9 +135,9 @@ "@baseplate-dev/plugin-storage#fastify/storage-module:services-create-presigned-download-url": "src/modules/storage/services/create-presigned-download-url.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:services-create-presigned-upload-url": "src/modules/storage/services/create-presigned-upload-url.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:services-download-file": "src/modules/storage/services/download-file.ts", + "@baseplate-dev/plugin-storage#fastify/storage-module:services-file-field": "src/modules/storage/services/file-field.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:services-get-public-url": "src/modules/storage/services/get-public-url.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:services-upload-file": "src/modules/storage/services/upload-file.ts", - "@baseplate-dev/plugin-storage#fastify/storage-module:services-validate-file-input": "src/modules/storage/services/validate-file-input.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:types-adapter": "src/modules/storage/types/adapter.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:types-file-category": "src/modules/storage/types/file-category.ts", "@baseplate-dev/plugin-storage#fastify/storage-module:utils-create-file-category": "src/modules/storage/utils/create-file-category.ts", diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts index 7d1649fb6..6960a881d 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts @@ -4,39 +4,43 @@ import { builder } from '@src/plugins/graphql/builder.js'; import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../../storage/schema/file-input.input-type.js'; -import { createUser, deleteUser, updateUser } from '../services/user.crud.js'; +import { + createUser, + deleteUser, + updateUser, +} from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const userEmbeddedCustomerDataInputType = builder.inputType( - 'UserEmbeddedCustomerData', +const userCustomerNestedInputInputType = builder.inputType( + 'UserCustomerNestedInput', { fields: (t) => ({ stripeCustomerId: t.string({ required: true }) }), }, ); -const userEmbeddedImagesDataInputType = builder.inputType( - 'UserEmbeddedImagesData', +const userImagesNestedInputInputType = builder.inputType( + 'UserImagesNestedInput', { fields: (t) => ({ - id: t.field({ type: 'Uuid' }), + id: t.id(), caption: t.string({ required: true }), file: t.field({ required: true, type: fileInputInputType }), }), }, ); -const userEmbeddedRolesDataInputType = builder.inputType( - 'UserEmbeddedRolesData', +const userRolesNestedInputInputType = builder.inputType( + 'UserRolesNestedInput', { fields: (t) => ({ role: t.string({ required: true }) }), }, ); -const userEmbeddedUserProfileDataInputType = builder.inputType( - 'UserEmbeddedUserProfileData', +const userUserProfileNestedInputInputType = builder.inputType( + 'UserUserProfileNestedInput', { fields: (t) => ({ - id: t.field({ type: 'Uuid' }), + id: t.id(), bio: t.string(), birthDay: t.field({ type: 'Date' }), avatar: t.field({ type: fileInputInputType }), @@ -44,21 +48,21 @@ const userEmbeddedUserProfileDataInputType = builder.inputType( }, ); -const userCreateDataInputType = builder.inputType('UserCreateData', { +const createUserDataInputType = builder.inputType('CreateUserData', { fields: (t) => ({ name: t.string(), email: t.string({ required: true }), - roles: t.field({ type: [userEmbeddedRolesDataInputType] }), - customer: t.field({ type: userEmbeddedCustomerDataInputType }), - userProfile: t.field({ type: userEmbeddedUserProfileDataInputType }), - images: t.field({ type: [userEmbeddedImagesDataInputType] }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), }), }); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ input: { - data: t.input.field({ required: true, type: userCreateDataInputType }), + data: t.input.field({ required: true, type: createUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], @@ -67,13 +71,13 @@ builder.mutationField('createUser', (t) => data: restrictObjectNulls( { ...data, - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), images: data.images?.map((image) => restrictObjectNulls(image, ['id']), ), + userProfile: + data.userProfile && restrictObjectNulls(data.userProfile, ['id']), }, - ['roles', 'customer', 'userProfile', 'images'], + ['customer', 'images', 'roles', 'userProfile'], ), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -83,14 +87,14 @@ builder.mutationField('createUser', (t) => }), ); -const userUpdateDataInputType = builder.inputType('UserUpdateData', { +const updateUserDataInputType = builder.inputType('UpdateUserData', { fields: (t) => ({ name: t.string(), email: t.string(), - roles: t.field({ type: [userEmbeddedRolesDataInputType] }), - customer: t.field({ type: userEmbeddedCustomerDataInputType }), - userProfile: t.field({ type: userEmbeddedUserProfileDataInputType }), - images: t.field({ type: [userEmbeddedImagesDataInputType] }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), }), }); @@ -98,23 +102,23 @@ builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ input: { id: t.input.field({ required: true, type: 'Uuid' }), - data: t.input.field({ required: true, type: userUpdateDataInputType }), + data: t.input.field({ required: true, type: updateUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ - id, + where: { id }, data: restrictObjectNulls( { ...data, - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), images: data.images?.map((image) => restrictObjectNulls(image, ['id']), ), + userProfile: + data.userProfile && restrictObjectNulls(data.userProfile, ['id']), }, - ['email', 'roles', 'images'], + ['email', 'customer', 'images', 'roles', 'userProfile'], ), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -131,7 +135,7 @@ builder.mutationField('deleteUser', (t) => authorize: ['admin'], resolve: async (root, { input: { id } }, context, info) => { const user = await deleteUser({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['user'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.crud.ts deleted file mode 100644 index 143013168..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.crud.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Prisma, UserImage } from '@src/generated/prisma/client.js'; -import type { DeleteServiceInput } from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -export async function deleteUserImage({ - id, - query, -}: DeleteServiceInput< - string, - Prisma.UserImageDefaultArgs ->): Promise { - return prisma.userImage.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.data-service.ts new file mode 100644 index 000000000..558304965 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-image.data-service.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { defineDeleteOperation } from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +import { fileField } from '../../../storage/services/file-field.js'; +import { userImageFileFileCategory } from '../constants/file-categories.js'; + +export const userImageInputFields = { + id: scalarField(z.string().uuid().optional()), + caption: scalarField(z.string()), + file: fileField({ + category: userImageFileFileCategory, + fileIdFieldName: 'fileId', + }), +}; + +export const deleteUserImage = defineDeleteOperation({ + model: 'userImage', + delete: ({ tx, where, query }) => + tx.userImage.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.crud.ts deleted file mode 100644 index 352a926ee..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.crud.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Prisma, UserProfile } from '@src/generated/prisma/client.js'; -import type { DeleteServiceInput } from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -export async function deleteUserProfile({ - id, - query, -}: DeleteServiceInput< - string, - Prisma.UserProfileDefaultArgs ->): Promise { - return prisma.userProfile.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.data-service.ts new file mode 100644 index 000000000..e918407c6 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user-profile.data-service.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { defineDeleteOperation } from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +import { fileField } from '../../../storage/services/file-field.js'; +import { userProfileAvatarFileCategory } from '../constants/file-categories.js'; + +export const userProfileInputFields = { + id: scalarField(z.string().uuid().optional()), + bio: scalarField(z.string().nullish()), + birthDay: scalarField(z.date().nullish()), + avatar: fileField({ + category: userProfileAvatarFileCategory, + fileIdFieldName: 'avatarId', + optional: true, + }), +}; + +export const deleteUserProfile = defineDeleteOperation({ + model: 'userProfile', + delete: ({ tx, where, query }) => + tx.userProfile.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.crud.ts deleted file mode 100644 index 60a5bea31..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.crud.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { Prisma, User } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { - applyDataPipeOutput, - mergePipeOperations, -} from '@src/utils/data-pipes.js'; -import { - createOneToManyCreateData, - createOneToManyUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-many.js'; -import { - createOneToOneCreateData, - createOneToOneUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-one.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -import type { FileUploadInput } from '../../../storage/services/validate-file-input.js'; - -import { validateFileInput } from '../../../storage/services/validate-file-input.js'; -import { - userImageFileFileCategory, - userProfileAvatarFileCategory, -} from '../constants/file-categories.js'; - -async function prepareUpsertEmbeddedImagesData( - data: UserEmbeddedImagesData, - context: ServiceContext, - whereUnique?: Prisma.UserImageWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where: Prisma.UserImageWhereUniqueInput; - create: Prisma.UserImageCreateWithoutUserInput; - update: Prisma.UserImageUpdateWithoutUserInput; - }> -> { - const { file, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.userImage.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.userId !== parentId) { - throw new Error('UserImage not attached to the correct parent item'); - } - - const fileOutput = await validateFileInput( - file, - userImageFileFileCategory, - context, - existingItem?.fileId, - ); - - return { - data: { - create: { file: fileOutput.data, ...rest }, - update: { file: fileOutput.data, ...rest }, - where: whereUnique ?? { id: '' }, - }, - operations: mergePipeOperations([fileOutput]), - }; -} - -async function prepareUpsertEmbeddedUserProfileData( - data: UserEmbeddedUserProfileData, - context: ServiceContext, - whereUnique?: Prisma.UserProfileWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where?: Prisma.UserProfileWhereUniqueInput; - create: Prisma.UserProfileCreateWithoutUserInput; - update: Prisma.UserProfileUpdateWithoutUserInput; - }> -> { - const { avatar, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.userProfile.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.userId !== parentId) { - throw new Error('UserProfile not attached to the correct parent item'); - } - - const avatarOutput = - avatar == null - ? avatar - : await validateFileInput( - avatar, - userProfileAvatarFileCategory, - context, - existingItem?.avatarId, - ); - - return { - data: { - create: { avatar: avatarOutput?.data, ...rest }, - update: { - avatar: createPrismaDisconnectOrConnectData(avatarOutput?.data), - ...rest, - }, - }, - operations: mergePipeOperations([avatarOutput]), - }; -} - -type UserEmbeddedCustomerData = Pick< - Prisma.CustomerUncheckedCreateInput, - 'stripeCustomerId' ->; - -interface UserEmbeddedImagesData - extends Pick { - file: FileUploadInput; -} - -type UserEmbeddedRolesData = Pick; - -interface UserEmbeddedUserProfileData - extends Pick< - Prisma.UserProfileUncheckedCreateInput, - 'id' | 'bio' | 'birthDay' - > { - avatar?: FileUploadInput | null; -} - -interface UserCreateData - extends Pick { - customer?: UserEmbeddedCustomerData; - images?: UserEmbeddedImagesData[]; - roles?: UserEmbeddedRolesData[]; - userProfile?: UserEmbeddedUserProfileData; -} - -export async function createUser({ - data, - query, - context, -}: CreateServiceInput): Promise { - const { roles, customer, userProfile, images, ...rest } = data; - - const customerOutput = await createOneToOneCreateData({ input: customer }); - - const imagesOutput = await createOneToManyCreateData({ - context, - input: images, - transform: prepareUpsertEmbeddedImagesData, - }); - - const rolesOutput = await createOneToManyCreateData({ input: roles }); - - const userProfileOutput = await createOneToOneCreateData({ - context, - input: userProfile, - transform: prepareUpsertEmbeddedUserProfileData, - }); - - return applyDataPipeOutput( - [rolesOutput, customerOutput, userProfileOutput, imagesOutput], - prisma.user.create({ - data: { - customer: { create: customerOutput.data?.create }, - images: { create: imagesOutput.data?.create }, - roles: { create: rolesOutput.data?.create }, - userProfile: { create: userProfileOutput.data?.create }, - ...rest, - }, - ...query, - }), - ); -} - -interface UserUpdateData - extends Pick, 'name' | 'email'> { - customer?: UserEmbeddedCustomerData | null; - images?: UserEmbeddedImagesData[]; - roles?: UserEmbeddedRolesData[]; - userProfile?: UserEmbeddedUserProfileData | null; -} - -export async function updateUser({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - UserUpdateData, - Prisma.UserDefaultArgs ->): Promise { - const { roles, customer, userProfile, images, ...rest } = data; - - const customerOutput = await createOneToOneUpsertData({ - deleteRelation: () => prisma.customer.deleteMany({ where: { id } }), - input: customer, - }); - - const imagesOutput = await createOneToManyUpsertData({ - context, - getWhereUnique: (input): Prisma.UserImageWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - idField: 'id', - input: images, - parentId: id, - transform: prepareUpsertEmbeddedImagesData, - }); - - const rolesOutput = await createOneToManyUpsertData({ - getWhereUnique: (input): Prisma.UserRoleWhereUniqueInput | undefined => ({ - userId_role: { role: input.role, userId: id }, - }), - idField: 'role', - input: roles, - }); - - const userProfileOutput = await createOneToOneUpsertData({ - context, - deleteRelation: () => - prisma.userProfile.deleteMany({ where: { userId: id } }), - getWhereUnique: (input): Prisma.UserProfileWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - input: userProfile, - parentId: id, - transform: prepareUpsertEmbeddedUserProfileData, - }); - - return applyDataPipeOutput( - [rolesOutput, customerOutput, userProfileOutput, imagesOutput], - prisma.user.update({ - where: { id }, - data: { - customer: customerOutput.data, - images: imagesOutput.data, - roles: rolesOutput.data, - userProfile: userProfileOutput.data, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteUser({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.user.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.data-service.ts new file mode 100644 index 000000000..e96814817 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/services/user.data-service.ts @@ -0,0 +1,96 @@ +import { pick } from 'es-toolkit'; +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { + createParentModelConfig, + nestedOneToManyField, + nestedOneToOneField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; + +import { userImageInputFields } from './user-image.data-service.js'; +import { userProfileInputFields } from './user-profile.data-service.js'; + +const parentModel = createParentModelConfig('user', (value) => ({ + id: value.id, +})); + +export const userInputFields = { + name: scalarField(z.string().nullish()), + email: scalarField(z.string()), + customer: nestedOneToOneField({ + buildData: (data) => data, + fields: { stripeCustomerId: scalarField(z.string()) }, + getWhereUnique: (parentModel) => ({ id: parentModel.id }), + model: 'customer', + parentModel, + relationName: 'user', + }), + images: nestedOneToManyField({ + buildData: (data) => data, + fields: pick(userImageInputFields, ['id', 'caption', 'file'] as const), + getWhereUnique: (input) => (input.id ? { id: input.id } : undefined), + model: 'userImage', + parentModel, + relationName: 'user', + }), + roles: nestedOneToManyField({ + buildData: (data) => data, + fields: { role: scalarField(z.string()) }, + getWhereUnique: (input, parentModel) => + input.role + ? { userId_role: { role: input.role, userId: parentModel.id } } + : undefined, + model: 'userRole', + parentModel, + relationName: 'user', + }), + userProfile: nestedOneToOneField({ + buildData: (data) => data, + fields: pick(userProfileInputFields, [ + 'id', + 'bio', + 'birthDay', + 'avatar', + ] as const), + getWhereUnique: (parentModel) => ({ userId: parentModel.id }), + model: 'userProfile', + parentModel, + relationName: 'user', + }), +}; + +export const createUser = defineCreateOperation({ + model: 'user', + fields: userInputFields, + create: ({ tx, data, query }) => + tx.user.create({ + data, + ...query, + }), +}); + +export const updateUser = defineUpdateOperation({ + model: 'user', + fields: userInputFields, + update: ({ tx, where, data, query }) => + tx.user.update({ + where, + data, + ...query, + }), +}); + +export const deleteUser = defineDeleteOperation({ + model: 'user', + delete: ({ tx, where, query }) => + tx.user.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts new file mode 100644 index 000000000..48778aabb --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts @@ -0,0 +1,199 @@ +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { FieldDefinition } from '@src/utils/data-operations/types.js'; + +import { prisma } from '@src/services/prisma.js'; +import { BadRequestError } from '@src/utils/http-errors.js'; + +import type { FileCategory } from '../types/file-category.js'; + +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; + +/** + * File input type - accepts a file ID string + */ +export interface FileInput { + id: string; +} + +/** + * Configuration for file field handler + */ +interface FileFieldConfig< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +> { + /** + * The category of files this field accepts + */ + category: TFileCategory; + /** + * The field name of the file ID in the existing model + */ + fileIdFieldName: keyof Prisma.$FilePayload['objects'][TFileCategory['referencedByRelation']][number]['scalars'] & + string; + + /** + * Whether the file is optional + */ + optional?: TOptional; +} + +/** + * Create a file field handler with validation and authorization + * + * This helper creates a field definition for managing file uploads. + * It validates that: + * - The file exists + * - The user is authorized to use the file (must be uploader or system role) + * - The file hasn't been referenced by another entity + * - The file category matches what's expected + * - The file was successfully uploaded + * + * After validation, it marks the file as referenced and returns a Prisma connect object. + * + * For create operations: + * - Returns connect object if file ID is provided and valid + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns connect object if file ID is provided and valid + * - Returns disconnect if input is null (removes file reference) + * - Returns undefined if input is not provided (no change) + * - Skips validation if the file ID hasn't changed from existing + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * avatar: fileField({ + * category: avatarFileCategory, + * }), + * }; + * ``` + */ +export function fileField< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +>( + config: FileFieldConfig, +): FieldDefinition< + TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? { connect: { id: string } } | undefined + : { connect: { id: string } }, + TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined +> { + return { + processInput: async (value: FileInput | null | undefined, processCtx) => { + const { serviceContext } = processCtx; + + // Handle null - disconnect the file + if (value === null) { + return { + data: { + create: undefined, + update: { disconnect: true } as TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + // Get existing file ID to check if we're changing it + const existingModel = (await processCtx.loadExisting()) as + | Record + | undefined; + + if (existingModel && !(config.fileIdFieldName in existingModel)) { + throw new BadRequestError( + `File ID field "${config.fileIdFieldName}" not found in existing model`, + ); + } + + const existingFileId = existingModel?.[config.fileIdFieldName]; + + // If we're updating and not changing the ID, skip checks + if (existingFileId === value.id) { + return { + data: { + create: { connect: { id: value.id } }, + update: { connect: { id: value.id } }, + }, + }; + } + + // Validate the file input + const { id } = value; + const isSystemUser = serviceContext.auth.roles.includes('system'); + const uploaderId = isSystemUser ? undefined : serviceContext.auth.userId; + const file = await prisma.file.findUnique({ + where: { id, uploaderId }, + }); + + // Check if file exists + if (!file) { + throw new BadRequestError( + `File with ID "${id}" not found. Please make sure the file exists and you were the original uploader.`, + ); + } + + // Check if file is already referenced + if (file.referencedAt) { + throw new BadRequestError( + `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, + ); + } + + // Check category match + if (file.category !== config.category.name) { + throw new BadRequestError( + `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${config.category.name}". Please upload a file of the correct type.`, + ); + } + + // Validate file was uploaded + if (!(file.adapter in STORAGE_ADAPTERS)) { + throw new BadRequestError( + `Unknown file adapter "${file.adapter}" configured for file "${id}".`, + ); + } + + const adapter = + STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; + + const fileMetadata = await adapter.getFileMetadata(file.storagePath); + if (!fileMetadata) { + throw new BadRequestError(`File "${id}" was not uploaded correctly.`); + } + + return { + data: { + create: { connect: { id } }, + update: { connect: { id } }, + }, + hooks: { + afterExecute: [ + async ({ tx }) => { + await tx.file.update({ + where: { id, referencedAt: null }, + data: { + referencedAt: new Date(), + size: fileMetadata.size, + }, + }); + }, + ], + }, + }; + }, + }; +} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/validate-file-input.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/validate-file-input.ts deleted file mode 100644 index 22461509e..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/validate-file-input.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { BadRequestError } from '@src/utils/http-errors.js'; - -import type { FileCategory } from '../types/file-category.js'; - -import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; - -export interface FileUploadInput { - id: string; -} - -/** - * Validates a file input and checks the upload is authorized - * @param input - The file input - * @param category - The category of the file - * @param context - The service context - * @param existingId - The existing ID of the file (if any) - * @returns The data pipe output - */ -export async function validateFileInput( - { id }: FileUploadInput, - category: FileCategory, - context: ServiceContext, - existingId?: string | null, -): Promise> { - // if we're updating and not changing the ID, skip checks - if (existingId === id) { - return { data: { connect: { id } } }; - } - - const file = - await /* TPL_FILE_MODEL:START */ prisma.file /* TPL_FILE_MODEL:END */ - .findUnique({ - where: { id }, - }); - - // Check if file exists - if (!file) { - throw new BadRequestError(`File with ID "${id}" does not exist`); - } - - // Check authorization: must be system role or the uploader - const isSystemUser = context.auth.roles.includes('system'); - const isUploader = file.uploaderId === context.auth.userId; - - if (!isSystemUser && !isUploader) { - throw new BadRequestError( - `Access denied: You can only use files that you uploaded. File "${id}" was uploaded by a different user.`, - ); - } - - // Check if file is already referenced - if (file.referencedAt) { - throw new BadRequestError( - `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, - ); - } - - // Check category match - if (file.category !== category.name) { - throw new BadRequestError( - `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${category.name}". Please upload a file of the correct type.`, - ); - } - - // Validate file was uploaded - const adapter = - STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; - const fileMetadata = await adapter.getFileMetadata(file.storagePath); - if (!fileMetadata) { - throw new BadRequestError(`File "${id}" was not uploaded correctly.`); - } - - return { - data: { connect: { id } }, - operations: { - afterPrismaPromises: [ - /* TPL_FILE_MODEL:START */ prisma.file /* TPL_FILE_MODEL:END */ - .update({ - where: { id }, - data: { - referencedAt: new Date(), - size: fileMetadata.size, - }, - }), - ], - }, - }; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/types/file-category.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/types/file-category.ts index 7907c503a..67fb2a34c 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/types/file-category.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/types/file-category.ts @@ -7,7 +7,11 @@ import type { StorageAdapterKey } from '../config/adapters.config.js'; * Configuration for a file category that specifies how files for a * particular model relation to File model should be handled. */ -export interface FileCategory { +export interface FileCategory< + TName extends string = string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +> { /** Name of category (must be CONSTANT_CASE) */ readonly name: TName; @@ -48,5 +52,5 @@ export interface FileCategory { /** * The relation that references this file category. */ - readonly referencedByRelation: keyof /* TPL_FILE_COUNT_OUTPUT_TYPE:START */ Prisma.FileCountOutputType /* TPL_FILE_COUNT_OUTPUT_TYPE:END */; + readonly referencedByRelation: TReferencedByRelation; } diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/utils/create-file-category.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/utils/create-file-category.ts index 5c95ef9ca..e44e28d0e 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/utils/create-file-category.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/utils/create-file-category.ts @@ -1,3 +1,5 @@ +import type { Prisma } from '@src/generated/prisma/client.js'; + import type { FileCategory } from '../types/file-category.js'; // Helper for common file size constraints @@ -17,9 +19,13 @@ export const MimeTypes = { ], } as const; -export function createFileCategory( - config: FileCategory, -): FileCategory { +export function createFileCategory< + TName extends string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +>( + config: FileCategory, +): FileCategory { if (!/^[A-Z][A-Z0-9_]*$/.test(config.name)) { throw new Error( 'File category name must be CONSTANT_CASE (e.g., USER_AVATAR, POST_IMAGE)', diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts index 6acc4bd16..0bfcf219a 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts @@ -7,36 +7,36 @@ import { createTodoItem, deleteTodoItem, updateTodoItem, -} from '../services/todo-item.crud.js'; +} from '../services/todo-item.data-service.js'; import { todoItemObjectType } from './todo-item.object-type.js'; -const todoItemAttachmentEmbeddedTagsDataInputType = builder.inputType( - 'TodoItemAttachmentEmbeddedTagsData', +const todoItemAttachmentTagsNestedInputInputType = builder.inputType( + 'TodoItemAttachmentTagsNestedInput', { fields: (t) => ({ tag: t.string({ required: true }) }), }, ); -const todoItemEmbeddedAttachmentsDataInputType = builder.inputType( - 'TodoItemEmbeddedAttachmentsData', +const todoItemAttachmentsNestedInputInputType = builder.inputType( + 'TodoItemAttachmentsNestedInput', { fields: (t) => ({ position: t.int({ required: true }), url: t.string({ required: true }), - id: t.field({ type: 'Uuid' }), - tags: t.field({ type: [todoItemAttachmentEmbeddedTagsDataInputType] }), + id: t.id(), + tags: t.field({ type: [todoItemAttachmentTagsNestedInputInputType] }), }), }, ); -const todoItemCreateDataInputType = builder.inputType('TodoItemCreateData', { +const createTodoItemDataInputType = builder.inputType('CreateTodoItemData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), position: t.int({ required: true }), text: t.string({ required: true }), done: t.boolean({ required: true }), assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemEmbeddedAttachmentsDataInputType] }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), }), }); @@ -45,7 +45,7 @@ builder.mutationField('createTodoItem', (t) => input: { data: t.input.field({ required: true, - type: todoItemCreateDataInputType, + type: createTodoItemDataInputType, }), }, payload: { todoItem: t.payload.field({ type: todoItemObjectType }) }, @@ -69,14 +69,14 @@ builder.mutationField('createTodoItem', (t) => }), ); -const todoItemUpdateDataInputType = builder.inputType('TodoItemUpdateData', { +const updateTodoItemDataInputType = builder.inputType('UpdateTodoItemData', { fields: (t) => ({ + todoListId: t.field({ type: 'Uuid' }), position: t.int(), text: t.string(), done: t.boolean(), assigneeId: t.field({ type: 'Uuid' }), - todoListId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemEmbeddedAttachmentsDataInputType] }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), }), }); @@ -86,14 +86,14 @@ builder.mutationField('updateTodoItem', (t) => id: t.input.field({ required: true, type: 'Uuid' }), data: t.input.field({ required: true, - type: todoItemUpdateDataInputType, + type: updateTodoItemDataInputType, }), }, payload: { todoItem: t.payload.field({ type: todoItemObjectType }) }, authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoItem = await updateTodoItem({ - id, + where: { id }, data: restrictObjectNulls( { ...data, @@ -101,7 +101,7 @@ builder.mutationField('updateTodoItem', (t) => restrictObjectNulls(attachment, ['id', 'tags']), ), }, - ['position', 'text', 'done', 'todoListId', 'attachments'], + ['todoListId', 'position', 'text', 'done', 'attachments'], ), context, query: queryFromInfo({ context, info, path: ['todoItem'] }), @@ -118,7 +118,7 @@ builder.mutationField('deleteTodoItem', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoItem = await deleteTodoItem({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts index 3995d8085..d2f576a20 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts @@ -7,14 +7,14 @@ import { createTodoListShare, deleteTodoListShare, updateTodoListShare, -} from '../services/todo-list-share.crud.js'; +} from '../services/todo-list-share.data-service.js'; import { todoListShareObjectType, todoListSharePrimaryKeyInputType, } from './todo-list-share.object-type.js'; -const todoListShareCreateDataInputType = builder.inputType( - 'TodoListShareCreateData', +const createTodoListShareDataInputType = builder.inputType( + 'CreateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), @@ -30,7 +30,7 @@ builder.mutationField('createTodoListShare', (t) => input: { data: t.input.field({ required: true, - type: todoListShareCreateDataInputType, + type: createTodoListShareDataInputType, }), }, payload: { @@ -48,8 +48,8 @@ builder.mutationField('createTodoListShare', (t) => }), ); -const todoListShareUpdateDataInputType = builder.inputType( - 'TodoListShareUpdateData', +const updateTodoListShareDataInputType = builder.inputType( + 'UpdateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ type: 'Uuid' }), @@ -69,7 +69,7 @@ builder.mutationField('updateTodoListShare', (t) => }), data: t.input.field({ required: true, - type: todoListShareUpdateDataInputType, + type: updateTodoListShareDataInputType, }), }, payload: { @@ -78,7 +78,7 @@ builder.mutationField('updateTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoListShare = await updateTodoListShare({ - id, + where: { todoListId_userId: id }, data: restrictObjectNulls(data, [ 'todoListId', 'userId', @@ -107,7 +107,7 @@ builder.mutationField('deleteTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoListShare = await deleteTodoListShare({ - id, + where: { todoListId_userId: id }, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts index f42bbbf30..56a0df1dc 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts @@ -8,17 +8,17 @@ import { createTodoList, deleteTodoList, updateTodoList, -} from '../services/todo-list.crud.js'; +} from '../services/todo-list.data-service.js'; import { todoListStatusEnum } from './enums.js'; import { todoListObjectType } from './todo-list.object-type.js'; -const todoListCreateDataInputType = builder.inputType('TodoListCreateData', { +const createTodoListDataInputType = builder.inputType('CreateTodoListData', { fields: (t) => ({ + ownerId: t.field({ required: true, type: 'Uuid' }), position: t.int({ required: true }), name: t.string({ required: true }), - ownerId: t.field({ required: true, type: 'Uuid' }), - status: t.field({ type: todoListStatusEnum }), createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), coverPhoto: t.field({ type: fileInputInputType }), }), }); @@ -28,7 +28,7 @@ builder.mutationField('createTodoList', (t) => input: { data: t.input.field({ required: true, - type: todoListCreateDataInputType, + type: createTodoListDataInputType, }), }, payload: { todoList: t.payload.field({ type: todoListObjectType }) }, @@ -44,13 +44,13 @@ builder.mutationField('createTodoList', (t) => }), ); -const todoListUpdateDataInputType = builder.inputType('TodoListUpdateData', { +const updateTodoListDataInputType = builder.inputType('UpdateTodoListData', { fields: (t) => ({ + ownerId: t.field({ type: 'Uuid' }), position: t.int(), name: t.string(), - ownerId: t.field({ type: 'Uuid' }), - status: t.field({ type: todoListStatusEnum }), createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), coverPhoto: t.field({ type: fileInputInputType }), }), }); @@ -61,18 +61,18 @@ builder.mutationField('updateTodoList', (t) => id: t.input.field({ required: true, type: 'Uuid' }), data: t.input.field({ required: true, - type: todoListUpdateDataInputType, + type: updateTodoListDataInputType, }), }, payload: { todoList: t.payload.field({ type: todoListObjectType }) }, authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoList = await updateTodoList({ - id, + where: { id }, data: restrictObjectNulls(data, [ + 'ownerId', 'position', 'name', - 'ownerId', 'createdAt', ]), context, @@ -90,7 +90,7 @@ builder.mutationField('deleteTodoList', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoList = await deleteTodoList({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['todoList'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item-attachment.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item-attachment.data-service.ts new file mode 100644 index 000000000..e0e634305 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item-attachment.data-service.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { + createParentModelConfig, + nestedOneToManyField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; + +const parentModel = createParentModelConfig('todoItemAttachment', (value) => ({ + id: value.id, +})); + +export const todoItemAttachmentInputFields = { + id: scalarField(z.string().uuid().optional()), + position: scalarField(z.number().int()), + url: scalarField(z.string()), + tags: nestedOneToManyField({ + buildData: (data) => data, + fields: { tag: scalarField(z.string()) }, + getWhereUnique: (input, parentModel) => + input.tag + ? { + todoItemAttachmentId_tag: { + tag: input.tag, + todoItemAttachmentId: parentModel.id, + }, + } + : undefined, + model: 'todoItemAttachmentTag', + parentModel, + relationName: 'todoItemAttachment', + }), +}; diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.crud.ts deleted file mode 100644 index bb6b25d8d..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.crud.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { Prisma, TodoItem } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { - applyDataPipeOutput, - mergePipeOperations, -} from '@src/utils/data-pipes.js'; -import { - createOneToManyCreateData, - createOneToManyUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-many.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -async function prepareUpsertEmbeddedAttachmentsData( - data: TodoItemEmbeddedAttachmentsData, - context: ServiceContext, - whereUnique?: Prisma.TodoItemAttachmentWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where: Prisma.TodoItemAttachmentWhereUniqueInput; - create: Prisma.TodoItemAttachmentCreateWithoutTodoItemInput; - update: Prisma.TodoItemAttachmentUpdateWithoutTodoItemInput; - }> -> { - const { tags, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.todoItemAttachment.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.todoItemId !== parentId) { - throw new Error( - 'TodoItemAttachment not attached to the correct parent item', - ); - } - - const tagsOutput = await createOneToManyUpsertData({ - getWhereUnique: ( - input, - ): Prisma.TodoItemAttachmentTagWhereUniqueInput | undefined => - existingItem - ? { - todoItemAttachmentId_tag: { - tag: input.tag, - todoItemAttachmentId: existingItem.id, - }, - } - : undefined, - idField: 'tag', - input: tags, - }); - - return { - data: { - create: { tags: { create: tagsOutput.data?.create }, ...rest }, - update: { tags: tagsOutput.data, ...rest }, - where: whereUnique ?? { id: '' }, - }, - operations: mergePipeOperations([tagsOutput]), - }; -} - -type TodoItemAttachmentEmbeddedTagsData = Pick< - Prisma.TodoItemAttachmentTagUncheckedCreateInput, - 'tag' ->; - -interface TodoItemEmbeddedAttachmentsData - extends Pick< - Prisma.TodoItemAttachmentUncheckedCreateInput, - 'position' | 'url' | 'id' - > { - tags?: TodoItemAttachmentEmbeddedTagsData[]; -} - -interface TodoItemCreateData - extends Pick< - Prisma.TodoItemUncheckedCreateInput, - 'todoListId' | 'position' | 'text' | 'done' | 'assigneeId' - > { - attachments?: TodoItemEmbeddedAttachmentsData[]; -} - -export async function createTodoItem({ - data, - query, - context, -}: CreateServiceInput< - TodoItemCreateData, - Prisma.TodoItemDefaultArgs ->): Promise { - const { attachments, assigneeId, todoListId, ...rest } = data; - - const assignee = - assigneeId == null ? undefined : { connect: { id: assigneeId } }; - - const attachmentsOutput = await createOneToManyCreateData({ - context, - input: attachments, - transform: prepareUpsertEmbeddedAttachmentsData, - }); - - const todoList = { connect: { id: todoListId } }; - - return applyDataPipeOutput( - [attachmentsOutput], - prisma.todoItem.create({ - data: { - assignee, - attachments: { create: attachmentsOutput.data?.create }, - todoList, - ...rest, - }, - ...query, - }), - ); -} - -interface TodoItemUpdateData - extends Pick< - Partial, - 'position' | 'text' | 'done' | 'assigneeId' | 'todoListId' - > { - attachments?: TodoItemEmbeddedAttachmentsData[]; -} - -export async function updateTodoItem({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - TodoItemUpdateData, - Prisma.TodoItemDefaultArgs ->): Promise { - const { attachments, assigneeId, todoListId, ...rest } = data; - - const assignee = - assigneeId == null ? assigneeId : { connect: { id: assigneeId } }; - - const attachmentsOutput = await createOneToManyUpsertData({ - context, - getWhereUnique: ( - input, - ): Prisma.TodoItemAttachmentWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - idField: 'id', - input: attachments, - parentId: id, - transform: prepareUpsertEmbeddedAttachmentsData, - }); - - const todoList = - todoListId == null ? todoListId : { connect: { id: todoListId } }; - - return applyDataPipeOutput( - [attachmentsOutput], - prisma.todoItem.update({ - where: { id }, - data: { - assignee: createPrismaDisconnectOrConnectData(assignee), - attachments: attachmentsOutput.data, - todoList, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteTodoItem({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.todoItem.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.data-service.ts new file mode 100644 index 000000000..ffd15e8f6 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-item.data-service.ts @@ -0,0 +1,79 @@ +import { pick } from 'es-toolkit'; +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { + createParentModelConfig, + nestedOneToManyField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +import { todoItemAttachmentInputFields } from './todo-item-attachment.data-service.js'; + +const parentModel = createParentModelConfig('todoItem', (value) => ({ + id: value.id, +})); + +export const todoItemInputFields = { + todoListId: scalarField(z.string().uuid()), + position: scalarField(z.number().int()), + text: scalarField(z.string()), + done: scalarField(z.boolean()), + assigneeId: scalarField(z.string().uuid().nullish()), + attachments: nestedOneToManyField({ + buildData: (data) => data, + fields: pick(todoItemAttachmentInputFields, [ + 'position', + 'url', + 'id', + 'tags', + ] as const), + getWhereUnique: (input) => (input.id ? { id: input.id } : undefined), + model: 'todoItemAttachment', + parentModel, + relationName: 'todoItem', + }), +}; + +export const createTodoItem = defineCreateOperation({ + model: 'todoItem', + fields: todoItemInputFields, + create: ({ tx, data: { assigneeId, todoListId, ...data }, query }) => + tx.todoItem.create({ + data: { + ...data, + assignee: relationHelpers.connectCreate({ id: assigneeId }), + todoList: relationHelpers.connectCreate({ id: todoListId }), + }, + ...query, + }), +}); + +export const updateTodoItem = defineUpdateOperation({ + model: 'todoItem', + fields: todoItemInputFields, + update: ({ tx, where, data: { assigneeId, todoListId, ...data }, query }) => + tx.todoItem.update({ + where, + data: { + ...data, + assignee: relationHelpers.connectUpdate({ id: assigneeId }), + todoList: relationHelpers.connectUpdate({ id: todoListId }), + }, + ...query, + }), +}); + +export const deleteTodoItem = defineDeleteOperation({ + model: 'todoItem', + delete: ({ tx, where, query }) => + tx.todoItem.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.crud.ts deleted file mode 100644 index 5ff1a90c0..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.crud.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Prisma, TodoListShare } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -type TodoListShareCreateData = Pick< - Prisma.TodoListShareUncheckedCreateInput, - 'todoListId' | 'userId' | 'updatedAt' | 'createdAt' ->; - -export async function createTodoListShare({ - data, - query, -}: CreateServiceInput< - TodoListShareCreateData, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.create({ data, ...query }); -} - -export type TodoListSharePrimaryKey = Pick< - TodoListShare, - 'todoListId' | 'userId' ->; - -type TodoListShareUpdateData = Pick< - Partial, - 'todoListId' | 'userId' | 'updatedAt' | 'createdAt' ->; - -export async function updateTodoListShare({ - id: todoListId_userId, - data, - query, -}: UpdateServiceInput< - TodoListSharePrimaryKey, - TodoListShareUpdateData, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.update({ - where: { todoListId_userId }, - data, - ...query, - }); -} - -export async function deleteTodoListShare({ - id: todoListId_userId, - query, -}: DeleteServiceInput< - TodoListSharePrimaryKey, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.delete({ - where: { todoListId_userId }, - ...query, - }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.data-service.ts new file mode 100644 index 000000000..75e6f1c18 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list-share.data-service.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +export const todoListShareInputFields = { + todoListId: scalarField(z.string().uuid()), + userId: scalarField(z.string().uuid()), + updatedAt: scalarField(z.date().optional()), + createdAt: scalarField(z.date().optional()), +}; + +export const createTodoListShare = defineCreateOperation({ + model: 'todoListShare', + fields: todoListShareInputFields, + create: ({ tx, data: { todoListId, userId, ...data }, query }) => + tx.todoListShare.create({ + data: { + ...data, + todoList: relationHelpers.connectCreate({ id: todoListId }), + user: relationHelpers.connectCreate({ id: userId }), + }, + ...query, + }), +}); + +export const updateTodoListShare = defineUpdateOperation({ + model: 'todoListShare', + fields: todoListShareInputFields, + update: ({ tx, where, data: { todoListId, userId, ...data }, query }) => + tx.todoListShare.update({ + where, + data: { + ...data, + todoList: relationHelpers.connectUpdate({ id: todoListId }), + user: relationHelpers.connectUpdate({ id: userId }), + }, + ...query, + }), +}); + +export const deleteTodoListShare = defineDeleteOperation({ + model: 'todoListShare', + delete: ({ tx, where, query }) => + tx.todoListShare.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.crud.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.crud.ts deleted file mode 100644 index ee68447d6..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.crud.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Prisma, TodoList } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; -import { applyDataPipeOutput } from '@src/utils/data-pipes.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -import type { FileUploadInput } from '../../storage/services/validate-file-input.js'; - -import { validateFileInput } from '../../storage/services/validate-file-input.js'; -import { todoListCoverPhotoFileCategory } from '../constants/file-categories.js'; - -interface TodoListCreateData - extends Pick< - Prisma.TodoListUncheckedCreateInput, - 'position' | 'name' | 'ownerId' | 'status' | 'createdAt' - > { - coverPhoto?: FileUploadInput | null; -} - -export async function createTodoList({ - data, - query, - context, -}: CreateServiceInput< - TodoListCreateData, - Prisma.TodoListDefaultArgs ->): Promise { - const { coverPhoto, ownerId, ...rest } = data; - - const coverPhotoOutput = - coverPhoto == null - ? coverPhoto - : await validateFileInput( - coverPhoto, - todoListCoverPhotoFileCategory, - context, - ); - - const owner = { connect: { id: ownerId } }; - - return applyDataPipeOutput( - [coverPhotoOutput], - prisma.todoList.create({ - data: { coverPhoto: coverPhotoOutput?.data, owner, ...rest }, - ...query, - }), - ); -} - -interface TodoListUpdateData - extends Pick< - Partial, - 'position' | 'name' | 'ownerId' | 'status' | 'createdAt' - > { - coverPhoto?: FileUploadInput | null; -} - -export async function updateTodoList({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - TodoListUpdateData, - Prisma.TodoListDefaultArgs ->): Promise { - const { coverPhoto, ownerId, ...rest } = data; - - const existingItem = await prisma.todoList.findUniqueOrThrow({ - where: { id }, - }); - - const coverPhotoOutput = - coverPhoto == null - ? coverPhoto - : await validateFileInput( - coverPhoto, - todoListCoverPhotoFileCategory, - context, - existingItem.coverPhotoId, - ); - - const owner = ownerId == null ? ownerId : { connect: { id: ownerId } }; - - return applyDataPipeOutput( - [coverPhotoOutput], - prisma.todoList.update({ - where: { id }, - data: { - coverPhoto: createPrismaDisconnectOrConnectData(coverPhotoOutput?.data), - owner, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteTodoList({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.todoList.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.data-service.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.data-service.ts new file mode 100644 index 000000000..17268b981 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/services/todo-list.data-service.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { $Enums } from '@src/generated/prisma/client.js'; +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +import { fileField } from '../../storage/services/file-field.js'; +import { todoListCoverPhotoFileCategory } from '../constants/file-categories.js'; + +export const todoListInputFields = { + ownerId: scalarField(z.string().uuid()), + position: scalarField(z.number().int()), + name: scalarField(z.string()), + createdAt: scalarField(z.date().optional()), + status: scalarField(z.nativeEnum($Enums.TodoListStatus).nullish()), + coverPhoto: fileField({ + category: todoListCoverPhotoFileCategory, + fileIdFieldName: 'coverPhotoId', + optional: true, + }), +}; + +export const createTodoList = defineCreateOperation({ + model: 'todoList', + fields: todoListInputFields, + create: ({ tx, data: { ownerId, ...data }, query }) => + tx.todoList.create({ + data: { ...data, owner: relationHelpers.connectCreate({ id: ownerId }) }, + ...query, + }), +}); + +export const updateTodoList = defineUpdateOperation({ + model: 'todoList', + fields: todoListInputFields, + update: ({ tx, where, data: { ownerId, ...data }, query }) => + tx.todoList.update({ + where, + data: { ...data, owner: relationHelpers.connectUpdate({ id: ownerId }) }, + ...query, + }), +}); + +export const deleteTodoList = defineDeleteOperation({ + model: 'todoList', + delete: ({ tx, where, query }) => + tx.todoList.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index 69bdf3da0..1cf274920 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -10,6 +10,7 @@ import type { AuthRole } from '@src/modules/accounts/auth/constants/auth-roles.c import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosAuthorizeByRolesPlugin } from './FieldAuthorizePlugin/index.js'; @@ -62,6 +63,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/arrays.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/arrays.ts deleted file mode 100644 index 6d9341493..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/arrays.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Checks if a value is not null or undefined. - * - * @param value - The value to check. - * @returns `true` if the value is not null or undefined, otherwise `false`. - */ -export function notEmpty( - value: TValue | null | undefined, -): value is TValue { - return value !== null && value !== undefined; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/crud-service-types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/crud-service-types.ts deleted file mode 100644 index f97f00610..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/crud-service-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ServiceContext } from './service-context.js'; - -export interface CreateServiceInput< - CreateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - data: CreateData; - context: Context; - query?: Query; -} - -export interface UpdateServiceInput< - PrimaryKey, - UpdateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - data: UpdateData; - context: Context; - query?: Query; -} - -export interface DeleteServiceInput< - PrimaryKey, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - context: Context; - query?: Query; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-pipes.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-pipes.ts deleted file mode 100644 index c6e7d8f60..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-pipes.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Prisma } from '../generated/prisma/client.js'; - -import { prisma } from '../services/prisma.js'; -import { notEmpty } from './arrays.js'; - -interface DataPipeOperations { - beforePrismaPromises?: Prisma.PrismaPromise[]; - afterPrismaPromises?: Prisma.PrismaPromise[]; -} - -export interface DataPipeOutput { - data: Output; - operations?: DataPipeOperations; -} - -export function mergePipeOperations( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): DataPipeOperations { - const operations = outputs - .map((o) => (o && 'data' in o ? o.operations : o)) - .filter(notEmpty); - - return { - beforePrismaPromises: operations.flatMap( - (op) => op.beforePrismaPromises ?? [], - ), - afterPrismaPromises: operations.flatMap( - (op) => op.afterPrismaPromises ?? [], - ), - }; -} - -// Taken from Prisma generated code -type UnwrapPromise

= P extends Promise ? R : P; -type UnwrapTuple = { - [K in keyof Tuple]: K extends `${number}` - ? Tuple[K] extends Prisma.PrismaPromise - ? X - : UnwrapPromise - : UnwrapPromise; -}; - -export async function applyDataPipeOutputToOperations< - Promises extends Prisma.PrismaPromise[], ->( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operations: [...Promises], -): Promise> { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - ...operations, - ...afterPrismaPromises, - ]); - - return results.slice( - beforePrismaPromises.length, - beforePrismaPromises.length + operations.length, - ) as UnwrapTuple; -} - -export async function applyDataPipeOutput( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operation: Prisma.PrismaPromise, -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - operation, - ...afterPrismaPromises, - ]); - - return results[beforePrismaPromises.length] as DataType; -} - -export async function applyDataPipeOutputWithoutOperation( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - await prisma.$transaction([...beforePrismaPromises, ...afterPrismaPromises]); -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-many.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-many.ts deleted file mode 100644 index ab0da298e..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-many.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { DataPipeOutput } from '../data-pipes.js'; -import type { ServiceContext } from '../service-context.js'; -import type { UpsertPayload } from './embedded-types.js'; - -import { notEmpty } from '../arrays.js'; -import { mergePipeOperations } from '../data-pipes.js'; - -// Create Helpers - -interface OneToManyCreatePipeInput { - input: DataInput[] | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToManyCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToManyCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToManyCreatePipeInput - | OneToManyCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'][] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputs = await Promise.all( - input.map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - return { - data: { create: createOutputs.map((output) => output.data) }, - operations: mergePipeOperations(createOutputs), - }; -} - -// Upsert Helpers - -interface UpsertManyPayload< - UpsertData extends UpsertPayload, - WhereUniqueInput, - IdField extends string | number | symbol, - IdType = string, -> { - deleteMany?: Record; - upsert?: { - where: WhereUniqueInput; - create: UpsertData['create']; - update: UpsertData['update']; - }[]; - create: UpsertData['create'][]; -} - -interface OneToManyUpsertPipeInput< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, -> { - input: DataInput[] | undefined; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform?: undefined; - context?: undefined; - parentId?: undefined; -} - -interface OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - context: ServiceContext; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - parentId?: ParentId; -} - -export async function createOneToManyUpsertData< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - idField, - context, - getWhereUnique, - transform, - parentId, -}: - | OneToManyUpsertPipeInput - | OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField, - ParentId, - UpsertData - >): Promise< - DataPipeOutput< - | UpsertManyPayload< - UpsertData, - WhereUniqueInput, - IdField, - Exclude - > - | undefined - > -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, undefined, parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputPromise = Promise.all( - input - .filter( - (item) => - item[idField] === undefined || getWhereUnique(item) === undefined, - ) - .map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - async function transformUpsertInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, getWhereUnique(item), parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const upsertOutputPromise = Promise.all( - input - .filter((item) => item[idField] !== undefined && getWhereUnique(item)) - .map(async (item) => { - const output = await transformUpsertInput(item); - return { - data: { - where: getWhereUnique(item) as WhereUniqueInput, - create: output.data.create, - update: output.data.update, - }, - operations: output.operations, - }; - }), - ); - - const [upsertOutput, createOutput] = await Promise.all([ - upsertOutputPromise, - createOutputPromise, - ]); - - return { - data: { - deleteMany: - idField && - ({ - [idField]: { - notIn: input.map((data) => data[idField]).filter(notEmpty), - }, - } as Record< - IdField, - { - notIn: Exclude[]; - } - >), - upsert: upsertOutput.map((output) => output.data), - create: createOutput.map((output) => output.data), - }, - operations: mergePipeOperations([...upsertOutput, ...createOutput]), - }; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-one.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-one.ts deleted file mode 100644 index 9d9762af5..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-one-to-one.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { Prisma } from '@src/generated/prisma/client.js'; - -import type { DataPipeOutput } from '../data-pipes.js'; -import type { ServiceContext } from '../service-context.js'; -import type { UpsertPayload } from './embedded-types.js'; - -// Create Helpers - -interface OneToOneCreatePipeInput { - input: DataInput | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToOneCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToOneCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToOneCreatePipeInput - | OneToOneCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve(transform(input, context)); - return { - data: { create: transformedData.data.create }, - operations: transformedData.operations, - }; - } - - return { - data: { create: input }, - }; -} - -// Upsert helpers - -interface OneToOneUpsertPipeInput { - input: DataInput | null | undefined; - transform?: undefined; - context?: undefined; - getWhereUnique?: undefined; - parentId?: undefined; - deleteRelation: () => Prisma.PrismaPromise; -} - -interface OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput extends object, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | null | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - context: ServiceContext; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - parentId?: ParentId; - deleteRelation: () => Prisma.PrismaPromise; -} - -export async function createOneToOneUpsertData< - DataInput, - WhereUniqueInput extends object, - ParentId, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, - getWhereUnique, - parentId, - deleteRelation, -}: - | OneToOneUpsertPipeInput - | OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - ParentId, - UpsertData - >): Promise< - DataPipeOutput<{ upsert: UpsertData } | { delete: true } | undefined> -> { - if (input === null) { - return { - data: undefined, - operations: { beforePrismaPromises: [deleteRelation()] }, - }; - } - if (input === undefined) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve( - transform(input, context, getWhereUnique(input), parentId), - ); - return { - data: { upsert: transformedData.data }, - operations: transformedData.operations, - }; - } - - return { - data: { - upsert: { - create: input, - update: input, - } as UpsertData, - }, - }; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-types.ts deleted file mode 100644 index f91ff262b..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/embedded-pipes/embedded-types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface UpsertPayload { - create: CreateData; - update: UpdateData; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/prisma-relations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/prisma-relations.ts deleted file mode 100644 index 2e0efbc61..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/prisma-relations.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Small helper function to make it easier to use optional relations in Prisma since the - * only way to set a Prisma relation to null is to disconnect it. - * - * See https://github.com/prisma/prisma/issues/5044 - */ -export function createPrismaDisconnectOrConnectData( - data?: { connect: UniqueWhere } | null, -): - | { - disconnect?: boolean; - connect?: UniqueWhere; - } - | undefined { - if (data === undefined) { - return undefined; - } - if (data === null) { - return { disconnect: true }; - } - return data; -} diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/vitest.config.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/vitest.config.ts index ae31e7bdb..3fc5643d0 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/vitest.config.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig( test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', setupFiles: ['tests/scripts/mock-redis.ts'], diff --git a/examples/todo-with-auth0/apps/backend/schema.graphql b/examples/todo-with-auth0/apps/backend/schema.graphql index 3d4a31cd3..7ed0aa069 100644 --- a/examples/todo-with-auth0/apps/backend/schema.graphql +++ b/examples/todo-with-auth0/apps/backend/schema.graphql @@ -29,9 +29,18 @@ type CreatePresignedUploadUrlPayload { url: String! } +input CreateTodoItemData { + assigneeId: Uuid + attachments: [TodoItemAttachmentsNestedInput!] + done: Boolean! + position: Int! + text: String! + todoListId: Uuid! +} + """Input type for createTodoItem mutation""" input CreateTodoItemInput { - data: TodoItemCreateData! + data: CreateTodoItemData! } """Payload type for createTodoItem mutation""" @@ -39,9 +48,18 @@ type CreateTodoItemPayload { todoItem: TodoItem! } +input CreateTodoListData { + coverPhoto: FileInput + createdAt: DateTime + name: String! + ownerId: Uuid! + position: Int! + status: TodoListStatus +} + """Input type for createTodoList mutation""" input CreateTodoListInput { - data: TodoListCreateData! + data: CreateTodoListData! } """Payload type for createTodoList mutation""" @@ -49,9 +67,16 @@ type CreateTodoListPayload { todoList: TodoList! } +input CreateTodoListShareData { + createdAt: DateTime + todoListId: Uuid! + updatedAt: DateTime + userId: Uuid! +} + """Input type for createTodoListShare mutation""" input CreateTodoListShareInput { - data: TodoListShareCreateData! + data: CreateTodoListShareData! } """Payload type for createTodoListShare mutation""" @@ -59,9 +84,18 @@ type CreateTodoListSharePayload { todoListShare: TodoListShare! } +input CreateUserData { + customer: UserCustomerNestedInput + email: String! + images: [UserImagesNestedInput!] + name: String + roles: [UserRolesNestedInput!] + userProfile: UserUserProfileNestedInput +} + """Input type for createUser mutation""" input CreateUserInput { - data: UserCreateData! + data: CreateUserData! } """Payload type for createUser mutation""" @@ -223,41 +257,23 @@ type TodoItemAttachment { url: String! } -input TodoItemAttachmentEmbeddedTagsData { - tag: String! -} - type TodoItemAttachmentTag { tag: String! todoItemAttachment: TodoItemAttachment! todoItemAttachmentId: Uuid! } -input TodoItemCreateData { - assigneeId: Uuid - attachments: [TodoItemEmbeddedAttachmentsData!] - done: Boolean! - position: Int! - text: String! - todoListId: Uuid! +input TodoItemAttachmentTagsNestedInput { + tag: String! } -input TodoItemEmbeddedAttachmentsData { - id: Uuid +input TodoItemAttachmentsNestedInput { + id: ID position: Int! - tags: [TodoItemAttachmentEmbeddedTagsData!] + tags: [TodoItemAttachmentTagsNestedInput!] url: String! } -input TodoItemUpdateData { - assigneeId: Uuid - attachments: [TodoItemEmbeddedAttachmentsData!] - done: Boolean - position: Int - text: String - todoListId: Uuid -} - type TodoList { coverPhoto: File createdAt: DateTime! @@ -270,15 +286,6 @@ type TodoList { updatedAt: DateTime! } -input TodoListCreateData { - coverPhoto: FileInput - createdAt: DateTime - name: String! - ownerId: Uuid! - position: Int! - status: TodoListStatus -} - type TodoListShare { createdAt: DateTime! todoList: TodoList! @@ -288,42 +295,28 @@ type TodoListShare { userId: Uuid! } -input TodoListShareCreateData { - createdAt: DateTime - todoListId: Uuid! - updatedAt: DateTime - userId: Uuid! -} - input TodoListSharePrimaryKey { todoListId: Uuid! userId: Uuid! } -input TodoListShareUpdateData { - createdAt: DateTime - todoListId: Uuid - updatedAt: DateTime - userId: Uuid -} - enum TodoListStatus { ACTIVE INACTIVE } -input TodoListUpdateData { - coverPhoto: FileInput - createdAt: DateTime - name: String - ownerId: Uuid +input UpdateTodoItemData { + assigneeId: Uuid + attachments: [TodoItemAttachmentsNestedInput!] + done: Boolean position: Int - status: TodoListStatus + text: String + todoListId: Uuid } """Input type for updateTodoItem mutation""" input UpdateTodoItemInput { - data: TodoItemUpdateData! + data: UpdateTodoItemData! id: Uuid! } @@ -332,9 +325,18 @@ type UpdateTodoItemPayload { todoItem: TodoItem! } +input UpdateTodoListData { + coverPhoto: FileInput + createdAt: DateTime + name: String + ownerId: Uuid + position: Int + status: TodoListStatus +} + """Input type for updateTodoList mutation""" input UpdateTodoListInput { - data: TodoListUpdateData! + data: UpdateTodoListData! id: Uuid! } @@ -343,9 +345,16 @@ type UpdateTodoListPayload { todoList: TodoList! } +input UpdateTodoListShareData { + createdAt: DateTime + todoListId: Uuid + updatedAt: DateTime + userId: Uuid +} + """Input type for updateTodoListShare mutation""" input UpdateTodoListShareInput { - data: TodoListShareUpdateData! + data: UpdateTodoListShareData! id: TodoListSharePrimaryKey! } @@ -354,9 +363,18 @@ type UpdateTodoListSharePayload { todoListShare: TodoListShare! } +input UpdateUserData { + customer: UserCustomerNestedInput + email: String + images: [UserImagesNestedInput!] + name: String + roles: [UserRolesNestedInput!] + userProfile: UserUserProfileNestedInput +} + """Input type for updateUser mutation""" input UpdateUserInput { - data: UserUpdateData! + data: UpdateUserData! id: Uuid! } @@ -377,36 +395,10 @@ type User { userProfile: UserProfile } -input UserCreateData { - customer: UserEmbeddedCustomerData - email: String! - images: [UserEmbeddedImagesData!] - name: String - roles: [UserEmbeddedRolesData!] - userProfile: UserEmbeddedUserProfileData -} - -input UserEmbeddedCustomerData { +input UserCustomerNestedInput { stripeCustomerId: String! } -input UserEmbeddedImagesData { - caption: String! - file: FileInput! - id: Uuid -} - -input UserEmbeddedRolesData { - role: String! -} - -input UserEmbeddedUserProfileData { - avatar: FileInput - bio: String - birthDay: Date - id: Uuid -} - type UserImage { file: File! fileId: Uuid! @@ -414,6 +406,12 @@ type UserImage { userId: Uuid! } +input UserImagesNestedInput { + caption: String! + file: FileInput! + id: ID +} + type UserProfile { avatar: File avatarId: Uuid @@ -433,13 +431,15 @@ type UserRole { userId: Uuid! } -input UserUpdateData { - customer: UserEmbeddedCustomerData - email: String - images: [UserEmbeddedImagesData!] - name: String - roles: [UserEmbeddedRolesData!] - userProfile: UserEmbeddedUserProfileData +input UserRolesNestedInput { + role: String! +} + +input UserUserProfileNestedInput { + avatar: FileInput + bio: String + birthDay: Date + id: ID } """ diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts index 7d1649fb6..6960a881d 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts @@ -4,39 +4,43 @@ import { builder } from '@src/plugins/graphql/builder.js'; import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../../storage/schema/file-input.input-type.js'; -import { createUser, deleteUser, updateUser } from '../services/user.crud.js'; +import { + createUser, + deleteUser, + updateUser, +} from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const userEmbeddedCustomerDataInputType = builder.inputType( - 'UserEmbeddedCustomerData', +const userCustomerNestedInputInputType = builder.inputType( + 'UserCustomerNestedInput', { fields: (t) => ({ stripeCustomerId: t.string({ required: true }) }), }, ); -const userEmbeddedImagesDataInputType = builder.inputType( - 'UserEmbeddedImagesData', +const userImagesNestedInputInputType = builder.inputType( + 'UserImagesNestedInput', { fields: (t) => ({ - id: t.field({ type: 'Uuid' }), + id: t.id(), caption: t.string({ required: true }), file: t.field({ required: true, type: fileInputInputType }), }), }, ); -const userEmbeddedRolesDataInputType = builder.inputType( - 'UserEmbeddedRolesData', +const userRolesNestedInputInputType = builder.inputType( + 'UserRolesNestedInput', { fields: (t) => ({ role: t.string({ required: true }) }), }, ); -const userEmbeddedUserProfileDataInputType = builder.inputType( - 'UserEmbeddedUserProfileData', +const userUserProfileNestedInputInputType = builder.inputType( + 'UserUserProfileNestedInput', { fields: (t) => ({ - id: t.field({ type: 'Uuid' }), + id: t.id(), bio: t.string(), birthDay: t.field({ type: 'Date' }), avatar: t.field({ type: fileInputInputType }), @@ -44,21 +48,21 @@ const userEmbeddedUserProfileDataInputType = builder.inputType( }, ); -const userCreateDataInputType = builder.inputType('UserCreateData', { +const createUserDataInputType = builder.inputType('CreateUserData', { fields: (t) => ({ name: t.string(), email: t.string({ required: true }), - roles: t.field({ type: [userEmbeddedRolesDataInputType] }), - customer: t.field({ type: userEmbeddedCustomerDataInputType }), - userProfile: t.field({ type: userEmbeddedUserProfileDataInputType }), - images: t.field({ type: [userEmbeddedImagesDataInputType] }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), }), }); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ input: { - data: t.input.field({ required: true, type: userCreateDataInputType }), + data: t.input.field({ required: true, type: createUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], @@ -67,13 +71,13 @@ builder.mutationField('createUser', (t) => data: restrictObjectNulls( { ...data, - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), images: data.images?.map((image) => restrictObjectNulls(image, ['id']), ), + userProfile: + data.userProfile && restrictObjectNulls(data.userProfile, ['id']), }, - ['roles', 'customer', 'userProfile', 'images'], + ['customer', 'images', 'roles', 'userProfile'], ), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -83,14 +87,14 @@ builder.mutationField('createUser', (t) => }), ); -const userUpdateDataInputType = builder.inputType('UserUpdateData', { +const updateUserDataInputType = builder.inputType('UpdateUserData', { fields: (t) => ({ name: t.string(), email: t.string(), - roles: t.field({ type: [userEmbeddedRolesDataInputType] }), - customer: t.field({ type: userEmbeddedCustomerDataInputType }), - userProfile: t.field({ type: userEmbeddedUserProfileDataInputType }), - images: t.field({ type: [userEmbeddedImagesDataInputType] }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), }), }); @@ -98,23 +102,23 @@ builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ input: { id: t.input.field({ required: true, type: 'Uuid' }), - data: t.input.field({ required: true, type: userUpdateDataInputType }), + data: t.input.field({ required: true, type: updateUserDataInputType }), }, payload: { user: t.payload.field({ type: userObjectType }) }, authorize: ['admin'], resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ - id, + where: { id }, data: restrictObjectNulls( { ...data, - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), images: data.images?.map((image) => restrictObjectNulls(image, ['id']), ), + userProfile: + data.userProfile && restrictObjectNulls(data.userProfile, ['id']), }, - ['email', 'roles', 'images'], + ['email', 'customer', 'images', 'roles', 'userProfile'], ), context, query: queryFromInfo({ context, info, path: ['user'] }), @@ -131,7 +135,7 @@ builder.mutationField('deleteUser', (t) => authorize: ['admin'], resolve: async (root, { input: { id } }, context, info) => { const user = await deleteUser({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['user'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.crud.ts deleted file mode 100644 index 143013168..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.crud.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Prisma, UserImage } from '@src/generated/prisma/client.js'; -import type { DeleteServiceInput } from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -export async function deleteUserImage({ - id, - query, -}: DeleteServiceInput< - string, - Prisma.UserImageDefaultArgs ->): Promise { - return prisma.userImage.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.data-service.ts new file mode 100644 index 000000000..558304965 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-image.data-service.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { defineDeleteOperation } from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +import { fileField } from '../../../storage/services/file-field.js'; +import { userImageFileFileCategory } from '../constants/file-categories.js'; + +export const userImageInputFields = { + id: scalarField(z.string().uuid().optional()), + caption: scalarField(z.string()), + file: fileField({ + category: userImageFileFileCategory, + fileIdFieldName: 'fileId', + }), +}; + +export const deleteUserImage = defineDeleteOperation({ + model: 'userImage', + delete: ({ tx, where, query }) => + tx.userImage.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.crud.ts deleted file mode 100644 index 352a926ee..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.crud.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Prisma, UserProfile } from '@src/generated/prisma/client.js'; -import type { DeleteServiceInput } from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -export async function deleteUserProfile({ - id, - query, -}: DeleteServiceInput< - string, - Prisma.UserProfileDefaultArgs ->): Promise { - return prisma.userProfile.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.data-service.ts new file mode 100644 index 000000000..e918407c6 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user-profile.data-service.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { defineDeleteOperation } from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; + +import { fileField } from '../../../storage/services/file-field.js'; +import { userProfileAvatarFileCategory } from '../constants/file-categories.js'; + +export const userProfileInputFields = { + id: scalarField(z.string().uuid().optional()), + bio: scalarField(z.string().nullish()), + birthDay: scalarField(z.date().nullish()), + avatar: fileField({ + category: userProfileAvatarFileCategory, + fileIdFieldName: 'avatarId', + optional: true, + }), +}; + +export const deleteUserProfile = defineDeleteOperation({ + model: 'userProfile', + delete: ({ tx, where, query }) => + tx.userProfile.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.int.test.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.int.test.ts index 161f2035d..ea073b80d 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.int.test.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.int.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { prisma } from '@src/services/prisma.js'; import { createTestServiceContext } from '@src/tests/helpers/service-context.test-helper.js'; -import { createUser, updateUser } from './user.crud.js'; +import { createUser, updateUser } from './user.data-service.js'; const context = createTestServiceContext(); @@ -47,7 +47,7 @@ describe('create', () => { }); await updateUser({ - id: createdItem.id, + where: { id: createdItem.id }, data: { roles: [{ role: 'kiosk' }], customer: { diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.ts deleted file mode 100644 index 60a5bea31..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.crud.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { Prisma, User } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { - applyDataPipeOutput, - mergePipeOperations, -} from '@src/utils/data-pipes.js'; -import { - createOneToManyCreateData, - createOneToManyUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-many.js'; -import { - createOneToOneCreateData, - createOneToOneUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-one.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -import type { FileUploadInput } from '../../../storage/services/validate-file-input.js'; - -import { validateFileInput } from '../../../storage/services/validate-file-input.js'; -import { - userImageFileFileCategory, - userProfileAvatarFileCategory, -} from '../constants/file-categories.js'; - -async function prepareUpsertEmbeddedImagesData( - data: UserEmbeddedImagesData, - context: ServiceContext, - whereUnique?: Prisma.UserImageWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where: Prisma.UserImageWhereUniqueInput; - create: Prisma.UserImageCreateWithoutUserInput; - update: Prisma.UserImageUpdateWithoutUserInput; - }> -> { - const { file, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.userImage.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.userId !== parentId) { - throw new Error('UserImage not attached to the correct parent item'); - } - - const fileOutput = await validateFileInput( - file, - userImageFileFileCategory, - context, - existingItem?.fileId, - ); - - return { - data: { - create: { file: fileOutput.data, ...rest }, - update: { file: fileOutput.data, ...rest }, - where: whereUnique ?? { id: '' }, - }, - operations: mergePipeOperations([fileOutput]), - }; -} - -async function prepareUpsertEmbeddedUserProfileData( - data: UserEmbeddedUserProfileData, - context: ServiceContext, - whereUnique?: Prisma.UserProfileWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where?: Prisma.UserProfileWhereUniqueInput; - create: Prisma.UserProfileCreateWithoutUserInput; - update: Prisma.UserProfileUpdateWithoutUserInput; - }> -> { - const { avatar, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.userProfile.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.userId !== parentId) { - throw new Error('UserProfile not attached to the correct parent item'); - } - - const avatarOutput = - avatar == null - ? avatar - : await validateFileInput( - avatar, - userProfileAvatarFileCategory, - context, - existingItem?.avatarId, - ); - - return { - data: { - create: { avatar: avatarOutput?.data, ...rest }, - update: { - avatar: createPrismaDisconnectOrConnectData(avatarOutput?.data), - ...rest, - }, - }, - operations: mergePipeOperations([avatarOutput]), - }; -} - -type UserEmbeddedCustomerData = Pick< - Prisma.CustomerUncheckedCreateInput, - 'stripeCustomerId' ->; - -interface UserEmbeddedImagesData - extends Pick { - file: FileUploadInput; -} - -type UserEmbeddedRolesData = Pick; - -interface UserEmbeddedUserProfileData - extends Pick< - Prisma.UserProfileUncheckedCreateInput, - 'id' | 'bio' | 'birthDay' - > { - avatar?: FileUploadInput | null; -} - -interface UserCreateData - extends Pick { - customer?: UserEmbeddedCustomerData; - images?: UserEmbeddedImagesData[]; - roles?: UserEmbeddedRolesData[]; - userProfile?: UserEmbeddedUserProfileData; -} - -export async function createUser({ - data, - query, - context, -}: CreateServiceInput): Promise { - const { roles, customer, userProfile, images, ...rest } = data; - - const customerOutput = await createOneToOneCreateData({ input: customer }); - - const imagesOutput = await createOneToManyCreateData({ - context, - input: images, - transform: prepareUpsertEmbeddedImagesData, - }); - - const rolesOutput = await createOneToManyCreateData({ input: roles }); - - const userProfileOutput = await createOneToOneCreateData({ - context, - input: userProfile, - transform: prepareUpsertEmbeddedUserProfileData, - }); - - return applyDataPipeOutput( - [rolesOutput, customerOutput, userProfileOutput, imagesOutput], - prisma.user.create({ - data: { - customer: { create: customerOutput.data?.create }, - images: { create: imagesOutput.data?.create }, - roles: { create: rolesOutput.data?.create }, - userProfile: { create: userProfileOutput.data?.create }, - ...rest, - }, - ...query, - }), - ); -} - -interface UserUpdateData - extends Pick, 'name' | 'email'> { - customer?: UserEmbeddedCustomerData | null; - images?: UserEmbeddedImagesData[]; - roles?: UserEmbeddedRolesData[]; - userProfile?: UserEmbeddedUserProfileData | null; -} - -export async function updateUser({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - UserUpdateData, - Prisma.UserDefaultArgs ->): Promise { - const { roles, customer, userProfile, images, ...rest } = data; - - const customerOutput = await createOneToOneUpsertData({ - deleteRelation: () => prisma.customer.deleteMany({ where: { id } }), - input: customer, - }); - - const imagesOutput = await createOneToManyUpsertData({ - context, - getWhereUnique: (input): Prisma.UserImageWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - idField: 'id', - input: images, - parentId: id, - transform: prepareUpsertEmbeddedImagesData, - }); - - const rolesOutput = await createOneToManyUpsertData({ - getWhereUnique: (input): Prisma.UserRoleWhereUniqueInput | undefined => ({ - userId_role: { role: input.role, userId: id }, - }), - idField: 'role', - input: roles, - }); - - const userProfileOutput = await createOneToOneUpsertData({ - context, - deleteRelation: () => - prisma.userProfile.deleteMany({ where: { userId: id } }), - getWhereUnique: (input): Prisma.UserProfileWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - input: userProfile, - parentId: id, - transform: prepareUpsertEmbeddedUserProfileData, - }); - - return applyDataPipeOutput( - [rolesOutput, customerOutput, userProfileOutput, imagesOutput], - prisma.user.update({ - where: { id }, - data: { - customer: customerOutput.data, - images: imagesOutput.data, - roles: rolesOutput.data, - userProfile: userProfileOutput.data, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteUser({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.user.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.int.test.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.int.test.ts new file mode 100644 index 000000000..31c0d34dc --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.int.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prisma } from '@src/services/prisma.js'; +import { createTestServiceContext } from '@src/tests/helpers/service-context.test-helper.js'; + +import { createUser, deleteUser, updateUser } from './user.data-service.js'; + +// Mock storage adapters to return successful metadata +// The mock returns dynamic size based on the path +vi.mock('@src/modules/storage/config/adapters.config.js', () => ({ + STORAGE_ADAPTERS: { + uploads: { + getFileMetadata: vi.fn().mockImplementation((path: string) => { + // Extract filename from path to determine size + if (path.includes('avatar.png')) return Promise.resolve({ size: 1024 }); + if (path.includes('avatar1.png')) + return Promise.resolve({ size: 1024 }); + if (path.includes('avatar2.png')) + return Promise.resolve({ size: 2048 }); + if (path.includes('image1.png')) return Promise.resolve({ size: 2048 }); + if (path.includes('image2.png')) return Promise.resolve({ size: 3072 }); + if (path.includes('image3.png')) return Promise.resolve({ size: 4096 }); + return Promise.resolve({ size: 1024 }); + }), + }, + url: { + getFileMetadata: vi.fn().mockResolvedValue({ size: 1024 }), + }, + }, +})); + +// Create a test user ID for file uploads +const TEST_USER_ID = '00000000-0000-0000-0000-000000000001'; + +const context = createTestServiceContext(); + +describe('createUser', () => { + beforeEach(async () => { + await prisma.user.deleteMany({}); + }); + + it('should create a user with basic fields', async () => { + const createdUser = await createUser({ + data: { + name: 'John Doe', + email: 'john@example.com', + }, + context, + }); + + expect(createdUser).toMatchObject({ + name: 'John Doe', + email: 'john@example.com', + }); + }); + + it('should create a user with nested customer', async () => { + const createdUser = await createUser({ + data: { + name: 'John Doe', + email: 'john@example.com', + customer: { + stripeCustomerId: 'cus_123456789', + }, + }, + context, + }); + + const userCheck = await prisma.user.findUnique({ + where: { id: createdUser.id }, + include: { customer: true }, + }); + + expect(userCheck).toMatchObject({ + customer: { + stripeCustomerId: 'cus_123456789', + }, + }); + }); + + it('should create a user with userProfile and avatar file', async () => { + // Create a test user for file ownership + const testUser = await prisma.user.create({ + data: { + id: TEST_USER_ID, + email: 'test-uploader@example.com', + }, + }); + + // Create a file for the avatar + const file = await prisma.file.create({ + data: { + filename: 'avatar.png', + storagePath: '/uploads/avatar.png', + category: 'USER_PROFILE_AVATAR', + adapter: 'uploads', + uploaderId: testUser.id, + mimeType: 'image/png', + size: 1024, + }, + }); + + const createdUser = await createUser({ + data: { + name: 'John Doe', + email: 'john@example.com', + userProfile: { + bio: 'Software developer', + avatar: { id: file.id }, + }, + }, + context, + }); + + const userCheck = await prisma.user.findUnique({ + where: { id: createdUser.id }, + include: { + userProfile: { + include: { avatar: true }, + }, + }, + }); + + expect(userCheck).toMatchObject({ + name: 'John Doe', + email: 'john@example.com', + userProfile: { + bio: 'Software developer', + avatar: { + id: file.id, + filename: 'avatar.png', + referencedAt: expect.any(Date) as Date, + size: 1024, + }, + }, + }); + }); + + it('should create a user with nested images', async () => { + // Create a test user for file ownership + const testUser = await prisma.user.create({ + data: { + id: TEST_USER_ID, + email: 'test-uploader@example.com', + }, + }); + + // Create files for the images + const file1 = await prisma.file.create({ + data: { + filename: 'image1.png', + storagePath: '/uploads/image1.png', + category: 'USER_IMAGE_FILE', + adapter: 'uploads', + uploaderId: testUser.id, + mimeType: 'image/png', + size: 2048, + }, + }); + + const file2 = await prisma.file.create({ + data: { + filename: 'image2.png', + storagePath: '/uploads/image2.png', + category: 'USER_IMAGE_FILE', + adapter: 'uploads', + uploaderId: testUser.id, + mimeType: 'image/png', + size: 3072, + }, + }); + + const createdUser = await createUser({ + data: { + name: 'John Doe', + email: 'john@example.com', + images: [ + { + id: '67362d38-abb6-4778-95bd-f6f398bc5c54', + caption: 'First image', + file: { id: file1.id }, + }, + { + id: '77362d38-abb6-4778-95bd-f6f398bc5c55', + caption: 'Second image', + file: { id: file2.id }, + }, + ], + }, + context, + }); + + const userCheck = await prisma.user.findUnique({ + where: { id: createdUser.id }, + include: { + images: { + include: { file: true }, + orderBy: { caption: 'asc' }, + }, + }, + }); + + expect(userCheck?.images).toHaveLength(2); + expect(userCheck).toMatchObject({ + name: 'John Doe', + email: 'john@example.com', + images: [ + { + id: '67362d38-abb6-4778-95bd-f6f398bc5c54', + caption: 'First image', + file: { + id: file1.id, + filename: 'image1.png', + referencedAt: expect.any(Date) as Date, + size: 2048, + }, + }, + { + id: '77362d38-abb6-4778-95bd-f6f398bc5c55', + caption: 'Second image', + file: { + id: file2.id, + filename: 'image2.png', + referencedAt: expect.any(Date) as Date, + size: 3072, + }, + }, + ], + }); + }); +}); + +describe('updateUser', () => { + beforeEach(async () => { + await prisma.user.deleteMany({}); + }); + + it('should update basic user fields', async () => { + const user = await prisma.user.create({ + data: { + name: 'John Doe', + email: 'john@example.com', + }, + }); + + const updatedUser = await updateUser({ + where: { id: user.id }, + data: { + name: 'Jane Doe', + email: 'jane@example.com', + }, + context, + }); + + expect(updatedUser).toMatchObject({ + name: 'Jane Doe', + email: 'jane@example.com', + }); + }); +}); + +describe('deleteUser', () => { + beforeEach(async () => { + await prisma.user.deleteMany({}); + }); + + it('should delete a user', async () => { + const user = await prisma.user.create({ + data: { + name: 'John Doe', + email: 'john@example.com', + }, + }); + + await deleteUser({ + where: { id: user.id }, + context, + }); + + const userCheck = await prisma.user.findUnique({ + where: { id: user.id }, + }); + + expect(userCheck).toBeNull(); + }); +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.ts new file mode 100644 index 000000000..e96814817 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/services/user.data-service.ts @@ -0,0 +1,96 @@ +import { pick } from 'es-toolkit'; +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { + createParentModelConfig, + nestedOneToManyField, + nestedOneToOneField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; + +import { userImageInputFields } from './user-image.data-service.js'; +import { userProfileInputFields } from './user-profile.data-service.js'; + +const parentModel = createParentModelConfig('user', (value) => ({ + id: value.id, +})); + +export const userInputFields = { + name: scalarField(z.string().nullish()), + email: scalarField(z.string()), + customer: nestedOneToOneField({ + buildData: (data) => data, + fields: { stripeCustomerId: scalarField(z.string()) }, + getWhereUnique: (parentModel) => ({ id: parentModel.id }), + model: 'customer', + parentModel, + relationName: 'user', + }), + images: nestedOneToManyField({ + buildData: (data) => data, + fields: pick(userImageInputFields, ['id', 'caption', 'file'] as const), + getWhereUnique: (input) => (input.id ? { id: input.id } : undefined), + model: 'userImage', + parentModel, + relationName: 'user', + }), + roles: nestedOneToManyField({ + buildData: (data) => data, + fields: { role: scalarField(z.string()) }, + getWhereUnique: (input, parentModel) => + input.role + ? { userId_role: { role: input.role, userId: parentModel.id } } + : undefined, + model: 'userRole', + parentModel, + relationName: 'user', + }), + userProfile: nestedOneToOneField({ + buildData: (data) => data, + fields: pick(userProfileInputFields, [ + 'id', + 'bio', + 'birthDay', + 'avatar', + ] as const), + getWhereUnique: (parentModel) => ({ userId: parentModel.id }), + model: 'userProfile', + parentModel, + relationName: 'user', + }), +}; + +export const createUser = defineCreateOperation({ + model: 'user', + fields: userInputFields, + create: ({ tx, data, query }) => + tx.user.create({ + data, + ...query, + }), +}); + +export const updateUser = defineUpdateOperation({ + model: 'user', + fields: userInputFields, + update: ({ tx, where, data, query }) => + tx.user.update({ + where, + data, + ...query, + }), +}); + +export const deleteUser = defineDeleteOperation({ + model: 'user', + delete: ({ tx, where, query }) => + tx.user.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/.templates-info.json b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/.templates-info.json index 2398b03be..063a99d3e 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/.templates-info.json +++ b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/.templates-info.json @@ -14,6 +14,11 @@ "instanceData": {}, "template": "services-download-file" }, + "file-field.ts": { + "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", + "instanceData": {}, + "template": "services-file-field" + }, "get-public-url.ts": { "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "instanceData": {}, @@ -23,10 +28,5 @@ "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "instanceData": {}, "template": "services-upload-file" - }, - "validate-file-input.ts": { - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "instanceData": {}, - "template": "services-validate-file-input" } } diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts new file mode 100644 index 000000000..48778aabb --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts @@ -0,0 +1,199 @@ +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { FieldDefinition } from '@src/utils/data-operations/types.js'; + +import { prisma } from '@src/services/prisma.js'; +import { BadRequestError } from '@src/utils/http-errors.js'; + +import type { FileCategory } from '../types/file-category.js'; + +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; + +/** + * File input type - accepts a file ID string + */ +export interface FileInput { + id: string; +} + +/** + * Configuration for file field handler + */ +interface FileFieldConfig< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +> { + /** + * The category of files this field accepts + */ + category: TFileCategory; + /** + * The field name of the file ID in the existing model + */ + fileIdFieldName: keyof Prisma.$FilePayload['objects'][TFileCategory['referencedByRelation']][number]['scalars'] & + string; + + /** + * Whether the file is optional + */ + optional?: TOptional; +} + +/** + * Create a file field handler with validation and authorization + * + * This helper creates a field definition for managing file uploads. + * It validates that: + * - The file exists + * - The user is authorized to use the file (must be uploader or system role) + * - The file hasn't been referenced by another entity + * - The file category matches what's expected + * - The file was successfully uploaded + * + * After validation, it marks the file as referenced and returns a Prisma connect object. + * + * For create operations: + * - Returns connect object if file ID is provided and valid + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns connect object if file ID is provided and valid + * - Returns disconnect if input is null (removes file reference) + * - Returns undefined if input is not provided (no change) + * - Skips validation if the file ID hasn't changed from existing + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * avatar: fileField({ + * category: avatarFileCategory, + * }), + * }; + * ``` + */ +export function fileField< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +>( + config: FileFieldConfig, +): FieldDefinition< + TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? { connect: { id: string } } | undefined + : { connect: { id: string } }, + TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined +> { + return { + processInput: async (value: FileInput | null | undefined, processCtx) => { + const { serviceContext } = processCtx; + + // Handle null - disconnect the file + if (value === null) { + return { + data: { + create: undefined, + update: { disconnect: true } as TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + // Get existing file ID to check if we're changing it + const existingModel = (await processCtx.loadExisting()) as + | Record + | undefined; + + if (existingModel && !(config.fileIdFieldName in existingModel)) { + throw new BadRequestError( + `File ID field "${config.fileIdFieldName}" not found in existing model`, + ); + } + + const existingFileId = existingModel?.[config.fileIdFieldName]; + + // If we're updating and not changing the ID, skip checks + if (existingFileId === value.id) { + return { + data: { + create: { connect: { id: value.id } }, + update: { connect: { id: value.id } }, + }, + }; + } + + // Validate the file input + const { id } = value; + const isSystemUser = serviceContext.auth.roles.includes('system'); + const uploaderId = isSystemUser ? undefined : serviceContext.auth.userId; + const file = await prisma.file.findUnique({ + where: { id, uploaderId }, + }); + + // Check if file exists + if (!file) { + throw new BadRequestError( + `File with ID "${id}" not found. Please make sure the file exists and you were the original uploader.`, + ); + } + + // Check if file is already referenced + if (file.referencedAt) { + throw new BadRequestError( + `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, + ); + } + + // Check category match + if (file.category !== config.category.name) { + throw new BadRequestError( + `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${config.category.name}". Please upload a file of the correct type.`, + ); + } + + // Validate file was uploaded + if (!(file.adapter in STORAGE_ADAPTERS)) { + throw new BadRequestError( + `Unknown file adapter "${file.adapter}" configured for file "${id}".`, + ); + } + + const adapter = + STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; + + const fileMetadata = await adapter.getFileMetadata(file.storagePath); + if (!fileMetadata) { + throw new BadRequestError(`File "${id}" was not uploaded correctly.`); + } + + return { + data: { + create: { connect: { id } }, + update: { connect: { id } }, + }, + hooks: { + afterExecute: [ + async ({ tx }) => { + await tx.file.update({ + where: { id, referencedAt: null }, + data: { + referencedAt: new Date(), + size: fileMetadata.size, + }, + }); + }, + ], + }, + }; + }, + }; +} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/validate-file-input.ts b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/validate-file-input.ts deleted file mode 100644 index 22461509e..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/validate-file-input.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { BadRequestError } from '@src/utils/http-errors.js'; - -import type { FileCategory } from '../types/file-category.js'; - -import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; - -export interface FileUploadInput { - id: string; -} - -/** - * Validates a file input and checks the upload is authorized - * @param input - The file input - * @param category - The category of the file - * @param context - The service context - * @param existingId - The existing ID of the file (if any) - * @returns The data pipe output - */ -export async function validateFileInput( - { id }: FileUploadInput, - category: FileCategory, - context: ServiceContext, - existingId?: string | null, -): Promise> { - // if we're updating and not changing the ID, skip checks - if (existingId === id) { - return { data: { connect: { id } } }; - } - - const file = - await /* TPL_FILE_MODEL:START */ prisma.file /* TPL_FILE_MODEL:END */ - .findUnique({ - where: { id }, - }); - - // Check if file exists - if (!file) { - throw new BadRequestError(`File with ID "${id}" does not exist`); - } - - // Check authorization: must be system role or the uploader - const isSystemUser = context.auth.roles.includes('system'); - const isUploader = file.uploaderId === context.auth.userId; - - if (!isSystemUser && !isUploader) { - throw new BadRequestError( - `Access denied: You can only use files that you uploaded. File "${id}" was uploaded by a different user.`, - ); - } - - // Check if file is already referenced - if (file.referencedAt) { - throw new BadRequestError( - `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, - ); - } - - // Check category match - if (file.category !== category.name) { - throw new BadRequestError( - `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${category.name}". Please upload a file of the correct type.`, - ); - } - - // Validate file was uploaded - const adapter = - STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; - const fileMetadata = await adapter.getFileMetadata(file.storagePath); - if (!fileMetadata) { - throw new BadRequestError(`File "${id}" was not uploaded correctly.`); - } - - return { - data: { connect: { id } }, - operations: { - afterPrismaPromises: [ - /* TPL_FILE_MODEL:START */ prisma.file /* TPL_FILE_MODEL:END */ - .update({ - where: { id }, - data: { - referencedAt: new Date(), - size: fileMetadata.size, - }, - }), - ], - }, - }; -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/types/file-category.ts b/examples/todo-with-auth0/apps/backend/src/modules/storage/types/file-category.ts index 7907c503a..67fb2a34c 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/storage/types/file-category.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/storage/types/file-category.ts @@ -7,7 +7,11 @@ import type { StorageAdapterKey } from '../config/adapters.config.js'; * Configuration for a file category that specifies how files for a * particular model relation to File model should be handled. */ -export interface FileCategory { +export interface FileCategory< + TName extends string = string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +> { /** Name of category (must be CONSTANT_CASE) */ readonly name: TName; @@ -48,5 +52,5 @@ export interface FileCategory { /** * The relation that references this file category. */ - readonly referencedByRelation: keyof /* TPL_FILE_COUNT_OUTPUT_TYPE:START */ Prisma.FileCountOutputType /* TPL_FILE_COUNT_OUTPUT_TYPE:END */; + readonly referencedByRelation: TReferencedByRelation; } diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/utils/create-file-category.ts b/examples/todo-with-auth0/apps/backend/src/modules/storage/utils/create-file-category.ts index 5c95ef9ca..e44e28d0e 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/storage/utils/create-file-category.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/storage/utils/create-file-category.ts @@ -1,3 +1,5 @@ +import type { Prisma } from '@src/generated/prisma/client.js'; + import type { FileCategory } from '../types/file-category.js'; // Helper for common file size constraints @@ -17,9 +19,13 @@ export const MimeTypes = { ], } as const; -export function createFileCategory( - config: FileCategory, -): FileCategory { +export function createFileCategory< + TName extends string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +>( + config: FileCategory, +): FileCategory { if (!/^[A-Z][A-Z0-9_]*$/.test(config.name)) { throw new Error( 'File category name must be CONSTANT_CASE (e.g., USER_AVATAR, POST_IMAGE)', diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts index 6acc4bd16..0bfcf219a 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts @@ -7,36 +7,36 @@ import { createTodoItem, deleteTodoItem, updateTodoItem, -} from '../services/todo-item.crud.js'; +} from '../services/todo-item.data-service.js'; import { todoItemObjectType } from './todo-item.object-type.js'; -const todoItemAttachmentEmbeddedTagsDataInputType = builder.inputType( - 'TodoItemAttachmentEmbeddedTagsData', +const todoItemAttachmentTagsNestedInputInputType = builder.inputType( + 'TodoItemAttachmentTagsNestedInput', { fields: (t) => ({ tag: t.string({ required: true }) }), }, ); -const todoItemEmbeddedAttachmentsDataInputType = builder.inputType( - 'TodoItemEmbeddedAttachmentsData', +const todoItemAttachmentsNestedInputInputType = builder.inputType( + 'TodoItemAttachmentsNestedInput', { fields: (t) => ({ position: t.int({ required: true }), url: t.string({ required: true }), - id: t.field({ type: 'Uuid' }), - tags: t.field({ type: [todoItemAttachmentEmbeddedTagsDataInputType] }), + id: t.id(), + tags: t.field({ type: [todoItemAttachmentTagsNestedInputInputType] }), }), }, ); -const todoItemCreateDataInputType = builder.inputType('TodoItemCreateData', { +const createTodoItemDataInputType = builder.inputType('CreateTodoItemData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), position: t.int({ required: true }), text: t.string({ required: true }), done: t.boolean({ required: true }), assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemEmbeddedAttachmentsDataInputType] }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), }), }); @@ -45,7 +45,7 @@ builder.mutationField('createTodoItem', (t) => input: { data: t.input.field({ required: true, - type: todoItemCreateDataInputType, + type: createTodoItemDataInputType, }), }, payload: { todoItem: t.payload.field({ type: todoItemObjectType }) }, @@ -69,14 +69,14 @@ builder.mutationField('createTodoItem', (t) => }), ); -const todoItemUpdateDataInputType = builder.inputType('TodoItemUpdateData', { +const updateTodoItemDataInputType = builder.inputType('UpdateTodoItemData', { fields: (t) => ({ + todoListId: t.field({ type: 'Uuid' }), position: t.int(), text: t.string(), done: t.boolean(), assigneeId: t.field({ type: 'Uuid' }), - todoListId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemEmbeddedAttachmentsDataInputType] }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), }), }); @@ -86,14 +86,14 @@ builder.mutationField('updateTodoItem', (t) => id: t.input.field({ required: true, type: 'Uuid' }), data: t.input.field({ required: true, - type: todoItemUpdateDataInputType, + type: updateTodoItemDataInputType, }), }, payload: { todoItem: t.payload.field({ type: todoItemObjectType }) }, authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoItem = await updateTodoItem({ - id, + where: { id }, data: restrictObjectNulls( { ...data, @@ -101,7 +101,7 @@ builder.mutationField('updateTodoItem', (t) => restrictObjectNulls(attachment, ['id', 'tags']), ), }, - ['position', 'text', 'done', 'todoListId', 'attachments'], + ['todoListId', 'position', 'text', 'done', 'attachments'], ), context, query: queryFromInfo({ context, info, path: ['todoItem'] }), @@ -118,7 +118,7 @@ builder.mutationField('deleteTodoItem', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoItem = await deleteTodoItem({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts index 3995d8085..d2f576a20 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts @@ -7,14 +7,14 @@ import { createTodoListShare, deleteTodoListShare, updateTodoListShare, -} from '../services/todo-list-share.crud.js'; +} from '../services/todo-list-share.data-service.js'; import { todoListShareObjectType, todoListSharePrimaryKeyInputType, } from './todo-list-share.object-type.js'; -const todoListShareCreateDataInputType = builder.inputType( - 'TodoListShareCreateData', +const createTodoListShareDataInputType = builder.inputType( + 'CreateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), @@ -30,7 +30,7 @@ builder.mutationField('createTodoListShare', (t) => input: { data: t.input.field({ required: true, - type: todoListShareCreateDataInputType, + type: createTodoListShareDataInputType, }), }, payload: { @@ -48,8 +48,8 @@ builder.mutationField('createTodoListShare', (t) => }), ); -const todoListShareUpdateDataInputType = builder.inputType( - 'TodoListShareUpdateData', +const updateTodoListShareDataInputType = builder.inputType( + 'UpdateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ type: 'Uuid' }), @@ -69,7 +69,7 @@ builder.mutationField('updateTodoListShare', (t) => }), data: t.input.field({ required: true, - type: todoListShareUpdateDataInputType, + type: updateTodoListShareDataInputType, }), }, payload: { @@ -78,7 +78,7 @@ builder.mutationField('updateTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoListShare = await updateTodoListShare({ - id, + where: { todoListId_userId: id }, data: restrictObjectNulls(data, [ 'todoListId', 'userId', @@ -107,7 +107,7 @@ builder.mutationField('deleteTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoListShare = await deleteTodoListShare({ - id, + where: { todoListId_userId: id }, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts index f42bbbf30..56a0df1dc 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts @@ -8,17 +8,17 @@ import { createTodoList, deleteTodoList, updateTodoList, -} from '../services/todo-list.crud.js'; +} from '../services/todo-list.data-service.js'; import { todoListStatusEnum } from './enums.js'; import { todoListObjectType } from './todo-list.object-type.js'; -const todoListCreateDataInputType = builder.inputType('TodoListCreateData', { +const createTodoListDataInputType = builder.inputType('CreateTodoListData', { fields: (t) => ({ + ownerId: t.field({ required: true, type: 'Uuid' }), position: t.int({ required: true }), name: t.string({ required: true }), - ownerId: t.field({ required: true, type: 'Uuid' }), - status: t.field({ type: todoListStatusEnum }), createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), coverPhoto: t.field({ type: fileInputInputType }), }), }); @@ -28,7 +28,7 @@ builder.mutationField('createTodoList', (t) => input: { data: t.input.field({ required: true, - type: todoListCreateDataInputType, + type: createTodoListDataInputType, }), }, payload: { todoList: t.payload.field({ type: todoListObjectType }) }, @@ -44,13 +44,13 @@ builder.mutationField('createTodoList', (t) => }), ); -const todoListUpdateDataInputType = builder.inputType('TodoListUpdateData', { +const updateTodoListDataInputType = builder.inputType('UpdateTodoListData', { fields: (t) => ({ + ownerId: t.field({ type: 'Uuid' }), position: t.int(), name: t.string(), - ownerId: t.field({ type: 'Uuid' }), - status: t.field({ type: todoListStatusEnum }), createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), coverPhoto: t.field({ type: fileInputInputType }), }), }); @@ -61,18 +61,18 @@ builder.mutationField('updateTodoList', (t) => id: t.input.field({ required: true, type: 'Uuid' }), data: t.input.field({ required: true, - type: todoListUpdateDataInputType, + type: updateTodoListDataInputType, }), }, payload: { todoList: t.payload.field({ type: todoListObjectType }) }, authorize: ['user'], resolve: async (root, { input: { id, data } }, context, info) => { const todoList = await updateTodoList({ - id, + where: { id }, data: restrictObjectNulls(data, [ + 'ownerId', 'position', 'name', - 'ownerId', 'createdAt', ]), context, @@ -90,7 +90,7 @@ builder.mutationField('deleteTodoList', (t) => authorize: ['user'], resolve: async (root, { input: { id } }, context, info) => { const todoList = await deleteTodoList({ - id, + where: { id }, context, query: queryFromInfo({ context, info, path: ['todoList'] }), }); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-attachment.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-attachment.data-service.ts new file mode 100644 index 000000000..e0e634305 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-attachment.data-service.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { + createParentModelConfig, + nestedOneToManyField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; + +const parentModel = createParentModelConfig('todoItemAttachment', (value) => ({ + id: value.id, +})); + +export const todoItemAttachmentInputFields = { + id: scalarField(z.string().uuid().optional()), + position: scalarField(z.number().int()), + url: scalarField(z.string()), + tags: nestedOneToManyField({ + buildData: (data) => data, + fields: { tag: scalarField(z.string()) }, + getWhereUnique: (input, parentModel) => + input.tag + ? { + todoItemAttachmentId_tag: { + tag: input.tag, + todoItemAttachmentId: parentModel.id, + }, + } + : undefined, + model: 'todoItemAttachmentTag', + parentModel, + relationName: 'todoItemAttachment', + }), +}; diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-service.int.test.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-service.int.test.ts index a4ec58255..98fbfde14 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-service.int.test.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item-service.int.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { prisma } from '@src/services/prisma.js'; import { createTestServiceContext } from '@src/tests/helpers/service-context.test-helper.js'; -import { createTodoItem, updateTodoItem } from './todo-item.crud.js'; +import { createTodoItem, updateTodoItem } from './todo-item.data-service.js'; const context = createTestServiceContext(); @@ -66,7 +66,7 @@ describe('create', () => { }); await updateTodoItem({ - id: createdItem.id, + where: { id: createdItem.id }, data: { todoListId: todoList.id, position: 1, diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.crud.ts deleted file mode 100644 index bb6b25d8d..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.crud.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { Prisma, TodoItem } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; -import type { DataPipeOutput } from '@src/utils/data-pipes.js'; -import type { ServiceContext } from '@src/utils/service-context.js'; - -import { prisma } from '@src/services/prisma.js'; -import { - applyDataPipeOutput, - mergePipeOperations, -} from '@src/utils/data-pipes.js'; -import { - createOneToManyCreateData, - createOneToManyUpsertData, -} from '@src/utils/embedded-pipes/embedded-one-to-many.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -async function prepareUpsertEmbeddedAttachmentsData( - data: TodoItemEmbeddedAttachmentsData, - context: ServiceContext, - whereUnique?: Prisma.TodoItemAttachmentWhereUniqueInput, - parentId?: string, -): Promise< - DataPipeOutput<{ - where: Prisma.TodoItemAttachmentWhereUniqueInput; - create: Prisma.TodoItemAttachmentCreateWithoutTodoItemInput; - update: Prisma.TodoItemAttachmentUpdateWithoutTodoItemInput; - }> -> { - const { tags, ...rest } = data; - - const existingItem = - whereUnique && - (await prisma.todoItemAttachment.findUniqueOrThrow({ where: whereUnique })); - - if (existingItem && existingItem.todoItemId !== parentId) { - throw new Error( - 'TodoItemAttachment not attached to the correct parent item', - ); - } - - const tagsOutput = await createOneToManyUpsertData({ - getWhereUnique: ( - input, - ): Prisma.TodoItemAttachmentTagWhereUniqueInput | undefined => - existingItem - ? { - todoItemAttachmentId_tag: { - tag: input.tag, - todoItemAttachmentId: existingItem.id, - }, - } - : undefined, - idField: 'tag', - input: tags, - }); - - return { - data: { - create: { tags: { create: tagsOutput.data?.create }, ...rest }, - update: { tags: tagsOutput.data, ...rest }, - where: whereUnique ?? { id: '' }, - }, - operations: mergePipeOperations([tagsOutput]), - }; -} - -type TodoItemAttachmentEmbeddedTagsData = Pick< - Prisma.TodoItemAttachmentTagUncheckedCreateInput, - 'tag' ->; - -interface TodoItemEmbeddedAttachmentsData - extends Pick< - Prisma.TodoItemAttachmentUncheckedCreateInput, - 'position' | 'url' | 'id' - > { - tags?: TodoItemAttachmentEmbeddedTagsData[]; -} - -interface TodoItemCreateData - extends Pick< - Prisma.TodoItemUncheckedCreateInput, - 'todoListId' | 'position' | 'text' | 'done' | 'assigneeId' - > { - attachments?: TodoItemEmbeddedAttachmentsData[]; -} - -export async function createTodoItem({ - data, - query, - context, -}: CreateServiceInput< - TodoItemCreateData, - Prisma.TodoItemDefaultArgs ->): Promise { - const { attachments, assigneeId, todoListId, ...rest } = data; - - const assignee = - assigneeId == null ? undefined : { connect: { id: assigneeId } }; - - const attachmentsOutput = await createOneToManyCreateData({ - context, - input: attachments, - transform: prepareUpsertEmbeddedAttachmentsData, - }); - - const todoList = { connect: { id: todoListId } }; - - return applyDataPipeOutput( - [attachmentsOutput], - prisma.todoItem.create({ - data: { - assignee, - attachments: { create: attachmentsOutput.data?.create }, - todoList, - ...rest, - }, - ...query, - }), - ); -} - -interface TodoItemUpdateData - extends Pick< - Partial, - 'position' | 'text' | 'done' | 'assigneeId' | 'todoListId' - > { - attachments?: TodoItemEmbeddedAttachmentsData[]; -} - -export async function updateTodoItem({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - TodoItemUpdateData, - Prisma.TodoItemDefaultArgs ->): Promise { - const { attachments, assigneeId, todoListId, ...rest } = data; - - const assignee = - assigneeId == null ? assigneeId : { connect: { id: assigneeId } }; - - const attachmentsOutput = await createOneToManyUpsertData({ - context, - getWhereUnique: ( - input, - ): Prisma.TodoItemAttachmentWhereUniqueInput | undefined => - input.id ? { id: input.id } : undefined, - idField: 'id', - input: attachments, - parentId: id, - transform: prepareUpsertEmbeddedAttachmentsData, - }); - - const todoList = - todoListId == null ? todoListId : { connect: { id: todoListId } }; - - return applyDataPipeOutput( - [attachmentsOutput], - prisma.todoItem.update({ - where: { id }, - data: { - assignee: createPrismaDisconnectOrConnectData(assignee), - attachments: attachmentsOutput.data, - todoList, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteTodoItem({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.todoItem.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.data-service.ts new file mode 100644 index 000000000..ffd15e8f6 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-item.data-service.ts @@ -0,0 +1,79 @@ +import { pick } from 'es-toolkit'; +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { + createParentModelConfig, + nestedOneToManyField, + scalarField, +} from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +import { todoItemAttachmentInputFields } from './todo-item-attachment.data-service.js'; + +const parentModel = createParentModelConfig('todoItem', (value) => ({ + id: value.id, +})); + +export const todoItemInputFields = { + todoListId: scalarField(z.string().uuid()), + position: scalarField(z.number().int()), + text: scalarField(z.string()), + done: scalarField(z.boolean()), + assigneeId: scalarField(z.string().uuid().nullish()), + attachments: nestedOneToManyField({ + buildData: (data) => data, + fields: pick(todoItemAttachmentInputFields, [ + 'position', + 'url', + 'id', + 'tags', + ] as const), + getWhereUnique: (input) => (input.id ? { id: input.id } : undefined), + model: 'todoItemAttachment', + parentModel, + relationName: 'todoItem', + }), +}; + +export const createTodoItem = defineCreateOperation({ + model: 'todoItem', + fields: todoItemInputFields, + create: ({ tx, data: { assigneeId, todoListId, ...data }, query }) => + tx.todoItem.create({ + data: { + ...data, + assignee: relationHelpers.connectCreate({ id: assigneeId }), + todoList: relationHelpers.connectCreate({ id: todoListId }), + }, + ...query, + }), +}); + +export const updateTodoItem = defineUpdateOperation({ + model: 'todoItem', + fields: todoItemInputFields, + update: ({ tx, where, data: { assigneeId, todoListId, ...data }, query }) => + tx.todoItem.update({ + where, + data: { + ...data, + assignee: relationHelpers.connectUpdate({ id: assigneeId }), + todoList: relationHelpers.connectUpdate({ id: todoListId }), + }, + ...query, + }), +}); + +export const deleteTodoItem = defineDeleteOperation({ + model: 'todoItem', + delete: ({ tx, where, query }) => + tx.todoItem.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.crud.ts deleted file mode 100644 index 5ff1a90c0..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.crud.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Prisma, TodoListShare } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; - -type TodoListShareCreateData = Pick< - Prisma.TodoListShareUncheckedCreateInput, - 'todoListId' | 'userId' | 'updatedAt' | 'createdAt' ->; - -export async function createTodoListShare({ - data, - query, -}: CreateServiceInput< - TodoListShareCreateData, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.create({ data, ...query }); -} - -export type TodoListSharePrimaryKey = Pick< - TodoListShare, - 'todoListId' | 'userId' ->; - -type TodoListShareUpdateData = Pick< - Partial, - 'todoListId' | 'userId' | 'updatedAt' | 'createdAt' ->; - -export async function updateTodoListShare({ - id: todoListId_userId, - data, - query, -}: UpdateServiceInput< - TodoListSharePrimaryKey, - TodoListShareUpdateData, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.update({ - where: { todoListId_userId }, - data, - ...query, - }); -} - -export async function deleteTodoListShare({ - id: todoListId_userId, - query, -}: DeleteServiceInput< - TodoListSharePrimaryKey, - Prisma.TodoListShareDefaultArgs ->): Promise { - return prisma.todoListShare.delete({ - where: { todoListId_userId }, - ...query, - }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.data-service.ts new file mode 100644 index 000000000..75e6f1c18 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list-share.data-service.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +export const todoListShareInputFields = { + todoListId: scalarField(z.string().uuid()), + userId: scalarField(z.string().uuid()), + updatedAt: scalarField(z.date().optional()), + createdAt: scalarField(z.date().optional()), +}; + +export const createTodoListShare = defineCreateOperation({ + model: 'todoListShare', + fields: todoListShareInputFields, + create: ({ tx, data: { todoListId, userId, ...data }, query }) => + tx.todoListShare.create({ + data: { + ...data, + todoList: relationHelpers.connectCreate({ id: todoListId }), + user: relationHelpers.connectCreate({ id: userId }), + }, + ...query, + }), +}); + +export const updateTodoListShare = defineUpdateOperation({ + model: 'todoListShare', + fields: todoListShareInputFields, + update: ({ tx, where, data: { todoListId, userId, ...data }, query }) => + tx.todoListShare.update({ + where, + data: { + ...data, + todoList: relationHelpers.connectUpdate({ id: todoListId }), + user: relationHelpers.connectUpdate({ id: userId }), + }, + ...query, + }), +}); + +export const deleteTodoListShare = defineDeleteOperation({ + model: 'todoListShare', + delete: ({ tx, where, query }) => + tx.todoListShare.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.crud.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.crud.ts deleted file mode 100644 index ee68447d6..000000000 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.crud.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Prisma, TodoList } from '@src/generated/prisma/client.js'; -import type { - CreateServiceInput, - DeleteServiceInput, - UpdateServiceInput, -} from '@src/utils/crud-service-types.js'; - -import { prisma } from '@src/services/prisma.js'; -import { applyDataPipeOutput } from '@src/utils/data-pipes.js'; -import { createPrismaDisconnectOrConnectData } from '@src/utils/prisma-relations.js'; - -import type { FileUploadInput } from '../../storage/services/validate-file-input.js'; - -import { validateFileInput } from '../../storage/services/validate-file-input.js'; -import { todoListCoverPhotoFileCategory } from '../constants/file-categories.js'; - -interface TodoListCreateData - extends Pick< - Prisma.TodoListUncheckedCreateInput, - 'position' | 'name' | 'ownerId' | 'status' | 'createdAt' - > { - coverPhoto?: FileUploadInput | null; -} - -export async function createTodoList({ - data, - query, - context, -}: CreateServiceInput< - TodoListCreateData, - Prisma.TodoListDefaultArgs ->): Promise { - const { coverPhoto, ownerId, ...rest } = data; - - const coverPhotoOutput = - coverPhoto == null - ? coverPhoto - : await validateFileInput( - coverPhoto, - todoListCoverPhotoFileCategory, - context, - ); - - const owner = { connect: { id: ownerId } }; - - return applyDataPipeOutput( - [coverPhotoOutput], - prisma.todoList.create({ - data: { coverPhoto: coverPhotoOutput?.data, owner, ...rest }, - ...query, - }), - ); -} - -interface TodoListUpdateData - extends Pick< - Partial, - 'position' | 'name' | 'ownerId' | 'status' | 'createdAt' - > { - coverPhoto?: FileUploadInput | null; -} - -export async function updateTodoList({ - id, - data, - query, - context, -}: UpdateServiceInput< - string, - TodoListUpdateData, - Prisma.TodoListDefaultArgs ->): Promise { - const { coverPhoto, ownerId, ...rest } = data; - - const existingItem = await prisma.todoList.findUniqueOrThrow({ - where: { id }, - }); - - const coverPhotoOutput = - coverPhoto == null - ? coverPhoto - : await validateFileInput( - coverPhoto, - todoListCoverPhotoFileCategory, - context, - existingItem.coverPhotoId, - ); - - const owner = ownerId == null ? ownerId : { connect: { id: ownerId } }; - - return applyDataPipeOutput( - [coverPhotoOutput], - prisma.todoList.update({ - where: { id }, - data: { - coverPhoto: createPrismaDisconnectOrConnectData(coverPhotoOutput?.data), - owner, - ...rest, - }, - ...query, - }), - ); -} - -export async function deleteTodoList({ - id, - query, -}: DeleteServiceInput): Promise { - return prisma.todoList.delete({ where: { id }, ...query }); -} diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.data-service.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.data-service.ts new file mode 100644 index 000000000..17268b981 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/services/todo-list.data-service.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { $Enums } from '@src/generated/prisma/client.js'; +import { + defineCreateOperation, + defineDeleteOperation, + defineUpdateOperation, +} from '@src/utils/data-operations/define-operations.js'; +import { scalarField } from '@src/utils/data-operations/field-definitions.js'; +import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; + +import { fileField } from '../../storage/services/file-field.js'; +import { todoListCoverPhotoFileCategory } from '../constants/file-categories.js'; + +export const todoListInputFields = { + ownerId: scalarField(z.string().uuid()), + position: scalarField(z.number().int()), + name: scalarField(z.string()), + createdAt: scalarField(z.date().optional()), + status: scalarField(z.nativeEnum($Enums.TodoListStatus).nullish()), + coverPhoto: fileField({ + category: todoListCoverPhotoFileCategory, + fileIdFieldName: 'coverPhotoId', + optional: true, + }), +}; + +export const createTodoList = defineCreateOperation({ + model: 'todoList', + fields: todoListInputFields, + create: ({ tx, data: { ownerId, ...data }, query }) => + tx.todoList.create({ + data: { ...data, owner: relationHelpers.connectCreate({ id: ownerId }) }, + ...query, + }), +}); + +export const updateTodoList = defineUpdateOperation({ + model: 'todoList', + fields: todoListInputFields, + update: ({ tx, where, data: { ownerId, ...data }, query }) => + tx.todoList.update({ + where, + data: { ...data, owner: relationHelpers.connectUpdate({ id: ownerId }) }, + ...query, + }), +}); + +export const deleteTodoList = defineDeleteOperation({ + model: 'todoList', + delete: ({ tx, where, query }) => + tx.todoList.delete({ + where, + ...query, + }), +}); diff --git a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts index 69bdf3da0..1cf274920 100644 --- a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts +++ b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts @@ -10,6 +10,7 @@ import type { AuthRole } from '@src/modules/accounts/auth/constants/auth-roles.c import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosAuthorizeByRolesPlugin } from './FieldAuthorizePlugin/index.js'; @@ -62,6 +63,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json b/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json index 621d787ef..d1eb02eee 100644 --- a/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json +++ b/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json @@ -4,21 +4,6 @@ "instanceData": {}, "template": "app-modules" }, - "arrays.ts": { - "generator": "@baseplate-dev/core-generators#node/ts-utils", - "instanceData": {}, - "template": "arrays" - }, - "crud-service-types.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "crud-service-types" - }, - "data-pipes.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "data-pipes" - }, "http-errors.ts": { "generator": "@baseplate-dev/fastify-generators#core/error-handler-service", "instanceData": {}, @@ -34,11 +19,6 @@ "instanceData": {}, "template": "nulls" }, - "prisma-relations.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "prisma-relations" - }, "request-service-context.ts": { "generator": "@baseplate-dev/fastify-generators#core/request-service-context", "instanceData": {}, diff --git a/examples/todo-with-auth0/apps/backend/src/utils/arrays.ts b/examples/todo-with-auth0/apps/backend/src/utils/arrays.ts deleted file mode 100644 index 6d9341493..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/arrays.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Checks if a value is not null or undefined. - * - * @param value - The value to check. - * @returns `true` if the value is not null or undefined, otherwise `false`. - */ -export function notEmpty( - value: TValue | null | undefined, -): value is TValue { - return value !== null && value !== undefined; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/crud-service-types.ts b/examples/todo-with-auth0/apps/backend/src/utils/crud-service-types.ts deleted file mode 100644 index f97f00610..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/crud-service-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ServiceContext } from './service-context.js'; - -export interface CreateServiceInput< - CreateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - data: CreateData; - context: Context; - query?: Query; -} - -export interface UpdateServiceInput< - PrimaryKey, - UpdateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - data: UpdateData; - context: Context; - query?: Query; -} - -export interface DeleteServiceInput< - PrimaryKey, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - context: Context; - query?: Query; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/.templates-info.json b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/.templates-info.json new file mode 100644 index 000000000..4ca668da0 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/.templates-info.json @@ -0,0 +1,32 @@ +{ + "define-operations.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "define-operations" + }, + "field-definitions.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "field-definitions" + }, + "prisma-types.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "prisma-types" + }, + "prisma-utils.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "prisma-utils" + }, + "relation-helpers.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "relation-helpers" + }, + "types.ts": { + "generator": "@baseplate-dev/fastify-generators#prisma/data-utils", + "instanceData": {}, + "template": "types" + } +} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-types.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-utils.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/relation-helpers.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-pipes.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-pipes.ts deleted file mode 100644 index c6e7d8f60..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/data-pipes.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Prisma } from '../generated/prisma/client.js'; - -import { prisma } from '../services/prisma.js'; -import { notEmpty } from './arrays.js'; - -interface DataPipeOperations { - beforePrismaPromises?: Prisma.PrismaPromise[]; - afterPrismaPromises?: Prisma.PrismaPromise[]; -} - -export interface DataPipeOutput { - data: Output; - operations?: DataPipeOperations; -} - -export function mergePipeOperations( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): DataPipeOperations { - const operations = outputs - .map((o) => (o && 'data' in o ? o.operations : o)) - .filter(notEmpty); - - return { - beforePrismaPromises: operations.flatMap( - (op) => op.beforePrismaPromises ?? [], - ), - afterPrismaPromises: operations.flatMap( - (op) => op.afterPrismaPromises ?? [], - ), - }; -} - -// Taken from Prisma generated code -type UnwrapPromise

= P extends Promise ? R : P; -type UnwrapTuple = { - [K in keyof Tuple]: K extends `${number}` - ? Tuple[K] extends Prisma.PrismaPromise - ? X - : UnwrapPromise - : UnwrapPromise; -}; - -export async function applyDataPipeOutputToOperations< - Promises extends Prisma.PrismaPromise[], ->( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operations: [...Promises], -): Promise> { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - ...operations, - ...afterPrismaPromises, - ]); - - return results.slice( - beforePrismaPromises.length, - beforePrismaPromises.length + operations.length, - ) as UnwrapTuple; -} - -export async function applyDataPipeOutput( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operation: Prisma.PrismaPromise, -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - operation, - ...afterPrismaPromises, - ]); - - return results[beforePrismaPromises.length] as DataType; -} - -export async function applyDataPipeOutputWithoutOperation( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - await prisma.$transaction([...beforePrismaPromises, ...afterPrismaPromises]); -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/.templates-info.json b/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/.templates-info.json deleted file mode 100644 index 82edbb877..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/.templates-info.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "embedded-one-to-many.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "embedded-one-to-many" - }, - "embedded-one-to-one.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "embedded-one-to-one" - }, - "embedded-types.ts": { - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "instanceData": {}, - "template": "embedded-types" - } -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-many.ts b/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-many.ts deleted file mode 100644 index ab0da298e..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-many.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { DataPipeOutput } from '../data-pipes.js'; -import type { ServiceContext } from '../service-context.js'; -import type { UpsertPayload } from './embedded-types.js'; - -import { notEmpty } from '../arrays.js'; -import { mergePipeOperations } from '../data-pipes.js'; - -// Create Helpers - -interface OneToManyCreatePipeInput { - input: DataInput[] | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToManyCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToManyCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToManyCreatePipeInput - | OneToManyCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'][] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputs = await Promise.all( - input.map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - return { - data: { create: createOutputs.map((output) => output.data) }, - operations: mergePipeOperations(createOutputs), - }; -} - -// Upsert Helpers - -interface UpsertManyPayload< - UpsertData extends UpsertPayload, - WhereUniqueInput, - IdField extends string | number | symbol, - IdType = string, -> { - deleteMany?: Record; - upsert?: { - where: WhereUniqueInput; - create: UpsertData['create']; - update: UpsertData['update']; - }[]; - create: UpsertData['create'][]; -} - -interface OneToManyUpsertPipeInput< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, -> { - input: DataInput[] | undefined; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform?: undefined; - context?: undefined; - parentId?: undefined; -} - -interface OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - context: ServiceContext; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - parentId?: ParentId; -} - -export async function createOneToManyUpsertData< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - idField, - context, - getWhereUnique, - transform, - parentId, -}: - | OneToManyUpsertPipeInput - | OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField, - ParentId, - UpsertData - >): Promise< - DataPipeOutput< - | UpsertManyPayload< - UpsertData, - WhereUniqueInput, - IdField, - Exclude - > - | undefined - > -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, undefined, parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputPromise = Promise.all( - input - .filter( - (item) => - item[idField] === undefined || getWhereUnique(item) === undefined, - ) - .map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - async function transformUpsertInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, getWhereUnique(item), parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const upsertOutputPromise = Promise.all( - input - .filter((item) => item[idField] !== undefined && getWhereUnique(item)) - .map(async (item) => { - const output = await transformUpsertInput(item); - return { - data: { - where: getWhereUnique(item) as WhereUniqueInput, - create: output.data.create, - update: output.data.update, - }, - operations: output.operations, - }; - }), - ); - - const [upsertOutput, createOutput] = await Promise.all([ - upsertOutputPromise, - createOutputPromise, - ]); - - return { - data: { - deleteMany: - idField && - ({ - [idField]: { - notIn: input.map((data) => data[idField]).filter(notEmpty), - }, - } as Record< - IdField, - { - notIn: Exclude[]; - } - >), - upsert: upsertOutput.map((output) => output.data), - create: createOutput.map((output) => output.data), - }, - operations: mergePipeOperations([...upsertOutput, ...createOutput]), - }; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-one.ts b/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-one.ts deleted file mode 100644 index 9d9762af5..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-one-to-one.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { Prisma } from '@src/generated/prisma/client.js'; - -import type { DataPipeOutput } from '../data-pipes.js'; -import type { ServiceContext } from '../service-context.js'; -import type { UpsertPayload } from './embedded-types.js'; - -// Create Helpers - -interface OneToOneCreatePipeInput { - input: DataInput | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToOneCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToOneCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToOneCreatePipeInput - | OneToOneCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve(transform(input, context)); - return { - data: { create: transformedData.data.create }, - operations: transformedData.operations, - }; - } - - return { - data: { create: input }, - }; -} - -// Upsert helpers - -interface OneToOneUpsertPipeInput { - input: DataInput | null | undefined; - transform?: undefined; - context?: undefined; - getWhereUnique?: undefined; - parentId?: undefined; - deleteRelation: () => Prisma.PrismaPromise; -} - -interface OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput extends object, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | null | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - context: ServiceContext; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - parentId?: ParentId; - deleteRelation: () => Prisma.PrismaPromise; -} - -export async function createOneToOneUpsertData< - DataInput, - WhereUniqueInput extends object, - ParentId, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, - getWhereUnique, - parentId, - deleteRelation, -}: - | OneToOneUpsertPipeInput - | OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - ParentId, - UpsertData - >): Promise< - DataPipeOutput<{ upsert: UpsertData } | { delete: true } | undefined> -> { - if (input === null) { - return { - data: undefined, - operations: { beforePrismaPromises: [deleteRelation()] }, - }; - } - if (input === undefined) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve( - transform(input, context, getWhereUnique(input), parentId), - ); - return { - data: { upsert: transformedData.data }, - operations: transformedData.operations, - }; - } - - return { - data: { - upsert: { - create: input, - update: input, - } as UpsertData, - }, - }; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-types.ts b/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-types.ts deleted file mode 100644 index f91ff262b..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/embedded-pipes/embedded-types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface UpsertPayload { - create: CreateData; - update: UpdateData; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/prisma-relations.ts b/examples/todo-with-auth0/apps/backend/src/utils/prisma-relations.ts deleted file mode 100644 index 2e0efbc61..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/prisma-relations.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Small helper function to make it easier to use optional relations in Prisma since the - * only way to set a Prisma relation to null is to disconnect it. - * - * See https://github.com/prisma/prisma/issues/5044 - */ -export function createPrismaDisconnectOrConnectData( - data?: { connect: UniqueWhere } | null, -): - | { - disconnect?: boolean; - connect?: UniqueWhere; - } - | undefined { - if (data === undefined) { - return undefined; - } - if (data === null) { - return { disconnect: true }; - } - return data; -} diff --git a/examples/todo-with-auth0/apps/backend/vitest.config.ts b/examples/todo-with-auth0/apps/backend/vitest.config.ts index ae31e7bdb..3fc5643d0 100644 --- a/examples/todo-with-auth0/apps/backend/vitest.config.ts +++ b/examples/todo-with-auth0/apps/backend/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig( test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', setupFiles: ['tests/scripts/mock-redis.ts'], diff --git a/examples/todo-with-auth0/apps/web/src/generated/graphql.tsx b/examples/todo-with-auth0/apps/web/src/generated/graphql.tsx index df5805d97..146a7616a 100644 --- a/examples/todo-with-auth0/apps/web/src/generated/graphql.tsx +++ b/examples/todo-with-auth0/apps/web/src/generated/graphql.tsx @@ -59,9 +59,18 @@ export type CreatePresignedUploadUrlPayload = { url: Scalars['String']['output']; }; +export type CreateTodoItemData = { + assigneeId?: InputMaybe; + attachments?: InputMaybe>; + done: Scalars['Boolean']['input']; + position: Scalars['Int']['input']; + text: Scalars['String']['input']; + todoListId: Scalars['Uuid']['input']; +}; + /** Input type for createTodoItem mutation */ export type CreateTodoItemInput = { - data: TodoItemCreateData; + data: CreateTodoItemData; }; /** Payload type for createTodoItem mutation */ @@ -70,9 +79,18 @@ export type CreateTodoItemPayload = { todoItem: TodoItem; }; +export type CreateTodoListData = { + coverPhoto?: InputMaybe; + createdAt?: InputMaybe; + name: Scalars['String']['input']; + ownerId: Scalars['Uuid']['input']; + position: Scalars['Int']['input']; + status?: InputMaybe; +}; + /** Input type for createTodoList mutation */ export type CreateTodoListInput = { - data: TodoListCreateData; + data: CreateTodoListData; }; /** Payload type for createTodoList mutation */ @@ -81,9 +99,16 @@ export type CreateTodoListPayload = { todoList: TodoList; }; +export type CreateTodoListShareData = { + createdAt?: InputMaybe; + todoListId: Scalars['Uuid']['input']; + updatedAt?: InputMaybe; + userId: Scalars['Uuid']['input']; +}; + /** Input type for createTodoListShare mutation */ export type CreateTodoListShareInput = { - data: TodoListShareCreateData; + data: CreateTodoListShareData; }; /** Payload type for createTodoListShare mutation */ @@ -92,9 +117,18 @@ export type CreateTodoListSharePayload = { todoListShare: TodoListShare; }; +export type CreateUserData = { + customer?: InputMaybe; + email: Scalars['String']['input']; + images?: InputMaybe>; + name?: InputMaybe; + roles?: InputMaybe>; + userProfile?: InputMaybe; +}; + /** Input type for createUser mutation */ export type CreateUserInput = { - data: UserCreateData; + data: CreateUserData; }; /** Payload type for createUser mutation */ @@ -338,10 +372,6 @@ export type TodoItemAttachment = { url: Scalars['String']['output']; }; -export type TodoItemAttachmentEmbeddedTagsData = { - tag: Scalars['String']['input']; -}; - export type TodoItemAttachmentTag = { __typename?: 'TodoItemAttachmentTag'; tag: Scalars['String']['output']; @@ -349,31 +379,17 @@ export type TodoItemAttachmentTag = { todoItemAttachmentId: Scalars['Uuid']['output']; }; -export type TodoItemCreateData = { - assigneeId?: InputMaybe; - attachments?: InputMaybe>; - done: Scalars['Boolean']['input']; - position: Scalars['Int']['input']; - text: Scalars['String']['input']; - todoListId: Scalars['Uuid']['input']; +export type TodoItemAttachmentTagsNestedInput = { + tag: Scalars['String']['input']; }; -export type TodoItemEmbeddedAttachmentsData = { - id?: InputMaybe; +export type TodoItemAttachmentsNestedInput = { + id?: InputMaybe; position: Scalars['Int']['input']; - tags?: InputMaybe>; + tags?: InputMaybe>; url: Scalars['String']['input']; }; -export type TodoItemUpdateData = { - assigneeId?: InputMaybe; - attachments?: InputMaybe>; - done?: InputMaybe; - position?: InputMaybe; - text?: InputMaybe; - todoListId?: InputMaybe; -}; - export type TodoList = { __typename?: 'TodoList'; coverPhoto?: Maybe; @@ -387,15 +403,6 @@ export type TodoList = { updatedAt: Scalars['DateTime']['output']; }; -export type TodoListCreateData = { - coverPhoto?: InputMaybe; - createdAt?: InputMaybe; - name: Scalars['String']['input']; - ownerId: Scalars['Uuid']['input']; - position: Scalars['Int']['input']; - status?: InputMaybe; -}; - export type TodoListShare = { __typename?: 'TodoListShare'; createdAt: Scalars['DateTime']['output']; @@ -406,41 +413,27 @@ export type TodoListShare = { userId: Scalars['Uuid']['output']; }; -export type TodoListShareCreateData = { - createdAt?: InputMaybe; - todoListId: Scalars['Uuid']['input']; - updatedAt?: InputMaybe; - userId: Scalars['Uuid']['input']; -}; - export type TodoListSharePrimaryKey = { todoListId: Scalars['Uuid']['input']; userId: Scalars['Uuid']['input']; }; -export type TodoListShareUpdateData = { - createdAt?: InputMaybe; - todoListId?: InputMaybe; - updatedAt?: InputMaybe; - userId?: InputMaybe; -}; - export type TodoListStatus = | 'ACTIVE' | 'INACTIVE'; -export type TodoListUpdateData = { - coverPhoto?: InputMaybe; - createdAt?: InputMaybe; - name?: InputMaybe; - ownerId?: InputMaybe; +export type UpdateTodoItemData = { + assigneeId?: InputMaybe; + attachments?: InputMaybe>; + done?: InputMaybe; position?: InputMaybe; - status?: InputMaybe; + text?: InputMaybe; + todoListId?: InputMaybe; }; /** Input type for updateTodoItem mutation */ export type UpdateTodoItemInput = { - data: TodoItemUpdateData; + data: UpdateTodoItemData; id: Scalars['Uuid']['input']; }; @@ -450,9 +443,18 @@ export type UpdateTodoItemPayload = { todoItem: TodoItem; }; +export type UpdateTodoListData = { + coverPhoto?: InputMaybe; + createdAt?: InputMaybe; + name?: InputMaybe; + ownerId?: InputMaybe; + position?: InputMaybe; + status?: InputMaybe; +}; + /** Input type for updateTodoList mutation */ export type UpdateTodoListInput = { - data: TodoListUpdateData; + data: UpdateTodoListData; id: Scalars['Uuid']['input']; }; @@ -462,9 +464,16 @@ export type UpdateTodoListPayload = { todoList: TodoList; }; +export type UpdateTodoListShareData = { + createdAt?: InputMaybe; + todoListId?: InputMaybe; + updatedAt?: InputMaybe; + userId?: InputMaybe; +}; + /** Input type for updateTodoListShare mutation */ export type UpdateTodoListShareInput = { - data: TodoListShareUpdateData; + data: UpdateTodoListShareData; id: TodoListSharePrimaryKey; }; @@ -474,9 +483,18 @@ export type UpdateTodoListSharePayload = { todoListShare: TodoListShare; }; +export type UpdateUserData = { + customer?: InputMaybe; + email?: InputMaybe; + images?: InputMaybe>; + name?: InputMaybe; + roles?: InputMaybe>; + userProfile?: InputMaybe; +}; + /** Input type for updateUser mutation */ export type UpdateUserInput = { - data: UserUpdateData; + data: UpdateUserData; id: Scalars['Uuid']['input']; }; @@ -499,36 +517,10 @@ export type User = { userProfile?: Maybe; }; -export type UserCreateData = { - customer?: InputMaybe; - email: Scalars['String']['input']; - images?: InputMaybe>; - name?: InputMaybe; - roles?: InputMaybe>; - userProfile?: InputMaybe; -}; - -export type UserEmbeddedCustomerData = { +export type UserCustomerNestedInput = { stripeCustomerId: Scalars['String']['input']; }; -export type UserEmbeddedImagesData = { - caption: Scalars['String']['input']; - file: FileInput; - id?: InputMaybe; -}; - -export type UserEmbeddedRolesData = { - role: Scalars['String']['input']; -}; - -export type UserEmbeddedUserProfileData = { - avatar?: InputMaybe; - bio?: InputMaybe; - birthDay?: InputMaybe; - id?: InputMaybe; -}; - export type UserImage = { __typename?: 'UserImage'; file: File; @@ -537,6 +529,12 @@ export type UserImage = { userId: Scalars['Uuid']['output']; }; +export type UserImagesNestedInput = { + caption: Scalars['String']['input']; + file: FileInput; + id?: InputMaybe; +}; + export type UserProfile = { __typename?: 'UserProfile'; avatar?: Maybe; @@ -558,13 +556,15 @@ export type UserRole = { userId: Scalars['Uuid']['output']; }; -export type UserUpdateData = { - customer?: InputMaybe; - email?: InputMaybe; - images?: InputMaybe>; - name?: InputMaybe; - roles?: InputMaybe>; - userProfile?: InputMaybe; +export type UserRolesNestedInput = { + role: Scalars['String']['input']; +}; + +export type UserUserProfileNestedInput = { + avatar?: InputMaybe; + bio?: InputMaybe; + birthDay?: InputMaybe; + id?: InputMaybe; }; export type CurrentUserFragment = { __typename?: 'User', id: string, email: string }; diff --git a/knip.config.js b/knip.config.js index bb071017e..95055425d 100644 --- a/knip.config.js +++ b/knip.config.js @@ -35,6 +35,9 @@ export default { 'packages/fastify-generators': { entry: ['src/index.{ts,tsx}'], project: 'src/**/*.{ts,tsx}', + paths: { + '#src/*': ['./src/*'], + }, }, 'packages/project-builder-web': { entry: ['src/main.{ts,tsx}'], @@ -118,13 +121,9 @@ export default { }, 'packages/tools': { project: 'src/**/*.{ts,tsx}', - ignoreDependencies: [ - // automatically imported by eslint-plugin-import-x - 'eslint-import-resolver-typescript', - ], }, 'packages/create-project': { - entry: ['src/index.{ts,tsx}'], + entry: ['src/create-baseplate-project.{ts,tsx}'], project: 'src/**/*.{ts,tsx}', paths: { '#src/*': ['./src/*'], diff --git a/package.json b/package.json index 9dfefc2c9..ac79fda6c 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "es-toolkit": "1.31.0", "eslint": "catalog:", "husky": "9.1.7", - "knip": "5.59.0", + "knip": "5.70.0", "lint-staged": "16.1.0", "prettier": "catalog:", "turbo": "2.5.0", diff --git a/packages/core-generators/package.json b/packages/core-generators/package.json index 96ea403e0..ae3b68cdc 100644 --- a/packages/core-generators/package.json +++ b/packages/core-generators/package.json @@ -35,6 +35,18 @@ "./extractors": { "types": "./dist/renderers/extractors.d.ts", "default": "./dist/renderers/extractors.js" + }, + "./test-helpers": { + "types": "./dist/test-helpers/index.d.ts", + "default": "./dist/test-helpers/index.js" + }, + "./test-helpers/setup": { + "types": "./dist/test-helpers/setup.d.ts", + "default": "./dist/test-helpers/setup.js" + }, + "./test-helpers/vitest-types": { + "types": "./dist/test-helpers/vitest-types.d.ts", + "default": "./dist/test-helpers/vitest-types.js" } }, "main": "dist/index.js", diff --git a/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts b/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts index 6b5d3f75f..54905c640 100644 --- a/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts +++ b/packages/core-generators/src/generators/node/ts-utils/generated/ts-import-providers.ts @@ -13,7 +13,7 @@ import { import { NODE_TS_UTILS_PATHS } from './template-paths.js'; -const tsUtilsImportsSchema = createTsImportMapSchema({ +export const tsUtilsImportsSchema = createTsImportMapSchema({ capitalizeString: {}, NormalizeTypes: { isTypeOnly: true }, notEmpty: {}, diff --git a/packages/core-generators/src/generators/node/vitest/vitest.generator.ts b/packages/core-generators/src/generators/node/vitest/vitest.generator.ts index e6975cf29..a1225fd38 100644 --- a/packages/core-generators/src/generators/node/vitest/vitest.generator.ts +++ b/packages/core-generators/src/generators/node/vitest/vitest.generator.ts @@ -130,6 +130,7 @@ export const vitestGenerator = createGenerator({ : undefined, setupFiles: setupFiles.length > 0 ? setupFiles.toSorted() : undefined, + maxWorkers: 1, }; const plugins = TsCodeUtils.mergeFragmentsAsArray({ diff --git a/packages/core-generators/src/renderers/typescript/extractor/render-ts-import-providers.ts b/packages/core-generators/src/renderers/typescript/extractor/render-ts-import-providers.ts index c0df2f4ec..2b40a093a 100644 --- a/packages/core-generators/src/renderers/typescript/extractor/render-ts-import-providers.ts +++ b/packages/core-generators/src/renderers/typescript/extractor/render-ts-import-providers.ts @@ -53,7 +53,7 @@ function renderDefaultTsImportProviders( 'createTsImportMapSchema', typescriptRendererIndex, ); - const importTemplateSchema = tsTemplate`const ${importProviderNames.providerSchemaName} = ${createImportMapSchema}( + const importTemplateSchema = tsTemplate`export const ${importProviderNames.providerSchemaName} = ${createImportMapSchema}( ${TsCodeUtils.mergeFragmentsAsObject( mapValues(projectExports, (projectExport) => JSON.stringify({ diff --git a/packages/core-generators/src/renderers/typescript/utils/ts-code-utils.ts b/packages/core-generators/src/renderers/typescript/utils/ts-code-utils.ts index 08001adca..f580b5838 100644 --- a/packages/core-generators/src/renderers/typescript/utils/ts-code-utils.ts +++ b/packages/core-generators/src/renderers/typescript/utils/ts-code-utils.ts @@ -468,6 +468,7 @@ export const TsCodeUtils = { templateWithImports( imports?: TsImportDeclaration[] | TsImportDeclaration, + { hoistedFragments }: { hoistedFragments?: TsHoistedFragment[] } = {}, ): ( strings: TemplateStringsArray, ...expressions: (TsCodeFragment | string)[] @@ -476,6 +477,7 @@ export const TsCodeUtils = { this.template(strings, ...expressions, { contents: '', imports: Array.isArray(imports) ? imports : imports ? [imports] : [], + hoistedFragments, }); }, diff --git a/packages/core-generators/src/test-helpers/import-map-helpers.ts b/packages/core-generators/src/test-helpers/import-map-helpers.ts new file mode 100644 index 000000000..8f8968393 --- /dev/null +++ b/packages/core-generators/src/test-helpers/import-map-helpers.ts @@ -0,0 +1,42 @@ +import type { + InferTsImportMapFromSchema, + TsImportMapSchemaEntry, +} from '../renderers/typescript/import-maps/types.js'; + +import { createTsImportMap } from '../renderers/typescript/import-maps/ts-import-map.js'; + +/** + * Creates a test import map with consistent module specifiers for testing + * + * Each import will use the pattern `/`, making it easy to + * identify which test module an import comes from + * + * @param importSchema - The import map schema to use + * @param name - Base name for the module (e.g., 'data-utils') + * @returns A mock import provider for testing + * + * @example + * ```typescript + * const schema = createTsImportMapSchema({ + * scalarField: {}, + * relationHelpers: {}, + * }); + * + * const imports = createTestTsImportMap(schema, 'data-utils'); + * // Creates imports: + * // scalarField -> 'data-utils/scalarField' + * // relationHelpers -> 'data-utils/relationHelpers' + * ``` + */ +export function createTestTsImportMap< + T extends Record, +>(importSchema: T, name: string): InferTsImportMapFromSchema { + // Build the imports object based on the schema + const imports: Record = {}; + + for (const key of Object.keys(importSchema)) { + imports[key] = `${name}/${key}`; + } + + return createTsImportMap(importSchema, imports as Record); +} diff --git a/packages/core-generators/src/test-helpers/import-map-helpers.unit.test.ts b/packages/core-generators/src/test-helpers/import-map-helpers.unit.test.ts new file mode 100644 index 000000000..8f16714c8 --- /dev/null +++ b/packages/core-generators/src/test-helpers/import-map-helpers.unit.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { createTsImportMapSchema } from '../renderers/typescript/import-maps/ts-import-map.js'; +import { createTestTsImportMap } from './import-map-helpers.js'; + +describe('createTestTsImportMap', () => { + it('creates import map with name-based module specifiers', () => { + const schema = createTsImportMapSchema({ + scalarField: {}, + relationHelpers: {}, + }); + + const importMap = createTestTsImportMap(schema, 'data-utils'); + + // Check that the import map has the expected keys + expect(importMap).toHaveProperty('scalarField'); + expect(importMap).toHaveProperty('relationHelpers'); + + // Check that imports use name-based module specifiers + expect(importMap.scalarField.moduleSpecifier).toBe( + 'data-utils/scalarField', + ); + expect(importMap.relationHelpers.moduleSpecifier).toBe( + 'data-utils/relationHelpers', + ); + }); + + it('creates consistent module specifiers for all keys', () => { + const schema = createTsImportMapSchema({ + scalarField: {}, + relationHelpers: {}, + $Enums: {}, + }); + + const importMap = createTestTsImportMap(schema, 'test-module'); + + expect(importMap.scalarField.moduleSpecifier).toBe( + 'test-module/scalarField', + ); + expect(importMap.$Enums.moduleSpecifier).toBe('test-module/$Enums'); + expect(importMap.relationHelpers.moduleSpecifier).toBe( + 'test-module/relationHelpers', + ); + }); + + it('creates fragments that can be used in code generation', () => { + const schema = createTsImportMapSchema({ + scalarField: {}, + }); + + const importMap = createTestTsImportMap(schema, 'data-utils'); + + const fragment = importMap.scalarField.fragment(); + + expect(fragment.contents).toBe('scalarField'); + expect(fragment.imports).toHaveLength(1); + expect(fragment.imports?.[0].moduleSpecifier).toBe( + 'data-utils/scalarField', + ); + expect(fragment.imports?.[0].namedImports).toEqual([ + { name: 'scalarField' }, + ]); + }); + + it('handles type-only imports correctly', () => { + const schema = createTsImportMapSchema({ + MyType: { isTypeOnly: true }, + }); + + const importMap = createTestTsImportMap(schema, 'types'); + + const fragment = importMap.MyType.typeFragment(); + + expect(fragment.imports).toHaveLength(1); + expect(fragment.imports?.[0].isTypeOnly).toBe(true); + expect(fragment.imports?.[0].moduleSpecifier).toBe('types/MyType'); + }); + + it('handles wildcard exports', () => { + const schema = createTsImportMapSchema({ + '*': {}, + }); + + const importMap = createTestTsImportMap(schema, 'wildcard-module'); + + expect(importMap['*'].moduleSpecifier).toBe('wildcard-module/*'); + }); + + it('handles exported aliases', () => { + const schema = createTsImportMapSchema({ + defaultExport: { exportedAs: 'default' }, + }); + + const importMap = createTestTsImportMap(schema, 'my-module'); + + const declaration = importMap.defaultExport.declaration(); + + expect(declaration.defaultImport).toBe('defaultExport'); + expect(declaration.moduleSpecifier).toBe('my-module/defaultExport'); + }); +}); diff --git a/packages/core-generators/src/test-helpers/index.ts b/packages/core-generators/src/test-helpers/index.ts new file mode 100644 index 000000000..624cb1c19 --- /dev/null +++ b/packages/core-generators/src/test-helpers/index.ts @@ -0,0 +1,43 @@ +/** + * Test helpers for @baseplate-dev/core-generators + * + * This module provides utilities for testing code generators, including: + * - Custom Vitest matchers for TypeScript fragments + * - Utilities for creating mock import providers + * - Fragment comparison functions + * + * @example + * ```typescript + * import { createTestTsImportMap, extendFragmentMatchers } from '@baseplate-dev/core-generators/test-helpers'; + * + * // Extend Vitest matchers (call once in setup) + * extendFragmentMatchers(); + * + * // Create mock import providers for testing + * const imports = createTestTsImportMap(schema); + * + * // Use custom matchers in tests + * expect(fragment).toMatchTsFragment(expectedFragment); + * expect(fragment).toIncludeImport('z', 'zod'); + * ``` + * + * @packageDocumentation + */ + +// Export import map helpers +export { createTestTsImportMap } from './import-map-helpers.js'; + +// Export matcher functions and types +export { + extendFragmentMatchers, + fragmentMatchers, + type ToIncludeImportOptions, + type ToMatchTsFragmentOptions, +} from './matchers.js'; + +// Export utility functions +export { + areFragmentsEqual, + type CompareFragmentsOptions, + normalizeFragment, +} from './utils.js'; diff --git a/packages/core-generators/src/test-helpers/matchers.ts b/packages/core-generators/src/test-helpers/matchers.ts new file mode 100644 index 000000000..370784775 --- /dev/null +++ b/packages/core-generators/src/test-helpers/matchers.ts @@ -0,0 +1,288 @@ +import { expect } from 'vitest'; + +import type { + TsCodeFragment, + TsImportDeclaration, + TsNamedImport, +} from '../renderers/typescript/index.js'; + +import { areFragmentsEqual, normalizeFragment } from './utils.js'; + +/** + * Options for toMatchTsFragment matcher + */ +export interface ToMatchTsFragmentOptions { + /** + * Whether to ignore hoisted fragments in comparison (default: false) + */ + ignoreHoistedFragments?: boolean; +} + +/** + * Options for toIncludeImport matcher + */ +export interface ToIncludeImportOptions { + /** + * Whether the import should be type-only + */ + isTypeOnly?: boolean; +} + +/** + * Formats imports for display in error messages + */ +function formatImports(imports?: TsImportDeclaration[]): string { + if (!imports || imports.length === 0) return '(none)'; + + return imports + .map((imp) => { + const parts: string[] = []; + + if (imp.isTypeOnly) parts.push('type'); + + if (imp.namespaceImport) { + parts.push(`* as ${imp.namespaceImport}`); + } + + if (imp.defaultImport) { + parts.push(imp.defaultImport); + } + + if (imp.namedImports && imp.namedImports.length > 0) { + const namedStr = imp.namedImports + .map((ni) => (ni.alias ? `${ni.name} as ${ni.alias}` : ni.name)) + .join(', '); + parts.push(`{ ${namedStr} }`); + } + + return ` ${parts.join(' ')} from '${imp.moduleSpecifier}'`; + }) + .join('\n'); +} + +/** + * Formats hoisted fragments for display in error messages + */ +function formatHoistedFragments( + fragments?: { key: string; contents: string }[], +): string { + if (!fragments || fragments.length === 0) return '(none)'; + + return fragments + .map((frag) => ` [${frag.key}]: ${frag.contents}`) + .join('\n'); +} + +/** + * Checks if an import declaration contains a specific named import + */ +function hasNamedImport( + imp: TsImportDeclaration, + name: string, + options?: ToIncludeImportOptions, +): boolean { + if (!imp.namedImports) return false; + + return imp.namedImports.some((ni: TsNamedImport) => { + if (ni.name !== name) return false; + + // If isTypeOnly is specified in options, check it matches + if (options?.isTypeOnly !== undefined) { + // Check both the named import level and the declaration level + const isImportTypeOnly = ni.isTypeOnly ?? imp.isTypeOnly ?? false; + return isImportTypeOnly === options.isTypeOnly; + } + + return true; + }); +} + +/** + * Custom Vitest matchers for TypeScript code fragments + */ +export const fragmentMatchers = { + /** + * Asserts that a TypeScript fragment matches the expected fragment + * Compares contents, imports (order-independent), and optionally hoisted fragments + * + * @example + * ```typescript + * expect(actualFragment).toMatchTsFragment(expectedFragment); + * expect(actualFragment).toMatchTsFragment(expectedFragment, { + * ignoreHoistedFragments: true + * }); + * ``` + */ + toMatchTsFragment( + this: { isNot: boolean }, + received: TsCodeFragment, + expected: TsCodeFragment, + options?: ToMatchTsFragmentOptions, + ) { + const { isNot } = this; + + // Normalize both fragments for comparison + const normalizedReceived = normalizeFragment(received, { + compareHoistedFragments: !options?.ignoreHoistedFragments, + }); + const normalizedExpected = normalizeFragment(expected, { + compareHoistedFragments: !options?.ignoreHoistedFragments, + }); + + // Check equality + const pass = areFragmentsEqual(received, expected, { + compareHoistedFragments: !options?.ignoreHoistedFragments, + }); + + return { + pass, + message: () => { + if (pass && isNot) { + return 'Expected fragments not to be equal'; + } + + const messages: string[] = ['Expected fragments to be equal']; + + // Check contents + if (normalizedReceived.contents !== normalizedExpected.contents) { + messages.push( + '', + 'Contents:', + ` Expected: ${normalizedExpected.contents}`, + ` Received: ${normalizedReceived.contents}`, + ); + } + + // Check imports + const receivedImportsStr = formatImports(normalizedReceived.imports); + const expectedImportsStr = formatImports(normalizedExpected.imports); + + if (receivedImportsStr !== expectedImportsStr) { + messages.push( + '', + 'Imports:', + 'Expected:', + expectedImportsStr, + 'Received:', + receivedImportsStr, + ); + } + + // Check hoisted fragments if not ignored + if (!options?.ignoreHoistedFragments) { + const receivedHoistedStr = formatHoistedFragments( + normalizedReceived.hoistedFragments, + ); + const expectedHoistedStr = formatHoistedFragments( + normalizedExpected.hoistedFragments, + ); + + if (receivedHoistedStr !== expectedHoistedStr) { + messages.push( + '', + 'Hoisted Fragments:', + 'Expected:', + expectedHoistedStr, + 'Received:', + receivedHoistedStr, + ); + } + } + + return messages.join('\n'); + }, + }; + }, + + /** + * Asserts that a fragment includes a specific import + * + * @example + * ```typescript + * expect(fragment).toIncludeImport('z', 'zod'); + * expect(fragment).toIncludeImport('Prisma', '@prisma/client', { isTypeOnly: true }); + * ``` + */ + toIncludeImport( + this: { isNot: boolean }, + received: TsCodeFragment, + name: string, + from: string, + options?: ToIncludeImportOptions, + ) { + const { isNot } = this; + + const imports = received.imports ?? []; + const matchingImport = imports.find((imp) => imp.moduleSpecifier === from); + + const pass = + matchingImport !== undefined && + hasNamedImport(matchingImport, name, options); + + return { + pass, + message: () => { + if (pass && isNot) { + const typeOnlyStr = options?.isTypeOnly ? ' (type-only)' : ''; + return `Expected not to include import "${name}"${typeOnlyStr} from "${from}"`; + } + + const typeOnlyStr = options?.isTypeOnly ? ' (type-only)' : ''; + const foundFrom = matchingImport + ? `, but found import from "${matchingImport.moduleSpecifier}"` + : ''; + + return [ + `Expected to include import "${name}"${typeOnlyStr} from "${from}"${foundFrom}`, + '', + 'Available imports:', + formatImports(imports), + ].join('\n'); + }, + }; + }, +}; + +/** + * Extends Vitest's expect with custom matchers for TypeScript fragments + * Call this function once in your test setup to enable the matchers + * + * @example + * ```typescript + * // In test setup file or at the top of test file + * import { extendFragmentMatchers } from '@baseplate-dev/core-generators/test-helpers'; + * + * extendFragmentMatchers(); + * ``` + */ +export function extendFragmentMatchers(): void { + expect.extend(fragmentMatchers); +} + +/** + * TypeScript module augmentation for custom matchers + * This provides type checking and autocomplete for the custom matchers + */ +interface FragmentMatchers { + /** + * Asserts that a TypeScript fragment matches the expected fragment + * Compares contents, imports (order-independent), and optionally hoisted fragments + */ + toMatchTsFragment( + expected: TsCodeFragment, + options?: ToMatchTsFragmentOptions, + ): R; + /** + * Asserts that a fragment includes a specific import + */ + toIncludeImport( + name: string, + from: string, + options?: ToIncludeImportOptions, + ): R; +} + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any + interface Matchers extends FragmentMatchers {} +} diff --git a/packages/core-generators/src/test-helpers/matchers.unit.test.ts b/packages/core-generators/src/test-helpers/matchers.unit.test.ts new file mode 100644 index 000000000..6bbad972a --- /dev/null +++ b/packages/core-generators/src/test-helpers/matchers.unit.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { + tsCodeFragment, + tsImportBuilder, +} from '../renderers/typescript/index.js'; +import { extendFragmentMatchers } from './matchers.js'; + +// Extend matchers before tests +extendFragmentMatchers(); + +describe('toMatchTsFragment', () => { + it('matches fragments with same contents and imports', () => { + const actual = tsCodeFragment('foo()', tsImportBuilder(['z']).from('zod')); + + const expected = tsCodeFragment( + 'foo()', + tsImportBuilder(['z']).from('zod'), + ); + + expect(actual).toMatchTsFragment(expected); + }); + + it('matches fragments with imports in different order', () => { + const actual = tsCodeFragment('foo()', [ + tsImportBuilder(['z']).from('zod'), + tsImportBuilder(['Prisma']).from('@prisma/client'), + ]); + + const expected = tsCodeFragment('foo()', [ + tsImportBuilder(['Prisma']).from('@prisma/client'), + tsImportBuilder(['z']).from('zod'), + ]); + + expect(actual).toMatchTsFragment(expected); + }); + + it('fails when contents differ', () => { + const actual = tsCodeFragment('foo()'); + const expected = tsCodeFragment('bar()'); + + expect(() => { + expect(actual).toMatchTsFragment(expected); + }).toThrow(); + }); + + it('fails when imports differ', () => { + const actual = tsCodeFragment('foo()', tsImportBuilder(['z']).from('zod')); + + const expected = tsCodeFragment( + 'foo()', + tsImportBuilder(['Prisma']).from('@prisma/client'), + ); + + expect(() => { + expect(actual).toMatchTsFragment(expected); + }).toThrow(); + }); + + it('matches fragments with hoisted fragments in different order', () => { + const actual = tsCodeFragment('foo()', undefined, { + hoistedFragments: [ + { key: 'b', contents: 'const b = 2;', imports: [] }, + { key: 'a', contents: 'const a = 1;', imports: [] }, + ], + }); + + const expected = tsCodeFragment('foo()', undefined, { + hoistedFragments: [ + { key: 'a', contents: 'const a = 1;', imports: [] }, + { key: 'b', contents: 'const b = 2;', imports: [] }, + ], + }); + + expect(actual).toMatchTsFragment(expected); + }); + + it('ignores hoisted fragments when option is set', () => { + const actual = tsCodeFragment('foo()', undefined, { + hoistedFragments: [{ key: 'a', contents: 'const a = 1;', imports: [] }], + }); + + const expected = tsCodeFragment('foo()'); + + expect(actual).toMatchTsFragment(expected, { + ignoreHoistedFragments: true, + }); + }); + + it('trims whitespace in contents', () => { + const actual = tsCodeFragment(' foo() '); + const expected = tsCodeFragment('foo()'); + + expect(actual).toMatchTsFragment(expected); + }); + + it('supports .not matcher', () => { + const actual = tsCodeFragment('foo()'); + const expected = tsCodeFragment('bar()'); + + expect(actual).not.toMatchTsFragment(expected); + }); +}); + +describe('toIncludeImport', () => { + it('passes when import is present', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['z']).from('zod'), + ); + + expect(fragment).toIncludeImport('z', 'zod'); + }); + + it('fails when import is not present', () => { + const fragment = tsCodeFragment('foo()'); + + expect(() => { + expect(fragment).toIncludeImport('z', 'zod'); + }).toThrow(); + }); + + it('fails when import name is present but from wrong module', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['z']).from('zodiac'), + ); + + expect(() => { + expect(fragment).toIncludeImport('z', 'zod'); + }).toThrow(); + }); + + it('checks type-only imports when specified', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['MyType']).typeOnly().from('types'), + ); + + expect(fragment).toIncludeImport('MyType', 'types', { isTypeOnly: true }); + }); + + it('fails when import is type-only but not expected to be', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['MyType']).typeOnly().from('types'), + ); + + expect(() => { + expect(fragment).toIncludeImport('MyType', 'types', { + isTypeOnly: false, + }); + }).toThrow(); + }); + + it('matches import regardless of type-only when not specified', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['MyType']).typeOnly().from('types'), + ); + + expect(fragment).toIncludeImport('MyType', 'types'); + }); + + it('supports .not matcher', () => { + const fragment = tsCodeFragment('foo()'); + + expect(fragment).not.toIncludeImport('z', 'zod'); + }); + + it('works with multiple imports from same module', () => { + const fragment = tsCodeFragment( + 'foo()', + tsImportBuilder(['z', 'ZodSchema', 'ZodError']).from('zod'), + ); + + expect(fragment).toIncludeImport('z', 'zod'); + expect(fragment).toIncludeImport('ZodSchema', 'zod'); + expect(fragment).toIncludeImport('ZodError', 'zod'); + }); + + it('works with multiple imports from different modules', () => { + const fragment = tsCodeFragment('foo()', [ + tsImportBuilder(['z']).from('zod'), + tsImportBuilder(['Prisma']).from('@prisma/client'), + ]); + + expect(fragment).toIncludeImport('z', 'zod'); + expect(fragment).toIncludeImport('Prisma', '@prisma/client'); + }); +}); diff --git a/packages/core-generators/src/test-helpers/setup.ts b/packages/core-generators/src/test-helpers/setup.ts new file mode 100644 index 000000000..352efb4a8 --- /dev/null +++ b/packages/core-generators/src/test-helpers/setup.ts @@ -0,0 +1,21 @@ +/** + * Auto-setup file for test helpers + * + * This file automatically extends Vitest matchers when imported. + * Add this to your vitest.config.ts setupFiles to enable custom matchers globally. + * + * @example + * ```typescript + * // vitest.config.ts + * export default defineConfig({ + * test: { + * setupFiles: ['@baseplate-dev/core-generators/test-helpers/setup'] + * } + * }); + * ``` + */ + +import { extendFragmentMatchers } from './matchers.js'; + +// Automatically extend matchers when this file is imported +extendFragmentMatchers(); diff --git a/packages/core-generators/src/test-helpers/utils.ts b/packages/core-generators/src/test-helpers/utils.ts new file mode 100644 index 000000000..848ca958a --- /dev/null +++ b/packages/core-generators/src/test-helpers/utils.ts @@ -0,0 +1,150 @@ +import { isDeepStrictEqual } from 'node:util'; + +import type { + TsCodeFragment, + TsHoistedFragment, + TsImportDeclaration, +} from '../renderers/typescript/index.js'; + +/** + * Options for fragment comparison + */ +export interface CompareFragmentsOptions { + /** + * Whether to compare hoisted fragments (default: true) + */ + compareHoistedFragments?: boolean; +} + +/** + * Normalizes imports for comparison by sorting them in a deterministic order + * This allows order-independent comparison of imports + * + * @param imports - Array of import declarations to normalize + * @returns Normalized array of import declarations + */ +export function normalizeImports( + imports: TsImportDeclaration[], +): TsImportDeclaration[] { + return imports + .map((imp) => ({ + ...imp, + // Sort named imports alphabetically + namedImports: imp.namedImports?.slice().sort((a, b) => { + const nameCompare = a.name.localeCompare(b.name); + if (nameCompare !== 0) return nameCompare; + // If names are equal, sort by alias + return (a.alias ?? '').localeCompare(b.alias ?? ''); + }), + })) + .sort((a, b) => { + // Primary sort: module specifier + const moduleCompare = a.moduleSpecifier.localeCompare(b.moduleSpecifier); + if (moduleCompare !== 0) return moduleCompare; + + // Secondary sort: import type (namespace > default > named) + const getImportTypeOrder = (imp: TsImportDeclaration): number => { + if (imp.namespaceImport) return 1; + if (imp.defaultImport) return 2; + if (imp.namedImports) return 3; + return 4; + }; + + const typeOrderA = getImportTypeOrder(a); + const typeOrderB = getImportTypeOrder(b); + if (typeOrderA !== typeOrderB) return typeOrderA - typeOrderB; + + // Tertiary sort: isTypeOnly (type-only imports first) + if (a.isTypeOnly && !b.isTypeOnly) return -1; + if (!a.isTypeOnly && b.isTypeOnly) return 1; + + return 0; + }); +} + +/** + * Normalizes hoisted fragments for comparison by sorting them by key + * + * @param fragments - Array of hoisted fragments to normalize + * @returns Normalized array of hoisted fragments + */ +export function normalizeHoistedFragments( + fragments?: TsHoistedFragment[], +): TsHoistedFragment[] | undefined { + if (!fragments || fragments.length === 0) return undefined; + + return [...fragments] + .map((frag) => { + const normalized = normalizeFragment(frag); + // Preserve the key property from the original hoisted fragment + return { + ...normalized, + key: frag.key, + } as TsHoistedFragment; + }) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Normalizes a code fragment for comparison + * This includes trimming contents, sorting imports, and normalizing hoisted fragments + * + * @param fragment - The fragment to normalize + * @param options - Options for normalization + * @returns Normalized fragment + */ +export function normalizeFragment( + fragment: TsCodeFragment, + options?: CompareFragmentsOptions, +): TsCodeFragment { + const normalized: TsCodeFragment = { + contents: fragment.contents.trim(), + }; + + // Only include imports if they exist + if (fragment.imports && fragment.imports.length > 0) { + normalized.imports = normalizeImports(fragment.imports); + } + + // Only include hoisted fragments if they exist and should be compared + if (options?.compareHoistedFragments !== false && fragment.hoistedFragments) { + const normalizedHoisted = normalizeHoistedFragments( + fragment.hoistedFragments, + ); + if (normalizedHoisted) { + normalized.hoistedFragments = normalizedHoisted; + } + } + + return normalized; +} + +/** + * Compares two TsCodeFragment objects for equality, ignoring import order + * This is useful for programmatic checks outside of test assertions + * + * @param actual - The actual fragment + * @param expected - The expected fragment + * @param options - Comparison options + * @returns True if fragments are equal + * + * @example + * ```typescript + * const actual = tsCodeFragment('foo()', ...); + * const expected = tsCodeFragment('foo()', ...); + * + * if (areFragmentsEqual(actual, expected)) { + * console.log('Fragments match!'); + * } + * ``` + */ +export function areFragmentsEqual( + actual: TsCodeFragment, + expected: TsCodeFragment, + options?: CompareFragmentsOptions, +): boolean { + const normalizedActual = normalizeFragment(actual, options); + const normalizedExpected = normalizeFragment(expected, options); + + return isDeepStrictEqual(normalizedActual, normalizedExpected); +} diff --git a/packages/core-generators/src/test-helpers/utils.unit.test.ts b/packages/core-generators/src/test-helpers/utils.unit.test.ts new file mode 100644 index 000000000..9df1b4d4c --- /dev/null +++ b/packages/core-generators/src/test-helpers/utils.unit.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import { + tsCodeFragment, + tsImportBuilder, +} from '../renderers/typescript/index.js'; +import { + areFragmentsEqual, + normalizeFragment, + normalizeHoistedFragments, + normalizeImports, +} from './utils.js'; + +describe('normalizeImports', () => { + it('sorts imports by module specifier', () => { + const imports = [ + { moduleSpecifier: 'zod', namedImports: [{ name: 'z' }] }, + { moduleSpecifier: '@prisma/client', namedImports: [{ name: 'Prisma' }] }, + { moduleSpecifier: 'react', namedImports: [{ name: 'useState' }] }, + ]; + + const normalized = normalizeImports(imports); + + expect(normalized).toEqual([ + { moduleSpecifier: '@prisma/client', namedImports: [{ name: 'Prisma' }] }, + { moduleSpecifier: 'react', namedImports: [{ name: 'useState' }] }, + { moduleSpecifier: 'zod', namedImports: [{ name: 'z' }] }, + ]); + }); + + it('sorts named imports alphabetically', () => { + const imports = [ + { + moduleSpecifier: 'zod', + namedImports: [ + { name: 'z' }, + { name: 'ZodError' }, + { name: 'ZodSchema' }, + ], + }, + ]; + + const normalized = normalizeImports(imports); + + expect(normalized[0].namedImports).toEqual([ + { name: 'z' }, + { name: 'ZodError' }, + { name: 'ZodSchema' }, + ]); + }); + + it('sorts by import type (namespace > default > named)', () => { + const imports = [ + { moduleSpecifier: 'a', namedImports: [{ name: 'foo' }] }, + { moduleSpecifier: 'a', defaultImport: 'Bar' }, + { moduleSpecifier: 'a', namespaceImport: 'baz' }, + ]; + + const normalized = normalizeImports(imports); + + expect(normalized[0]).toHaveProperty('namespaceImport'); + expect(normalized[1]).toHaveProperty('defaultImport'); + expect(normalized[2]).toHaveProperty('namedImports'); + }); + + it('places type-only imports first within same module', () => { + const imports = [ + { + moduleSpecifier: 'a', + namedImports: [{ name: 'foo' }], + isTypeOnly: false, + }, + { + moduleSpecifier: 'a', + namedImports: [{ name: 'bar' }], + isTypeOnly: true, + }, + ]; + + const normalized = normalizeImports(imports); + + expect(normalized[0].isTypeOnly).toBe(true); + expect(normalized[1].isTypeOnly).toBe(false); + }); +}); + +describe('normalizeHoistedFragments', () => { + it('returns undefined for empty array', () => { + expect(normalizeHoistedFragments([])).toBeUndefined(); + }); + + it('returns undefined for undefined input', () => { + expect(normalizeHoistedFragments(undefined)).toBeUndefined(); + }); + + it('sorts hoisted fragments by key', () => { + const fragments = [ + { key: 'c', contents: 'const c = 3;', imports: [] }, + { key: 'a', contents: 'const a = 1;', imports: [] }, + { key: 'b', contents: 'const b = 2;', imports: [] }, + ]; + + const normalized = normalizeHoistedFragments(fragments); + + expect(normalized).toEqual([ + { key: 'a', contents: 'const a = 1;' }, + { key: 'b', contents: 'const b = 2;' }, + { key: 'c', contents: 'const c = 3;' }, + ]); + }); +}); + +describe('normalizeFragment', () => { + it('trims fragment contents', () => { + const fragment = tsCodeFragment(' foo() '); + + const normalized = normalizeFragment(fragment); + + expect(normalized.contents).toBe('foo()'); + }); + + it('normalizes imports', () => { + const fragment = tsCodeFragment('foo()', [ + tsImportBuilder(['z']).from('zod'), + tsImportBuilder(['Prisma']).from('@prisma/client'), + ]); + + const normalized = normalizeFragment(fragment); + + expect(normalized.imports?.[0].moduleSpecifier).toBe('@prisma/client'); + expect(normalized.imports?.[1].moduleSpecifier).toBe('zod'); + }); + + it('normalizes hoisted fragments by default', () => { + const fragment = tsCodeFragment('foo()', undefined, { + hoistedFragments: [ + { key: 'b', contents: 'const b = 2;', imports: [] }, + { key: 'a', contents: 'const a = 1;', imports: [] }, + ], + }); + + const normalized = normalizeFragment(fragment); + + expect(normalized.hoistedFragments?.[0].key).toBe('a'); + expect(normalized.hoistedFragments?.[1].key).toBe('b'); + }); + + it('ignores hoisted fragments when compareHoistedFragments is false', () => { + const fragment = tsCodeFragment('foo()', undefined, { + hoistedFragments: [{ key: 'a', contents: 'const a = 1;', imports: [] }], + }); + + const normalized = normalizeFragment(fragment, { + compareHoistedFragments: false, + }); + + expect(normalized.hoistedFragments).toBeUndefined(); + }); +}); + +describe('areFragmentsEqual', () => { + it('returns true for identical fragments', () => { + const fragment1 = tsCodeFragment('foo()'); + const fragment2 = tsCodeFragment('foo()'); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(true); + }); + + it('returns false for different contents', () => { + const fragment1 = tsCodeFragment('foo()'); + const fragment2 = tsCodeFragment('bar()'); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(false); + }); + + it('returns true for same imports in different order', () => { + const fragment1 = tsCodeFragment('foo()', [ + tsImportBuilder(['z']).from('zod'), + tsImportBuilder(['Prisma']).from('@prisma/client'), + ]); + + const fragment2 = tsCodeFragment('foo()', [ + tsImportBuilder(['Prisma']).from('@prisma/client'), + tsImportBuilder(['z']).from('zod'), + ]); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(true); + }); + + it('returns false for different imports', () => { + const fragment1 = tsCodeFragment( + 'foo()', + tsImportBuilder(['z']).from('zod'), + ); + + const fragment2 = tsCodeFragment( + 'foo()', + tsImportBuilder(['Prisma']).from('@prisma/client'), + ); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(false); + }); + + it('returns true for same hoisted fragments in different order', () => { + const fragment1 = tsCodeFragment('foo()', undefined, { + hoistedFragments: [ + { key: 'b', contents: 'const b = 2;', imports: [] }, + { key: 'a', contents: 'const a = 1;', imports: [] }, + ], + }); + + const fragment2 = tsCodeFragment('foo()', undefined, { + hoistedFragments: [ + { key: 'a', contents: 'const a = 1;', imports: [] }, + { key: 'b', contents: 'const b = 2;', imports: [] }, + ], + }); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(true); + }); + + it('ignores hoisted fragments when compareHoistedFragments is false', () => { + const fragment1 = tsCodeFragment('foo()', undefined, { + hoistedFragments: [{ key: 'a', contents: 'const a = 1;', imports: [] }], + }); + + const fragment2 = tsCodeFragment('foo()'); + + expect( + areFragmentsEqual(fragment1, fragment2, { + compareHoistedFragments: false, + }), + ).toBe(true); + }); + + it('handles fragments with whitespace differences in contents', () => { + const fragment1 = tsCodeFragment(' foo() '); + const fragment2 = tsCodeFragment('foo()'); + + expect(areFragmentsEqual(fragment1, fragment2)).toBe(true); + }); +}); diff --git a/packages/core-generators/src/test-helpers/vitest-types.d.ts b/packages/core-generators/src/test-helpers/vitest-types.d.ts new file mode 100644 index 000000000..dec665641 --- /dev/null +++ b/packages/core-generators/src/test-helpers/vitest-types.d.ts @@ -0,0 +1,34 @@ +import type { TsCodeFragment } from '#src/renderers/typescript/index.js'; + +import type { + ToIncludeImportOptions, + ToMatchTsFragmentOptions, +} from './matchers.ts'; + +/** + * TypeScript module augmentation for custom matchers + * This provides type checking and autocomplete for the custom matchers + */ +interface FragmentMatchers { + /** + * Asserts that a TypeScript fragment matches the expected fragment + * Compares contents, imports (order-independent), and optionally hoisted fragments + */ + toMatchTsFragment( + expected: TsCodeFragment, + options?: ToMatchTsFragmentOptions, + ): R; + /** + * Asserts that a fragment includes a specific import + */ + toIncludeImport( + name: string, + from: string, + options?: ToIncludeImportOptions, + ): R; +} + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any + interface Matchers extends FragmentMatchers {} +} diff --git a/packages/fastify-generators/package.json b/packages/fastify-generators/package.json index 8b2cd54cf..57d3728d5 100644 --- a/packages/fastify-generators/package.json +++ b/packages/fastify-generators/package.json @@ -40,6 +40,7 @@ "lint": "eslint .", "prettier:check": "prettier --check .", "prettier:write": "prettier -w .", + "test": "vitest", "tsc:watch": "tsc -p tsconfig.build.json --preserveWatchOutput -w", "typecheck": "tsc --noEmit", "watch": "concurrently pnpm:watch:*", @@ -62,7 +63,8 @@ "cpx2": "catalog:", "eslint": "catalog:", "prettier": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "engines": { "node": "^22.0.0" diff --git a/packages/fastify-generators/src/generators/auth/auth-context/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/auth/auth-context/generated/ts-import-providers.ts index 79cfeff7d..8d2472b17 100644 --- a/packages/fastify-generators/src/generators/auth/auth-context/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/auth/auth-context/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { AUTH_AUTH_CONTEXT_PATHS } from './template-paths.js'; -const authContextImportsSchema = createTsImportMapSchema({ +export const authContextImportsSchema = createTsImportMapSchema({ AuthContext: { isTypeOnly: true }, AuthSessionInfo: { isTypeOnly: true }, AuthUserSessionInfo: { isTypeOnly: true }, diff --git a/packages/fastify-generators/src/generators/auth/auth-roles/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/auth/auth-roles/generated/ts-import-providers.ts index df243d877..e5cebfdd9 100644 --- a/packages/fastify-generators/src/generators/auth/auth-roles/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/auth/auth-roles/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { AUTH_AUTH_ROLES_PATHS } from './template-paths.js'; -const authRolesImportsSchema = createTsImportMapSchema({ +export const authRolesImportsSchema = createTsImportMapSchema({ AUTH_ROLE_CONFIG: {}, AuthRole: { isTypeOnly: true }, DEFAULT_PUBLIC_ROLES: {}, diff --git a/packages/fastify-generators/src/generators/auth/index.ts b/packages/fastify-generators/src/generators/auth/index.ts index 57304b316..7af80959c 100644 --- a/packages/fastify-generators/src/generators/auth/index.ts +++ b/packages/fastify-generators/src/generators/auth/index.ts @@ -4,5 +4,4 @@ export * from './auth-plugin/index.js'; export * from './auth-roles/index.js'; export * from './password-hasher-service/index.js'; export * from './placeholder-auth-service/index.js'; -export * from './prisma-password-transformer/index.js'; export * from './user-session-types/index.js'; diff --git a/packages/fastify-generators/src/generators/auth/password-hasher-service/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/auth/password-hasher-service/generated/ts-import-providers.ts index 1b4eb2ac8..dcc57e351 100644 --- a/packages/fastify-generators/src/generators/auth/password-hasher-service/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/auth/password-hasher-service/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { AUTH_PASSWORD_HASHER_SERVICE_PATHS } from './template-paths.js'; -const passwordHasherServiceImportsSchema = createTsImportMapSchema({ +export const passwordHasherServiceImportsSchema = createTsImportMapSchema({ createPasswordHash: {}, verifyPasswordHash: {}, }); diff --git a/packages/fastify-generators/src/generators/auth/prisma-password-transformer/index.ts b/packages/fastify-generators/src/generators/auth/prisma-password-transformer/index.ts deleted file mode 100644 index fcdd21013..000000000 --- a/packages/fastify-generators/src/generators/auth/prisma-password-transformer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-password-transformer.generator.js'; diff --git a/packages/fastify-generators/src/generators/auth/prisma-password-transformer/prisma-password-transformer.generator.ts b/packages/fastify-generators/src/generators/auth/prisma-password-transformer/prisma-password-transformer.generator.ts deleted file mode 100644 index da8d1847f..000000000 --- a/packages/fastify-generators/src/generators/auth/prisma-password-transformer/prisma-password-transformer.generator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { tsCodeFragment } from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { z } from 'zod'; - -import { prismaCrudServiceSetupProvider } from '#src/generators/prisma/prisma-crud-service/index.js'; - -import { passwordHasherServiceImportsProvider } from '../password-hasher-service/index.js'; - -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); - -export const prismaPasswordTransformerGenerator = createGenerator({ - name: 'auth/prisma-password-transformer', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: () => ({ - main: createGeneratorTask({ - dependencies: { - passwordHasherServiceImports: passwordHasherServiceImportsProvider, - prismaCrudServiceSetup: prismaCrudServiceSetupProvider, - }, - run({ prismaCrudServiceSetup, passwordHasherServiceImports }) { - prismaCrudServiceSetup.addTransformer('password', { - buildTransformer: () => ({ - inputFields: [ - { - type: tsCodeFragment('string | null'), - dtoField: { - name: 'password', - type: 'scalar', - scalarType: 'string', - isOptional: true, - isNullable: true, - }, - }, - ], - outputFields: [ - { - name: 'passwordHash', - transformer: tsCodeFragment( - 'const passwordHash = password ?? await createPasswordHash(password);', - passwordHasherServiceImports.createPasswordHash.declaration(), - ), - }, - ], - isAsync: true, - }), - }); - return {}; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/auth/user-session-types/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/auth/user-session-types/generated/ts-import-providers.ts index 60fac5206..46c09df9d 100644 --- a/packages/fastify-generators/src/generators/auth/user-session-types/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/auth/user-session-types/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { AUTH_USER_SESSION_TYPES_PATHS } from './template-paths.js'; -const userSessionTypesImportsSchema = createTsImportMapSchema({ +export const userSessionTypesImportsSchema = createTsImportMapSchema({ UserSessionPayload: { isTypeOnly: true }, UserSessionService: { isTypeOnly: true }, }); diff --git a/packages/fastify-generators/src/generators/bull/bull-mq/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/bull/bull-mq/generated/ts-import-providers.ts index c475b223a..88460adda 100644 --- a/packages/fastify-generators/src/generators/bull/bull-mq/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/bull/bull-mq/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { BULL_BULL_MQ_PATHS } from './template-paths.js'; -const bullMqImportsSchema = createTsImportMapSchema({ +export const bullMqImportsSchema = createTsImportMapSchema({ createWorker: {}, getOrCreateManagedQueue: {}, ManagedRepeatableJobConfig: { isTypeOnly: true }, diff --git a/packages/fastify-generators/src/generators/core/app-module-setup/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/app-module-setup/generated/ts-import-providers.ts index 5beb2003b..76d34413f 100644 --- a/packages/fastify-generators/src/generators/core/app-module-setup/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/app-module-setup/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_APP_MODULE_SETUP_PATHS } from './template-paths.js'; -const appModuleSetupImportsSchema = createTsImportMapSchema({ +export const appModuleSetupImportsSchema = createTsImportMapSchema({ flattenAppModule: {}, }); diff --git a/packages/fastify-generators/src/generators/core/axios/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/axios/generated/ts-import-providers.ts index f581e887d..117625672 100644 --- a/packages/fastify-generators/src/generators/core/axios/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/axios/generated/ts-import-providers.ts @@ -12,7 +12,9 @@ import { import { CORE_AXIOS_PATHS } from './template-paths.js'; -const axiosImportsSchema = createTsImportMapSchema({ getAxiosErrorInfo: {} }); +export const axiosImportsSchema = createTsImportMapSchema({ + getAxiosErrorInfo: {}, +}); export type AxiosImportsProvider = TsImportMapProviderFromSchema< typeof axiosImportsSchema diff --git a/packages/fastify-generators/src/generators/core/config-service/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/config-service/generated/ts-import-providers.ts index bb1e08915..7eb846d76 100644 --- a/packages/fastify-generators/src/generators/core/config-service/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/config-service/generated/ts-import-providers.ts @@ -12,7 +12,9 @@ import { import { CORE_CONFIG_SERVICE_PATHS } from './template-paths.js'; -const configServiceImportsSchema = createTsImportMapSchema({ config: {} }); +export const configServiceImportsSchema = createTsImportMapSchema({ + config: {}, +}); export type ConfigServiceImportsProvider = TsImportMapProviderFromSchema< typeof configServiceImportsSchema diff --git a/packages/fastify-generators/src/generators/core/error-handler-service/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/error-handler-service/generated/ts-import-providers.ts index 49f273049..5e77d71e6 100644 --- a/packages/fastify-generators/src/generators/core/error-handler-service/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/error-handler-service/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_ERROR_HANDLER_SERVICE_PATHS } from './template-paths.js'; -const errorHandlerServiceImportsSchema = createTsImportMapSchema({ +export const errorHandlerServiceImportsSchema = createTsImportMapSchema({ BadRequestError: {}, ForbiddenError: {}, handleZodRequestValidationError: {}, diff --git a/packages/fastify-generators/src/generators/core/fastify-redis/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/fastify-redis/generated/ts-import-providers.ts index a6abd0b6f..bade49ea8 100644 --- a/packages/fastify-generators/src/generators/core/fastify-redis/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/fastify-redis/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_FASTIFY_REDIS_PATHS } from './template-paths.js'; -const fastifyRedisImportsSchema = createTsImportMapSchema({ +export const fastifyRedisImportsSchema = createTsImportMapSchema({ createRedisClient: {}, getRedisClient: {}, }); diff --git a/packages/fastify-generators/src/generators/core/fastify-sentry/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/fastify-sentry/generated/ts-import-providers.ts index bba3481d2..c4b26dfc5 100644 --- a/packages/fastify-generators/src/generators/core/fastify-sentry/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/fastify-sentry/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_FASTIFY_SENTRY_PATHS } from './template-paths.js'; -const fastifySentryImportsSchema = createTsImportMapSchema({ +export const fastifySentryImportsSchema = createTsImportMapSchema({ isSentryEnabled: {}, logErrorToSentry: {}, registerSentryEventProcessor: {}, diff --git a/packages/fastify-generators/src/generators/core/logger-service/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/logger-service/generated/ts-import-providers.ts index 8ef38b54a..0dfb8523b 100644 --- a/packages/fastify-generators/src/generators/core/logger-service/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/logger-service/generated/ts-import-providers.ts @@ -12,7 +12,9 @@ import { import { CORE_LOGGER_SERVICE_PATHS } from './template-paths.js'; -const loggerServiceImportsSchema = createTsImportMapSchema({ logger: {} }); +export const loggerServiceImportsSchema = createTsImportMapSchema({ + logger: {}, +}); export type LoggerServiceImportsProvider = TsImportMapProviderFromSchema< typeof loggerServiceImportsSchema diff --git a/packages/fastify-generators/src/generators/core/request-context/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/request-context/generated/ts-import-providers.ts index cb6204288..7ee6ffbfc 100644 --- a/packages/fastify-generators/src/generators/core/request-context/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/request-context/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REQUEST_CONTEXT_PATHS } from './template-paths.js'; -const requestContextImportsSchema = createTsImportMapSchema({ +export const requestContextImportsSchema = createTsImportMapSchema({ RequestInfo: { isTypeOnly: true }, }); diff --git a/packages/fastify-generators/src/generators/core/request-service-context/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/request-service-context/generated/ts-import-providers.ts index 4770386b6..f06092454 100644 --- a/packages/fastify-generators/src/generators/core/request-service-context/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/request-service-context/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REQUEST_SERVICE_CONTEXT_PATHS } from './template-paths.js'; -const requestServiceContextImportsSchema = createTsImportMapSchema({ +export const requestServiceContextImportsSchema = createTsImportMapSchema({ createContextFromRequest: {}, RequestServiceContext: { isTypeOnly: true }, }); diff --git a/packages/fastify-generators/src/generators/core/service-context/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/core/service-context/generated/ts-import-providers.ts index 38baa59b9..ff8c08ee2 100644 --- a/packages/fastify-generators/src/generators/core/service-context/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/core/service-context/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_SERVICE_CONTEXT_PATHS } from './template-paths.js'; -const serviceContextImportsSchema = createTsImportMapSchema({ +export const serviceContextImportsSchema = createTsImportMapSchema({ createServiceContext: {}, createSystemServiceContext: {}, createTestServiceContext: {}, diff --git a/packages/fastify-generators/src/generators/core/service-file/service-file.generator.ts b/packages/fastify-generators/src/generators/core/service-file/service-file.generator.ts index 02977ade2..912c90b8c 100644 --- a/packages/fastify-generators/src/generators/core/service-file/service-file.generator.ts +++ b/packages/fastify-generators/src/generators/core/service-file/service-file.generator.ts @@ -75,6 +75,10 @@ export interface ServiceFileProvider { * Register a method with the service file. */ registerMethod(method: ServiceMethod): void; + /** + * Register a header typescript code fragment. + */ + registerHeader(header: { name: string; fragment: TsCodeFragment }): void; } export const serviceFileProvider = @@ -106,6 +110,10 @@ export const serviceFileGenerator = createGenerator({ }, run({ appModule, typescriptFile }) { const methodsContainer = new NamedArrayFieldContainer(); + const headersContainer = new NamedArrayFieldContainer<{ + name: string; + fragment: TsCodeFragment; + }>(); const servicesFolder = path.join( appModule.getModuleFolder(), 'services', @@ -124,16 +132,25 @@ export const serviceFileGenerator = createGenerator({ registerMethod(method) { methodsContainer.add(method); }, + registerHeader(header) { + headersContainer.add(header); + }, }, }, build: async (builder) => { + const orderedHeaders = headersContainer + .getValue() + .sort((a, b) => a.name.localeCompare(b.name)); const orderedMethods = methodsContainer .getValue() .sort((a, b) => a.order - b.order); - if (orderedMethods.length > 0) { + if (orderedMethods.length > 0 || orderedHeaders.length > 0) { const mergedMethods = mergeFragmentsWithHoistedFragmentsPresorted( - orderedMethods.map((m) => m.fragment), + [ + ...orderedHeaders.map((h) => h.fragment), + ...orderedMethods.map((m) => m.fragment), + ], ); await builder.apply( typescriptFile.renderTemplateFragment({ diff --git a/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts b/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts index 66c845b97..1a9020888 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts @@ -1,6 +1,7 @@ import type { TsCodeFragment } from '@baseplate-dev/core-generators'; import { + tsCodeFragment, TsCodeUtils, tsImportBuilder, tsUtilsImportsProvider, @@ -13,11 +14,20 @@ import { import { quot, sortObjectKeys } from '@baseplate-dev/utils'; import { z } from 'zod'; +import type { ServiceOutputDtoInjectedArg } from '#src/types/service-output.js'; + import { serviceFileOutputProvider } from '#src/generators/core/service-file/index.js'; import { pothosFieldProvider, pothosTypeOutputProvider, } from '#src/generators/pothos/_providers/index.js'; +import { getPrimaryKeyDefinition } from '#src/generators/prisma/_shared/crud-method/primary-key-input.js'; +import { prismaOutputProvider } from '#src/generators/prisma/index.js'; +import { + contextKind, + prismaQueryKind, + prismaWhereUniqueInputKind, +} from '#src/types/service-dto-kinds.js'; import { lowerCaseFirst } from '#src/utils/case.js'; import { writePothosInputFieldsFromDtoFields, @@ -37,9 +47,9 @@ const descriptorSchema = z.object({ */ modelName: z.string().min(1), /** - * The type of the mutation. + * The name of the mutation. */ - type: z.enum(['create', 'update', 'delete']), + name: z.string().min(1), /** * The reference to the crud service. */ @@ -48,28 +58,72 @@ const descriptorSchema = z.object({ * The order of the type in the types file. */ order: z.number(), - /** - * Whether the mutation has a primary key input type. - */ - hasPrimaryKeyInputType: z.boolean(), }); +type InjectedArgRequirements = 'context' | 'info' | 'id'; + +/** + * Handles injected service arguments by generating the appropriate code fragments. + * Injected arguments are provided by the framework (context, query, where/id mapping). + */ +function handleInjectedArg( + arg: ServiceOutputDtoInjectedArg, + context: { returnFieldName: string }, +): { fragment: TsCodeFragment; requirements: InjectedArgRequirements[] } { + switch (arg.kind) { + case contextKind: { + // expect context + return { fragment: tsCodeFragment('context'), requirements: ['context'] }; + } + + case prismaQueryKind: { + // expect context and info + return { + fragment: TsCodeUtils.formatFragment( + 'queryFromInfo({ context, info, path: PATH })', + { PATH: `[${quot(context.returnFieldName)}]` }, + tsImportBuilder(['queryFromInfo']).from('@pothos/plugin-prisma'), + ), + requirements: ['context', 'info'], + }; + } + + case prismaWhereUniqueInputKind: { + // expect id + const typedArg = arg as ServiceOutputDtoInjectedArg< + typeof prismaWhereUniqueInputKind + >; + const { idFields } = typedArg.metadata; + return { + fragment: + idFields.length === 1 + ? TsCodeUtils.mergeFragmentsAsObject({ + [idFields[0]]: 'id', + }) + : TsCodeUtils.mergeFragmentsAsObject({ + [idFields.join('_')]: 'id', + }), + requirements: ['id'], + }; + } + + default: { + throw new Error(`Unknown injected argument kind: ${arg.kind.name}`); + } + } +} + export const pothosPrismaCrudMutationGenerator = createGenerator({ name: 'pothos/pothos-prisma-crud-mutation', generatorFileUrl: import.meta.url, descriptorSchema, scopes: [pothosFieldScope], - buildTasks: ({ - modelName, - type, - crudServiceRef, - order, - hasPrimaryKeyInputType, - }) => ({ + buildTasks: ({ modelName, name, crudServiceRef, order }) => ({ main: createGeneratorTask({ dependencies: { pothosSchemaBaseTypes: pothosSchemaBaseTypesProvider, pothosTypesFile: pothosTypesFileProvider, + prismaOutput: prismaOutputProvider, serviceFileOutput: serviceFileOutputProvider .dependency() .reference(crudServiceRef), @@ -80,9 +134,7 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ pothosPrimaryKeyInputType: pothosTypeOutputProvider .dependency() .optionalReference( - hasPrimaryKeyInputType - ? getPothosPrismaPrimaryKeyTypeOutputName(modelName) - : undefined, + getPothosPrismaPrimaryKeyTypeOutputName(modelName), ), }, exports: { @@ -95,54 +147,43 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ tsUtilsImports, pothosObjectType, pothosPrimaryKeyInputType, + prismaOutput, }) { - const serviceOutput = serviceFileOutput.getServiceMethod(type); + const serviceOutput = serviceFileOutput.getServiceMethod(name); + const prismaModel = prismaOutput.getPrismaModel(modelName); const typeReferences = [ pothosObjectType.getTypeReference(), pothosPrimaryKeyInputType?.getTypeReference(), ].filter((x) => x !== undefined); - const mutationName = `${type}${modelName}`; - const customFields = createNonOverwriteableMap< Record >({}); + const returnFieldName = lowerCaseFirst(modelName); - // unwrap input object arguments - const unwrappedArguments = serviceOutput.arguments.flatMap((arg) => { - if ( - arg.name === 'input' && - arg.type === 'nested' && - !arg.isPrismaType - ) { - return arg.nestedType.fields; - } - return [arg]; - }); - - const inputArgument = - serviceOutput.arguments.length > 0 - ? serviceOutput.arguments[0] - : undefined; - - if ( - !inputArgument || - inputArgument.name !== 'input' || - inputArgument.type !== 'nested' || - inputArgument.isPrismaType - ) { - throw new Error('Expected input argument to be a nested object'); - } - - const inputFields = writePothosInputFieldsFromDtoFields( - inputArgument.nestedType.fields, - { - pothosSchemaBaseTypes, - typeReferences, - schemaBuilder: pothosTypesFile.getBuilderFragment(), - fieldBuilder: 't.input', - }, + const serviceArgs = serviceOutput.arguments; + const injectedArgs = serviceArgs + .filter((arg) => arg.type === 'injected') + .map((arg) => handleInjectedArg(arg, { returnFieldName })); + const argRequirements = new Set( + injectedArgs.flatMap((arg) => arg.requirements), ); + const nonInjectedArgs = serviceArgs.filter( + (arg) => arg.type === 'scalar' || arg.type === 'nested', + ); + + const inputArgs = [ + argRequirements.has('id') + ? getPrimaryKeyDefinition(prismaModel) + : undefined, + ...nonInjectedArgs, + ].filter((x) => x !== undefined); + const inputFields = writePothosInputFieldsFromDtoFields(inputArgs, { + pothosSchemaBaseTypes, + typeReferences, + schemaBuilder: pothosTypesFile.getBuilderFragment(), + fieldBuilder: 't.input', + }); return { providers: { @@ -153,8 +194,6 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ }, }, build: () => { - const returnFieldName = lowerCaseFirst(modelName); - const payloadFields = writePothosSimpleObjectFieldsFromDtoFields( [ { @@ -172,39 +211,37 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ }, ); - const argNames = inputArgument.nestedType.fields.map( - (arg) => arg.name, - ); + const argNames = inputArgs.map((arg) => arg.name); + + const resolveFunctionArgs = [ + 'root', + `{ input: { ${argNames.join(', ')} } }`, + argRequirements.has('context') || argRequirements.has('info') + ? 'context' + : undefined, + argRequirements.has('info') ? 'info' : undefined, + ] + .filter((x) => x !== undefined) + .join(', '); const resolveFunction = TsCodeUtils.formatFragment( - `async (root, { input: INPUT_PARTS }, context, info) => { + `async (ARGS) => { const RETURN_FIELD_NAME = await SERVICE_CALL(SERVICE_ARGUMENTS); return { RETURN_FIELD_NAME }; }`, { - INPUT_PARTS: `{ ${argNames.join(', ')} }`, - CONTEXT: serviceOutput.requiresContext ? 'context' : '', + ARGS: resolveFunctionArgs, RETURN_FIELD_NAME: returnFieldName, SERVICE_CALL: serviceOutput.referenceFragment, SERVICE_ARGUMENTS: TsCodeUtils.mergeFragmentsAsObject( - { - ...Object.fromEntries( - unwrappedArguments.map((arg) => [ - arg.name, - writeValueFromPothosArg(arg, tsUtilsImports), - ]), - ), - context: 'context', - query: TsCodeUtils.formatFragment( - 'queryFromInfo({ context, info, path: PATH })', - { - PATH: `[${quot(returnFieldName)}]`, - }, - tsImportBuilder(['queryFromInfo']).from( - '@pothos/plugin-prisma', - ), - ), - }, + Object.fromEntries( + serviceArgs.map((arg) => [ + arg.name, + arg.type === 'injected' + ? handleInjectedArg(arg, { returnFieldName }).fragment + : writeValueFromPothosArg(arg, tsUtilsImports), + ]), + ), { disableSort: true }, ), }, @@ -223,7 +260,7 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ );`, { BUILDER: pothosTypesFile.getBuilderFragment(), - NAME: quot(mutationName), + NAME: quot(name), OPTIONS: TsCodeUtils.mergeFragmentsAsObject(fieldOptions, { disableSort: true, }), @@ -231,7 +268,7 @@ export const pothosPrismaCrudMutationGenerator = createGenerator({ ); pothosTypesFile.typeDefinitions.add({ - name: mutationName, + name, fragment: mutationFragment, order, }); diff --git a/packages/fastify-generators/src/generators/pothos/pothos-prisma/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/pothos/pothos-prisma/generated/ts-import-providers.ts index f150c98fa..4b265b8de 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos-prisma/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos-prisma/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { POTHOS_POTHOS_PRISMA_PATHS } from './template-paths.js'; -const pothosPrismaImportsSchema = createTsImportMapSchema({ +export const pothosPrismaImportsSchema = createTsImportMapSchema({ getDatamodel: {}, PrismaTypes: { isTypeOnly: true, exportedAs: 'default' }, }); diff --git a/packages/fastify-generators/src/generators/pothos/pothos-prisma/pothos-prisma.generator.ts b/packages/fastify-generators/src/generators/pothos/pothos-prisma/pothos-prisma.generator.ts index eee2bdb7b..cb0f81390 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos-prisma/pothos-prisma.generator.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos-prisma/pothos-prisma.generator.ts @@ -15,6 +15,7 @@ import { doubleQuot } from '@baseplate-dev/utils'; import { z } from 'zod'; import { FASTIFY_PACKAGES } from '#src/constants/fastify-packages.js'; +import { configServiceImportsProvider } from '#src/generators/core/index.js'; import { prismaImportsProvider, prismaSchemaProvider, @@ -49,11 +50,18 @@ export const pothosPrismaGenerator = createGenerator({ prismaImports: prismaImportsProvider, pothosPrismaImports: pothosPrismaImportsProvider, renderers: POTHOS_POTHOS_PRISMA_GENERATED.renderers.provider, + configServiceImports: configServiceImportsProvider, }, exports: { pothosPrisma: pothosPrismaProvider.export(packageScope), }, - run({ pothosConfig, prismaImports, pothosPrismaImports, renderers }) { + run({ + pothosConfig, + prismaImports, + pothosPrismaImports, + renderers, + configServiceImports, + }) { return { providers: { pothosPrisma: {}, @@ -79,6 +87,7 @@ export const pothosPrismaGenerator = createGenerator({ dmmf: ${pothosPrismaImports.getDatamodel.fragment()}(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: ${configServiceImports.config.fragment()}.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }`, ); await builder.apply(renderers.pothosPrismaTypes.render({})); diff --git a/packages/fastify-generators/src/generators/pothos/pothos/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/pothos/pothos/generated/ts-import-providers.ts index c711c37da..5c4d2b9e0 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { POTHOS_POTHOS_PATHS } from './template-paths.js'; -const pothosImportsSchema = createTsImportMapSchema({ builder: {} }); +export const pothosImportsSchema = createTsImportMapSchema({ builder: {} }); export type PothosImportsProvider = TsImportMapProviderFromSchema< typeof pothosImportsSchema diff --git a/packages/fastify-generators/src/generators/prisma/_providers/prisma-generated-imports.ts b/packages/fastify-generators/src/generators/prisma/_providers/prisma-generated-imports.ts index d08cbd760..d6738d765 100644 --- a/packages/fastify-generators/src/generators/prisma/_providers/prisma-generated-imports.ts +++ b/packages/fastify-generators/src/generators/prisma/_providers/prisma-generated-imports.ts @@ -7,6 +7,7 @@ export const prismaGeneratedImportsSchema = createTsImportMapSchema({ PrismaClient: {}, Prisma: {}, '*': {}, + $Enums: {}, }); export type PrismaGeneratedImportsProvider = TsImportMapProviderFromSchema< diff --git a/packages/fastify-generators/src/generators/prisma/_providers/providers.json b/packages/fastify-generators/src/generators/prisma/_providers/providers.json index e1b71ac7c..6cee18a2e 100644 --- a/packages/fastify-generators/src/generators/prisma/_providers/providers.json +++ b/packages/fastify-generators/src/generators/prisma/_providers/providers.json @@ -7,7 +7,8 @@ "projectExports": { "PrismaClient": {}, "Prisma": {}, - "*": {} + "*": {}, + "$Enums": {} } } } diff --git a/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-operation-callbacks.ts b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-operation-callbacks.ts new file mode 100644 index 000000000..66b552d22 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-operation-callbacks.ts @@ -0,0 +1,223 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { tsTemplate } from '@baseplate-dev/core-generators'; + +import type { PrismaOutputModel } from '#src/types/prisma-output.js'; + +import type { DataUtilsImportsProvider } from '../../data-utils/index.js'; + +import { generateRelationBuildData } from './generate-relation-build-data.js'; + +/** + * Configuration for generating create operation callback + */ +interface GenerateCreateCallbackConfig { + /** Prisma model to analyze for relations */ + prismaModel: PrismaOutputModel; + /** Field names that are included in the input */ + inputFieldNames: string[]; + /** Data utils imports provider for accessing relationHelpers fragments */ + dataUtilsImports: DataUtilsImportsProvider; + /** Prisma model variable name in camelCase (e.g., 'todoItem', 'user') */ + modelVariableName: string; +} + +/** + * Result of generating create operation callback + */ +interface GenerateCreateCallbackResult { + /** Complete create callback fragment: ({ tx, data, query }) => tx.model.create({...}) */ + createCallbackFragment: TsCodeFragment; +} + +/** + * Generates a create operation callback that transforms foreign key fields into Prisma relation objects + * + * @param config - Configuration including Prisma model, input fields, and model name + * @returns Result containing the create callback function fragment + * + * @example + * // No relations + * generateCreateCallback({...}) + * // Returns: ({ tx, data, query }) => tx.user.create({ data, ...query }) + * + * @example + * // With relations + * generateCreateCallback({...}) + * // Returns: ({ tx, data, query }) => + * // tx.todoItem.create({ + * // data: { + * // ...data, + * // assignee: relationHelpers.connectCreate({ id: data.assigneeId }), + * // todoList: relationHelpers.connectCreate({ id: data.todoListId }), + * // }, + * // ...query, + * // }) + */ +export function generateCreateCallback( + config: GenerateCreateCallbackConfig, +): GenerateCreateCallbackResult { + const { prismaModel, inputFieldNames, dataUtilsImports, modelVariableName } = + config; + + const { argumentFragment, returnFragment, passthrough } = + generateRelationBuildData({ + prismaModel, + inputFieldNames, + operationType: 'create', + dataUtilsImports, + }); + + if (passthrough) { + return { + createCallbackFragment: tsTemplate` + ({ tx, data, query }) => + tx.${modelVariableName}.create({ + data, + ...query, + }) + `, + }; + } + + return { + createCallbackFragment: tsTemplate` + ({ tx, data: ${argumentFragment}, query }) => + tx.${modelVariableName}.create({ + data: ${returnFragment}, + ...query, + }) + `, + }; +} + +/** + * Configuration for generating update operation callback + */ +interface GenerateUpdateCallbackConfig { + /** Prisma model to analyze for relations */ + prismaModel: PrismaOutputModel; + /** Field names that are included in the input */ + inputFieldNames: string[]; + /** Data utils imports provider for accessing relationHelpers fragments */ + dataUtilsImports: DataUtilsImportsProvider; + /** Prisma model variable name in camelCase (e.g., 'todoItem', 'user') */ + modelVariableName: string; +} + +/** + * Result of generating update operation callback + */ +interface GenerateUpdateCallbackResult { + /** Complete update callback fragment: ({ tx, where, data, query }) => tx.model.update({...}) */ + updateCallbackFragment: TsCodeFragment; +} + +/** + * Generates an update operation callback that transforms foreign key fields into Prisma relation objects + * + * @param config - Configuration including Prisma model, input fields, and model name + * @returns Result containing the update callback function fragment + * + * @example + * // No relations + * generateUpdateCallback({...}) + * // Returns: ({ tx, where, data, query }) => tx.user.update({ where, data, ...query }) + * + * @example + * // With relations + * generateUpdateCallback({...}) + * // Returns: ({ tx, where, data, query }) => + * // tx.todoItem.update({ + * // where, + * // data: { + * // ...data, + * // assignee: relationHelpers.connectUpdate({ id: data.assigneeId }), + * // todoList: relationHelpers.connectUpdate({ id: data.todoListId }), + * // }, + * // ...query, + * // }) + */ +export function generateUpdateCallback( + config: GenerateUpdateCallbackConfig, +): GenerateUpdateCallbackResult { + const { prismaModel, inputFieldNames, dataUtilsImports, modelVariableName } = + config; + + const { argumentFragment, returnFragment, passthrough } = + generateRelationBuildData({ + prismaModel, + inputFieldNames, + operationType: 'update', + dataUtilsImports, + }); + + if (passthrough) { + return { + updateCallbackFragment: tsTemplate` + ({ tx, where, data, query }) => + tx.${modelVariableName}.update({ + where, + data, + ...query, + }) + `, + }; + } + + return { + updateCallbackFragment: tsTemplate` + ({ tx, where, data: ${argumentFragment}, query }) => + tx.${modelVariableName}.update({ + where, + data: ${returnFragment}, + ...query, + }) + `, + }; +} + +/** + * Configuration for generating delete operation callback + */ +interface GenerateDeleteCallbackConfig { + /** Prisma model variable name in camelCase (e.g., 'todoItem', 'user') */ + modelVariableName: string; +} + +/** + * Result of generating delete operation callback + */ +interface GenerateDeleteCallbackResult { + /** Complete delete callback fragment: ({ tx, where, query }) => tx.model.delete({...}) */ + deleteCallbackFragment: TsCodeFragment; +} + +/** + * Generates a delete operation callback + * + * Delete operations don't need data transformation, so this simply generates + * a callback that passes through the where clause and query parameters. + * + * @param config - Configuration with model name + * @returns Result containing the delete callback function fragment + * + * @example + * generateDeleteCallback({ modelVariableName: 'todoItem' }) + * // Returns: ({ tx, where, query }) => tx.todoItem.delete({ where, ...query }) + */ +export function generateDeleteCallback( + config: GenerateDeleteCallbackConfig, +): GenerateDeleteCallbackResult { + const { modelVariableName } = config; + + return { + deleteCallbackFragment: tsTemplate` + ({ tx, where, query }) => + tx.${modelVariableName}.delete({ + where, + ...query, + }) + `, + }; +} diff --git a/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.ts b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.ts new file mode 100644 index 000000000..85a08e175 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.ts @@ -0,0 +1,347 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { TsCodeUtils, tsTemplate } from '@baseplate-dev/core-generators'; + +import type { + PrismaOutputModel, + PrismaOutputRelationField, +} from '#src/types/prisma-output.js'; + +import type { DataUtilsImportsProvider } from '../../data-utils/index.js'; + +/** + * Configuration for generating relation buildData function + */ +interface GenerateRelationBuildDataConfig { + /** Prisma model to analyze for relations */ + prismaModel: PrismaOutputModel; + /** Field names that are included in the input (to determine which relations to include) */ + inputFieldNames: string[]; + /** Operation type - determines whether to use connectCreate or connectUpdate */ + operationType: 'create' | 'update' | 'upsert'; + /** Data utils imports provider for accessing relationHelpers fragments */ + dataUtilsImports: DataUtilsImportsProvider; +} + +/** + * Result of generating relation buildData function + */ +interface GenerateRelationBuildDataResult { + /** Argument pattern for the function (e.g., "{ ownerId, ...data }" or "data") */ + argumentFragment: TsCodeFragment; + /** Return value with relation transformations (e.g., "{ ...data, owner: relationHelpers.connectCreate(...) }") */ + returnFragment: TsCodeFragment; + /** Whether this is a simple passthrough (no relations to transform) */ + passthrough: boolean; + /** Complete buildData function fragment: ({ fk1, fk2, ...data }) => ({ ...data, relation1: ..., relation2: ... }) */ + buildDataFunctionFragment: TsCodeFragment; +} + +/** + * Generates a TypeScript code fragment for a unique where object + * + * @param foreignKeyFields - Array of foreign key field names (e.g., ['ownerId'] or ['userId', 'tenantId']) + * @param referencedFields - Array of referenced field names in target model (e.g., ['id'] or ['id', 'tenantId']) + * @returns TypeScript code fragment for the where object + * + * @example + * // Single field + * generateUniqueWhereFragment(['ownerId'], ['id']) => { id: ownerId } + * + * @example + * // Composite key + * generateUniqueWhereFragment(['userId', 'tenantId'], ['id', 'tenantId']) => { id: userId, tenantId } + */ +function generateUniqueWhereFragment( + foreignKeyFields: string[], + referencedFields: string[], +): TsCodeFragment { + if (foreignKeyFields.length !== referencedFields.length) { + throw new Error( + `Foreign key fields and referenced fields must have the same length. Got ${foreignKeyFields.length} and ${referencedFields.length}`, + ); + } + + return TsCodeUtils.mergeFragmentsAsObject( + Object.fromEntries( + foreignKeyFields.map((fkField, index) => [ + referencedFields[index], + fkField, + ]), + ), + ); +} + +/** + * Finds all relations in the Prisma model that should be included based on input fields + */ +function findRelevantRelations( + prismaModel: PrismaOutputModel, + inputFieldNames: string[], +): PrismaOutputRelationField[] { + const relevantRelations = prismaModel.fields.filter( + (field): field is PrismaOutputRelationField => + field.type === 'relation' && + !!field.fields && + // Include relation if at least one of its foreign key fields is in the input + field.fields.some((fkField) => inputFieldNames.includes(fkField)), + ); + + for (const relation of relevantRelations) { + const missingFields = relation.fields?.filter( + (fkField) => !inputFieldNames.includes(fkField), + ); + if (missingFields?.length) { + throw new Error( + `Relation ${relation.name} requires all fields as inputs (missing ${missingFields.join(', ')})`, + ); + } + } + + return relevantRelations; +} + +/** + * Extracts all unique foreign key field names from relations + */ +function extractForeignKeyFields( + relations: PrismaOutputRelationField[], +): string[] { + const allFkFields = relations.flatMap((rel) => rel.fields ?? []); + // Remove duplicates (though this should rarely happen) + return [...new Set(allFkFields)]; +} + +/** + * Generates a relation helper call fragment for a single relation + * + * @param relation - Prisma relation field metadata + * @param operationType - Whether this is a create or update operation + * @param relationHelpersFragment - Code fragment for accessing relationHelpers + * @returns TypeScript code fragment for the relation helper call + * + * @example + * // Single field, create operation + * generateRelationHelperCall(...) => relationHelpers.connectCreate({ id: ownerId }) + * + * @example + * // Composite key, update operation + * generateRelationHelperCall(...) => relationHelpers.connectUpdate({ id: userId, tenantId }) + */ +function generateRelationHelperCall( + relation: PrismaOutputRelationField, + operationType: 'create' | 'update', + relationHelpersFragment: TsCodeFragment, +): TsCodeFragment { + const helperMethod = + operationType === 'create' ? 'connectCreate' : 'connectUpdate'; + const uniqueWhere = generateUniqueWhereFragment( + relation.fields ?? [], + relation.references ?? [], + ); + + return tsTemplate`${relationHelpersFragment}.${helperMethod}(${uniqueWhere})`; +} + +/** + * Generates the complete buildData function fragment + * + * @example + * // With foreign keys + * generateBuildDataFunction(['ownerId'], [...]) => ({ ownerId, ...data }) => ({ ...data, owner: ... }) + * + * @example + * // No foreign keys (pass-through) + * generateBuildDataFunction([], []) => (data) => data + * + * @example + * // All fields are foreign keys (no spread) + * generateBuildDataFunction(['ownerId', 'assigneeId'], [...], ['ownerId', 'assigneeId']) => + * ({ ownerId, assigneeId }) => ({ owner: ..., assignee: ... }) + */ +function generateBuildDataBody( + foreignKeyFields: string[], + operationType: 'create' | 'update', + dataUtilsImports: DataUtilsImportsProvider, + relevantRelations: PrismaOutputRelationField[], + allInputFieldNames?: string[], + dataName = 'data', +): { + argumentFragment: TsCodeFragment; + returnFragment: TsCodeFragment; + passthrough: boolean; +} { + if (relevantRelations.length === 0) { + return { + argumentFragment: tsTemplate`${dataName}`, + returnFragment: tsTemplate`${dataName}`, + passthrough: true, + }; + } + + const relationHelpersFragment = dataUtilsImports.relationHelpers.fragment(); + const relationFragments = relevantRelations.map((relation) => ({ + relationName: relation.name, + fragment: generateRelationHelperCall( + relation, + operationType, + relationHelpersFragment, + ), + })); + + const sortedForeignKeyFields = foreignKeyFields.toSorted(); + + // Determine if we need to spread remaining data + const allFieldsAreForeignKeys = allInputFieldNames?.every((field) => + foreignKeyFields.includes(field), + ); + + // Build function parameter: ({ fk1, fk2, ...data }) or ({ fk1, fk2 }) + const paramPattern = allFieldsAreForeignKeys + ? `{ ${sortedForeignKeyFields.join(', ')} }` + : `{ ${sortedForeignKeyFields.join(', ')}, ...${dataName} }`; + + // Build return object using mergeFragmentsAsObject + const returnObjectFragments: Record = {}; + + // Add spread for remaining data if not all fields are foreign keys + if (!allFieldsAreForeignKeys) { + returnObjectFragments[`...${dataName}`] = tsTemplate`${dataName}`; + } + + // Add relation fragments + const sortedRelationFragments = relationFragments.toSorted(); + for (const { relationName, fragment } of sortedRelationFragments) { + returnObjectFragments[relationName] = fragment; + } + + // Disable sorting when we have a spread key + const returnObject = TsCodeUtils.mergeFragmentsAsObject( + returnObjectFragments, + { disableSort: !allFieldsAreForeignKeys }, + ); + + return { + argumentFragment: tsTemplate`${paramPattern}`, + returnFragment: returnObject, + passthrough: false, + }; +} + +/** + * Generates buildData function that transforms foreign key fields into Prisma relation objects + * + * This helper analyzes a Prisma model to find relations whose foreign key fields are included + * in the input, then generates a buildData function that destructures those FK fields and + * uses relationHelpers to build the appropriate Prisma connect/disconnect objects. + * + * @param config - Configuration including Prisma model, input fields, and operation type + * @returns Result containing FK fields, relation mappings, and the buildData function fragment + * + * @example + * // Single relation + * generateRelationBuildData({ + * prismaModel: { fields: [...] }, + * inputFieldNames: ['name', 'ownerId'], + * operationType: 'create', + * dataUtilsImports, + * }) + * // Returns: buildData: ({ ownerId, ...data }) => ({ ...data, owner: relationHelpers.connectCreate({ id: ownerId }) }) + * + * @example + * // Multiple relations + * generateRelationBuildData({ + * prismaModel: { fields: [...] }, + * inputFieldNames: ['text', 'todoListId', 'assigneeId'], + * operationType: 'create', + * dataUtilsImports, + * }) + * // Returns: buildData: ({ todoListId, assigneeId, ...data }) => ({ ...data, todoList: ..., assignee: ... }) + * + * @example + * // Composite key relation + * generateRelationBuildData({ + * prismaModel: { fields: [...] }, + * inputFieldNames: ['name', 'userId', 'tenantId'], + * operationType: 'create', + * dataUtilsImports, + * }) + * // Returns: buildData: ({ userId, tenantId, ...data }) => ({ ...data, owner: relationHelpers.connectCreate({ id: userId, tenantId }) }) + * + * @example + * // No relations (pass-through) + * generateRelationBuildData({ + * prismaModel: { fields: [...] }, + * inputFieldNames: ['name', 'description'], + * operationType: 'create', + * dataUtilsImports, + * }) + * // Returns: buildData: (data) => data + */ +export function generateRelationBuildData( + config: GenerateRelationBuildDataConfig, +): GenerateRelationBuildDataResult { + const { prismaModel, inputFieldNames, operationType, dataUtilsImports } = + config; + + // Find all relations that have at least one FK field in the input + const relevantRelations = findRelevantRelations(prismaModel, inputFieldNames); + + // Extract all foreign key field names + const foreignKeyFieldNames = extractForeignKeyFields(relevantRelations); + + // Generate the complete buildData function + if (operationType === 'upsert') { + const createDataBody = generateBuildDataBody( + foreignKeyFieldNames, + 'create', + dataUtilsImports, + relevantRelations, + inputFieldNames, + 'createData', + ); + const updateDataBody = generateBuildDataBody( + foreignKeyFieldNames, + 'update', + dataUtilsImports, + relevantRelations, + inputFieldNames, + 'updateData', + ); + + if (createDataBody.passthrough && updateDataBody.passthrough) { + return { + argumentFragment: tsTemplate`data`, + returnFragment: tsTemplate`data`, + passthrough: true, + buildDataFunctionFragment: tsTemplate`(data) => data`, + }; + } + + // For upsert with relations, we don't expose individual fragments since the structure is complex + // Consumers should use buildDataFunctionFragment directly + return { + argumentFragment: tsTemplate`{ create: ${createDataBody.argumentFragment}, update: ${updateDataBody.argumentFragment}}`, + returnFragment: tsTemplate`{ create: ${createDataBody.returnFragment}, update: ${updateDataBody.returnFragment} }`, + passthrough: false, + buildDataFunctionFragment: tsTemplate` + ({ create: ${createDataBody.argumentFragment}, update: ${updateDataBody.argumentFragment}}) => + ({ create: ${createDataBody.returnFragment}, update: ${updateDataBody.returnFragment} })`, + }; + } else { + const buildDataBody = generateBuildDataBody( + foreignKeyFieldNames, + operationType, + dataUtilsImports, + relevantRelations, + inputFieldNames, + ); + + return { + argumentFragment: buildDataBody.argumentFragment, + returnFragment: buildDataBody.returnFragment, + passthrough: buildDataBody.passthrough, + buildDataFunctionFragment: tsTemplate`(${buildDataBody.argumentFragment}) => (${buildDataBody.returnFragment})`, + }; + } +} diff --git a/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.unit.test.ts b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.unit.test.ts new file mode 100644 index 000000000..04d72d96c --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/generate-relation-build-data.unit.test.ts @@ -0,0 +1,321 @@ +import { createTestTsImportMap } from '@baseplate-dev/core-generators/test-helpers'; +import { describe, expect, it } from 'vitest'; + +import type { PrismaOutputModel } from '#src/types/prisma-output.js'; + +import { + createMockRelationField, + createMockScalarField, +} from '#src/types/prisma-output.test-helper.js'; + +import { dataUtilsImportsSchema } from '../../data-utils/generated/ts-import-providers.js'; +import { generateRelationBuildData } from './generate-relation-build-data.js'; + +const mockDataUtilsImports = createTestTsImportMap( + dataUtilsImportsSchema, + 'data-utils', +); + +describe('generateRelationBuildData', () => { + describe('single relation with single FK field', () => { + it('should generate buildData for a single required relation', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoList', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('ownerId'), + createMockRelationField('owner', ['ownerId'], ['id']), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'ownerId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ ownerId, ...data }) => ({...data, + owner: relationHelpers.connectCreate({id: ownerId,}),})" + `); + }); + + it('should generate buildData for a single optional relation', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoItem', + fields: [ + createMockScalarField('id'), + createMockScalarField('text'), + createMockScalarField('assigneeId'), + createMockRelationField('assignee', ['assigneeId'], ['id'], true), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['text', 'assigneeId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ assigneeId, ...data }) => ({...data, + assignee: relationHelpers.connectCreate({id: assigneeId,}),})" + `); + }); + }); + + describe('multiple relations', () => { + it('should generate buildData for multiple relations', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoItem', + fields: [ + createMockScalarField('id'), + createMockScalarField('text'), + createMockScalarField('todoListId'), + createMockScalarField('assigneeId'), + createMockRelationField('todoList', ['todoListId'], ['id']), + createMockRelationField('assignee', ['assigneeId'], ['id'], true), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['text', 'todoListId', 'assigneeId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ assigneeId, todoListId, ...data }) => ({...data, + todoList: relationHelpers.connectCreate({id: todoListId,}), + assignee: relationHelpers.connectCreate({id: assigneeId,}),})" + `); + }); + }); + + describe('composite key relations', () => { + it('should generate buildData for a composite key relation', () => { + const prismaModel: PrismaOutputModel = { + name: 'Resource', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('userId'), + createMockScalarField('tenantId'), + createMockRelationField( + 'owner', + ['userId', 'tenantId'], + ['id', 'tenantId'], + ), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'userId', 'tenantId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ tenantId, userId, ...data }) => ({...data, + owner: relationHelpers.connectCreate({id: userId, + tenantId,}),})" + `); + }); + + it('should use shorthand syntax when FK field matches reference field', () => { + const prismaModel: PrismaOutputModel = { + name: 'Resource', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('tenantId'), + createMockScalarField('organizationId'), + createMockRelationField( + 'owner', + ['tenantId', 'organizationId'], + ['tenantId', 'organizationId'], + ), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'tenantId', 'organizationId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + // Should use shorthand syntax for matching field names + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ organizationId, tenantId, ...data }) => ({...data, + owner: relationHelpers.connectCreate({organizationId, + tenantId,}),})" + `); + }); + }); + + describe('no relations', () => { + it('should generate pass-through function when no FK fields in input', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoList', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('ownerId'), + createMockRelationField('owner', ['ownerId'], ['id']), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name'], // ownerId not included + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + const generatedCode = result.buildDataFunctionFragment.contents; + expect(generatedCode).toBe('(data) => (data)'); + }); + + it('should generate pass-through function when model has no relations', () => { + const prismaModel: PrismaOutputModel = { + name: 'Simple', + fields: [createMockScalarField('id'), createMockScalarField('name')], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + const generatedCode = result.buildDataFunctionFragment.contents; + expect(generatedCode).toBe('(data) => (data)'); + }); + }); + + describe('all fields are foreign keys', () => { + it('should not include spread operator when all input fields are FKs', () => { + const prismaModel: PrismaOutputModel = { + name: 'Junction', + fields: [ + createMockScalarField('userId'), + createMockScalarField('projectId'), + createMockRelationField('user', ['userId'], ['id']), + createMockRelationField('project', ['projectId'], ['id']), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['userId', 'projectId'], // Only FK fields + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + // Should not include spread operator when all input fields are FKs + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ projectId, userId }) => ({project: relationHelpers.connectCreate({id: projectId,}), + user: relationHelpers.connectCreate({id: userId,}),})" + `); + }); + }); + + describe('operation types', () => { + it('should use connectCreate for create operations', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoList', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('ownerId'), + createMockRelationField('owner', ['ownerId'], ['id']), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'ownerId'], + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ ownerId, ...data }) => ({...data, + owner: relationHelpers.connectCreate({id: ownerId,}),})" + `); + }); + + it('should use connectUpdate for update operations', () => { + const prismaModel: PrismaOutputModel = { + name: 'TodoList', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('ownerId'), + createMockRelationField('owner', ['ownerId'], ['id']), + ], + idFields: null, + }; + + const result = generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'ownerId'], + operationType: 'update', + dataUtilsImports: mockDataUtilsImports, + }); + + expect(result.buildDataFunctionFragment.contents).toMatchInlineSnapshot(` + "({ ownerId, ...data }) => ({...data, + owner: relationHelpers.connectUpdate({id: ownerId,}),})" + `); + }); + }); + + describe('partial FK fields', () => { + it('should include relation if at least one FK field is in input', () => { + const prismaModel: PrismaOutputModel = { + name: 'Resource', + fields: [ + createMockScalarField('id'), + createMockScalarField('name'), + createMockScalarField('userId'), + createMockScalarField('tenantId'), + createMockRelationField( + 'owner', + ['userId', 'tenantId'], + ['id', 'tenantId'], + ), + ], + idFields: null, + }; + + // This should throw an error because not all FK fields are provided + expect(() => + generateRelationBuildData({ + prismaModel, + inputFieldNames: ['name', 'userId'], // tenantId not included - missing required FK field + operationType: 'create', + dataUtilsImports: mockDataUtilsImports, + }), + ).toThrow( + 'Relation owner requires all fields as inputs (missing tenantId)', + ); + }); + }); +}); diff --git a/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/index.ts b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/index.ts new file mode 100644 index 000000000..46098e2b9 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/build-data-helpers/index.ts @@ -0,0 +1,2 @@ +export * from './generate-operation-callbacks.js'; +export * from './generate-relation-build-data.js'; diff --git a/packages/fastify-generators/src/generators/prisma/_shared/crud-method/data-method.ts b/packages/fastify-generators/src/generators/prisma/_shared/crud-method/data-method.ts deleted file mode 100644 index a256523bd..000000000 --- a/packages/fastify-generators/src/generators/prisma/_shared/crud-method/data-method.ts +++ /dev/null @@ -1,398 +0,0 @@ -import type { - TsCodeFragment, - TsHoistedFragment, -} from '@baseplate-dev/core-generators'; - -import { - tsCodeFragment, - TsCodeUtils, - tsHoistedFragment, - tsTemplate, -} from '@baseplate-dev/core-generators'; -import { notEmpty, safeMergeAllWithOptions } from '@baseplate-dev/utils'; -import { sortBy } from 'es-toolkit'; - -import type { ServiceContextImportsProvider } from '#src/generators/core/service-context/index.js'; -import type { - PrismaDataTransformer, - PrismaDataTransformOutputField, -} from '#src/providers/prisma/prisma-data-transformable.js'; -import type { PrismaOutputRelationField } from '#src/types/prisma-output.js'; -import type { ServiceOutputDto } from '#src/types/service-output.js'; - -import { upperCaseFirst } from '#src/utils/case.js'; - -import type { PrismaGeneratedImportsProvider } from '../../_providers/prisma-generated-imports.js'; -import type { PrismaUtilsImportsProvider } from '../../prisma-utils/index.js'; -import type { PrismaOutputProvider } from '../../prisma/index.js'; - -export interface PrismaDataMethodOptions { - name: string; - modelName: string; - prismaFieldNames: string[]; - prismaOutput: PrismaOutputProvider; - operationName: 'create' | 'update'; - operationType: 'create' | 'upsert' | 'update'; - whereUniqueExpression: string | null; - // optionally check parent ID matches existing item - parentIdCheckField?: string; - isPartial: boolean; - transformers: PrismaDataTransformer[]; - serviceContextImports: ServiceContextImportsProvider; - prismaUtils: PrismaUtilsImportsProvider; - prismaGeneratedImports: PrismaGeneratedImportsProvider; -} - -export function getDataMethodContextRequired({ - transformers, -}: Pick): boolean { - return transformers.some((t) => t.needsContext); -} - -export function wrapWithApplyDataPipe( - operation: TsCodeFragment, - pipeNames: string[], - prismaUtils: PrismaUtilsImportsProvider, -): TsCodeFragment { - if (pipeNames.length === 0) { - return operation; - } - return TsCodeUtils.templateWithImports( - prismaUtils.applyDataPipeOutput.declaration(), - )`applyDataPipeOutput([${pipeNames.join(', ')}], ${operation})`; -} - -export function getDataMethodDataType({ - modelName, - prismaFieldNames, - prismaOutput, - operationName, - isPartial, - transformers, -}: Omit): ServiceOutputDto { - const prismaDefinition = prismaOutput.getPrismaModel(modelName); - const prismaFields = prismaFieldNames.map((fieldName) => { - const field = prismaDefinition.fields.find((f) => f.name === fieldName); - if (!field) { - throw new Error( - `Could not find field ${fieldName} in model ${modelName}`, - ); - } - return field; - }); - const transformerFields = transformers.flatMap((transformer) => - transformer.inputFields.map((f) => f.dtoField), - ); - return { - name: `${modelName}${upperCaseFirst(operationName)}Data`, - fields: [ - ...prismaFields.map((field) => { - if (field.type !== 'scalar') { - throw new Error( - `Non-scalar fields not suppported in data method operation`, - ); - } - return { - type: 'scalar' as const, - name: field.name, - isList: field.isList, - scalarType: field.scalarType, - enumType: field.enumType - ? prismaOutput.getServiceEnum(field.enumType) - : undefined, - ...(isPartial - ? { isOptional: true, isNullable: field.isOptional } - : { - isOptional: field.isOptional || field.hasDefault, - isNullable: field.isOptional, - }), - }; - }), - ...transformerFields, - ], - }; -} - -export function getDataInputTypeBlock( - dataInputTypeName: string, - { - modelName, - prismaFieldNames, - operationName, - transformers, - prismaGeneratedImports, - }: Omit, -): TsHoistedFragment { - const prismaFieldSelection = prismaFieldNames - .map((field) => `'${field}'`) - .join(' | '); - - const transformerInputs = transformers.flatMap( - (transformer) => transformer.inputFields, - ); - - let prismaDataInput = tsCodeFragment( - `Prisma.${modelName}UncheckedCreateInput`, - prismaGeneratedImports.Prisma.typeDeclaration(), - ); - prismaDataInput = - operationName === 'create' - ? prismaDataInput - : tsTemplate`Partial<${prismaDataInput}>`; - - if (transformerInputs.length === 0) { - return tsHoistedFragment( - dataInputTypeName, - tsTemplate`type ${dataInputTypeName} = Pick<${prismaDataInput}, ${prismaFieldSelection}>;`, - ); - } - - const customFields = safeMergeAllWithOptions( - transformers.flatMap((transformer) => - transformer.inputFields.map((f) => ({ - [`${f.dtoField.name}${f.dtoField.isOptional ? '?' : ''}`]: f.type, - })), - ), - ); - - return tsHoistedFragment( - dataInputTypeName, - tsTemplate` - interface ${dataInputTypeName} extends Pick<${prismaDataInput}, ${prismaFieldSelection}> { - ${TsCodeUtils.mergeFragmentsAsInterfaceContent(customFields)} - }`, - ); -} - -export function getDataMethodDataExpressions({ - transformers, - operationType, - whereUniqueExpression, - parentIdCheckField, - prismaOutput, - modelName, - prismaFieldNames, - prismaUtils, -}: Pick< - PrismaDataMethodOptions, - | 'prismaOutput' - | 'modelName' - | 'transformers' - | 'operationType' - | 'whereUniqueExpression' - | 'parentIdCheckField' - | 'prismaFieldNames' - | 'prismaUtils' ->): { - functionBody: TsCodeFragment | string; - createExpression: TsCodeFragment; - updateExpression: TsCodeFragment; - dataPipeNames: string[]; -} { - if (transformers.length === 0) { - return { - functionBody: '', - createExpression: tsCodeFragment('data'), - updateExpression: tsCodeFragment('data'), - dataPipeNames: [], - }; - } - - // if there are transformers, try to use the CheckedDataInput instead of Unchecked to allow nested creations - const outputModel = prismaOutput.getPrismaModel(modelName); - const relationFields = outputModel.fields.filter( - (field): field is PrismaOutputRelationField => - field.type === 'relation' && - !!field.fields && - field.fields.some((relationScalarField) => - prismaFieldNames.includes(relationScalarField), - ), - ); - - const relationTransformers = relationFields.map( - (field): PrismaDataTransformer => { - const relationScalarFields = field.fields ?? []; - const missingFields = relationScalarFields.filter( - (f) => !prismaFieldNames.includes(f), - ); - if (missingFields.length > 0) { - throw new Error( - `Relation named ${ - field.name - } requires all fields as inputs (missing ${missingFields.join( - ', ', - )})`, - ); - } - - // create pseudo-transformer for relation fields - const transformerPrefix = - operationType === 'update' || field.isOptional - ? `${relationScalarFields - .map((f) => `${f} == null`) - .join(' || ')} ? ${ - operationType === 'create' - ? 'undefined' - : relationScalarFields.join(' && ') - } : ` - : ''; - - const foreignModel = prismaOutput.getPrismaModel(field.modelType); - const foreignIdFields = foreignModel.idFields; - - if (!foreignIdFields?.length) { - throw new Error(`Foreign model has to have primary key`); - } - - const uniqueWhereValue = TsCodeUtils.mergeFragmentsAsObject( - Object.fromEntries( - foreignIdFields.map((idField): [string, string] => { - const idx = field.references?.findIndex( - (refName) => refName === idField, - ); - if (idx == null || idx === -1) { - throw new Error( - `Relation ${field.name} must have a reference to the primary key of ${field.modelType}`, - ); - } - const localField = relationScalarFields[idx]; - return [idField, localField]; - }), - ), - ); - - const uniqueWhere = - foreignIdFields.length > 1 - ? tsTemplate`{ ${foreignIdFields.join('_')}: ${uniqueWhereValue}}` - : uniqueWhereValue; - - const transformer = tsTemplate`const ${field.name} = ${transformerPrefix} { connect: ${uniqueWhere} }`; - - return { - inputFields: relationScalarFields.map((f) => ({ - type: tsCodeFragment(''), - dtoField: { name: f, type: 'scalar', scalarType: 'string' }, - })), - outputFields: [ - { - name: field.name, - transformer, - createExpression: - operationType === 'upsert' - ? `${field.name} || undefined` - : undefined, - updateExpression: field.isOptional - ? tsCodeFragment( - `createPrismaDisconnectOrConnectData(${field.name})`, - prismaUtils.createPrismaDisconnectOrConnectData.declaration(), - ) - : undefined, - }, - ], - isAsync: false, - }; - }, - ); - - const augmentedTransformers = [...transformers, ...relationTransformers]; - - const customInputs = augmentedTransformers.flatMap((t) => - t.inputFields.map((f) => f.dtoField.name), - ); - - const needsExistingItem = - operationType !== 'create' && - augmentedTransformers.some((t) => t.needsExistingItem); - - const existingItemGetter = needsExistingItem - ? TsCodeUtils.formatFragment( - ` -const existingItem = OPTIONAL_WHERE -(await PRISMA_MODEL.findUniqueOrThrow({ where: WHERE_UNIQUE })) -`, - { - OPTIONAL_WHERE: - // TODO: Make it a bit more flexible - operationType === 'upsert' && whereUniqueExpression - ? `${whereUniqueExpression} && ` - : '', - PRISMA_MODEL: prismaOutput.getPrismaModelFragment(modelName), - WHERE_UNIQUE: whereUniqueExpression ?? '', - }, - ) - : tsCodeFragment(''); - - const parentIdCheck = - parentIdCheckField && - ` - if (existingItem && existingItem.${parentIdCheckField} !== parentId) { - throw new Error('${modelName} not attached to the correct parent item'); - } - `; - - const functionBody = TsCodeUtils.formatFragment( - `const { CUSTOM_INPUTS, ...rest } = data; - - EXISTING_ITEM_GETTER - - PARENT_ID_CHECK - -TRANSFORMERS`, - { - CUSTOM_INPUTS: customInputs.join(', '), - EXISTING_ITEM_GETTER: existingItemGetter, - PARENT_ID_CHECK: parentIdCheck ?? '', - TRANSFORMERS: TsCodeUtils.mergeFragments( - new Map( - augmentedTransformers - .flatMap((t) => - t.outputFields.map( - (f): [string, TsCodeFragment] | undefined => - f.transformer && [f.name, f.transformer], - ), - ) - .filter(notEmpty), - ), - '\n\n', - ), - }, - ); - - function createExpressionEntries( - expressionExtractor: ( - field: PrismaDataTransformOutputField, - ) => TsCodeFragment | string | undefined, - ): TsCodeFragment { - const dataExpressionEntries = [ - ...sortBy( - augmentedTransformers.flatMap((t) => - t.outputFields.map((f): [string, TsCodeFragment | string] => [ - f.name, - expressionExtractor(f) ?? - (f.pipeOutputName ? `${f.pipeOutputName}.data` : f.name), - ]), - ), - [([name]) => name], - ), - ['...', 'rest'] as [string, string], - ]; - - return TsCodeUtils.mergeFragmentsAsObject( - Object.fromEntries(dataExpressionEntries), - { disableSort: true }, - ); - } - - const createExpression = createExpressionEntries((f) => f.createExpression); - - const updateExpression = createExpressionEntries((f) => f.updateExpression); - - return { - functionBody, - createExpression, - updateExpression, - dataPipeNames: transformers.flatMap((t) => - t.outputFields.map((f) => f.pipeOutputName).filter(notEmpty), - ), - }; -} diff --git a/packages/fastify-generators/src/generators/prisma/_shared/crud-method/primary-key-input.ts b/packages/fastify-generators/src/generators/prisma/_shared/crud-method/primary-key-input.ts index 47479c206..5251d4604 100644 --- a/packages/fastify-generators/src/generators/prisma/_shared/crud-method/primary-key-input.ts +++ b/packages/fastify-generators/src/generators/prisma/_shared/crud-method/primary-key-input.ts @@ -1,20 +1,6 @@ -import type { TsHoistedFragment } from '@baseplate-dev/core-generators'; - -import { tsHoistedFragment } from '@baseplate-dev/core-generators'; -import { quot } from '@baseplate-dev/utils'; - import type { PrismaOutputModel } from '#src/types/prisma-output.js'; import type { ServiceOutputDtoField } from '#src/types/service-output.js'; -import { getScalarFieldTypeInfo } from '#src/types/field-types.js'; - -interface PrimaryKeyOutput { - argumentName: string; - whereClause: string; - headerTypeBlock?: TsHoistedFragment; - argumentType: string; -} - export function getPrimaryKeyDefinition( model: PrismaOutputModel, ): ServiceOutputDtoField { @@ -79,48 +65,3 @@ export function getModelIdFieldName(model: PrismaOutputModel): string { // handle multiple primary key case return idFields.join('_'); } - -export function getPrimaryKeyExpressions( - model: PrismaOutputModel, -): PrimaryKeyOutput { - const { idFields, fields } = model; - if (!idFields?.length) { - throw new Error(`Model ${model.name} has no primary key`); - } - - const idFieldName = getModelIdFieldName(model); - - if (idFields.length === 1) { - // handle trivial one primary key case - const idField = fields.find((f) => f.name === idFieldName); - - if (!idField || idField.type !== 'scalar') { - throw new Error(`Model ${model.name} must have a scalar primary key`); - } - - const argumentType = getScalarFieldTypeInfo( - idField.scalarType, - ).typescriptType; - - return { - argumentName: idFieldName, - whereClause: `{ ${idFieldName} }`, - argumentType, - }; - } - - // handle multiple primary key case - const primaryKeyInputName = `${model.name}PrimaryKey`; - - const headerTypeBlock = tsHoistedFragment( - `input-type:${primaryKeyInputName}`, - `export type ${primaryKeyInputName} = Pick<${model.name}, ${idFields.map(quot).join(' | ')}>`, - ); - - return { - argumentName: idFieldName, - whereClause: `{ ${idFieldName} }`, - headerTypeBlock, - argumentType: primaryKeyInputName, - }; -} diff --git a/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.ts b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.ts new file mode 100644 index 000000000..65de1d455 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.ts @@ -0,0 +1,86 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { TsCodeUtils, tsTemplate } from '@baseplate-dev/core-generators'; + +import type { ScalarFieldType } from '#src/types/field-types.js'; +import type { PrismaOutputScalarField } from '#src/types/prisma-output.js'; +import type { ServiceOutputEnum } from '#src/types/service-output.js'; + +import { scalarPrismaFieldToServiceInputField } from '#src/types/service-output.js'; + +import type { PrismaGeneratedImportsProvider } from '../../_providers/prisma-generated-imports.js'; +import type { DataUtilsImportsProvider } from '../../data-utils/index.js'; +import type { InputFieldDefinitionOutput } from './types.js'; + +/** + * Configuration for generating a scalar field definition + */ +interface GenerateScalarFieldConfig { + /** Name of the field */ + fieldName: string; + /** Prisma scalar field */ + scalarField: PrismaOutputScalarField; + /** Data utils imports */ + dataUtilsImports: DataUtilsImportsProvider; + /** Prisma generated imports */ + prismaGeneratedImports: PrismaGeneratedImportsProvider; + /** Lookup function for enums */ + lookupEnum: (name: string) => ServiceOutputEnum; +} + +const SCALAR_TYPE_TO_ZOD_TYPE: Record = { + string: 'string()', + int: 'number().int()', + float: 'number()', + decimal: 'number()', + boolean: 'boolean()', + date: 'date()', + dateTime: 'date()', + json: 'unknown()', + jsonObject: 'record(z.unknown())', + uuid: 'string().uuid()', + enum: '', +}; + +function generateValidator({ + scalarField, + prismaGeneratedImports, +}: GenerateScalarFieldConfig): TsCodeFragment { + const { scalarType, enumType, isOptional, hasDefault } = scalarField; + const zFrag = TsCodeUtils.importFragment('z', 'zod'); + + // Determine the modifier: optional => nullish(), hasDefault => optional(), else => none + let modifier = ''; + if (isOptional) { + modifier = '.nullish()'; + } else if (hasDefault) { + modifier = '.optional()'; + } + + if (scalarType === 'enum') { + if (!enumType) { + throw new Error('Enum name is required for enum scalar type'); + } + const enumFrag = prismaGeneratedImports.$Enums.fragment(); + return tsTemplate`${zFrag}.nativeEnum(${enumFrag}.${enumType})${modifier}`; + } + + return tsTemplate`${zFrag}.${SCALAR_TYPE_TO_ZOD_TYPE[scalarType]}${modifier}`; +} + +/** + * Generates a scalar field definition, e.g. scalarField(z.string()) + */ +export function generateScalarInputField( + config: GenerateScalarFieldConfig, +): InputFieldDefinitionOutput { + const validator = generateValidator(config); + return { + name: config.fieldName, + fragment: tsTemplate`${config.dataUtilsImports.scalarField.fragment()}(${validator})`, + outputDtoField: scalarPrismaFieldToServiceInputField( + config.scalarField, + config.lookupEnum, + ), + }; +} diff --git a/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.unit.test.ts b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.unit.test.ts new file mode 100644 index 000000000..f93ee4d24 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/generate-scalar-input-field.unit.test.ts @@ -0,0 +1,417 @@ +import { createTestTsImportMap } from '@baseplate-dev/core-generators/test-helpers'; +import { describe, expect, it } from 'vitest'; + +import type { ServiceOutputEnum } from '#src/types/service-output.js'; + +import { prismaGeneratedImportsSchema } from '../../_providers/prisma-generated-imports.js'; +import { dataUtilsImportsSchema } from '../../data-utils/generated/ts-import-providers.js'; +import { generateScalarInputField } from './generate-scalar-input-field.js'; + +describe('generateScalarInputField', () => { + const dataUtilsImports = createTestTsImportMap( + dataUtilsImportsSchema, + 'data-utils', + ); + + const prismaGeneratedImports = createTestTsImportMap( + prismaGeneratedImportsSchema, + 'prisma', + ); + + const baseScalarField = { + name: 'field', + id: false, + type: 'scalar' as const, + isOptional: false, + isList: false, + hasDefault: false, + order: 0, + }; + + const lookupEnum: (name: string) => ServiceOutputEnum = (name) => ({ + name, + values: [], + expression: prismaGeneratedImports.$Enums.fragment(), + }); + + describe('scalar types', () => { + it('generates scalarField call for string type', () => { + const result = generateScalarInputField({ + fieldName: 'name', + scalarField: { ...baseScalarField, name: 'name', scalarType: 'string' }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.name).toBe('name'); + expect(result.fragment.contents).toBe('scalarField(z.string())'); + expect(result.fragment).toIncludeImport('z', 'zod'); + expect(result.fragment).toIncludeImport( + 'scalarField', + 'data-utils/scalarField', + ); + }); + + it('generates scalarField call for int type', () => { + const result = generateScalarInputField({ + fieldName: 'age', + scalarField: { ...baseScalarField, name: 'age', scalarType: 'int' }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.number().int())'); + }); + + it('generates scalarField call for float type', () => { + const result = generateScalarInputField({ + fieldName: 'weight', + scalarField: { + ...baseScalarField, + name: 'weight', + scalarType: 'float', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.number())'); + }); + + it('generates scalarField call for decimal type', () => { + const result = generateScalarInputField({ + fieldName: 'height', + scalarField: { + ...baseScalarField, + name: 'height', + scalarType: 'decimal', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.number())'); + }); + + it('generates scalarField call for boolean type', () => { + const result = generateScalarInputField({ + fieldName: 'isActive', + scalarField: { + ...baseScalarField, + name: 'isActive', + scalarType: 'boolean', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.boolean())'); + }); + + it('generates scalarField call for date type', () => { + const result = generateScalarInputField({ + fieldName: 'createdAt', + scalarField: { + ...baseScalarField, + name: 'createdAt', + scalarType: 'date', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.date())'); + }); + + it('generates scalarField call for dateTime type', () => { + const result = generateScalarInputField({ + fieldName: 'updatedAt', + scalarField: { + ...baseScalarField, + name: 'updatedAt', + scalarType: 'dateTime', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.date())'); + }); + + it('generates scalarField call for json type', () => { + const result = generateScalarInputField({ + fieldName: 'data', + scalarField: { ...baseScalarField, name: 'data', scalarType: 'json' }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.unknown())'); + }); + + it('generates scalarField call for jsonObject type', () => { + const result = generateScalarInputField({ + fieldName: 'metadata', + scalarField: { + ...baseScalarField, + name: 'metadata', + scalarType: 'jsonObject', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.record(z.unknown()))', + ); + }); + + it('generates scalarField call for uuid type', () => { + const result = generateScalarInputField({ + fieldName: 'id', + scalarField: { ...baseScalarField, name: 'id', scalarType: 'uuid' }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe('scalarField(z.string().uuid())'); + }); + }); + + describe('optional fields', () => { + it('generates scalarField with nullish for optional string', () => { + const result = generateScalarInputField({ + fieldName: 'name', + scalarField: { + ...baseScalarField, + name: 'name', + scalarType: 'string', + isOptional: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.string().nullish())', + ); + }); + + it('generates scalarField with nullish for optional int', () => { + const result = generateScalarInputField({ + fieldName: 'age', + scalarField: { + ...baseScalarField, + name: 'age', + scalarType: 'int', + isOptional: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.number().int().nullish())', + ); + }); + }); + + describe('fields with defaults', () => { + it('generates scalarField with optional for string with default', () => { + const result = generateScalarInputField({ + fieldName: 'name', + scalarField: { + ...baseScalarField, + name: 'name', + scalarType: 'string', + hasDefault: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.string().optional())', + ); + }); + + it('generates scalarField with optional for int with default', () => { + const result = generateScalarInputField({ + fieldName: 'age', + scalarField: { + ...baseScalarField, + name: 'age', + scalarType: 'int', + hasDefault: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.number().int().optional())', + ); + }); + + it('generates scalarField with optional for enum with default', () => { + const result = generateScalarInputField({ + fieldName: 'status', + scalarField: { + ...baseScalarField, + name: 'status', + scalarType: 'enum', + enumType: 'Status', + hasDefault: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.nativeEnum($Enums.Status).optional())', + ); + }); + + it('prioritizes nullish over optional when both isOptional and hasDefault are true', () => { + const result = generateScalarInputField({ + fieldName: 'name', + scalarField: { + ...baseScalarField, + name: 'name', + scalarType: 'string', + isOptional: true, + hasDefault: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.string().nullish())', + ); + }); + }); + + describe('enum type', () => { + it('generates scalarField with nativeEnum for enum type', () => { + const result = generateScalarInputField({ + fieldName: 'status', + scalarField: { + ...baseScalarField, + name: 'status', + scalarType: 'enum', + enumType: 'Status', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.nativeEnum($Enums.Status))', + ); + expect(result.fragment).toIncludeImport('z', 'zod'); + expect(result.fragment).toIncludeImport('$Enums', 'prisma/$Enums'); + expect(result.fragment).toIncludeImport( + 'scalarField', + 'data-utils/scalarField', + ); + }); + + it('generates scalarField with nullish for optional enum', () => { + const result = generateScalarInputField({ + fieldName: 'status', + scalarField: { + ...baseScalarField, + name: 'status', + scalarType: 'enum', + enumType: 'Status', + isOptional: true, + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + expect(result.fragment.contents).toBe( + 'scalarField(z.nativeEnum($Enums.Status).nullish())', + ); + }); + + it('throws error when enum name is missing for enum type', () => { + expect(() => + generateScalarInputField({ + fieldName: 'status', + scalarField: { + ...baseScalarField, + name: 'status', + scalarType: 'enum', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }), + ).toThrow('Enum name is required for enum scalar type'); + }); + }); + + describe('imports', () => { + it('includes all required imports', () => { + const result = generateScalarInputField({ + fieldName: 'name', + scalarField: { ...baseScalarField, name: 'name', scalarType: 'string' }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + // Should have 2 imports: z from zod, scalarField from data-utils/scalarField + expect(result.fragment.imports).toHaveLength(2); + expect(result.fragment).toIncludeImport('z', 'zod'); + expect(result.fragment).toIncludeImport( + 'scalarField', + 'data-utils/scalarField', + ); + }); + + it('includes $Enums import for enum types', () => { + const result = generateScalarInputField({ + fieldName: 'status', + scalarField: { + ...baseScalarField, + name: 'status', + scalarType: 'enum', + enumType: 'Status', + }, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum, + }); + + // Should have 3 imports: z, scalarField, $Enums + expect(result.fragment.imports).toHaveLength(3); + expect(result.fragment).toIncludeImport('z', 'zod'); + expect(result.fragment).toIncludeImport( + 'scalarField', + 'data-utils/scalarField', + ); + expect(result.fragment).toIncludeImport('$Enums', 'prisma/$Enums'); + }); + }); +}); diff --git a/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/types.ts b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/types.ts new file mode 100644 index 000000000..db1e64f6b --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/_shared/field-definition-generators/types.ts @@ -0,0 +1,9 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import type { ServiceOutputDtoField } from '#src/types/service-output.js'; + +export interface InputFieldDefinitionOutput { + name: string; + fragment: TsCodeFragment; + outputDtoField: ServiceOutputDtoField; +} diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/data-utils.generator.ts b/packages/fastify-generators/src/generators/prisma/data-utils/data-utils.generator.ts new file mode 100644 index 000000000..742879fca --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/data-utils.generator.ts @@ -0,0 +1,36 @@ +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { z } from 'zod'; + +import { PRISMA_DATA_UTILS_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; + +const descriptorSchema = z.object({}); + +/** + * Generator for prisma/data-utils + */ +export const dataUtilsGenerator = createGenerator({ + name: 'prisma/data-utils', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: () => ({ + paths: GENERATED_TEMPLATES.paths.task, + renderers: GENERATED_TEMPLATES.renderers.task, + imports: GENERATED_TEMPLATES.imports.task, + main: createGeneratorTask({ + dependencies: { + renderers: GENERATED_TEMPLATES.renderers.provider, + }, + run({ renderers }) { + return { + build: async (builder) => { + await builder.apply( + renderers.dataOperationsGroup.render({ + variables: {}, + }), + ); + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/extractor.json b/packages/fastify-generators/src/generators/prisma/data-utils/extractor.json new file mode 100644 index 000000000..42eed2e9b --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/extractor.json @@ -0,0 +1,190 @@ +{ + "name": "prisma/data-utils", + "templates": { + "define-operations": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": { + "errorHandlerServiceImportsProvider": { + "importName": "errorHandlerServiceImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/error-handler-service/generated/ts-import-providers.ts" + }, + "prismaGeneratedImportsProvider": { + "importName": "prismaGeneratedImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" + }, + "prismaImportsProvider": { + "importName": "prismaImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma/generated/ts-import-providers.ts" + }, + "serviceContextImportsProvider": { + "importName": "serviceContextImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{src-root}/utils/data-operations/define-operations.ts", + "projectExports": { + "defineCreateOperation": { + "isTypeOnly": false, + "name": "defineCreateOperation" + }, + "defineDeleteOperation": { + "isTypeOnly": false, + "name": "defineDeleteOperation" + }, + "defineUpdateOperation": { + "isTypeOnly": false, + "name": "defineUpdateOperation" + } + }, + "referencedGeneratorTemplates": ["prisma-types", "prisma-utils", "types"], + "sourceFile": "src/utils/data-operations/define-operations.ts", + "variables": {} + }, + "field-definitions": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": { + "prismaImportsProvider": { + "importName": "prismaImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{src-root}/utils/data-operations/field-definitions.ts", + "projectExports": { + "createParentModelConfig": { + "isTypeOnly": false, + "name": "createParentModelConfig" + }, + "nestedOneToManyField": { + "isTypeOnly": false, + "name": "nestedOneToManyField" + }, + "NestedOneToManyFieldConfig": { + "isTypeOnly": true, + "name": "NestedOneToManyFieldConfig" + }, + "nestedOneToOneField": { + "isTypeOnly": false, + "name": "nestedOneToOneField" + }, + "NestedOneToOneFieldConfig": { + "isTypeOnly": true, + "name": "NestedOneToOneFieldConfig" + }, + "ParentModelConfig": { + "isTypeOnly": true, + "name": "ParentModelConfig" + }, + "scalarField": { "isTypeOnly": false, "name": "scalarField" } + }, + "referencedGeneratorTemplates": [ + "define-operations", + "prisma-types", + "prisma-utils", + "types" + ], + "sourceFile": "src/utils/data-operations/field-definitions.ts", + "variables": {} + }, + "prisma-types": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": { + "prismaGeneratedImportsProvider": { + "importName": "prismaGeneratedImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" + }, + "prismaImportsProvider": { + "importName": "prismaImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{src-root}/utils/data-operations/prisma-types.ts", + "sourceFile": "src/utils/data-operations/prisma-types.ts", + "variables": {} + }, + "prisma-utils": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": {}, + "pathRootRelativePath": "{src-root}/utils/data-operations/prisma-utils.ts", + "referencedGeneratorTemplates": ["prisma-types", "types"], + "sourceFile": "src/utils/data-operations/prisma-utils.ts", + "variables": {} + }, + "relation-helpers": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": {}, + "pathRootRelativePath": "{src-root}/utils/data-operations/relation-helpers.ts", + "projectExports": { + "relationHelpers": { "isTypeOnly": false, "name": "relationHelpers" } + }, + "sourceFile": "src/utils/data-operations/relation-helpers.ts", + "variables": {} + }, + "types": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "data-operations", + "importMapProviders": { + "prismaGeneratedImportsProvider": { + "importName": "prismaGeneratedImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" + }, + "serviceContextImportsProvider": { + "importName": "serviceContextImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{src-root}/utils/data-operations/types.ts", + "projectExports": { + "AnyFieldDefinition": { + "isTypeOnly": true, + "name": "AnyFieldDefinition" + }, + "AnyOperationHooks": { + "isTypeOnly": true, + "name": "AnyOperationHooks" + }, + "DataOperationType": { + "isTypeOnly": true, + "name": "DataOperationType" + }, + "FieldContext": { "isTypeOnly": true, "name": "FieldContext" }, + "FieldDefinition": { "isTypeOnly": true, "name": "FieldDefinition" }, + "FieldTransformData": { + "isTypeOnly": true, + "name": "FieldTransformData" + }, + "FieldTransformResult": { + "isTypeOnly": true, + "name": "FieldTransformResult" + }, + "InferFieldsOutput": { + "isTypeOnly": true, + "name": "InferFieldsOutput" + }, + "InferInput": { "isTypeOnly": true, "name": "InferInput" }, + "OperationContext": { "isTypeOnly": true, "name": "OperationContext" }, + "OperationHooks": { "isTypeOnly": true, "name": "OperationHooks" }, + "PrismaTransaction": { + "isTypeOnly": true, + "name": "PrismaTransaction" + }, + "TransactionalOperationContext": { + "isTypeOnly": true, + "name": "TransactionalOperationContext" + } + }, + "sourceFile": "src/utils/data-operations/types.ts", + "variables": {} + } + } +} diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/generated/index.ts b/packages/fastify-generators/src/generators/prisma/data-utils/generated/index.ts new file mode 100644 index 000000000..2fb615b72 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/generated/index.ts @@ -0,0 +1,11 @@ +import { PRISMA_DATA_UTILS_PATHS } from './template-paths.js'; +import { PRISMA_DATA_UTILS_RENDERERS } from './template-renderers.js'; +import { PRISMA_DATA_UTILS_IMPORTS } from './ts-import-providers.js'; +import { PRISMA_DATA_UTILS_TEMPLATES } from './typed-templates.js'; + +export const PRISMA_DATA_UTILS_GENERATED = { + imports: PRISMA_DATA_UTILS_IMPORTS, + paths: PRISMA_DATA_UTILS_PATHS, + renderers: PRISMA_DATA_UTILS_RENDERERS, + templates: PRISMA_DATA_UTILS_TEMPLATES, +}; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/generated/template-paths.ts b/packages/fastify-generators/src/generators/prisma/data-utils/generated/template-paths.ts new file mode 100644 index 000000000..53f0dcbcb --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/generated/template-paths.ts @@ -0,0 +1,41 @@ +import { packageInfoProvider } from '@baseplate-dev/core-generators'; +import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; + +export interface PrismaDataUtilsPaths { + defineOperations: string; + fieldDefinitions: string; + prismaTypes: string; + prismaUtils: string; + relationHelpers: string; + types: string; +} + +const prismaDataUtilsPaths = createProviderType( + 'prisma-data-utils-paths', +); + +const prismaDataUtilsPathsTask = createGeneratorTask({ + dependencies: { packageInfo: packageInfoProvider }, + exports: { prismaDataUtilsPaths: prismaDataUtilsPaths.export() }, + run({ packageInfo }) { + const srcRoot = packageInfo.getPackageSrcPath(); + + return { + providers: { + prismaDataUtilsPaths: { + defineOperations: `${srcRoot}/utils/data-operations/define-operations.ts`, + fieldDefinitions: `${srcRoot}/utils/data-operations/field-definitions.ts`, + prismaTypes: `${srcRoot}/utils/data-operations/prisma-types.ts`, + prismaUtils: `${srcRoot}/utils/data-operations/prisma-utils.ts`, + relationHelpers: `${srcRoot}/utils/data-operations/relation-helpers.ts`, + types: `${srcRoot}/utils/data-operations/types.ts`, + }, + }, + }; + }, +}); + +export const PRISMA_DATA_UTILS_PATHS = { + provider: prismaDataUtilsPaths, + task: prismaDataUtilsPathsTask, +}; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-renderers.ts b/packages/fastify-generators/src/generators/prisma/data-utils/generated/template-renderers.ts similarity index 57% rename from packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-renderers.ts rename to packages/fastify-generators/src/generators/prisma/data-utils/generated/template-renderers.ts index 730fd8bf1..ad72f816d 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-renderers.ts +++ b/packages/fastify-generators/src/generators/prisma/data-utils/generated/template-renderers.ts @@ -1,25 +1,23 @@ import type { RenderTsTemplateGroupActionInput } from '@baseplate-dev/core-generators'; import type { BuilderAction } from '@baseplate-dev/sync'; -import { - tsUtilsImportsProvider, - typescriptFileProvider, -} from '@baseplate-dev/core-generators'; +import { typescriptFileProvider } from '@baseplate-dev/core-generators'; import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; +import { errorHandlerServiceImportsProvider } from '#src/generators/core/error-handler-service/generated/ts-import-providers.js'; import { serviceContextImportsProvider } from '#src/generators/core/service-context/generated/ts-import-providers.js'; import { prismaGeneratedImportsProvider } from '#src/generators/prisma/_providers/prisma-generated-imports.js'; import { prismaImportsProvider } from '#src/generators/prisma/prisma/generated/ts-import-providers.js'; -import { PRISMA_PRISMA_UTILS_PATHS } from './template-paths.js'; -import { PRISMA_PRISMA_UTILS_TEMPLATES } from './typed-templates.js'; +import { PRISMA_DATA_UTILS_PATHS } from './template-paths.js'; +import { PRISMA_DATA_UTILS_TEMPLATES } from './typed-templates.js'; -export interface PrismaPrismaUtilsRenderers { - utilsGroup: { +export interface PrismaDataUtilsRenderers { + dataOperationsGroup: { render: ( options: Omit< RenderTsTemplateGroupActionInput< - typeof PRISMA_PRISMA_UTILS_TEMPLATES.utilsGroup + typeof PRISMA_DATA_UTILS_TEMPLATES.dataOperationsGroup >, 'importMapProviders' | 'group' | 'paths' | 'generatorPaths' >, @@ -27,42 +25,41 @@ export interface PrismaPrismaUtilsRenderers { }; } -const prismaPrismaUtilsRenderers = - createProviderType( - 'prisma-prisma-utils-renderers', - ); +const prismaDataUtilsRenderers = createProviderType( + 'prisma-data-utils-renderers', +); -const prismaPrismaUtilsRenderersTask = createGeneratorTask({ +const prismaDataUtilsRenderersTask = createGeneratorTask({ dependencies: { - paths: PRISMA_PRISMA_UTILS_PATHS.provider, + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + paths: PRISMA_DATA_UTILS_PATHS.provider, prismaGeneratedImports: prismaGeneratedImportsProvider, prismaImports: prismaImportsProvider, serviceContextImports: serviceContextImportsProvider, - tsUtilsImports: tsUtilsImportsProvider, typescriptFile: typescriptFileProvider, }, - exports: { prismaPrismaUtilsRenderers: prismaPrismaUtilsRenderers.export() }, + exports: { prismaDataUtilsRenderers: prismaDataUtilsRenderers.export() }, run({ + errorHandlerServiceImports, paths, prismaGeneratedImports, prismaImports, serviceContextImports, - tsUtilsImports, typescriptFile, }) { return { providers: { - prismaPrismaUtilsRenderers: { - utilsGroup: { + prismaDataUtilsRenderers: { + dataOperationsGroup: { render: (options) => typescriptFile.renderTemplateGroup({ - group: PRISMA_PRISMA_UTILS_TEMPLATES.utilsGroup, + group: PRISMA_DATA_UTILS_TEMPLATES.dataOperationsGroup, paths, importMapProviders: { + errorHandlerServiceImports, prismaGeneratedImports, prismaImports, serviceContextImports, - tsUtilsImports, }, generatorPaths: paths, ...options, @@ -74,7 +71,7 @@ const prismaPrismaUtilsRenderersTask = createGeneratorTask({ }, }); -export const PRISMA_PRISMA_UTILS_RENDERERS = { - provider: prismaPrismaUtilsRenderers, - task: prismaPrismaUtilsRenderersTask, +export const PRISMA_DATA_UTILS_RENDERERS = { + provider: prismaDataUtilsRenderers, + task: prismaDataUtilsRenderersTask, }; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/prisma/data-utils/generated/ts-import-providers.ts new file mode 100644 index 000000000..af56c5bee --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/generated/ts-import-providers.ts @@ -0,0 +1,90 @@ +import type { TsImportMapProviderFromSchema } from '@baseplate-dev/core-generators'; + +import { + createTsImportMap, + createTsImportMapSchema, + packageScope, +} from '@baseplate-dev/core-generators'; +import { + createGeneratorTask, + createReadOnlyProviderType, +} from '@baseplate-dev/sync'; + +import { PRISMA_DATA_UTILS_PATHS } from './template-paths.js'; + +export const dataUtilsImportsSchema = createTsImportMapSchema({ + AnyFieldDefinition: { isTypeOnly: true }, + AnyOperationHooks: { isTypeOnly: true }, + createParentModelConfig: {}, + DataOperationType: { isTypeOnly: true }, + defineCreateOperation: {}, + defineDeleteOperation: {}, + defineUpdateOperation: {}, + FieldContext: { isTypeOnly: true }, + FieldDefinition: { isTypeOnly: true }, + FieldTransformData: { isTypeOnly: true }, + FieldTransformResult: { isTypeOnly: true }, + InferFieldsOutput: { isTypeOnly: true }, + InferInput: { isTypeOnly: true }, + nestedOneToManyField: {}, + NestedOneToManyFieldConfig: { isTypeOnly: true }, + nestedOneToOneField: {}, + NestedOneToOneFieldConfig: { isTypeOnly: true }, + OperationContext: { isTypeOnly: true }, + OperationHooks: { isTypeOnly: true }, + ParentModelConfig: { isTypeOnly: true }, + PrismaTransaction: { isTypeOnly: true }, + relationHelpers: {}, + scalarField: {}, + TransactionalOperationContext: { isTypeOnly: true }, +}); + +export type DataUtilsImportsProvider = TsImportMapProviderFromSchema< + typeof dataUtilsImportsSchema +>; + +export const dataUtilsImportsProvider = + createReadOnlyProviderType('data-utils-imports'); + +const prismaDataUtilsImportsTask = createGeneratorTask({ + dependencies: { + paths: PRISMA_DATA_UTILS_PATHS.provider, + }, + exports: { dataUtilsImports: dataUtilsImportsProvider.export(packageScope) }, + run({ paths }) { + return { + providers: { + dataUtilsImports: createTsImportMap(dataUtilsImportsSchema, { + AnyFieldDefinition: paths.types, + AnyOperationHooks: paths.types, + createParentModelConfig: paths.fieldDefinitions, + DataOperationType: paths.types, + defineCreateOperation: paths.defineOperations, + defineDeleteOperation: paths.defineOperations, + defineUpdateOperation: paths.defineOperations, + FieldContext: paths.types, + FieldDefinition: paths.types, + FieldTransformData: paths.types, + FieldTransformResult: paths.types, + InferFieldsOutput: paths.types, + InferInput: paths.types, + nestedOneToManyField: paths.fieldDefinitions, + NestedOneToManyFieldConfig: paths.fieldDefinitions, + nestedOneToOneField: paths.fieldDefinitions, + NestedOneToOneFieldConfig: paths.fieldDefinitions, + OperationContext: paths.types, + OperationHooks: paths.types, + ParentModelConfig: paths.fieldDefinitions, + PrismaTransaction: paths.types, + relationHelpers: paths.relationHelpers, + scalarField: paths.fieldDefinitions, + TransactionalOperationContext: paths.types, + }), + }, + }; + }, +}); + +export const PRISMA_DATA_UTILS_IMPORTS = { + task: prismaDataUtilsImportsTask, +}; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/generated/typed-templates.ts b/packages/fastify-generators/src/generators/prisma/data-utils/generated/typed-templates.ts new file mode 100644 index 000000000..d4a8c297e --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/generated/typed-templates.ts @@ -0,0 +1,151 @@ +import { createTsTemplateFile } from '@baseplate-dev/core-generators'; +import path from 'node:path'; + +import { errorHandlerServiceImportsProvider } from '#src/generators/core/error-handler-service/generated/ts-import-providers.js'; +import { serviceContextImportsProvider } from '#src/generators/core/service-context/generated/ts-import-providers.js'; +import { prismaGeneratedImportsProvider } from '#src/generators/prisma/_providers/prisma-generated-imports.js'; +import { prismaImportsProvider } from '#src/generators/prisma/prisma/generated/ts-import-providers.js'; + +const defineOperations = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: { + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + prismaGeneratedImports: prismaGeneratedImportsProvider, + prismaImports: prismaImportsProvider, + serviceContextImports: serviceContextImportsProvider, + }, + name: 'define-operations', + projectExports: { + defineCreateOperation: { isTypeOnly: false }, + defineDeleteOperation: { isTypeOnly: false }, + defineUpdateOperation: { isTypeOnly: false }, + }, + referencedGeneratorTemplates: { prismaTypes: {}, prismaUtils: {}, types: {} }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/define-operations.ts', + ), + }, + variables: {}, +}); + +const fieldDefinitions = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: { prismaImports: prismaImportsProvider }, + name: 'field-definitions', + projectExports: { + createParentModelConfig: { isTypeOnly: false }, + nestedOneToManyField: { isTypeOnly: false }, + NestedOneToManyFieldConfig: { isTypeOnly: true }, + nestedOneToOneField: { isTypeOnly: false }, + NestedOneToOneFieldConfig: { isTypeOnly: true }, + ParentModelConfig: { isTypeOnly: true }, + scalarField: { isTypeOnly: false }, + }, + referencedGeneratorTemplates: { + defineOperations: {}, + prismaTypes: {}, + prismaUtils: {}, + types: {}, + }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/field-definitions.ts', + ), + }, + variables: {}, +}); + +const prismaTypes = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: { + prismaGeneratedImports: prismaGeneratedImportsProvider, + prismaImports: prismaImportsProvider, + }, + name: 'prisma-types', + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/prisma-types.ts', + ), + }, + variables: {}, +}); + +const prismaUtils = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: {}, + name: 'prisma-utils', + referencedGeneratorTemplates: { prismaTypes: {}, types: {} }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/prisma-utils.ts', + ), + }, + variables: {}, +}); + +const relationHelpers = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: {}, + name: 'relation-helpers', + projectExports: { relationHelpers: { isTypeOnly: false } }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/relation-helpers.ts', + ), + }, + variables: {}, +}); + +const types = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'data-operations', + importMapProviders: { + prismaGeneratedImports: prismaGeneratedImportsProvider, + serviceContextImports: serviceContextImportsProvider, + }, + name: 'types', + projectExports: { + AnyFieldDefinition: { isTypeOnly: true }, + AnyOperationHooks: { isTypeOnly: true }, + DataOperationType: { isTypeOnly: true }, + FieldContext: { isTypeOnly: true }, + FieldDefinition: { isTypeOnly: true }, + FieldTransformData: { isTypeOnly: true }, + FieldTransformResult: { isTypeOnly: true }, + InferFieldsOutput: { isTypeOnly: true }, + InferInput: { isTypeOnly: true }, + OperationContext: { isTypeOnly: true }, + OperationHooks: { isTypeOnly: true }, + PrismaTransaction: { isTypeOnly: true }, + TransactionalOperationContext: { isTypeOnly: true }, + }, + source: { + path: path.join( + import.meta.dirname, + '../templates/src/utils/data-operations/types.ts', + ), + }, + variables: {}, +}); + +export const dataOperationsGroup = { + defineOperations, + fieldDefinitions, + prismaTypes, + prismaUtils, + relationHelpers, + types, +}; + +export const PRISMA_DATA_UTILS_TEMPLATES = { dataOperationsGroup }; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/index.ts b/packages/fastify-generators/src/generators/prisma/data-utils/index.ts new file mode 100644 index 000000000..538619121 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/index.ts @@ -0,0 +1,3 @@ +export * from './data-utils.generator.js'; +export type { DataUtilsImportsProvider } from './generated/ts-import-providers.js'; +export { dataUtilsImportsProvider } from './generated/ts-import-providers.js'; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..9a033ee57 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts @@ -0,0 +1,931 @@ +// @ts-nocheck + +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from '$prismaTypes'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from '$types'; +import type { Prisma } from '%prismaGeneratedImports'; +import type { ServiceContext } from '%serviceContextImports'; +import type { Result } from '@prisma/client/runtime/client'; + +import { makeGenericPrismaDelegate } from '$prismaUtils'; +import { NotFoundError } from '%errorHandlerServiceImports'; +import { prisma } from '%prismaImports'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..34b6e1f7f --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +// @ts-nocheck + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from '$prismaTypes'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from '$types'; +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { invokeHooks, transformFields } from '$defineOperations'; +import { makeGenericPrismaDelegate } from '$prismaUtils'; +import { prisma } from '%prismaImports'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-types.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..889d0bcb3 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,164 @@ +// @ts-nocheck + +import type { Prisma } from '%prismaGeneratedImports'; +import type { prisma } from '%prismaImports'; +import type { Args, Result } from '@prisma/client/runtime/client'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-utils.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..34c72eebd --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,80 @@ +// @ts-nocheck + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from '$prismaTypes'; +import type { PrismaTransaction } from '$types'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/relation-helpers.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..ea3d31cac --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,196 @@ +// @ts-nocheck + +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts new file mode 100644 index 000000000..739e637c5 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +// @ts-nocheck + +import type { PrismaClient } from '%prismaGeneratedImports'; +import type { ServiceContext } from '%serviceContextImports'; +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/embedded-relation-transformer.generator.ts b/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/embedded-relation-transformer.generator.ts deleted file mode 100644 index 72c42c768..000000000 --- a/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/embedded-relation-transformer.generator.ts +++ /dev/null @@ -1,637 +0,0 @@ -import type { - TsCodeFragment, - TsHoistedFragment, -} from '@baseplate-dev/core-generators'; - -import { - tsCodeFragment, - TsCodeUtils, - tsHoistedFragment, - tsTemplate, -} from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { notEmpty, quot } from '@baseplate-dev/utils'; -import { z } from 'zod'; - -import type { - PrismaDataTransformer, - PrismaDataTransformerOptions, - PrismaDataTransformInputField, -} from '#src/providers/prisma/prisma-data-transformable.js'; -import type { - PrismaOutputModel, - PrismaOutputRelationField, -} from '#src/types/prisma-output.js'; - -import { serviceContextImportsProvider } from '#src/generators/core/service-context/index.js'; -import { upperCaseFirst } from '#src/utils/case.js'; - -import type { PrismaDataMethodOptions } from '../_shared/crud-method/data-method.js'; -import type { PrismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import type { PrismaOutputProvider } from '../prisma/index.js'; - -import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import { - getDataInputTypeBlock, - getDataMethodDataExpressions, - getDataMethodDataType, -} from '../_shared/crud-method/data-method.js'; -import { - prismaCrudServiceProvider, - prismaCrudServiceSetupProvider, -} from '../prisma-crud-service/index.js'; -import { prismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import { prismaOutputProvider } from '../prisma/index.js'; - -const descriptorSchema = z.object({ - name: z.string().min(1), - inputName: z.string().optional(), - localRelationName: z.string().optional(), - embeddedFieldNames: z.array(z.string().min(1)), - foreignModelName: z.string().optional(), - embeddedTransformerNames: z.array(z.string().min(1)).optional(), -}); - -function getForeignModelRelation( - prismaOutput: PrismaOutputProvider, - modelName: string, - localRelationName: string, -): { - localModel: PrismaOutputModel; - foreignModel: PrismaOutputModel; - localRelation: PrismaOutputRelationField; - foreignRelation: PrismaOutputRelationField; -} { - const localModel = prismaOutput.getPrismaModel(modelName); - const localRelation = localModel.fields.find( - (f) => f.name === localRelationName, - ); - - if (!localRelation || localRelation.type !== 'relation') { - throw new Error( - `${modelName}.${localRelationName} is not a relation field`, - ); - } - - // find the relationship on the foreign model since that's where the details of the relation exist - const foreignModel = prismaOutput.getPrismaModel(localRelation.modelType); - const foreignRelation = foreignModel.fields.find( - (f): f is PrismaOutputRelationField => - f.type === 'relation' && - (localRelation.relationName - ? f.name === localRelation.relationName - : f.modelType === modelName), - ); - - if (!foreignRelation) { - throw new Error( - `Could not find foreign relation on ${localRelation.modelType} for ${modelName}.${localRelationName}`, - ); - } - - return { localModel, foreignModel, localRelation, foreignRelation }; -} - -interface EmbeddedTransformFunctionOutput { - name: string; - func: TsHoistedFragment; -} - -function createEmbeddedTransformFunction(options: { - name: string; - inputDataType: string; - outputDataType: string; - dataMethodOptions: Omit; - prismaUtils: PrismaUtilsImportsProvider; - serviceContextType: TsCodeFragment; - isOneToOne?: boolean; - whereUniqueType: string; -}): EmbeddedTransformFunctionOutput { - const { - name, - inputDataType, - outputDataType, - serviceContextType, - prismaUtils, - isOneToOne, - dataMethodOptions, - whereUniqueType, - } = options; - - dataMethodOptions.prismaOutput.getPrismaModel(dataMethodOptions.modelName); - - const isAsync = dataMethodOptions.transformers.some((t) => t.isAsync); - - const { functionBody, createExpression, updateExpression, dataPipeNames } = - getDataMethodDataExpressions(dataMethodOptions); - - const outputPipeType = tsCodeFragment( - `DataPipeOutput<${outputDataType}>`, - prismaUtils.DataPipeOutput.typeDeclaration(), - ); - const outputType = isAsync - ? tsTemplate`Promise<${outputPipeType}>` - : outputPipeType; - - // get a primary key to add a dummy where unique (since create operations don't have a whereunique) - const prismaModel = options.dataMethodOptions.prismaOutput.getPrismaModel( - options.dataMethodOptions.modelName, - ); - - const primaryKey = prismaModel.idFields?.[0]; - if (!primaryKey) { - throw new Error( - `Model ${options.dataMethodOptions.modelName} must have at least one primary key`, - ); - } - - const func = TsCodeUtils.formatFragment( - `${ - isAsync ? 'async ' : '' - }function FUNC_NAME(data: INPUT_DATA_TYPE, context: CONTEXT_TYPE, whereUnique?: WHERE_UNIQUE_TYPE, parentId?: string): OUTPUT_TYPE { - FUNCTION_BODY - - return DATA_RESULT; - }`, - { - FUNC_NAME: name, - INPUT_DATA_TYPE: inputDataType, - FUNCTION_BODY: functionBody, - WHERE_UNIQUE_TYPE: whereUniqueType, - DATA_RESULT: TsCodeUtils.mergeFragmentsAsObject({ - data: TsCodeUtils.mergeFragmentsAsObject({ - where: isOneToOne - ? undefined - : `whereUnique ?? { ${primaryKey}: '' }`, - create: createExpression, - update: updateExpression, - }), - operations: - dataPipeNames.length === 0 - ? undefined - : tsCodeFragment( - `mergePipeOperations([${dataPipeNames.join(', ')}])`, - prismaUtils.mergePipeOperations.declaration(), - ), - }), - OUTPUT_TYPE: outputType, - CONTEXT_TYPE: serviceContextType, - }, - ); - - return { - name, - func: tsHoistedFragment(`embedded-transform-${name}`, func), - }; -} - -export const embeddedRelationTransformerGenerator = createGenerator({ - name: 'prisma/embedded-relation-transformer', - generatorFileUrl: import.meta.url, - descriptorSchema, - getInstanceName: (descriptor) => descriptor.name, - buildTasks: ({ - name: localRelationName, - embeddedFieldNames = [], - embeddedTransformerNames, - inputName: inputNameDescriptor, - foreignModelName, - }) => ({ - main: createGeneratorTask({ - dependencies: { - prismaOutput: prismaOutputProvider, - prismaCrudServiceSetup: prismaCrudServiceSetupProvider, - foreignCrudService: prismaCrudServiceProvider - .dependency() - .optionalReference(foreignModelName), - serviceContextImports: serviceContextImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - prismaGeneratedImports: prismaGeneratedImportsProvider, - }, - run({ - prismaOutput, - prismaCrudServiceSetup, - foreignCrudService, - serviceContextImports, - prismaUtilsImports, - prismaGeneratedImports, - }) { - function buildTransformer({ - operationType, - }: PrismaDataTransformerOptions): PrismaDataTransformer { - const modelName = prismaCrudServiceSetup.getModelName(); - const inputName = inputNameDescriptor ?? localRelationName; - - const { localModel, foreignModel, localRelation, foreignRelation } = - getForeignModelRelation(prismaOutput, modelName, localRelationName); - - if (localModel.idFields?.length !== 1) { - throw new Error( - `${modelName} must have exactly one id field if used in an embedded relation`, - ); - } - const localId = localModel.idFields[0]; - - if (embeddedTransformerNames && !foreignCrudService) { - throw new Error( - `Cannot use embedded transformers without a foreign crud service`, - ); - } - - const isOneToOne = !localRelation.isList; - - // get transformers - const embeddedTransformerFactories = - embeddedTransformerNames - ?.map((name) => foreignCrudService?.getTransformerByName(name)) - .filter(notEmpty) ?? []; - - const embeddedFields = embeddedFieldNames.map((name) => { - const field = foreignModel.fields.find((f) => f.name === name); - if (!field) { - throw new Error( - `Could not find field ${name} on ${foreignModel.name}`, - ); - } - if (field.type !== 'scalar') { - throw new Error( - `Field ${name} on ${foreignModel.name} is not a scalar`, - ); - } - return field; - }); - - const dataInputName = `${modelName}Embedded${upperCaseFirst( - localRelationName, - )}Data`; - - const upsertTransformers = embeddedTransformerFactories.map( - (factory) => factory.buildTransformer({ operationType: 'upsert' }), - ); - - // If we use the existing item, we should check that its ID is actually owned - // by the parent - const getForeignRelationParentField = (): string => { - // figure out which field is parent ID - const foreignParentIdx = foreignRelation.references?.findIndex( - (reference) => reference === localId, - ); - // foreign parent ID is not in list - if (foreignParentIdx == null || foreignParentIdx === -1) { - throw new Error( - `Foreign reference must contain primary key of local model`, - ); - } - const foreignParentField = - foreignRelation.fields?.[foreignParentIdx]; - if (!foreignParentField) { - throw new Error(`Unable to find foreign parent field`); - } - return foreignParentField; - }; - - const dataMethodOptions: Omit = { - modelName: foreignModel.name, - prismaFieldNames: embeddedFields.map((f) => f.name), - operationName: 'create', - transformers: upsertTransformers, - prismaOutput, - isPartial: false, - serviceContextImports, - prismaUtils: prismaUtilsImports, - operationType: 'upsert', - whereUniqueExpression: 'whereUnique', - parentIdCheckField: upsertTransformers.some( - (t) => t.needsExistingItem, - ) - ? getForeignRelationParentField() - : undefined, - prismaGeneratedImports, - }; - - const foreignModelName = upperCaseFirst(foreignModel.name); - const foreignRelationName = upperCaseFirst(foreignRelation.name); - const outputDataType = `{ - where${ - isOneToOne ? '?' : '' - }: Prisma.${foreignModelName}WhereUniqueInput; - create: Prisma.${foreignModelName}CreateWithout${foreignRelationName}Input; - update: Prisma.${foreignModelName}UpdateWithout${foreignRelationName}Input; - }`; - - const upsertFunction = - embeddedTransformerFactories.length === 0 - ? undefined - : createEmbeddedTransformFunction({ - name: `prepareUpsertEmbedded${upperCaseFirst( - localRelationName, - )}Data`, - inputDataType: dataInputName, - outputDataType, - dataMethodOptions, - isOneToOne, - prismaUtils: prismaUtilsImports, - serviceContextType: - serviceContextImports.ServiceContext.typeFragment(), - whereUniqueType: `Prisma.${upperCaseFirst( - foreignModel.name, - )}WhereUniqueInput`, - }); - - const dataInputType = getDataInputTypeBlock( - dataInputName, - dataMethodOptions, - ); - const dataMethodDataType = getDataMethodDataType(dataMethodOptions); - - const isNullable = - !localRelation.isList && operationType === 'update'; - - const inputField: PrismaDataTransformInputField = { - type: tsCodeFragment( - `${dataInputName}${localRelation.isList ? '[]' : ''}${ - isNullable ? ' | null' : '' - }`, - undefined, - { - hoistedFragments: [dataInputType], - }, - ), - dtoField: { - name: inputName, - isOptional: true, - isNullable, - type: 'nested', - isList: localRelation.isList, - nestedType: { - name: dataInputName, - fields: dataMethodDataType.fields, - }, - }, - }; - - /** - * This is a fairly complex piece of logic. We have the following scenarios: - * - * Update/Create: - * - Update operation - * - Create operation - * - * Relationship Type: - * - 1:many relationship - * - 1:1 relationship - * - * Data Preprocessing: - * - May have transform function - * - May have no transform function - */ - - const embeddedCallExpression = (() => { - if (operationType === 'create') { - return isOneToOne - ? prismaUtilsImports.createOneToOneCreateData.fragment() - : prismaUtilsImports.createOneToManyCreateData.fragment(); - } - return isOneToOne - ? prismaUtilsImports.createOneToOneUpsertData.fragment() - : prismaUtilsImports.createOneToManyUpsertData.fragment(); - })(); - - // finds the discriminator ID field in the input for 1:many relationships - const getDiscriminatorIdField = (): string => { - const foreignIds = foreignModel.idFields ?? []; - const discriminatorIdFields = foreignIds.filter((foreignId) => - embeddedFieldNames.includes(foreignId), - ); - - if (discriminatorIdFields.length !== 1) { - throw new Error( - `Expected 1 discriminator ID field for ${localRelationName}, found ${discriminatorIdFields.length}`, - ); - } - return discriminatorIdFields[0]; - }; - - const getWhereUniqueFunction = (): { - func: TsCodeFragment; - needsExistingItem: boolean; - } => { - const returnType = tsCodeFragment( - `Prisma.${upperCaseFirst( - foreignModel.name, - )}WhereUniqueInput | undefined`, - prismaGeneratedImports.Prisma.typeDeclaration(), - ); - - // convert primary keys to where unique - const foreignIds = foreignModel.idFields ?? []; - const primaryKeyFields = foreignIds.map( - ( - idField, - ): { - name: string; - value: string; - requiredInputField?: string; // we may require some input fields to be specified - usesInput?: boolean; - needsExistingItem?: boolean; - } => { - // check if ID field is in relation - const idRelationIdx = foreignRelation.fields?.findIndex( - (relationField) => relationField === idField, - ); - if (idRelationIdx != null && idRelationIdx !== -1) { - const localField = - foreignRelation.references?.[idRelationIdx]; - if (!localField) { - throw new Error( - `Could not find corresponding relation field for ${idField}`, - ); - } - // short-circuit case for updates - if (operationType === 'update' && localId === localField) { - return { name: idField, value: localId }; - } - return { - name: idField, - value: `existingItem.${localField}`, - needsExistingItem: true, - }; - } - // check if ID field is in input - const embeddedField = embeddedFields.find( - (f) => f.name === idField, - ); - if (embeddedField) { - return { - name: idField, - value: `input.${idField}`, - usesInput: true, - requiredInputField: - embeddedField.isOptional || embeddedField.hasDefault - ? idField - : undefined, - }; - } - throw new Error( - `Could not find ID field ${idField} in either embedded object or relation for relation ${localRelationName} of ${modelName}`, - ); - }, - ); - - const primaryKeyExpression = TsCodeUtils.mergeFragmentsAsObject( - Object.fromEntries( - primaryKeyFields.map((keyField): [string, string] => [ - keyField.name, - keyField.value, - ]), - ), - { wrapWithParenthesis: true }, - ); - - const value = - primaryKeyFields.length > 1 - ? TsCodeUtils.mergeFragmentsAsObject( - { - [foreignIds.join('_')]: primaryKeyExpression, - }, - { wrapWithParenthesis: true }, - ) - : primaryKeyExpression; - - const usesInput = primaryKeyFields.some((k) => k.usesInput); - const needsExistingItem = primaryKeyFields.some( - (k) => k.needsExistingItem, - ); - - const requirementsList = [ - ...(needsExistingItem && operationType === 'upsert' - ? ['existingItem'] - : []), - ...primaryKeyFields - .map( - (f) => - f.requiredInputField && `input.${f.requiredInputField}`, - ) - .filter(notEmpty), - ]; - - return { - func: TsCodeUtils.formatFragment( - `(INPUT): RETURN_TYPE => VALUE`, - { - INPUT: usesInput ? 'input' : '', - RETURN_TYPE: returnType, - PREFIX: '', - VALUE: - requirementsList.length > 0 - ? tsTemplate`${requirementsList.join(' && ')} ? ${value} : undefined` - : value, - }, - ), - needsExistingItem, - }; - }; - - const embeddedCallArgs = ((): { - args: TsCodeFragment; - needsExistingItem?: boolean; - } => { - if (operationType === 'create') { - return { - args: TsCodeUtils.mergeFragmentsAsObject({ - input: inputName, - transform: upsertFunction?.name, - context: upsertFunction && 'context', - }), - }; - } - const whereUniqueResult = getWhereUniqueFunction(); - - const parentId = - operationType === 'update' ? localId : `existingItem.${localId}`; - - const transformAdditions = upsertFunction - ? { - transform: upsertFunction.name, - context: 'context', - getWhereUnique: whereUniqueResult.func, - parentId, - } - : {}; - - const oneToManyAdditions = isOneToOne - ? {} - : { - idField: quot(getDiscriminatorIdField()), - getWhereUnique: whereUniqueResult.func, - }; - - const parentField = getForeignRelationParentField(); - - const oneToOneAdditions = isOneToOne - ? { - deleteRelation: TsCodeUtils.formatFragment( - '() => PRISMA_MODEL.deleteMany({ where: WHERE_ARGS })', - { - PRISMA_MODEL: prismaOutput.getPrismaModelFragment( - foreignModel.name, - ), - WHERE_ARGS: TsCodeUtils.mergeFragmentsAsObject({ - [parentField]: parentId, - }), - }, - ), - } - : {}; - - return { - args: TsCodeUtils.mergeFragmentsAsObject({ - input: inputName, - ...transformAdditions, - ...oneToManyAdditions, - ...oneToOneAdditions, - }), - needsExistingItem: - whereUniqueResult.needsExistingItem || - (operationType === 'upsert' && !!upsertFunction), - }; - })(); - - const outputName = `${localRelationName}Output`; - - const transformer = TsCodeUtils.formatFragment( - `const OUTPUT_NAME = await EMBEDDED_CALL(ARGS)`, - { - OUTPUT_NAME: outputName, - EMBEDDED_CALL: embeddedCallExpression, - ARGS: embeddedCallArgs.args, - }, - [], - { hoistedFragments: upsertFunction ? [upsertFunction.func] : [] }, - ); - - return { - inputFields: [inputField], - outputFields: [ - { - name: localRelationName, - pipeOutputName: outputName, - transformer, - createExpression: `{ create: ${outputName}.data?.create }`, - }, - ], - needsExistingItem: embeddedCallArgs.needsExistingItem, - isAsync: true, - needsContext: !!upsertFunction, - }; - } - - prismaCrudServiceSetup.addTransformer(localRelationName, { - buildTransformer, - }); - - return {}; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/index.ts b/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/index.ts deleted file mode 100644 index 890087ed9..000000000 --- a/packages/fastify-generators/src/generators/prisma/embedded-relation-transformer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './embedded-relation-transformer.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/index.ts b/packages/fastify-generators/src/generators/prisma/index.ts index 6b5f7d69b..bcc6813d0 100644 --- a/packages/fastify-generators/src/generators/prisma/index.ts +++ b/packages/fastify-generators/src/generators/prisma/index.ts @@ -1,9 +1,10 @@ export * from './_providers/index.js'; -export * from './embedded-relation-transformer/index.js'; -export * from './prisma-crud-create/index.js'; -export * from './prisma-crud-delete/index.js'; -export * from './prisma-crud-service/index.js'; -export * from './prisma-crud-update/index.js'; +export * from './data-utils/index.js'; +export * from './prisma-data-create/index.js'; +export * from './prisma-data-delete/index.js'; +export * from './prisma-data-nested-field/index.js'; +export * from './prisma-data-service/index.js'; +export * from './prisma-data-update/index.js'; export * from './prisma-enum/index.js'; export * from './prisma-field/index.js'; export * from './prisma-model-id/index.js'; @@ -11,5 +12,4 @@ export * from './prisma-model-index/index.js'; export * from './prisma-model-unique/index.js'; export * from './prisma-model/index.js'; export * from './prisma-relation-field/index.js'; -export * from './prisma-utils/index.js'; export * from './prisma/index.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-create/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-create/index.ts deleted file mode 100644 index 11f26213d..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-create/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-crud-create.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-create/prisma-crud-create.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-create/prisma-crud-create.generator.ts deleted file mode 100644 index 71ad271fd..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-create/prisma-crud-create.generator.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { TsCodeFragment } from '@baseplate-dev/core-generators'; - -import { TsCodeUtils } from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { NUMBER_VALIDATORS } from '@baseplate-dev/utils'; -import { z } from 'zod'; - -import type { - PrismaDataTransformer, - PrismaDataTransformerOptions, -} from '#src/providers/prisma/prisma-data-transformable.js'; -import type { ServiceOutputMethod } from '#src/types/service-output.js'; - -import { serviceContextImportsProvider } from '#src/generators/core/service-context/index.js'; -import { serviceFileProvider } from '#src/generators/core/service-file/index.js'; -import { prismaToServiceOutputDto } from '#src/types/service-output.js'; - -import type { PrismaDataMethodOptions } from '../_shared/crud-method/data-method.js'; - -import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import { - getDataInputTypeBlock, - getDataMethodContextRequired, - getDataMethodDataExpressions, - getDataMethodDataType, - wrapWithApplyDataPipe, -} from '../_shared/crud-method/data-method.js'; -import { prismaCrudServiceProvider } from '../prisma-crud-service/index.js'; -import { prismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import { prismaOutputProvider } from '../prisma/index.js'; - -const descriptorSchema = z.object({ - name: z.string().min(1), - order: NUMBER_VALIDATORS.POSITIVE_INT, - modelName: z.string().min(1), - prismaFields: z.array(z.string().min(1)), - transformerNames: z.array(z.string().min(1)).optional(), -}); - -function getMethodDefinition( - serviceMethodReference: TsCodeFragment, - options: PrismaDataMethodOptions, -): ServiceOutputMethod { - const { name, modelName, prismaOutput } = options; - const prismaDefinition = prismaOutput.getPrismaModel(modelName); - const dataType = getDataMethodDataType(options); - const hasContext = getDataMethodContextRequired(options); - - return { - name, - referenceFragment: serviceMethodReference, - arguments: [ - { - type: 'nested', - name: 'input', - nestedType: { - name: 'CreateServiceInput', - fields: [ - { - name: 'data', - type: 'nested', - nestedType: dataType, - }, - ], - }, - }, - ], - requiresContext: hasContext, - returnType: prismaToServiceOutputDto(prismaDefinition, (enumName) => - prismaOutput.getServiceEnum(enumName), - ), - }; -} - -function getMethodBlock(options: PrismaDataMethodOptions): TsCodeFragment { - const { name, modelName, prismaOutput, prismaUtils, prismaGeneratedImports } = - options; - - const createInputTypeName = `${modelName}CreateData`; - - const typeHeaderBlock = getDataInputTypeBlock(createInputTypeName, options); - - const { functionBody, createExpression, dataPipeNames } = - getDataMethodDataExpressions(options); - - const contextRequired = getDataMethodContextRequired(options); - - const modelType = prismaOutput.getModelTypeFragment(modelName); - - const operation = TsCodeUtils.formatFragment( - `PRISMA_MODEL.create(CREATE_ARGS)`, - { - PRISMA_MODEL: prismaOutput.getPrismaModelFragment(modelName), - CREATE_ARGS: TsCodeUtils.mergeFragmentsAsObjectPresorted({ - data: createExpression, - '...': 'query', - }), - }, - ); - - return TsCodeUtils.formatFragment( - ` -export async function METHOD_NAME({ data, query, EXTRA_ARGS }: CreateServiceInput): Promise { - FUNCTION_BODY - - return OPERATION; -} -`.trim(), - { - METHOD_NAME: name, - CREATE_INPUT_TYPE_NAME: createInputTypeName, - MODEL_TYPE: modelType, - EXTRA_ARGS: contextRequired ? 'context' : '', - QUERY_ARGS: `Prisma.${modelName}DefaultArgs`, - FUNCTION_BODY: functionBody, - OPERATION: wrapWithApplyDataPipe(operation, dataPipeNames, prismaUtils), - }, - [ - prismaGeneratedImports.Prisma.typeDeclaration(), - prismaUtils.CreateServiceInput.typeDeclaration(), - ], - { hoistedFragments: [typeHeaderBlock] }, - ); -} - -export const prismaCrudCreateGenerator = createGenerator({ - name: 'prisma/prisma-crud-create', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: (descriptor) => ({ - main: createGeneratorTask({ - dependencies: { - prismaOutput: prismaOutputProvider, - serviceFile: serviceFileProvider.dependency(), - crudPrismaService: prismaCrudServiceProvider, - serviceContextImports: serviceContextImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - prismaGeneratedImports: prismaGeneratedImportsProvider, - }, - run({ - prismaOutput, - serviceFile, - crudPrismaService, - serviceContextImports, - prismaUtilsImports, - prismaGeneratedImports, - }) { - const { name, modelName, prismaFields, transformerNames } = descriptor; - - const methodName = `${name}${modelName}`; - - const serviceMethodReference = TsCodeUtils.importFragment( - methodName, - serviceFile.getServicePath(), - ); - const transformerOption: PrismaDataTransformerOptions = { - operationType: 'create', - }; - const transformers: PrismaDataTransformer[] = - transformerNames?.map((transformerName) => - crudPrismaService - .getTransformerByName(transformerName) - .buildTransformer(transformerOption), - ) ?? []; - - return { - build: () => { - const methodOptions: PrismaDataMethodOptions = { - name: methodName, - modelName, - prismaFieldNames: prismaFields, - prismaOutput, - operationName: 'create', - isPartial: false, - transformers, - serviceContextImports, - prismaUtils: prismaUtilsImports, - prismaGeneratedImports, - operationType: 'create', - whereUniqueExpression: null, - }; - - serviceFile.registerMethod({ - order: descriptor.order, - name, - fragment: getMethodBlock(methodOptions), - outputMethod: getMethodDefinition( - serviceMethodReference, - methodOptions, - ), - }); - }, - }; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/index.ts deleted file mode 100644 index 784b9b1da..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-crud-delete.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/prisma-crud-delete.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/prisma-crud-delete.generator.ts deleted file mode 100644 index 92bd136ed..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/prisma-crud-delete.generator.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { TsCodeFragment } from '@baseplate-dev/core-generators'; - -import { TsCodeUtils } from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { NUMBER_VALIDATORS } from '@baseplate-dev/utils'; -import { z } from 'zod'; - -import type { ServiceOutputMethod } from '#src/types/service-output.js'; - -import { serviceFileProvider } from '#src/generators/core/service-file/index.js'; -import { prismaToServiceOutputDto } from '#src/types/service-output.js'; - -import type { PrismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import type { PrismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import type { PrismaOutputProvider } from '../prisma/index.js'; - -import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import { - getPrimaryKeyDefinition, - getPrimaryKeyExpressions, -} from '../_shared/crud-method/primary-key-input.js'; -import { prismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import { prismaOutputProvider } from '../prisma/index.js'; - -const descriptorSchema = z.object({ - name: z.string().min(1), - order: NUMBER_VALIDATORS.POSITIVE_INT, - modelName: z.string().min(1), -}); - -interface PrismaDeleteMethodOptions { - methodName: string; - descriptor: z.infer; - prismaOutput: PrismaOutputProvider; - serviceMethodReference: TsCodeFragment; - prismaUtils: PrismaUtilsImportsProvider; - prismaGeneratedImports: PrismaGeneratedImportsProvider; -} - -function getMethodDefinition({ - methodName, - descriptor: { modelName }, - prismaOutput, - serviceMethodReference, -}: PrismaDeleteMethodOptions): ServiceOutputMethod { - const prismaDefinition = prismaOutput.getPrismaModel(modelName); - const idArgument = getPrimaryKeyDefinition(prismaDefinition); - return { - name: methodName, - referenceFragment: serviceMethodReference, - arguments: [ - { - type: 'nested', - name: 'input', - nestedType: { - name: 'DeleteServiceInput', - fields: [idArgument], - }, - }, - ], - returnType: prismaToServiceOutputDto(prismaDefinition, (enumName) => - prismaOutput.getServiceEnum(enumName), - ), - }; -} - -function getMethodBlock({ - methodName, - descriptor: { modelName }, - prismaOutput, - prismaUtils, - prismaGeneratedImports, -}: PrismaDeleteMethodOptions): TsCodeFragment { - const modelType = prismaOutput.getModelTypeFragment(modelName); - - const model = prismaOutput.getPrismaModel(modelName); - const primaryKey = getPrimaryKeyExpressions(model); - - return TsCodeUtils.formatFragment( - ` -export async function OPERATION_NAME({ ID_ARG, query }: DeleteServiceInput): Promise { -return PRISMA_MODEL.delete({ where: WHERE_CLAUSE, ...query }); -} -`.trim(), - { - OPERATION_NAME: methodName, - MODEL_TYPE: modelType, - ID_ARG: - primaryKey.argumentName === 'id' - ? 'id' - : `id : ${primaryKey.argumentName}`, - PRIMARY_KEY_TYPE: primaryKey.argumentType, - QUERY_ARGS: `Prisma.${modelName}DefaultArgs`, - WHERE_CLAUSE: primaryKey.whereClause, - PRISMA_MODEL: prismaOutput.getPrismaModelFragment(modelName), - }, - [ - prismaUtils.DeleteServiceInput.typeDeclaration(), - prismaGeneratedImports.Prisma.typeDeclaration(), - ], - { - hoistedFragments: primaryKey.headerTypeBlock && [ - primaryKey.headerTypeBlock, - ], - }, - ); -} - -export const prismaCrudDeleteGenerator = createGenerator({ - name: 'prisma/prisma-crud-delete', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: (descriptor) => ({ - main: createGeneratorTask({ - dependencies: { - prismaOutput: prismaOutputProvider, - serviceFile: serviceFileProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - prismaGeneratedImports: prismaGeneratedImportsProvider, - }, - run({ - prismaOutput, - serviceFile, - prismaUtilsImports, - prismaGeneratedImports, - }) { - const { name, modelName } = descriptor; - - const methodName = `${name}${modelName}`; - - const serviceMethodReference = TsCodeUtils.importFragment( - methodName, - serviceFile.getServicePath(), - ); - - const methodOptions = { - methodName, - descriptor, - prismaOutput, - serviceMethodReference, - prismaUtils: prismaUtilsImports, - prismaGeneratedImports, - }; - - serviceFile.registerMethod({ - order: descriptor.order, - name, - fragment: getMethodBlock(methodOptions), - outputMethod: getMethodDefinition(methodOptions), - }); - - return {}; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-service/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-service/index.ts deleted file mode 100644 index 056486b60..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-service/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-crud-service.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-service/prisma-crud-service.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-service/prisma-crud-service.generator.ts deleted file mode 100644 index 3dd38411a..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-service/prisma-crud-service.generator.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { packageScope } from '@baseplate-dev/core-generators'; -import { - createGenerator, - createGeneratorTask, - createNonOverwriteableMap, - createProviderType, - createReadOnlyProviderType, -} from '@baseplate-dev/sync'; -import { z } from 'zod'; - -import type { PrismaDataTransformerFactory } from '#src/providers/prisma/prisma-data-transformable.js'; - -const descriptorSchema = z.object({ - modelName: z.string().min(1), -}); - -export interface PrismaCrudServiceSetupProvider { - getModelName(): string; - addTransformer(name: string, factory: PrismaDataTransformerFactory): void; -} - -export const prismaCrudServiceSetupProvider = - createProviderType( - 'prisma-crud-service-setup', - ); - -export interface PrismaCrudServiceProvider { - getTransformerByName(name: string): PrismaDataTransformerFactory; -} - -export const prismaCrudServiceProvider = - createReadOnlyProviderType('prisma-crud-service'); - -export const prismaCrudServiceGenerator = createGenerator({ - name: 'prisma/prisma-crud-service', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: ({ modelName }) => ({ - main: createGeneratorTask({ - outputs: { - prismaCrudService: prismaCrudServiceProvider - // export to children and project under model name - .export() - .andExport(packageScope, modelName), - }, - exports: { - prismaCrudServiceSetup: prismaCrudServiceSetupProvider.export(), - }, - run() { - const transformers = createNonOverwriteableMap< - Record - >({}); - - return { - providers: { - prismaCrudServiceSetup: { - getModelName() { - return modelName; - }, - addTransformer(name, transformer) { - transformers.set(name, transformer); - }, - }, - }, - build: () => ({ - prismaCrudService: { - getTransformerByName(name) { - const transformer = transformers.get(name); - if (!transformer) { - throw new Error(`Transformer ${name} not found`); - } - return transformer; - }, - }, - }), - }; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-update/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-update/index.ts deleted file mode 100644 index 1f6f73697..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-update/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-crud-update.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-crud-update/prisma-crud-update.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-crud-update/prisma-crud-update.generator.ts deleted file mode 100644 index 58b5dfb7d..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-update/prisma-crud-update.generator.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { TsCodeFragment } from '@baseplate-dev/core-generators'; - -import { TsCodeUtils } from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { notEmpty, NUMBER_VALIDATORS } from '@baseplate-dev/utils'; -import { z } from 'zod'; - -import type { - PrismaDataTransformer, - PrismaDataTransformerOptions, -} from '#src/providers/prisma/prisma-data-transformable.js'; -import type { ServiceOutputMethod } from '#src/types/service-output.js'; - -import { serviceContextImportsProvider } from '#src/generators/core/service-context/index.js'; -import { serviceFileProvider } from '#src/generators/core/service-file/index.js'; -import { prismaToServiceOutputDto } from '#src/types/service-output.js'; - -import type { PrismaDataMethodOptions } from '../_shared/crud-method/data-method.js'; - -import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import { - getDataInputTypeBlock, - getDataMethodContextRequired, - getDataMethodDataExpressions, - getDataMethodDataType, - wrapWithApplyDataPipe, -} from '../_shared/crud-method/data-method.js'; -import { - getPrimaryKeyDefinition, - getPrimaryKeyExpressions, -} from '../_shared/crud-method/primary-key-input.js'; -import { prismaCrudServiceProvider } from '../prisma-crud-service/index.js'; -import { prismaUtilsImportsProvider } from '../prisma-utils/index.js'; -import { prismaOutputProvider } from '../prisma/index.js'; - -const descriptorSchema = z.object({ - name: z.string().min(1), - order: NUMBER_VALIDATORS.POSITIVE_INT, - modelName: z.string().min(1), - prismaFields: z.array(z.string().min(1)), - transformerNames: z.array(z.string().min(1)).optional(), -}); - -function getMethodDefinition( - serviceMethodReference: TsCodeFragment, - options: PrismaDataMethodOptions, -): ServiceOutputMethod { - const { name, modelName, prismaOutput } = options; - const prismaDefinition = prismaOutput.getPrismaModel(modelName); - - const dataType = getDataMethodDataType(options); - const idArgument = getPrimaryKeyDefinition(prismaDefinition); - const contextRequired = getDataMethodContextRequired(options); - - return { - name, - referenceFragment: serviceMethodReference, - arguments: [ - { - type: 'nested', - name: 'input', - nestedType: { - name: 'UpdateServiceInput', - fields: [ - idArgument, - { - name: 'data', - type: 'nested', - nestedType: dataType, - }, - ], - }, - }, - ], - requiresContext: contextRequired, - returnType: prismaToServiceOutputDto(prismaDefinition, (enumName) => - prismaOutput.getServiceEnum(enumName), - ), - }; -} - -function getMethodBlock(options: PrismaDataMethodOptions): TsCodeFragment { - const { name, modelName, prismaOutput, prismaUtils, prismaGeneratedImports } = - options; - - const updateInputTypeName = `${modelName}UpdateData`; - - const typeHeaderBlock = getDataInputTypeBlock(updateInputTypeName, options); - - const { functionBody, updateExpression, dataPipeNames } = - getDataMethodDataExpressions(options); - - const contextRequired = getDataMethodContextRequired(options); - - const modelType = prismaOutput.getModelTypeFragment(modelName); - - const model = prismaOutput.getPrismaModel(modelName); - const primaryKey = getPrimaryKeyExpressions(model); - - const operation = TsCodeUtils.formatFragment( - `PRISMA_MODEL.update(UPDATE_ARGS)`, - { - PRISMA_MODEL: prismaOutput.getPrismaModelFragment(modelName), - UPDATE_ARGS: TsCodeUtils.mergeFragmentsAsObjectPresorted({ - where: primaryKey.whereClause, - data: updateExpression, - '...': 'query', - }), - }, - ); - - return TsCodeUtils.formatFragment( - ` -export async function METHOD_NAME({ ID_ARG, data, query, EXTRA_ARGS }: UpdateServiceInput): Promise { - FUNCTION_BODY - - return OPERATION; -} -`.trim(), - { - METHOD_NAME: name, - UPDATE_INPUT_TYPE_NAME: updateInputTypeName, - MODEL_TYPE: modelType, - ID_ARG: - primaryKey.argumentName === 'id' - ? 'id' - : `id : ${primaryKey.argumentName}`, - PRIMARY_KEY_TYPE: primaryKey.argumentType, - QUERY_ARGS: `Prisma.${modelName}DefaultArgs`, - PRISMA_MODEL: prismaOutput.getPrismaModelFragment(modelName), - FUNCTION_BODY: functionBody, - OPERATION: wrapWithApplyDataPipe(operation, dataPipeNames, prismaUtils), - EXTRA_ARGS: contextRequired ? 'context' : '', - }, - [ - prismaUtils.UpdateServiceInput.typeDeclaration(), - prismaGeneratedImports.Prisma.typeDeclaration(), - ], - { - hoistedFragments: [typeHeaderBlock, primaryKey.headerTypeBlock].filter( - notEmpty, - ), - }, - ); -} - -export const prismaCrudUpdateGenerator = createGenerator({ - name: 'prisma/prisma-crud-update', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: (descriptor) => ({ - main: createGeneratorTask({ - dependencies: { - prismaOutput: prismaOutputProvider, - serviceFile: serviceFileProvider.dependency(), - crudPrismaService: prismaCrudServiceProvider, - serviceContextImports: serviceContextImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - prismaGeneratedImports: prismaGeneratedImportsProvider, - }, - run({ - prismaOutput, - serviceFile, - crudPrismaService, - serviceContextImports, - prismaUtilsImports, - prismaGeneratedImports, - }) { - const { name, modelName, prismaFields, transformerNames } = descriptor; - const methodName = `${name}${modelName}`; - - const serviceMethodReference = TsCodeUtils.importFragment( - methodName, - serviceFile.getServicePath(), - ); - const transformerOption: PrismaDataTransformerOptions = { - operationType: 'update', - }; - const transformers: PrismaDataTransformer[] = - transformerNames?.map((transformerName) => - crudPrismaService - .getTransformerByName(transformerName) - .buildTransformer(transformerOption), - ) ?? []; - - return { - build: () => { - const model = prismaOutput.getPrismaModel(modelName); - const primaryKey = getPrimaryKeyExpressions(model); - const methodOptions: PrismaDataMethodOptions = { - name: methodName, - modelName, - prismaFieldNames: prismaFields, - prismaOutput, - operationName: 'update', - isPartial: true, - transformers, - serviceContextImports, - prismaUtils: prismaUtilsImports, - prismaGeneratedImports, - operationType: 'update', - whereUniqueExpression: primaryKey.whereClause, - }; - - serviceFile.registerMethod({ - order: descriptor.order, - name, - fragment: getMethodBlock(methodOptions), - outputMethod: getMethodDefinition( - serviceMethodReference, - methodOptions, - ), - }); - }, - }; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-create/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-create/index.ts new file mode 100644 index 000000000..1f730a894 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-create/index.ts @@ -0,0 +1 @@ +export * from './prisma-data-create.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts new file mode 100644 index 000000000..362e627e8 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts @@ -0,0 +1,125 @@ +import { + TsCodeUtils, + tsImportBuilder, + tsTemplate, + tsTemplateWithImports, +} from '@baseplate-dev/core-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { + lowercaseFirstChar, + quot, + uppercaseFirstChar, +} from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import { serviceFileProvider } from '#src/generators/core/index.js'; +import { contextKind, prismaQueryKind } from '#src/types/service-dto-kinds.js'; +import { + createServiceOutputDtoInjectedArg, + prismaToServiceOutputDto, +} from '#src/types/service-output.js'; + +import { generateCreateCallback } from '../_shared/build-data-helpers/index.js'; +import { dataUtilsImportsProvider } from '../data-utils/index.js'; +import { prismaDataServiceProvider } from '../prisma-data-service/prisma-data-service.generator.js'; +import { prismaOutputProvider } from '../prisma/prisma.generator.js'; + +const descriptorSchema = z.object({ + name: z.string().min(1), + modelName: z.string().min(1), + fields: z.array(z.string().min(1)), +}); + +/** + * Generator for prisma/prisma-data-create + */ +export const prismaDataCreateGenerator = createGenerator({ + name: 'prisma/prisma-data-create', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: ({ name, modelName, fields }) => ({ + main: createGeneratorTask({ + dependencies: { + serviceFile: serviceFileProvider, + prismaDataService: prismaDataServiceProvider, + dataUtilsImports: dataUtilsImportsProvider, + prismaOutput: prismaOutputProvider, + }, + run({ serviceFile, prismaDataService, dataUtilsImports, prismaOutput }) { + const serviceFields = prismaDataService.getFields(); + const usedFields = serviceFields.filter((field) => + fields.includes(field.name), + ); + if (usedFields.length !== fields.length) { + throw new Error( + `Fields ${fields.filter((field) => !usedFields.some((f) => f.name === field)).join(', ')} not found in service fields`, + ); + } + return { + build: () => { + const fieldsFragment = + fields.length === serviceFields.length + ? prismaDataService.getFieldsVariableName() + : tsTemplateWithImports([ + tsImportBuilder(['pick']).from('es-toolkit'), + ])`pick(${prismaDataService.getFieldsVariableName()}, [${fields.map((field) => quot(field)).join(', ')}] as const)`; + + // Generate create callback that transforms FK fields into relations + const { createCallbackFragment } = generateCreateCallback({ + prismaModel: prismaOutput.getPrismaModel(modelName), + inputFieldNames: fields, + dataUtilsImports, + modelVariableName: lowercaseFirstChar(modelName), + }); + + const createOperation = tsTemplate` + export const ${name} = ${dataUtilsImports.defineCreateOperation.fragment()}({ + model: ${quot(lowercaseFirstChar(modelName))}, + fields: ${fieldsFragment}, + create: ${createCallbackFragment}, + }) + `; + serviceFile.getServicePath(); + + prismaDataService.registerMethod({ + name, + type: 'create', + fragment: createOperation, + outputMethod: { + name, + referenceFragment: TsCodeUtils.importFragment( + name, + serviceFile.getServicePath(), + ), + arguments: [ + { + name: 'data', + type: 'nested', + nestedType: { + name: `${uppercaseFirstChar(name)}Data`, + fields: usedFields.map((field) => field.outputDtoField), + }, + }, + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'context', + kind: contextKind, + }), + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'query', + kind: prismaQueryKind, + }), + ], + returnType: prismaToServiceOutputDto( + prismaOutput.getPrismaModel(modelName), + (enumName) => prismaOutput.getServiceEnum(enumName), + ), + }, + }); + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-delete/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-delete/index.ts new file mode 100644 index 000000000..98338c01c --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-delete/index.ts @@ -0,0 +1 @@ +export * from './prisma-data-delete.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-delete/prisma-data-delete.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-delete/prisma-data-delete.generator.ts new file mode 100644 index 000000000..6d876790f --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-delete/prisma-data-delete.generator.ts @@ -0,0 +1,100 @@ +import { TsCodeUtils, tsTemplate } from '@baseplate-dev/core-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { lowercaseFirstChar, quot } from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import { serviceFileProvider } from '#src/generators/core/index.js'; +import { + contextKind, + prismaQueryKind, + prismaWhereUniqueInputKind, +} from '#src/types/service-dto-kinds.js'; +import { + createServiceOutputDtoInjectedArg, + prismaToServiceOutputDto, +} from '#src/types/service-output.js'; + +import { generateDeleteCallback } from '../_shared/build-data-helpers/index.js'; +import { dataUtilsImportsProvider } from '../data-utils/index.js'; +import { prismaDataServiceProvider } from '../prisma-data-service/prisma-data-service.generator.js'; +import { prismaOutputProvider } from '../prisma/prisma.generator.js'; + +const descriptorSchema = z.object({ + name: z.string().min(1), + modelName: z.string().min(1), +}); + +/** + * Generator for prisma/prisma-data-delete + */ +export const prismaDataDeleteGenerator = createGenerator({ + name: 'prisma/prisma-data-delete', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: ({ name, modelName }) => ({ + main: createGeneratorTask({ + dependencies: { + serviceFile: serviceFileProvider, + prismaDataService: prismaDataServiceProvider, + dataUtilsImports: dataUtilsImportsProvider, + prismaOutput: prismaOutputProvider, + }, + run({ serviceFile, prismaDataService, dataUtilsImports, prismaOutput }) { + return { + build: () => { + // Generate delete callback + const { deleteCallbackFragment } = generateDeleteCallback({ + modelVariableName: lowercaseFirstChar(modelName), + }); + + const deleteOperation = tsTemplate` + export const ${name} = ${dataUtilsImports.defineDeleteOperation.fragment()}({ + model: ${quot(lowercaseFirstChar(modelName))}, + delete: ${deleteCallbackFragment}, + }) + `; + serviceFile.getServicePath(); + + const prismaModel = prismaOutput.getPrismaModel(modelName); + + prismaDataService.registerMethod({ + name, + type: 'delete', + fragment: deleteOperation, + outputMethod: { + name, + referenceFragment: TsCodeUtils.importFragment( + name, + serviceFile.getServicePath(), + ), + arguments: [ + createServiceOutputDtoInjectedArg({ + name: 'where', + type: 'injected', + kind: prismaWhereUniqueInputKind, + metadata: { + idFields: prismaModel.idFields ?? [], + }, + }), + createServiceOutputDtoInjectedArg({ + name: 'context', + type: 'injected', + kind: contextKind, + }), + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'query', + kind: prismaQueryKind, + }), + ], + returnType: prismaToServiceOutputDto(prismaModel, (enumName) => + prismaOutput.getServiceEnum(enumName), + ), + }, + }); + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/index.ts new file mode 100644 index 000000000..9d53489d5 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/index.ts @@ -0,0 +1 @@ +export * from './prisma-data-nested-field.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/nested-field-writer.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/nested-field-writer.ts new file mode 100644 index 000000000..d76461c20 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/nested-field-writer.ts @@ -0,0 +1,294 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { + TsCodeUtils, + tsImportBuilder, + tsTemplate, + tsTemplateWithImports, +} from '@baseplate-dev/core-generators'; +import { + lowercaseFirstChar, + quot, + uppercaseFirstChar, +} from '@baseplate-dev/utils'; + +import type { + PrismaOutputModel, + PrismaOutputRelationField, +} from '#src/types/prisma-output.js'; + +import type { InputFieldDefinitionOutput } from '../_shared/field-definition-generators/types.js'; +import type { DataUtilsImportsProvider } from '../data-utils/index.js'; + +import { generateRelationBuildData } from '../_shared/build-data-helpers/generate-relation-build-data.js'; + +interface WritePrismaDataNestedFieldInput { + parentModel: PrismaOutputModel; + nestedModel: PrismaOutputModel; + relation: PrismaOutputRelationField; + /** The fragment referencing the existing fields array (otherwise we inline the fields) */ + dataServiceFieldsFragment?: TsCodeFragment; + nestedFields: InputFieldDefinitionOutput[]; + dataUtilsImports: DataUtilsImportsProvider; +} + +/** + * Creates a where unique function for parent model config. + * Generates: (parentModel) => ({ id: parentModel.id }) + * + * @param model - The parent Prisma model + * @param argName - The argument name for the function (default: 'value') + * @returns TypeScript code fragment for the where unique function + */ +function createPrismaWhereUniqueFunction( + model: PrismaOutputModel, + argName = 'value', +): TsCodeFragment { + const primaryKeys = model.idFields; + + if (!primaryKeys) { + throw new Error( + `Primary keys on model ${model.name} are required to generate where unique function`, + ); + } + + return tsTemplate`(${argName}) => ${TsCodeUtils.mergeFragmentsAsObject( + Object.fromEntries(primaryKeys.map((k) => [k, `${argName}.${k}`])), + { wrapWithParenthesis: true }, + )}`; +} + +/** + * Creates a where unique function for one-to-one nested relations. + * Maps parent model fields to nested model's unique constraint based on relation field mapping. + * + * For example, UserProfile.userId unique references User.id: + * Generates: (parentModel) => ({ userId: parentModel.id }) + * + * @param relation - The relation field from nested model to parent + * @param nestedModel - The nested Prisma model + * @returns TypeScript code fragment for the where unique function + */ +function createOneToOneWhereUniqueFunction( + relation: PrismaOutputRelationField, + nestedModel: PrismaOutputModel, +): TsCodeFragment { + const relationFields = relation.fields ?? []; + const referencedFields = relation.references ?? []; + + if (relationFields.length === 0 || referencedFields.length === 0) { + throw new Error( + `Relation ${relation.name} on model ${nestedModel.name} must have fields and references defined`, + ); + } + + if (relationFields.length !== referencedFields.length) { + throw new Error( + `Relation ${relation.name} fields and references must have the same length`, + ); + } + + // Map parent model fields to nested model fields + // E.g., { userId: parentModel.id } for UserProfile -> User relation + const whereUniqueObject = Object.fromEntries( + relationFields.map((relationField, index) => { + const referencedField = referencedFields[index]; + return [relationField, `parentModel.${referencedField}`]; + }), + ); + + return tsTemplate`(parentModel) => ${TsCodeUtils.mergeFragmentsAsObject( + whereUniqueObject, + { wrapWithParenthesis: true }, + )}`; +} + +/** + * Creates a where unique function for one-to-many nested relations. + * Combines input fields with parent model fields to form a composite unique constraint. + * + * For example, UserRole with @@id([userId, role]) where role is in input: + * Generates: (input, parentModel) => ({ userId_role: { userId: parentModel.id, role: input.role } }) + * + * For simple cases like UserImage with just id in input: + * Generates: (input) => ({ id: input.id }) + * + * @param nestedModel - The nested Prisma model + * @param relation - The relation field from nested model to parent + * @param inputFieldNames - Field names that are in the input + * @param parentModel - The parent Prisma model + * @returns TypeScript code fragment for the where unique function + */ +function createOneToManyWhereUniqueFunction( + nestedModel: PrismaOutputModel, + relation: PrismaOutputRelationField, + inputFieldNames: string[], +): TsCodeFragment { + const { idFields } = nestedModel; + + if (!idFields || idFields.length === 0) { + throw new Error( + `Nested model ${nestedModel.name} must have id fields for one-to-many relation`, + ); + } + + const relationFields = relation.fields ?? []; + const referencedFields = relation.references ?? []; + + // Build the where unique object, determining if each field comes from input or parent + const whereFields: Record = {}; + + for (const idField of idFields) { + // Check if this field is a relation field (comes from parent) + const relationFieldIndex = relationFields.indexOf(idField); + + if (relationFieldIndex !== -1) { + // Field comes from parent model via relation + const referencedField = referencedFields[relationFieldIndex]; + whereFields[idField] = `parentModel.${referencedField}`; + } else if (inputFieldNames.includes(idField)) { + // Field comes from input + whereFields[idField] = `input.${idField}`; + } else { + throw new Error( + `ID field ${idField} of ${nestedModel.name} is not in input fields or relation fields`, + ); + } + } + + const inputIdFields = idFields.filter((f) => inputFieldNames.includes(f)); + + // Check if all fields come from input (no parent dependency) + const allFromInput = idFields.length === inputIdFields.length; + + // Generate the where unique object + const whereUniqueObj = TsCodeUtils.mergeFragmentsAsObject(whereFields, { + wrapWithParenthesis: true, + }); + + // Create a conditional for whether the input field sugggests the object has a prior value + const hasExistingConditional = inputIdFields + .map((field) => `input.${field}`) + .join('&&'); + + // For composite keys, wrap in composite key syntax + if (idFields.length > 1) { + const compositeKey = idFields.join('_'); + const compositeWhereUnique = TsCodeUtils.mergeFragmentsAsObject( + { + [compositeKey]: whereUniqueObj, + }, + { wrapWithParenthesis: true }, + ); + + const conditionalWhereUnique = tsTemplate`${hasExistingConditional} ? ${compositeWhereUnique} : undefined`; + + return allFromInput + ? tsTemplate`(input) => ${conditionalWhereUnique}` + : tsTemplate`(input, parentModel) => ${conditionalWhereUnique}`; + } + + const conditionalWhereUniqueObj = tsTemplate`${hasExistingConditional} ? ${whereUniqueObj} : undefined`; + + // For single field, just return the field mapping + return allFromInput + ? tsTemplate`(input) => ${conditionalWhereUniqueObj}` + : tsTemplate`(input, parentModel) => ${conditionalWhereUniqueObj}`; +} + +function writeParentModelConfigFragment({ + parentModel, + dataUtilsImports, +}: WritePrismaDataNestedFieldInput): TsCodeFragment { + const whereUniqueFunction = createPrismaWhereUniqueFunction(parentModel); + + return tsTemplate`const parentModel = ${dataUtilsImports.createParentModelConfig.fragment()}(${quot(lowercaseFirstChar(parentModel.name))}, ${whereUniqueFunction})`; +} + +export function writePrismaDataNestedField( + input: WritePrismaDataNestedFieldInput, +): InputFieldDefinitionOutput { + const { + parentModel, + nestedModel, + relation, + nestedFields, + dataServiceFieldsFragment, + dataUtilsImports, + } = input; + + const parentModelConfigFrag = writeParentModelConfigFragment(input); + const fieldConstructor = relation.isList + ? dataUtilsImports.nestedOneToManyField + : dataUtilsImports.nestedOneToOneField; + + const nestedFieldNames = nestedFields.map((f) => f.name); + const pickedFieldsFragment = dataServiceFieldsFragment + ? tsTemplateWithImports([ + tsImportBuilder(['pick']).from('es-toolkit'), + ])`pick(${dataServiceFieldsFragment}, ${JSON.stringify(nestedFieldNames)} as const)` + : TsCodeUtils.mergeFragmentsAsObject( + Object.fromEntries(nestedFields.map((f) => [f.name, f.fragment])), + ); + + const reverseRelation = nestedModel.fields.find( + (f): f is PrismaOutputRelationField => + f.type === 'relation' && + f.relationName === relation.relationName && + f.modelType === parentModel.name, + ); + + if (!reverseRelation) { + throw new Error( + `Reverse relation ${relation.name} not found on model ${nestedModel.name}`, + ); + } + + // Generate the appropriate getWhereUnique function based on relation type + const getWhereUniqueFragment = relation.isList + ? createOneToManyWhereUniqueFunction( + nestedModel, + reverseRelation, + nestedFieldNames, + ) + : createOneToOneWhereUniqueFunction(reverseRelation, nestedModel); + + const fieldOptions = TsCodeUtils.mergeFragmentsAsObject({ + parentModel: 'parentModel', + model: quot(lowercaseFirstChar(nestedModel.name)), + relationName: quot(reverseRelation.name), + fields: pickedFieldsFragment, + getWhereUnique: getWhereUniqueFragment, + buildData: generateRelationBuildData({ + prismaModel: nestedModel, + inputFieldNames: nestedFieldNames, + operationType: 'upsert', + dataUtilsImports, + }).buildDataFunctionFragment, + }); + + const fragment = tsTemplateWithImports([], { + hoistedFragments: [ + { ...parentModelConfigFrag, key: 'parent-model-config' }, + ], + })` + ${fieldConstructor.fragment()}(${fieldOptions}) + `; + + return { + name: relation.name, + fragment, + outputDtoField: { + name: relation.name, + type: 'nested', + isPrismaType: false, + isOptional: true, + isNullable: false, + isList: relation.isList, + nestedType: { + name: `${uppercaseFirstChar(parentModel.name)}${uppercaseFirstChar(relation.name)}NestedInput`, + fields: nestedFields.map((f) => f.outputDtoField), + }, + }, + }; +} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/prisma-data-nested-field.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/prisma-data-nested-field.generator.ts new file mode 100644 index 000000000..39a8d3d6f --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-nested-field/prisma-data-nested-field.generator.ts @@ -0,0 +1,162 @@ +import { + createNodePackagesTask, + extractPackageVersions, +} from '@baseplate-dev/core-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { z } from 'zod'; + +import { FASTIFY_PACKAGES } from '#src/constants/fastify-packages.js'; + +import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; +import { generateScalarInputField } from '../_shared/field-definition-generators/generate-scalar-input-field.js'; +import { dataUtilsImportsProvider } from '../data-utils/index.js'; +import { + prismaDataServiceProvider, + prismaDataServiceSetupProvider, +} from '../prisma-data-service/prisma-data-service.generator.js'; +import { prismaOutputProvider } from '../prisma/prisma.generator.js'; +import { writePrismaDataNestedField } from './nested-field-writer.js'; + +const descriptorSchema = z.object({ + /** Name of the model */ + modelName: z.string().min(1), + /** Name of the relation */ + relationName: z.string().min(1), + /** Name of the nested model */ + nestedModelName: z.string().min(1), + /** Fields on the nested model to use */ + scalarFieldNames: z.array(z.string().min(1)).min(1), + /** Fields on the nested model to use as virtual input fields */ + virtualInputFieldNames: z.array(z.string().min(1)).optional(), +}); + +/** + * Generator for prisma/prisma-data-nested-field + */ +export const prismaDataNestedFieldGenerator = createGenerator({ + name: 'prisma/prisma-data-nested-field', + generatorFileUrl: import.meta.url, + descriptorSchema, + getInstanceName: (descriptor) => descriptor.relationName, + buildTasks: ({ + modelName, + relationName, + nestedModelName, + scalarFieldNames, + virtualInputFieldNames, + }) => ({ + deps: createNodePackagesTask({ + prod: extractPackageVersions(FASTIFY_PACKAGES, ['es-toolkit']), + }), + nestedPrismaDataServiceSetup: createGeneratorTask({ + dependencies: { + prismaDataServiceSetup: prismaDataServiceSetupProvider + .dependency() + .optionalReference(nestedModelName), + }, + run({ prismaDataServiceSetup }) { + return { + build: () => { + // Make sure that if we have a nested data service, we add the field names to the service. + prismaDataServiceSetup?.additionalModelFieldNames.push( + ...scalarFieldNames, + ); + }, + }; + }, + }), + main: createGeneratorTask({ + dependencies: { + prismaOutput: prismaOutputProvider, + prismaDataServiceSetup: prismaDataServiceSetupProvider, + nestedPrismaDataService: prismaDataServiceProvider + .dependency() + .optionalReference(nestedModelName), + dataUtilsImports: dataUtilsImportsProvider, + prismaGeneratedImports: prismaGeneratedImportsProvider, + }, + run({ + prismaOutput, + prismaDataServiceSetup, + nestedPrismaDataService, + dataUtilsImports, + prismaGeneratedImports, + }) { + const parentModel = prismaOutput.getPrismaModel(modelName); + const nestedModel = prismaOutput.getPrismaModel(nestedModelName); + const relation = parentModel.fields.find( + (f) => f.name === relationName, + ); + + if (relation?.type !== 'relation') { + throw new Error( + `Relation ${relationName} not found on model ${modelName}`, + ); + } + + if (relation.modelType !== nestedModelName) { + throw new Error( + `Expected relation ${relationName} on model ${modelName} to reference ${nestedModelName}. Got ${relation.modelType}`, + ); + } + + const dataServiceFields = nestedPrismaDataService?.getFields(); + const fieldNames = [ + ...scalarFieldNames, + ...(virtualInputFieldNames ?? []), + ]; + const nestedFields = (() => { + if (dataServiceFields) { + return fieldNames.map((name) => { + const field = dataServiceFields.find((f) => f.name === name); + if (!field) { + throw new Error(`Field ${name} not found in data service`); + } + return field; + }); + } + const modelFields = fieldNames.map((name) => { + const field = nestedModel.fields.find((f) => f.name === name); + if (!field) { + throw new Error(`Field ${name} not found in model ${modelName}`); + } + if (field.type !== 'scalar') { + throw new Error( + `Only scalar fields are supported if no data service is provided`, + ); + } + return field; + }); + + return modelFields.map((field) => + generateScalarInputField({ + fieldName: field.name, + scalarField: field, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum: (name) => prismaOutput.getServiceEnum(name), + }), + ); + })(); + + const dataServiceFieldsFragment = + nestedPrismaDataService?.getFieldsFragment(); + + return { + build: () => { + prismaDataServiceSetup.virtualInputFields.add( + writePrismaDataNestedField({ + parentModel, + nestedModel, + relation, + dataServiceFieldsFragment, + nestedFields, + dataUtilsImports, + }), + ); + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-service/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-service/index.ts new file mode 100644 index 000000000..9f176fd0e --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-service/index.ts @@ -0,0 +1 @@ +export * from './prisma-data-service.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-service/prisma-data-service.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-service/prisma-data-service.generator.ts new file mode 100644 index 000000000..7286f5101 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-service/prisma-data-service.generator.ts @@ -0,0 +1,219 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { + packageScope, + TsCodeUtils, + tsTemplate, +} from '@baseplate-dev/core-generators'; +import { + createConfigProviderTaskWithInfo, + createGenerator, + createGeneratorTask, + createProviderType, +} from '@baseplate-dev/sync'; +import { + lowercaseFirstChar, + NamedArrayFieldContainer, +} from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import type { PrismaOutputScalarField } from '#src/types/prisma-output.js'; +import type { ServiceOutputMethod } from '#src/types/service-output.js'; + +import { serviceFileProvider } from '#src/generators/core/index.js'; + +import type { InputFieldDefinitionOutput } from '../_shared/field-definition-generators/types.js'; + +import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; +import { generateScalarInputField } from '../_shared/field-definition-generators/generate-scalar-input-field.js'; +import { dataUtilsImportsProvider } from '../data-utils/index.js'; +import { prismaOutputProvider } from '../prisma/prisma.generator.js'; + +const descriptorSchema = z.object({ + modelName: z.string().min(1), + modelFieldNames: z.array(z.string()), +}); + +type Descriptor = z.infer; + +const [ + createPrismaDataServiceTask, + prismaDataServiceSetupProvider, + prismaDataServiceValuesProvider, +] = createConfigProviderTaskWithInfo( + (t) => ({ + /** Additional model field names to add to the data service */ + additionalModelFieldNames: t.array([]), + /** Virtual input fields to add to the data service */ + virtualInputFields: t.namedArray([]), + }), + { + prefix: 'prisma-data-service', + configScope: (provider, descriptor) => + provider.export().andExport(packageScope, descriptor.modelName), + infoFromDescriptor: (descriptor: Descriptor) => ({ + modelName: descriptor.modelName, + }), + }, +); + +export { prismaDataServiceSetupProvider }; + +interface PrismaDataServiceMethod { + name: string; + type: 'create' | 'update' | 'delete'; + fragment: TsCodeFragment; + outputMethod: ServiceOutputMethod; +} + +export interface PrismaDataServiceProvider { + getFields(): InputFieldDefinitionOutput[]; + getFieldsVariableName(): string; + /** + * Gets the fragment with the fields imported in. + */ + getFieldsFragment(): TsCodeFragment; + registerMethod(method: PrismaDataServiceMethod): void; +} + +export const prismaDataServiceProvider = + createProviderType('prisma-data-service'); + +const TYPE_TO_ORDER: Record = { + create: 1, + update: 2, + delete: 3, +}; + +/** + * Generator for prisma/prisma-data-service + */ +export const prismaDataServiceGenerator = createGenerator({ + name: 'prisma/prisma-data-service', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: (descriptor) => ({ + config: createPrismaDataServiceTask(descriptor), + main: createGeneratorTask({ + dependencies: { + configValues: prismaDataServiceValuesProvider, + prismaOutput: prismaOutputProvider, + serviceFile: serviceFileProvider, + dataUtilsImports: dataUtilsImportsProvider, + prismaGeneratedImports: prismaGeneratedImportsProvider, + }, + exports: { + prismaDataService: prismaDataServiceProvider + .export() + .andExport(packageScope, descriptor.modelName), + }, + run({ + configValues, + prismaOutput, + serviceFile, + dataUtilsImports, + prismaGeneratedImports, + }) { + const { modelName, modelFieldNames } = descriptor; + const model = prismaOutput.getPrismaModel(modelName); + const { virtualInputFields, additionalModelFieldNames } = configValues; + const modelScalarFields = model.fields.filter( + (f): f is PrismaOutputScalarField => f.type === 'scalar', + ); + + const modelScalarFieldNames = new Set([ + ...modelFieldNames, + ...additionalModelFieldNames, + ]); + + const invalidModelFieldNames = modelFieldNames.filter( + (fieldName) => !modelScalarFieldNames.has(fieldName), + ); + if (invalidModelFieldNames.length > 0) { + throw new Error( + `Fields ${invalidModelFieldNames.join(', ')} are not scalar fields in model ${modelName}`, + ); + } + + const methods = new NamedArrayFieldContainer(); + + // Check if modelFields and virtual input fields overlap + const overlappingFields = virtualInputFields.filter((field) => + modelScalarFieldNames.has(field.name), + ); + if (overlappingFields.length > 0) { + throw new Error( + `Fields ${overlappingFields.map((field) => field.name).join(', ')} overlap with model fields`, + ); + } + + const inputFields = [ + // preserve order of model fields + ...modelScalarFields + .filter((f) => modelScalarFieldNames.has(f.name)) + .map((field) => + generateScalarInputField({ + fieldName: field.name, + scalarField: field, + dataUtilsImports, + prismaGeneratedImports, + lookupEnum: (name) => prismaOutput.getServiceEnum(name), + }), + ), + ...virtualInputFields.toSorted((a, b) => + a.name.localeCompare(b.name), + ), + ]; + + const inputFieldsObject = TsCodeUtils.mergeFragmentsAsObject( + Object.fromEntries( + inputFields.map((field) => [field.name, field.fragment]), + ), + { disableSort: true }, + ); + + const fieldsVariableName = `${lowercaseFirstChar(modelName)}InputFields`; + + const inputFieldsFragment = tsTemplate` + export const ${fieldsVariableName} = ${inputFieldsObject};`; + + return { + providers: { + prismaDataService: { + getFields() { + return inputFields; + }, + getFieldsVariableName() { + return fieldsVariableName; + }, + getFieldsFragment() { + return TsCodeUtils.importFragment( + fieldsVariableName, + serviceFile.getServicePath(), + ); + }, + registerMethod(method) { + methods.add(method); + }, + }, + }, + build: () => { + serviceFile.registerHeader({ + name: 'input-fields', + fragment: inputFieldsFragment, + }); + + for (const method of methods.getValue()) { + serviceFile.registerMethod({ + name: method.name, + order: TYPE_TO_ORDER[method.type], + fragment: method.fragment, + outputMethod: method.outputMethod, + }); + } + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-update/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-update/index.ts new file mode 100644 index 000000000..62e1aaa75 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-update/index.ts @@ -0,0 +1 @@ +export * from './prisma-data-update.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts new file mode 100644 index 000000000..1925fd688 --- /dev/null +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts @@ -0,0 +1,141 @@ +import { + TsCodeUtils, + tsImportBuilder, + tsTemplate, + tsTemplateWithImports, +} from '@baseplate-dev/core-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { + lowercaseFirstChar, + quot, + uppercaseFirstChar, +} from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import { serviceFileProvider } from '#src/generators/core/index.js'; +import { + contextKind, + prismaQueryKind, + prismaWhereUniqueInputKind, +} from '#src/types/service-dto-kinds.js'; +import { + createServiceOutputDtoInjectedArg, + prismaToServiceOutputDto, +} from '#src/types/service-output.js'; + +import { generateUpdateCallback } from '../_shared/build-data-helpers/index.js'; +import { dataUtilsImportsProvider } from '../data-utils/index.js'; +import { prismaDataServiceProvider } from '../prisma-data-service/prisma-data-service.generator.js'; +import { prismaOutputProvider } from '../prisma/prisma.generator.js'; + +const descriptorSchema = z.object({ + name: z.string().min(1), + modelName: z.string().min(1), + fields: z.array(z.string().min(1)), +}); + +/** + * Generator for prisma/prisma-data-update + */ +export const prismaDataUpdateGenerator = createGenerator({ + name: 'prisma/prisma-data-update', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: ({ name, modelName, fields }) => ({ + main: createGeneratorTask({ + dependencies: { + serviceFile: serviceFileProvider, + prismaDataService: prismaDataServiceProvider, + dataUtilsImports: dataUtilsImportsProvider, + prismaOutput: prismaOutputProvider, + }, + run({ serviceFile, prismaDataService, dataUtilsImports, prismaOutput }) { + const serviceFields = prismaDataService.getFields(); + const usedFields = serviceFields.filter((field) => + fields.includes(field.name), + ); + if (usedFields.length !== fields.length) { + throw new Error( + `Fields ${fields.filter((field) => !usedFields.some((f) => f.name === field)).join(', ')} not found in service fields`, + ); + } + return { + build: () => { + const fieldsFragment = + fields.length === serviceFields.length + ? prismaDataService.getFieldsVariableName() + : tsTemplateWithImports([ + tsImportBuilder(['pick']).from('es-toolkit'), + ])`pick(${prismaDataService.getFieldsVariableName()}, [${fields.map((field) => quot(field)).join(', ')}] as const)`; + + // Generate update callback that transforms FK fields into relations + const { updateCallbackFragment } = generateUpdateCallback({ + prismaModel: prismaOutput.getPrismaModel(modelName), + inputFieldNames: fields, + dataUtilsImports, + modelVariableName: lowercaseFirstChar(modelName), + }); + + const updateOperation = tsTemplate` + export const ${name} = ${dataUtilsImports.defineUpdateOperation.fragment()}({ + model: ${quot(lowercaseFirstChar(modelName))}, + fields: ${fieldsFragment}, + update: ${updateCallbackFragment}, + }) + `; + serviceFile.getServicePath(); + + const prismaModel = prismaOutput.getPrismaModel(modelName); + + prismaDataService.registerMethod({ + name, + type: 'update', + fragment: updateOperation, + outputMethod: { + name, + referenceFragment: TsCodeUtils.importFragment( + name, + serviceFile.getServicePath(), + ), + arguments: [ + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'where', + kind: prismaWhereUniqueInputKind, + metadata: { + idFields: prismaModel.idFields ?? [], + }, + }), + { + name: 'data', + type: 'nested', + nestedType: { + name: `${uppercaseFirstChar(name)}Data`, + fields: usedFields.map((field) => ({ + ...field.outputDtoField, + isOptional: true, + })), + }, + }, + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'context', + kind: contextKind, + }), + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'query', + kind: prismaQueryKind, + }), + ], + returnType: prismaToServiceOutputDto(prismaModel, (enumName) => + prismaOutput.getServiceEnum(enumName), + ), + }, + }); + }, + }; + }, + }), + }), +}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/extractor.json b/packages/fastify-generators/src/generators/prisma/prisma-utils/extractor.json deleted file mode 100644 index f0d862f15..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/extractor.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "name": "prisma/prisma-utils", - "templates": { - "crud-service-types": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": { - "serviceContextImportsProvider": { - "importName": "serviceContextImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" - } - }, - "pathRootRelativePath": "{src-root}/utils/crud-service-types.ts", - "projectExports": { - "CreateServiceInput": { "isTypeOnly": true }, - "DeleteServiceInput": { "isTypeOnly": true }, - "UpdateServiceInput": { "isTypeOnly": true } - }, - "sourceFile": "src/utils/crud-service-types.ts", - "variables": {} - }, - "data-pipes": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": { - "prismaGeneratedImportsProvider": { - "importName": "prismaGeneratedImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" - }, - "prismaImportsProvider": { - "importName": "prismaImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma/generated/ts-import-providers.ts" - }, - "tsUtilsImportsProvider": { - "importName": "tsUtilsImportsProvider", - "packagePathSpecifier": "@baseplate-dev/core-generators:src/generators/node/ts-utils/generated/ts-import-providers.ts" - } - }, - "pathRootRelativePath": "{src-root}/utils/data-pipes.ts", - "projectExports": { - "applyDataPipeOutput": {}, - "applyDataPipeOutputToOperations": {}, - "applyDataPipeOutputWithoutOperation": {}, - "DataPipeOutput": { "isTypeOnly": true }, - "mergePipeOperations": {} - }, - "sourceFile": "src/utils/data-pipes.ts", - "variables": {} - }, - "embedded-one-to-many": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": { - "serviceContextImportsProvider": { - "importName": "serviceContextImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" - }, - "tsUtilsImportsProvider": { - "importName": "tsUtilsImportsProvider", - "packagePathSpecifier": "@baseplate-dev/core-generators:src/generators/node/ts-utils/generated/ts-import-providers.ts" - } - }, - "pathRootRelativePath": "{src-root}/utils/embedded-pipes/embedded-one-to-many.ts", - "projectExports": { - "createOneToManyCreateData": {}, - "createOneToManyUpsertData": {} - }, - "referencedGeneratorTemplates": ["data-pipes", "embedded-types"], - "sourceFile": "src/utils/embedded-pipes/embedded-one-to-many.ts", - "variables": {} - }, - "embedded-one-to-one": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": { - "prismaGeneratedImportsProvider": { - "importName": "prismaGeneratedImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" - }, - "serviceContextImportsProvider": { - "importName": "serviceContextImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" - } - }, - "pathRootRelativePath": "{src-root}/utils/embedded-pipes/embedded-one-to-one.ts", - "projectExports": { - "createOneToOneCreateData": {}, - "createOneToOneUpsertData": {} - }, - "referencedGeneratorTemplates": ["data-pipes", "embedded-types"], - "sourceFile": "src/utils/embedded-pipes/embedded-one-to-one.ts", - "variables": {} - }, - "embedded-types": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": {}, - "pathRootRelativePath": "{src-root}/utils/embedded-pipes/embedded-types.ts", - "projectExports": { "UpsertPayload": { "isTypeOnly": true } }, - "sourceFile": "src/utils/embedded-pipes/embedded-types.ts", - "variables": {} - }, - "prisma-relations": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/fastify-generators#prisma/prisma-utils", - "group": "utils", - "importMapProviders": {}, - "pathRootRelativePath": "{src-root}/utils/prisma-relations.ts", - "projectExports": { "createPrismaDisconnectOrConnectData": {} }, - "sourceFile": "src/utils/prisma-relations.ts", - "variables": {} - } - } -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/index.ts deleted file mode 100644 index d1d3f475a..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PRISMA_PRISMA_UTILS_PATHS } from './template-paths.js'; -import { PRISMA_PRISMA_UTILS_RENDERERS } from './template-renderers.js'; -import { PRISMA_PRISMA_UTILS_IMPORTS } from './ts-import-providers.js'; -import { PRISMA_PRISMA_UTILS_TEMPLATES } from './typed-templates.js'; - -export const PRISMA_PRISMA_UTILS_GENERATED = { - imports: PRISMA_PRISMA_UTILS_IMPORTS, - paths: PRISMA_PRISMA_UTILS_PATHS, - renderers: PRISMA_PRISMA_UTILS_RENDERERS, - templates: PRISMA_PRISMA_UTILS_TEMPLATES, -}; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-paths.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-paths.ts deleted file mode 100644 index c7699b248..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/template-paths.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { packageInfoProvider } from '@baseplate-dev/core-generators'; -import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; - -export interface PrismaPrismaUtilsPaths { - crudServiceTypes: string; - dataPipes: string; - embeddedOneToMany: string; - embeddedOneToOne: string; - embeddedTypes: string; - prismaRelations: string; -} - -const prismaPrismaUtilsPaths = createProviderType( - 'prisma-prisma-utils-paths', -); - -const prismaPrismaUtilsPathsTask = createGeneratorTask({ - dependencies: { packageInfo: packageInfoProvider }, - exports: { prismaPrismaUtilsPaths: prismaPrismaUtilsPaths.export() }, - run({ packageInfo }) { - const srcRoot = packageInfo.getPackageSrcPath(); - - return { - providers: { - prismaPrismaUtilsPaths: { - crudServiceTypes: `${srcRoot}/utils/crud-service-types.ts`, - dataPipes: `${srcRoot}/utils/data-pipes.ts`, - embeddedOneToMany: `${srcRoot}/utils/embedded-pipes/embedded-one-to-many.ts`, - embeddedOneToOne: `${srcRoot}/utils/embedded-pipes/embedded-one-to-one.ts`, - embeddedTypes: `${srcRoot}/utils/embedded-pipes/embedded-types.ts`, - prismaRelations: `${srcRoot}/utils/prisma-relations.ts`, - }, - }, - }; - }, -}); - -export const PRISMA_PRISMA_UTILS_PATHS = { - provider: prismaPrismaUtilsPaths, - task: prismaPrismaUtilsPathsTask, -}; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/ts-import-providers.ts deleted file mode 100644 index 14a63a9f7..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/ts-import-providers.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { TsImportMapProviderFromSchema } from '@baseplate-dev/core-generators'; - -import { - createTsImportMap, - createTsImportMapSchema, - packageScope, -} from '@baseplate-dev/core-generators'; -import { - createGeneratorTask, - createReadOnlyProviderType, -} from '@baseplate-dev/sync'; - -import { PRISMA_PRISMA_UTILS_PATHS } from './template-paths.js'; - -const prismaUtilsImportsSchema = createTsImportMapSchema({ - applyDataPipeOutput: {}, - applyDataPipeOutputToOperations: {}, - applyDataPipeOutputWithoutOperation: {}, - createOneToManyCreateData: {}, - createOneToManyUpsertData: {}, - createOneToOneCreateData: {}, - createOneToOneUpsertData: {}, - createPrismaDisconnectOrConnectData: {}, - CreateServiceInput: { isTypeOnly: true }, - DataPipeOutput: { isTypeOnly: true }, - DeleteServiceInput: { isTypeOnly: true }, - mergePipeOperations: {}, - UpdateServiceInput: { isTypeOnly: true }, - UpsertPayload: { isTypeOnly: true }, -}); - -export type PrismaUtilsImportsProvider = TsImportMapProviderFromSchema< - typeof prismaUtilsImportsSchema ->; - -export const prismaUtilsImportsProvider = - createReadOnlyProviderType( - 'prisma-utils-imports', - ); - -const prismaPrismaUtilsImportsTask = createGeneratorTask({ - dependencies: { - paths: PRISMA_PRISMA_UTILS_PATHS.provider, - }, - exports: { - prismaUtilsImports: prismaUtilsImportsProvider.export(packageScope), - }, - run({ paths }) { - return { - providers: { - prismaUtilsImports: createTsImportMap(prismaUtilsImportsSchema, { - applyDataPipeOutput: paths.dataPipes, - applyDataPipeOutputToOperations: paths.dataPipes, - applyDataPipeOutputWithoutOperation: paths.dataPipes, - createOneToManyCreateData: paths.embeddedOneToMany, - createOneToManyUpsertData: paths.embeddedOneToMany, - createOneToOneCreateData: paths.embeddedOneToOne, - createOneToOneUpsertData: paths.embeddedOneToOne, - createPrismaDisconnectOrConnectData: paths.prismaRelations, - CreateServiceInput: paths.crudServiceTypes, - DataPipeOutput: paths.dataPipes, - DeleteServiceInput: paths.crudServiceTypes, - mergePipeOperations: paths.dataPipes, - UpdateServiceInput: paths.crudServiceTypes, - UpsertPayload: paths.embeddedTypes, - }), - }, - }; - }, -}); - -export const PRISMA_PRISMA_UTILS_IMPORTS = { - task: prismaPrismaUtilsImportsTask, -}; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/typed-templates.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/typed-templates.ts deleted file mode 100644 index 46c3fc9b2..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/generated/typed-templates.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - createTsTemplateFile, - tsUtilsImportsProvider, -} from '@baseplate-dev/core-generators'; -import path from 'node:path'; - -import { serviceContextImportsProvider } from '#src/generators/core/service-context/generated/ts-import-providers.js'; -import { prismaGeneratedImportsProvider } from '#src/generators/prisma/_providers/prisma-generated-imports.js'; -import { prismaImportsProvider } from '#src/generators/prisma/prisma/generated/ts-import-providers.js'; - -const crudServiceTypes = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: { serviceContextImports: serviceContextImportsProvider }, - name: 'crud-service-types', - projectExports: { - CreateServiceInput: { isTypeOnly: true }, - DeleteServiceInput: { isTypeOnly: true }, - UpdateServiceInput: { isTypeOnly: true }, - }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/crud-service-types.ts', - ), - }, - variables: {}, -}); - -const dataPipes = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: { - prismaGeneratedImports: prismaGeneratedImportsProvider, - prismaImports: prismaImportsProvider, - tsUtilsImports: tsUtilsImportsProvider, - }, - name: 'data-pipes', - projectExports: { - applyDataPipeOutput: {}, - applyDataPipeOutputToOperations: {}, - applyDataPipeOutputWithoutOperation: {}, - DataPipeOutput: { isTypeOnly: true }, - mergePipeOperations: {}, - }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/data-pipes.ts', - ), - }, - variables: {}, -}); - -const embeddedOneToMany = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: { - serviceContextImports: serviceContextImportsProvider, - tsUtilsImports: tsUtilsImportsProvider, - }, - name: 'embedded-one-to-many', - projectExports: { - createOneToManyCreateData: {}, - createOneToManyUpsertData: {}, - }, - referencedGeneratorTemplates: { dataPipes: {}, embeddedTypes: {} }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/embedded-pipes/embedded-one-to-many.ts', - ), - }, - variables: {}, -}); - -const embeddedOneToOne = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: { - prismaGeneratedImports: prismaGeneratedImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, - name: 'embedded-one-to-one', - projectExports: { - createOneToOneCreateData: {}, - createOneToOneUpsertData: {}, - }, - referencedGeneratorTemplates: { dataPipes: {}, embeddedTypes: {} }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/embedded-pipes/embedded-one-to-one.ts', - ), - }, - variables: {}, -}); - -const embeddedTypes = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: {}, - name: 'embedded-types', - projectExports: { UpsertPayload: { isTypeOnly: true } }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/embedded-pipes/embedded-types.ts', - ), - }, - variables: {}, -}); - -const prismaRelations = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: {}, - name: 'prisma-relations', - projectExports: { createPrismaDisconnectOrConnectData: {} }, - source: { - path: path.join( - import.meta.dirname, - '../templates/src/utils/prisma-relations.ts', - ), - }, - variables: {}, -}); - -export const utilsGroup = { - crudServiceTypes, - dataPipes, - embeddedOneToMany, - embeddedOneToOne, - embeddedTypes, - prismaRelations, -}; - -export const PRISMA_PRISMA_UTILS_TEMPLATES = { utilsGroup }; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/index.ts deleted file mode 100644 index bc0ea6d6c..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { PrismaUtilsImportsProvider } from './generated/ts-import-providers.js'; -export { prismaUtilsImportsProvider } from './generated/ts-import-providers.js'; -export * from './prisma-utils.generator.js'; diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/prisma-utils.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/prisma-utils.generator.ts deleted file mode 100644 index e2ea6f850..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/prisma-utils.generator.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - tsUtilsImportsProvider, - typescriptFileProvider, -} from '@baseplate-dev/core-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { z } from 'zod'; - -import { serviceContextImportsProvider } from '#src/generators/core/service-context/index.js'; - -import { prismaGeneratedImportsProvider } from '../_providers/prisma-generated-imports.js'; -import { prismaImportsProvider } from '../prisma/index.js'; -import { PRISMA_PRISMA_UTILS_GENERATED } from './generated/index.js'; - -const descriptorSchema = z.object({}); - -export const prismaUtilsGenerator = createGenerator({ - name: 'prisma/prisma-utils', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: () => ({ - paths: PRISMA_PRISMA_UTILS_GENERATED.paths.task, - imports: PRISMA_PRISMA_UTILS_GENERATED.imports.task, - main: createGeneratorTask({ - dependencies: { - typescriptFile: typescriptFileProvider, - serviceContextImports: serviceContextImportsProvider, - tsUtilsImports: tsUtilsImportsProvider, - prismaImports: prismaImportsProvider, - paths: PRISMA_PRISMA_UTILS_GENERATED.paths.provider, - prismaGeneratedImports: prismaGeneratedImportsProvider, - }, - run({ - typescriptFile, - serviceContextImports, - tsUtilsImports, - prismaImports, - paths, - prismaGeneratedImports, - }) { - return { - build: (builder) => { - typescriptFile.addLazyTemplateGroup({ - group: PRISMA_PRISMA_UTILS_GENERATED.templates.utilsGroup, - generatorInfo: builder.generatorInfo, - paths, - generatorPaths: paths, - importMapProviders: { - serviceContextImports, - tsUtilsImports, - prismaImports, - prismaGeneratedImports, - }, - }); - }, - }; - }, - }), - }), -}); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/crud-service-types.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/crud-service-types.ts deleted file mode 100644 index 5a6d77b8b..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/crud-service-types.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-nocheck - -import type { ServiceContext } from '%serviceContextImports'; - -export interface CreateServiceInput< - CreateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - data: CreateData; - context: Context; - query?: Query; -} - -export interface UpdateServiceInput< - PrimaryKey, - UpdateData, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - data: UpdateData; - context: Context; - query?: Query; -} - -export interface DeleteServiceInput< - PrimaryKey, - Query, - Context extends ServiceContext = ServiceContext, -> { - id: PrimaryKey; - context: Context; - query?: Query; -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/data-pipes.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/data-pipes.ts deleted file mode 100644 index 8b2570267..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/data-pipes.ts +++ /dev/null @@ -1,86 +0,0 @@ -// @ts-nocheck - -import type { Prisma } from '%prismaGeneratedImports'; - -import { prisma } from '%prismaImports'; -import { notEmpty } from '%tsUtilsImports'; - -interface DataPipeOperations { - beforePrismaPromises?: Prisma.PrismaPromise[]; - afterPrismaPromises?: Prisma.PrismaPromise[]; -} - -export interface DataPipeOutput { - data: Output; - operations?: DataPipeOperations; -} - -export function mergePipeOperations( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): DataPipeOperations { - const operations = outputs - .map((o) => (o && 'data' in o ? o.operations : o)) - .filter(notEmpty); - - return { - beforePrismaPromises: operations.flatMap( - (op) => op.beforePrismaPromises ?? [], - ), - afterPrismaPromises: operations.flatMap( - (op) => op.afterPrismaPromises ?? [], - ), - }; -} - -// Taken from Prisma generated code -type UnwrapPromise

= P extends Promise ? R : P; -type UnwrapTuple = { - [K in keyof Tuple]: K extends `${number}` - ? Tuple[K] extends Prisma.PrismaPromise - ? X - : UnwrapPromise - : UnwrapPromise; -}; - -export async function applyDataPipeOutputToOperations< - Promises extends Prisma.PrismaPromise[], ->( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operations: [...Promises], -): Promise> { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - ...operations, - ...afterPrismaPromises, - ]); - - return results.slice( - beforePrismaPromises.length, - beforePrismaPromises.length + operations.length, - ) as UnwrapTuple; -} - -export async function applyDataPipeOutput( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], - operation: Prisma.PrismaPromise, -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - const results = await prisma.$transaction([ - ...beforePrismaPromises, - operation, - ...afterPrismaPromises, - ]); - - return results[beforePrismaPromises.length] as DataType; -} - -export async function applyDataPipeOutputWithoutOperation( - outputs: (DataPipeOutput | DataPipeOperations | undefined | null)[], -): Promise { - const { beforePrismaPromises = [], afterPrismaPromises = [] } = - mergePipeOperations(outputs); - await prisma.$transaction([...beforePrismaPromises, ...afterPrismaPromises]); -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-many.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-many.ts deleted file mode 100644 index ff0a70970..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-many.ts +++ /dev/null @@ -1,245 +0,0 @@ -// @ts-nocheck - -import type { DataPipeOutput } from '$dataPipes'; -import type { UpsertPayload } from '$embeddedTypes'; -import type { ServiceContext } from '%serviceContextImports'; - -import { mergePipeOperations } from '$dataPipes'; -import { notEmpty } from '%tsUtilsImports'; - -// Create Helpers - -interface OneToManyCreatePipeInput { - input: DataInput[] | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToManyCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToManyCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToManyCreatePipeInput - | OneToManyCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'][] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputs = await Promise.all( - input.map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - return { - data: { create: createOutputs.map((output) => output.data) }, - operations: mergePipeOperations(createOutputs), - }; -} - -// Upsert Helpers - -interface UpsertManyPayload< - UpsertData extends UpsertPayload, - WhereUniqueInput, - IdField extends string | number | symbol, - IdType = string, -> { - deleteMany?: Record; - upsert?: { - where: WhereUniqueInput; - create: UpsertData['create']; - update: UpsertData['update']; - }[]; - create: UpsertData['create'][]; -} - -interface OneToManyUpsertPipeInput< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, -> { - input: DataInput[] | undefined; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform?: undefined; - context?: undefined; - parentId?: undefined; -} - -interface OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput[] | undefined; - context: ServiceContext; - idField: IdField; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - parentId?: ParentId; -} - -export async function createOneToManyUpsertData< - DataInput, - WhereUniqueInput, - IdField extends keyof DataInput, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - idField, - context, - getWhereUnique, - transform, - parentId, -}: - | OneToManyUpsertPipeInput - | OneToManyUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - IdField, - ParentId, - UpsertData - >): Promise< - DataPipeOutput< - | UpsertManyPayload< - UpsertData, - WhereUniqueInput, - IdField, - Exclude - > - | undefined - > -> { - if (!input) { - return { data: undefined, operations: {} }; - } - - async function transformCreateInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, undefined, parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const createOutputPromise = Promise.all( - input - .filter( - (item) => - item[idField] === undefined || getWhereUnique(item) === undefined, - ) - .map(async (item) => { - const output = await transformCreateInput(item); - return { - data: output.data.create, - operations: output.operations, - }; - }), - ); - - async function transformUpsertInput( - item: DataInput, - ): Promise> { - return transform - ? transform(item, context, getWhereUnique(item), parentId) - : ({ - data: { create: item, update: item }, - } as DataPipeOutput); - } - - const upsertOutputPromise = Promise.all( - input - .filter((item) => item[idField] !== undefined && getWhereUnique(item)) - .map(async (item) => { - const output = await transformUpsertInput(item); - return { - data: { - where: getWhereUnique(item) as WhereUniqueInput, - create: output.data.create, - update: output.data.update, - }, - operations: output.operations, - }; - }), - ); - - const [upsertOutput, createOutput] = await Promise.all([ - upsertOutputPromise, - createOutputPromise, - ]); - - return { - data: { - deleteMany: - idField && - ({ - [idField]: { - notIn: input.map((data) => data[idField]).filter(notEmpty), - }, - } as Record< - IdField, - { - notIn: Exclude[]; - } - >), - upsert: upsertOutput.map((output) => output.data), - create: createOutput.map((output) => output.data), - }, - operations: mergePipeOperations([...upsertOutput, ...createOutput]), - }; -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-one.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-one.ts deleted file mode 100644 index 3c9f30731..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-one-to-one.ts +++ /dev/null @@ -1,147 +0,0 @@ -// @ts-nocheck - -import type { DataPipeOutput } from '$dataPipes'; -import type { UpsertPayload } from '$embeddedTypes'; -import type { Prisma } from '%prismaGeneratedImports'; -import type { ServiceContext } from '%serviceContextImports'; - -// Create Helpers - -interface OneToOneCreatePipeInput { - input: DataInput | undefined; - transform?: undefined; - context?: undefined; -} - -interface OneToOneCreatePipeInputWithTransform< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - ) => Promise> | DataPipeOutput; - context: ServiceContext; -} - -export async function createOneToOneCreateData< - DataInput, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, -}: - | OneToOneCreatePipeInput - | OneToOneCreatePipeInputWithTransform): Promise< - DataPipeOutput<{ create: UpsertData['create'] } | undefined> -> { - if (!input) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve(transform(input, context)); - return { - data: { create: transformedData.data.create }, - operations: transformedData.operations, - }; - } - - return { - data: { create: input }, - }; -} - -// Upsert helpers - -interface OneToOneUpsertPipeInput { - input: DataInput | null | undefined; - transform?: undefined; - context?: undefined; - getWhereUnique?: undefined; - parentId?: undefined; - deleteRelation: () => Prisma.PrismaPromise; -} - -interface OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput extends object, - ParentId = unknown, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, -> { - input: DataInput | null | undefined; - transform: ( - input: DataInput, - context: ServiceContext, - updateKey?: WhereUniqueInput, - parentId?: ParentId, - ) => Promise> | DataPipeOutput; - context: ServiceContext; - getWhereUnique: (input: DataInput) => WhereUniqueInput | undefined; - parentId?: ParentId; - deleteRelation: () => Prisma.PrismaPromise; -} - -export async function createOneToOneUpsertData< - DataInput, - WhereUniqueInput extends object, - ParentId, - UpsertData extends UpsertPayload = { - create: DataInput; - update: DataInput; - }, ->({ - input, - context, - transform, - getWhereUnique, - parentId, - deleteRelation, -}: - | OneToOneUpsertPipeInput - | OneToOneUpsertPipeInputWithTransform< - DataInput, - WhereUniqueInput, - ParentId, - UpsertData - >): Promise< - DataPipeOutput<{ upsert: UpsertData } | { delete: true } | undefined> -> { - if (input === null) { - return { - data: undefined, - operations: { beforePrismaPromises: [deleteRelation()] }, - }; - } - if (input === undefined) { - return { data: undefined, operations: {} }; - } - if (transform) { - const transformedData = await Promise.resolve( - transform(input, context, getWhereUnique(input), parentId), - ); - return { - data: { upsert: transformedData.data }, - operations: transformedData.operations, - }; - } - - return { - data: { - upsert: { - create: input, - update: input, - } as UpsertData, - }, - }; -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-types.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-types.ts deleted file mode 100644 index ca78f259f..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/embedded-pipes/embedded-types.ts +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-nocheck - -export interface UpsertPayload { - create: CreateData; - update: UpdateData; -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/prisma-relations.ts b/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/prisma-relations.ts deleted file mode 100644 index 976f05854..000000000 --- a/packages/fastify-generators/src/generators/prisma/prisma-utils/templates/src/utils/prisma-relations.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck - -/** - * Small helper function to make it easier to use optional relations in Prisma since the - * only way to set a Prisma relation to null is to disconnect it. - * - * See https://github.com/prisma/prisma/issues/5044 - */ -export function createPrismaDisconnectOrConnectData( - data?: { connect: UniqueWhere } | null, -): - | { - disconnect?: boolean; - connect?: UniqueWhere; - } - | undefined { - if (data === undefined) { - return undefined; - } - if (data === null) { - return { disconnect: true }; - } - return data; -} diff --git a/packages/fastify-generators/src/generators/prisma/prisma/extractor.json b/packages/fastify-generators/src/generators/prisma/prisma/extractor.json index 9a206c59d..6fd4bc962 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma/extractor.json +++ b/packages/fastify-generators/src/generators/prisma/prisma/extractor.json @@ -13,7 +13,12 @@ "fileOptions": { "kind": "singleton" }, "group": "generated", "pathRootRelativePath": "{src-root}/generated/prisma/client.ts", - "projectExports": { "*": {}, "Prisma": {}, "PrismaClient": {} }, + "projectExports": { + "*": {}, + "$Enums": {}, + "Prisma": {}, + "PrismaClient": {} + }, "projectExportsOnly": true, "sourceFile": "src/generated/prisma/client.ts" }, diff --git a/packages/fastify-generators/src/generators/prisma/prisma/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/prisma/prisma/generated/ts-import-providers.ts index 97a821a48..d9ee1c1d6 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma/generated/ts-import-providers.ts @@ -17,7 +17,7 @@ import { import { PRISMA_PRISMA_PATHS } from './template-paths.js'; -const prismaImportsSchema = createTsImportMapSchema({ prisma: {} }); +export const prismaImportsSchema = createTsImportMapSchema({ prisma: {} }); export type PrismaImportsProvider = TsImportMapProviderFromSchema< typeof prismaImportsSchema @@ -41,6 +41,7 @@ const prismaPrismaImportsTask = createGeneratorTask({ prismaGeneratedImportsSchema, { '*': paths.client, + $Enums: paths.client, Prisma: paths.client, PrismaClient: paths.client, }, diff --git a/packages/fastify-generators/src/generators/prisma/prisma/generated/typed-templates.ts b/packages/fastify-generators/src/generators/prisma/prisma/generated/typed-templates.ts index fe3095543..75e9df3eb 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma/generated/typed-templates.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma/generated/typed-templates.ts @@ -7,7 +7,7 @@ const client = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'generated', name: 'client', - projectExports: { '*': {}, Prisma: {}, PrismaClient: {} }, + projectExports: { '*': {}, $Enums: {}, Prisma: {}, PrismaClient: {} }, projectExportsOnly: true, source: { contents: '' }, variables: {}, diff --git a/packages/fastify-generators/src/generators/stripe/fastify-stripe/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/stripe/fastify-stripe/generated/ts-import-providers.ts index fe9af945e..f0baf2171 100644 --- a/packages/fastify-generators/src/generators/stripe/fastify-stripe/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/stripe/fastify-stripe/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { STRIPE_FASTIFY_STRIPE_PATHS } from './template-paths.js'; -const fastifyStripeImportsSchema = createTsImportMapSchema({ +export const fastifyStripeImportsSchema = createTsImportMapSchema({ stripe: {}, StripeEventHandler: { isTypeOnly: true }, stripeEventService: {}, diff --git a/packages/fastify-generators/src/generators/vitest/prisma-vitest/generated/ts-import-providers.ts b/packages/fastify-generators/src/generators/vitest/prisma-vitest/generated/ts-import-providers.ts index 3e2d01ad6..32f1618a0 100644 --- a/packages/fastify-generators/src/generators/vitest/prisma-vitest/generated/ts-import-providers.ts +++ b/packages/fastify-generators/src/generators/vitest/prisma-vitest/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { VITEST_PRISMA_VITEST_PATHS } from './template-paths.js'; -const prismaVitestImportsSchema = createTsImportMapSchema({ +export const prismaVitestImportsSchema = createTsImportMapSchema({ createTestDatabase: {}, createTestDatabaseFromTemplate: {}, destroyTestDatabase: {}, diff --git a/packages/fastify-generators/src/providers/prisma/prisma-data-transformable.ts b/packages/fastify-generators/src/providers/prisma/prisma-data-transformable.ts deleted file mode 100644 index 22402861f..000000000 --- a/packages/fastify-generators/src/providers/prisma/prisma-data-transformable.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { TsCodeFragment } from '@baseplate-dev/core-generators'; - -import type { ServiceOutputDtoField } from '#src/types/service-output.js'; - -export interface PrismaDataTransformInputField { - type: TsCodeFragment; - dtoField: ServiceOutputDtoField; -} - -export interface PrismaDataTransformOutputField { - name: string; - transformer?: TsCodeFragment; - pipeOutputName?: string; - createExpression?: TsCodeFragment | string; - updateExpression?: TsCodeFragment | string; -} - -export interface PrismaDataTransformer { - inputFields: PrismaDataTransformInputField[]; - outputFields: PrismaDataTransformOutputField[]; - isAsync: boolean; - needsContext?: boolean; - needsExistingItem?: boolean; -} - -export interface PrismaDataTransformerOptions { - operationType: 'create' | 'update' | 'upsert'; -} - -export interface PrismaDataTransformerFactory { - buildTransformer: ( - transformerOptions: PrismaDataTransformerOptions, - ) => PrismaDataTransformer; -} diff --git a/packages/fastify-generators/src/types/prisma-output.test-helper.ts b/packages/fastify-generators/src/types/prisma-output.test-helper.ts new file mode 100644 index 000000000..75aeab50e --- /dev/null +++ b/packages/fastify-generators/src/types/prisma-output.test-helper.ts @@ -0,0 +1,36 @@ +import type { + PrismaOutputRelationField, + PrismaOutputScalarField, +} from './prisma-output.js'; + +// Helper to create a scalar field +export const createMockScalarField = ( + name: string, +): PrismaOutputScalarField => ({ + name, + type: 'scalar', + id: false, + isOptional: false, + isList: false, + hasDefault: false, + order: 0, + scalarType: 'string', +}); + +// Helper to create a relation field +export const createMockRelationField = ( + name: string, + fields: string[], + references: string[], + isOptional = false, +): PrismaOutputRelationField => ({ + name, + type: 'relation', + id: false, + isOptional, + isList: false, + hasDefault: false, + modelType: 'Related', + fields, + references, +}); diff --git a/packages/fastify-generators/src/types/service-dto-kinds.ts b/packages/fastify-generators/src/types/service-dto-kinds.ts new file mode 100644 index 000000000..d49d80ff8 --- /dev/null +++ b/packages/fastify-generators/src/types/service-dto-kinds.ts @@ -0,0 +1,86 @@ +/** + * Brand type for service DTO kinds with associated metadata. + * + * This allows creating strongly-typed kinds where each kind has + * optional associated metadata type that is enforced at compile time + * + * @template TMetadata - The metadata type associated with this kind + * + * @example + * ```typescript + * const myKind = createServiceDtoKind<{ foo: string }>('my-kind'); + * // When using this kind, metadata must be { foo: string } + * ``` + */ +export interface ServiceDtoKind { + readonly name: string; + readonly __metadata?: TMetadata; +} + +/** + * Factory function to create a strongly-typed DTO kind. + * + * Use this to define new kinds of injected service arguments. + * The metadata type parameter defines what metadata is required when using this kind. + * + * @template TName - The string literal name for this kind + * @template TMetadata - The metadata type for this kind (defaults to empty object) + * @param name - The name of the kind + * @returns A strongly-typed kind definition + * + * @example + * ```typescript + * // Kind with no metadata + * const simpleKind = createServiceDtoKind<'simple', {}>('simple'); + * + * // Kind with required metadata + * const complexKind = createServiceDtoKind<'complex', { + * required: string; + * optional?: number; + * }>('complex'); + * ``` + */ +function createServiceDtoKind( + name: string, +): ServiceDtoKind { + return { name }; +} + +/** + * Extract the metadata type from a kind. + * + * @template TKind - The service DTO kind to extract metadata from + * + * @example + * ```typescript + * const kind = createServiceDtoKind<'test', { value: number }>('test'); + * type Meta = InferKindMetadata; // { value: number } + * ``` + */ +export type InferKindMetadata = + TKind extends ServiceDtoKind ? TMeta : never; + +/** + * Context argument kind. + * + * Provides service context (user info, request details, etc.) to the service function. + * This argument is injected by the framework at runtime. + */ +export const contextKind = createServiceDtoKind('context'); + +/** + * Prisma query argument kind. + * + * Provides Prisma select/include query options to shape the returned data. + * This argument is typically constructed from GraphQL selection sets. + */ +export const prismaQueryKind = createServiceDtoKind('prisma-query'); + +/** + * Where unique input argument kind. + * + * Maps an input field (typically 'id') to a Prisma where unique input. + */ +export const prismaWhereUniqueInputKind = createServiceDtoKind<{ + idFields: string[]; +}>('prisma-where-unique-input'); diff --git a/packages/fastify-generators/src/types/service-output.ts b/packages/fastify-generators/src/types/service-output.ts index fde2a3bdc..54e8f0bd8 100644 --- a/packages/fastify-generators/src/types/service-output.ts +++ b/packages/fastify-generators/src/types/service-output.ts @@ -8,6 +8,7 @@ import type { PrismaOutputRelationField, PrismaOutputScalarField, } from './prisma-output.js'; +import type { InferKindMetadata, ServiceDtoKind } from './service-dto-kinds.js'; // TODO: Rename ServiceOutput to Service since it's both inputs and outputs @@ -58,10 +59,50 @@ export interface ServiceOutputDtoNestedFieldWithPrisma schemaFieldName?: string; } +/** + * Injected arg - a service argument that is provided by the framework + * at runtime rather than from user input. + * + * Examples: context, query, id + * + * The kind and metadata are strongly typed based on the TKind parameter. + * + * @template TKind - The service DTO kind that defines the type and metadata + */ +export interface ServiceOutputDtoInjectedArg< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TKind extends ServiceDtoKind = ServiceDtoKind, +> extends ServiceOutputDtoBaseField { + type: 'injected'; + kind: TKind; + metadata: InferKindMetadata; +} + +export function createServiceOutputDtoInjectedArg< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TKind extends ServiceDtoKind, +>( + arg: undefined extends InferKindMetadata + ? Omit, 'metadata'> + : ServiceOutputDtoInjectedArg, +): ServiceOutputDtoInjectedArg { + return { + ...arg, + metadata: + 'metadata' in arg + ? arg.metadata + : (undefined as InferKindMetadata), + }; +} + export type ServiceOutputDtoField = | ServiceOutputDtoScalarField | ServiceOutputDtoNestedField; +export type ServiceOutputArgField = + | ServiceOutputDtoField + | ServiceOutputDtoInjectedArg; + export interface ServiceOutputDto { name: string; fields: ServiceOutputDtoField[]; @@ -73,12 +114,12 @@ export interface ServiceOutputMethod { * Fragment that references the method. */ referenceFragment: TsCodeFragment; - arguments: ServiceOutputDtoField[]; + arguments: ServiceOutputArgField[]; returnType: ServiceOutputDto; requiresContext?: boolean; } -export function scalarPrismaFieldToServiceField( +export function scalarPrismaFieldToServiceOutputField( field: PrismaOutputScalarField, lookupEnum: (name: string) => ServiceOutputEnum, ): ServiceOutputDtoField { @@ -98,6 +139,23 @@ export function scalarPrismaFieldToServiceField( }; } +export function scalarPrismaFieldToServiceInputField( + field: PrismaOutputScalarField, + lookupEnum: (name: string) => ServiceOutputEnum, +): ServiceOutputDtoScalarField { + return { + type: 'scalar', + name: field.name, + isOptional: field.hasDefault || field.isOptional, + isNullable: field.isOptional, + isList: field.isList, + scalarType: field.scalarType, + enumType: + field.enumType === undefined ? undefined : lookupEnum(field.enumType), + isId: field.id, + }; +} + export function nestedPrismaFieldToServiceField( field: PrismaOutputRelationField, ): ServiceOutputDtoNestedField { @@ -122,7 +180,7 @@ export function prismaToServiceOutputDto( name: model.name, fields: model.fields.map((field) => field.type === 'scalar' - ? scalarPrismaFieldToServiceField(field, lookupEnum) + ? scalarPrismaFieldToServiceOutputField(field, lookupEnum) : nestedPrismaFieldToServiceField(field), ), }; diff --git a/packages/fastify-generators/src/vitest.d.ts b/packages/fastify-generators/src/vitest.d.ts new file mode 100644 index 000000000..46c6b1317 --- /dev/null +++ b/packages/fastify-generators/src/vitest.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/fastify-generators/vitest.config.js b/packages/fastify-generators/vitest.config.js new file mode 100644 index 000000000..7562826f0 --- /dev/null +++ b/packages/fastify-generators/vitest.config.js @@ -0,0 +1,10 @@ +import { createNodeVitestConfig } from '@baseplate-dev/tools/vitest-node'; +import { mergeConfig } from 'vitest/config'; + +const baseConfig = createNodeVitestConfig(import.meta.dirname); + +export default mergeConfig(baseConfig, { + test: { + setupFiles: ['@baseplate-dev/core-generators/test-helpers/setup'], + }, +}); diff --git a/packages/project-builder-lib/src/compiler/model-transformer-compiler-spec.ts b/packages/project-builder-lib/src/compiler/model-transformer-compiler-spec.ts index 0ac0c7069..aba20d785 100644 --- a/packages/project-builder-lib/src/compiler/model-transformer-compiler-spec.ts +++ b/packages/project-builder-lib/src/compiler/model-transformer-compiler-spec.ts @@ -12,7 +12,7 @@ export interface ModelTransformerCompiler< T extends TransformerConfig = TransformerConfig, > { name: string; - compileTransformer: ( + compileField: ( definition: T, { definitionContainer, diff --git a/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts b/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts index c1271ae68..19c87bda6 100644 --- a/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts +++ b/packages/project-builder-lib/src/schema/models/transformers/built-in-transformers.ts @@ -13,24 +13,6 @@ import { } from '../types.js'; import { baseTransformerFields, createModelTransformerType } from './types.js'; -export const createPasswordTransformerSchema = definitionSchema((ctx) => - ctx.withEnt( - z.object({ - ...baseTransformerFields, - type: z.literal('password'), - }), - { - type: modelTransformerEntityType, - parentPath: { context: 'model' }, - getNameResolver: () => 'password', - }, - ), -); - -export type PasswordTransformerConfig = def.InferOutput< - typeof createPasswordTransformerSchema ->; - export const createEmbeddedRelationTransformerSchema = definitionSchema((ctx) => ctx.withRefBuilder( ctx.withEnt( @@ -84,11 +66,6 @@ export type EmbeddedRelationTransformerConfig = def.InferOutput< >; export const BUILT_IN_TRANSFORMERS = [ - createModelTransformerType({ - name: 'password', - createSchema: createPasswordTransformerSchema, - getName: () => 'Password', - }), createModelTransformerType({ name: 'embeddedRelation', createSchema: createEmbeddedRelationTransformerSchema, diff --git a/packages/project-builder-server/src/compiler/backend/fastify.ts b/packages/project-builder-server/src/compiler/backend/fastify.ts index 7015a74f8..42ebf2657 100644 --- a/packages/project-builder-server/src/compiler/backend/fastify.ts +++ b/packages/project-builder-server/src/compiler/backend/fastify.ts @@ -6,6 +6,7 @@ import { axiosGenerator, bullMqGenerator, composeFastifyApplication, + dataUtilsGenerator, fastifyBullBoardGenerator, fastifyPostmarkGenerator, fastifyRedisGenerator, @@ -17,7 +18,6 @@ import { pothosScalarGenerator, pothosSentryGenerator, prismaGenerator, - prismaUtilsGenerator, prismaVitestGenerator, readmeGenerator, yogaPluginGenerator, @@ -83,7 +83,7 @@ export function buildFastify( defaultDatabaseUrl: getPostgresSettings(projectDefinition).url, }), prismaVitest: prismaVitestGenerator({}), - prismaUtils: prismaUtilsGenerator({}), + dataUtils: dataUtilsGenerator({}), yoga: yogaPluginGenerator({ enableSubscriptions: app.enableSubscriptions, }), diff --git a/packages/project-builder-server/src/compiler/backend/graphql.ts b/packages/project-builder-server/src/compiler/backend/graphql.ts index ed9d5fbbf..06ddc84c5 100644 --- a/packages/project-builder-server/src/compiler/backend/graphql.ts +++ b/packages/project-builder-server/src/compiler/backend/graphql.ts @@ -16,7 +16,7 @@ import { pothosTypesFileGenerator, } from '@baseplate-dev/fastify-generators'; import { authConfigSpec, ModelUtils } from '@baseplate-dev/project-builder-lib'; -import { notEmpty } from '@baseplate-dev/utils'; +import { notEmpty, uppercaseFirstChar } from '@baseplate-dev/utils'; import { kebabCase } from 'change-case'; import type { BackendAppEntryBuilder } from '../app-entry-builder.js'; @@ -153,8 +153,7 @@ function buildMutationsFileForModel( const sharedMutationConfig = { modelName: model.name, - crudServiceRef: `prisma-crud-service:${model.name}`, - hasPrimaryKeyInputType: ModelUtils.getModelIdFields(model).length > 1, + crudServiceRef: `prisma-data-service:${model.name}`, }; return pothosTypesFileGenerator({ @@ -165,7 +164,7 @@ function buildMutationsFileForModel( ? pothosPrismaCrudMutationGenerator({ ...sharedMutationConfig, order: 0, - type: 'create', + name: `create${uppercaseFirstChar(model.name)}`, children: { authorize: isAuthEnabled && create.roles @@ -180,7 +179,7 @@ function buildMutationsFileForModel( ? pothosPrismaCrudMutationGenerator({ ...sharedMutationConfig, order: 1, - type: 'update', + name: `update${uppercaseFirstChar(model.name)}`, children: { authorize: isAuthEnabled && update.roles @@ -195,7 +194,7 @@ function buildMutationsFileForModel( ? pothosPrismaCrudMutationGenerator({ ...sharedMutationConfig, order: 2, - type: 'delete', + name: `delete${uppercaseFirstChar(model.name)}`, children: { authorize: isAuthEnabled && del.roles diff --git a/packages/project-builder-server/src/compiler/backend/services.ts b/packages/project-builder-server/src/compiler/backend/services.ts index 110d325d2..aa2404fd6 100644 --- a/packages/project-builder-server/src/compiler/backend/services.ts +++ b/packages/project-builder-server/src/compiler/backend/services.ts @@ -7,20 +7,18 @@ import type { import type { GeneratorBundle } from '@baseplate-dev/sync'; import { - embeddedRelationTransformerGenerator, - prismaCrudCreateGenerator, - prismaCrudDeleteGenerator, - prismaCrudServiceGenerator, - prismaCrudUpdateGenerator, - prismaPasswordTransformerGenerator, + prismaDataCreateGenerator, + prismaDataDeleteGenerator, + prismaDataNestedFieldGenerator, + prismaDataServiceGenerator, + prismaDataUpdateGenerator, serviceFileGenerator, } from '@baseplate-dev/fastify-generators'; import { modelTransformerCompilerSpec, ModelUtils, - undefinedIfEmpty, } from '@baseplate-dev/project-builder-lib'; -import { notEmpty } from '@baseplate-dev/utils'; +import { notEmpty, uppercaseFirstChar } from '@baseplate-dev/utils'; import { kebabCase } from 'change-case'; import type { BackendAppEntryBuilder } from '../app-entry-builder.js'; @@ -28,50 +26,42 @@ import type { BackendAppEntryBuilder } from '../app-entry-builder.js'; const embeddedRelationTransformerCompiler: ModelTransformerCompiler = { name: 'embeddedRelation', - compileTransformer(definition, { definitionContainer, model }) { + compileField(definition, { definitionContainer, model }) { // find foreign relation - const foreignRelation = ModelUtils.getRelationsToModel( + const nestedRelation = ModelUtils.getRelationsToModel( definitionContainer.definition, model.id, ).find( ({ relation }) => relation.foreignId === definition.foreignRelationRef, ); - if (!foreignRelation) { + if (!nestedRelation) { throw new Error( - `Could not find relation ${definition.foreignRelationRef} for embedded relation transformer`, + `Could not find relation ${definition.foreignRelationRef} for nested relation field`, ); } - const foreignModel = foreignRelation.model; - - return embeddedRelationTransformerGenerator({ - name: foreignRelation.relation.foreignRelationName, - embeddedFieldNames: definition.embeddedFieldNames.map((e) => + return prismaDataNestedFieldGenerator({ + modelName: model.name, + relationName: definitionContainer.nameFromId( + definition.foreignRelationRef, + ), + nestedModelName: nestedRelation.model.name, + scalarFieldNames: definition.embeddedFieldNames.map((e) => definitionContainer.nameFromId(e), ), - embeddedTransformerNames: definition.embeddedTransformerNames?.map( - (t) => definitionContainer.nameFromId(t), + virtualInputFieldNames: definition.embeddedTransformerNames?.map((t) => + definitionContainer.nameFromId(t), ), - foreignModelName: definition.embeddedTransformerNames - ? foreignModel.name - : undefined, }); }, }; -const passwordTransformerCompiler: ModelTransformerCompiler = { - name: 'password', - compileTransformer() { - return prismaPasswordTransformerGenerator({}); - }, -}; - -function buildTransformer( +function buildVirtualInputField( appBuilder: BackendAppEntryBuilder, transformer: TransformerConfig, model: ModelConfig, -): GeneratorBundle { +): GeneratorBundle | undefined { const { pluginStore } = appBuilder; const compilerImplementation = pluginStore.getPluginSpec( modelTransformerCompilerSpec, @@ -79,71 +69,74 @@ function buildTransformer( const compiler = compilerImplementation.getModelTransformerCompiler( transformer.type, - [embeddedRelationTransformerCompiler, passwordTransformerCompiler], + [embeddedRelationTransformerCompiler], ); - return compiler.compileTransformer(transformer, { + return compiler.compileField(transformer, { definitionContainer: appBuilder.definitionContainer, model, }); } -function buildServiceForModel( +function buildDataServiceForModel( appBuilder: BackendAppEntryBuilder, model: ModelConfig, ): GeneratorBundle | undefined { - const { service } = model; if (!ModelUtils.hasService(model)) { return undefined; } + const createFields = model.service.create.enabled + ? (model.service.create.fields?.map((f) => appBuilder.nameFromId(f)) ?? []) + : []; + const updateFields = model.service.update.enabled + ? (model.service.update.fields?.map((f) => appBuilder.nameFromId(f)) ?? []) + : []; + const allFields = [...new Set([...createFields, ...updateFields])]; + return serviceFileGenerator({ - name: `${model.name}Service`, - id: `prisma-crud-service:${model.name}`, - fileName: `${kebabCase(model.name)}.crud`, + name: `${model.name}DataService`, + id: `prisma-data-service:${model.name}`, + fileName: `${kebabCase(model.name)}.data-service`, children: { - $crud: prismaCrudServiceGenerator({ + $data: prismaDataServiceGenerator({ modelName: model.name, + modelFieldNames: allFields, children: { - transformers: service.transformers.map((transfomer) => - buildTransformer(appBuilder, transfomer, model), - ), - create: - service.create.fields?.length && service.create.enabled - ? prismaCrudCreateGenerator({ - name: 'create', - order: 1, + $fields: model.service.transformers + .map((transfomer) => + buildVirtualInputField(appBuilder, transfomer, model), + ) + .filter(notEmpty), + $create: + createFields.length > 0 + ? prismaDataCreateGenerator({ + name: `create${uppercaseFirstChar(model.name)}`, modelName: model.name, - prismaFields: service.create.fields.map((f) => - appBuilder.nameFromId(f), - ), - transformerNames: undefinedIfEmpty( - service.create.transformerNames?.map((f) => - appBuilder.nameFromId(f), - ), - ), + fields: [ + ...createFields, + ...(model.service.create.transformerNames?.map((t) => + appBuilder.nameFromId(t), + ) ?? []), + ], }) : undefined, - update: - service.update.fields?.length && service.update.enabled - ? prismaCrudUpdateGenerator({ - name: 'update', - order: 2, + $update: + updateFields.length > 0 + ? prismaDataUpdateGenerator({ + name: `update${uppercaseFirstChar(model.name)}`, modelName: model.name, - prismaFields: service.update.fields.map((f) => - appBuilder.nameFromId(f), - ), - transformerNames: undefinedIfEmpty( - service.update.transformerNames?.map((f) => - appBuilder.nameFromId(f), - ), - ), + fields: [ + ...updateFields, + ...(model.service.update.transformerNames?.map((t) => + appBuilder.nameFromId(t), + ) ?? []), + ], }) : undefined, - delete: service.delete.enabled - ? prismaCrudDeleteGenerator({ - name: 'delete', - order: 3, + $delete: model.service.delete.enabled + ? prismaDataDeleteGenerator({ + name: `delete${uppercaseFirstChar(model.name)}`, modelName: model.name, }) : undefined, @@ -168,6 +161,6 @@ export function buildServicesForFeature( m.service.transformers.length > 0, ); return models - .map((model) => buildServiceForModel(appBuilder, model)) + .map((model) => buildDataServiceForModel(appBuilder, model)) .filter(notEmpty); } diff --git a/packages/react-generators/src/generators/admin/admin-components/generated/ts-import-providers.ts b/packages/react-generators/src/generators/admin/admin-components/generated/ts-import-providers.ts index b7e7708be..e111a56fb 100644 --- a/packages/react-generators/src/generators/admin/admin-components/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/admin/admin-components/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { ADMIN_ADMIN_COMPONENTS_PATHS } from './template-paths.js'; -const adminComponentsImportsSchema = createTsImportMapSchema({ +export const adminComponentsImportsSchema = createTsImportMapSchema({ EmbeddedListField: {}, EmbeddedListFieldController: {}, EmbeddedListFieldProps: { isTypeOnly: true }, diff --git a/packages/react-generators/src/generators/admin/admin-layout/generated/ts-import-providers.ts b/packages/react-generators/src/generators/admin/admin-layout/generated/ts-import-providers.ts index ed57a2fc3..b03a2ccb9 100644 --- a/packages/react-generators/src/generators/admin/admin-layout/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/admin/admin-layout/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { ADMIN_ADMIN_LAYOUT_PATHS } from './template-paths.js'; -const adminLayoutImportsSchema = createTsImportMapSchema({ Route: {} }); +export const adminLayoutImportsSchema = createTsImportMapSchema({ Route: {} }); export type AdminLayoutImportsProvider = TsImportMapProviderFromSchema< typeof adminLayoutImportsSchema diff --git a/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-providers.ts b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-providers.ts index 492e1394f..c8d30c11e 100644 --- a/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { APOLLO_APOLLO_ERROR_PATHS } from './template-paths.js'; -const apolloErrorImportsSchema = createTsImportMapSchema({ +export const apolloErrorImportsSchema = createTsImportMapSchema({ getApolloErrorCode: {}, }); diff --git a/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-providers.ts b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-providers.ts index 85c3a3177..648a8dadd 100644 --- a/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-providers.ts @@ -17,7 +17,7 @@ import { import { APOLLO_REACT_APOLLO_PATHS } from './template-paths.js'; -const reactApolloImportsSchema = createTsImportMapSchema({ +export const reactApolloImportsSchema = createTsImportMapSchema({ config: {}, createApolloCache: {}, createApolloClient: {}, diff --git a/packages/react-generators/src/generators/auth/auth-errors/generated/ts-import-providers.ts b/packages/react-generators/src/generators/auth/auth-errors/generated/ts-import-providers.ts index d6011c826..7a4a7cbd6 100644 --- a/packages/react-generators/src/generators/auth/auth-errors/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/auth/auth-errors/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { AUTH_AUTH_ERRORS_PATHS } from './template-paths.js'; -const authErrorsImportsSchema = createTsImportMapSchema({ +export const authErrorsImportsSchema = createTsImportMapSchema({ InvalidRoleError: {}, }); diff --git a/packages/react-generators/src/generators/core/react-components/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-components/generated/ts-import-providers.ts index 1b1cd9823..3f00fd8c1 100644 --- a/packages/react-generators/src/generators/core/react-components/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-components/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_COMPONENTS_PATHS } from './template-paths.js'; -const reactComponentsImportsSchema = createTsImportMapSchema({ +export const reactComponentsImportsSchema = createTsImportMapSchema({ AddOptionRequiredFields: { isTypeOnly: true }, Alert: {}, AlertDescription: {}, diff --git a/packages/react-generators/src/generators/core/react-config/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-config/generated/ts-import-providers.ts index 6c87c88c5..b58edd4c9 100644 --- a/packages/react-generators/src/generators/core/react-config/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-config/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_CONFIG_PATHS } from './template-paths.js'; -const reactConfigImportsSchema = createTsImportMapSchema({ config: {} }); +export const reactConfigImportsSchema = createTsImportMapSchema({ config: {} }); export type ReactConfigImportsProvider = TsImportMapProviderFromSchema< typeof reactConfigImportsSchema diff --git a/packages/react-generators/src/generators/core/react-error/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-error/generated/ts-import-providers.ts index 575da8335..8d05a38a0 100644 --- a/packages/react-generators/src/generators/core/react-error/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-error/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_ERROR_PATHS } from './template-paths.js'; -const reactErrorImportsSchema = createTsImportMapSchema({ +export const reactErrorImportsSchema = createTsImportMapSchema({ formatError: {}, logAndFormatError: {}, logError: {}, diff --git a/packages/react-generators/src/generators/core/react-logger/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-logger/generated/ts-import-providers.ts index 111325ae0..3772ce34f 100644 --- a/packages/react-generators/src/generators/core/react-logger/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-logger/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_LOGGER_PATHS } from './template-paths.js'; -const reactLoggerImportsSchema = createTsImportMapSchema({ logger: {} }); +export const reactLoggerImportsSchema = createTsImportMapSchema({ logger: {} }); export type ReactLoggerImportsProvider = TsImportMapProviderFromSchema< typeof reactLoggerImportsSchema diff --git a/packages/react-generators/src/generators/core/react-router/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-router/generated/ts-import-providers.ts index c3adc8df9..aab2d42e9 100644 --- a/packages/react-generators/src/generators/core/react-router/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-router/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_ROUTER_PATHS } from './template-paths.js'; -const reactRouterImportsSchema = createTsImportMapSchema({ +export const reactRouterImportsSchema = createTsImportMapSchema({ AppRoutes: {}, router: {}, }); diff --git a/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-providers.ts index e253c5bc8..d8475a7d7 100644 --- a/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_SENTRY_PATHS } from './template-paths.js'; -const reactSentryImportsSchema = createTsImportMapSchema({ +export const reactSentryImportsSchema = createTsImportMapSchema({ logBreadcrumbToSentry: {}, logErrorToSentry: {}, }); diff --git a/packages/react-generators/src/generators/core/react-utils/generated/ts-import-providers.ts b/packages/react-generators/src/generators/core/react-utils/generated/ts-import-providers.ts index b02c13ae9..59cdfca79 100644 --- a/packages/react-generators/src/generators/core/react-utils/generated/ts-import-providers.ts +++ b/packages/react-generators/src/generators/core/react-utils/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { CORE_REACT_UTILS_PATHS } from './template-paths.js'; -const reactUtilsImportsSchema = createTsImportMapSchema({ +export const reactUtilsImportsSchema = createTsImportMapSchema({ getSafeLocalStorage: {}, }); diff --git a/packages/sync/src/runner/dependency-map.ts b/packages/sync/src/runner/dependency-map.ts index 990ff1d55..f51e2d9c7 100644 --- a/packages/sync/src/runner/dependency-map.ts +++ b/packages/sync/src/runner/dependency-map.ts @@ -174,7 +174,7 @@ function buildTaskDependencyMap( generatorIdToScopesMap[parentEntryId]?.providers.get(providerId); if (!resolvedTask) { - if (!optional || exportName) { + if (!optional) { throw new Error( `Could not resolve dependency ${provider}${exportName ? ` (${exportName})` : ''} for ${entry.id} (generator ${entry.generatorInfo.name})`, ); diff --git a/packages/sync/src/templates/metadata/read-template-info-files.ts b/packages/sync/src/templates/metadata/read-template-info-files.ts index b1c45705f..235eadc07 100644 --- a/packages/sync/src/templates/metadata/read-template-info-files.ts +++ b/packages/sync/src/templates/metadata/read-template-info-files.ts @@ -33,7 +33,11 @@ export async function readTemplateInfoFiles( ignoreInstance?: ignore.Ignore, ): Promise { const templateInfoFiles = await globby( - [path.join('**', TEMPLATES_INFO_FILENAME)], + [ + path.posix.join('**', TEMPLATES_INFO_FILENAME), + '!/apps/**', + '!/packages/**', + ], { absolute: true, onlyFiles: true, diff --git a/packages/sync/src/utils/create-config-provider-task-with-info.ts b/packages/sync/src/utils/create-config-provider-task-with-info.ts index 8d50abd70..65c9756f4 100644 --- a/packages/sync/src/utils/create-config-provider-task-with-info.ts +++ b/packages/sync/src/utils/create-config-provider-task-with-info.ts @@ -8,7 +8,7 @@ import { createFieldMap } from '@baseplate-dev/utils'; import type { GeneratorTask } from '#src/generators/generators.js'; import type { ProviderExportScope } from '#src/providers/export-scopes.js'; -import type { ProviderType } from '#src/providers/providers.js'; +import type { ProviderExport, ProviderType } from '#src/providers/providers.js'; import { createGeneratorTask } from '#src/generators/generators.js'; import { @@ -19,10 +19,7 @@ import { /** * Options for creating a configuration provider task with additional info */ -interface ConfigProviderTaskWithInfoOptions< - Descriptor extends Record, - InfoFromDescriptor extends Record, -> { +interface ConfigProviderTaskWithInfoOptions { /** * The prefix for the providers */ @@ -36,11 +33,15 @@ interface ConfigProviderTaskWithInfoOptions< /** * The scope for the config provider */ - configScope?: ProviderExportScope; + configScope?: + | ProviderExportScope + | ((provider: ProviderType, descriptor: Descriptor) => ProviderExport); /** * The scope for the config values provider */ - configValuesScope?: ProviderExportScope; + configValuesScope?: + | ProviderExportScope + | ((provider: ProviderType, descriptor: Descriptor) => ProviderExport); /** * Function to extract additional info from the descriptor */ @@ -116,9 +117,17 @@ export function createConfigProviderTaskWithInfo< return [ (descriptor) => createGeneratorTask({ - exports: { config: configProvider.export(configScope) }, + exports: { + config: + typeof configScope === 'function' + ? configScope(configProvider, descriptor) + : configProvider.export(configScope), + }, outputs: { - configValues: configValuesProvider.export(configValuesScope), + configValues: + typeof configValuesScope === 'function' + ? configValuesScope(configValuesProvider, descriptor) + : configValuesProvider.export(configValuesScope), }, run() { const config = createFieldMap(schemaBuilder); diff --git a/packages/tools/eslint-configs/typescript.js b/packages/tools/eslint-configs/typescript.js index dbee330aa..9e0455678 100644 --- a/packages/tools/eslint-configs/typescript.js +++ b/packages/tools/eslint-configs/typescript.js @@ -33,6 +33,7 @@ export function generateTypescriptEslintConfig(options = []) { '**/*.test.{js,ts,jsx,tsx}', '**/*.bench.{js,ts,jsx,tsx}', '**/tests/**/*', + '**/test-helpers/**/*', '**/__mocks__/**/*', // allow dev dependencies for config files at root level '*.{js,ts}', diff --git a/packages/tools/vitest.config.node.js b/packages/tools/vitest.config.node.js index ae472920b..885e050bb 100644 --- a/packages/tools/vitest.config.node.js +++ b/packages/tools/vitest.config.node.js @@ -21,7 +21,11 @@ export function createNodeVitestConfig(dirname) { }, }, mockReset: true, - exclude: [...defaultExclude, '**/e2e/**'], + exclude: [ + ...defaultExclude, + '**/e2e/**', + '**/generators/*/*/templates/**', + ], }, }); } diff --git a/packages/utils/src/string/case.ts b/packages/utils/src/string/case.ts new file mode 100644 index 000000000..9c59a2b5d --- /dev/null +++ b/packages/utils/src/string/case.ts @@ -0,0 +1,39 @@ +/** + * Lowercase the first character of a string, leaving the remainder unchanged. + * + * - Returns the input unchanged when it's an empty string + * - Non-alphabetic first characters are returned as-is + * + * @param str - The input string + * @returns The string with the first character lowercased + * @example + * lowercaseFirstChar('Hello') // 'hello' + * lowercaseFirstChar('hello') // 'hello' + * lowercaseFirstChar('1World') // '1World' + */ +export function lowercaseFirstChar(str: string): string { + if (str.length === 0) { + return str; + } + return str.charAt(0).toLowerCase() + str.slice(1); +} + +/** + * Uppercase the first character of a string, leaving the remainder unchanged. + * + * - Returns the input unchanged when it's an empty string + * - Non-alphabetic first characters are returned as-is + * + * @param str - The input string + * @returns The string with the first character uppercased + * @example + * uppercaseFirstChar('hello') // 'Hello' + * uppercaseFirstChar('Hello') // 'Hello' + * uppercaseFirstChar('#tag') // '#tag' + */ +export function uppercaseFirstChar(str: string): string { + if (str.length === 0) { + return str; + } + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/utils/src/string/case.unit.test.ts b/packages/utils/src/string/case.unit.test.ts new file mode 100644 index 000000000..7c79b77fa --- /dev/null +++ b/packages/utils/src/string/case.unit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { lowercaseFirstChar, uppercaseFirstChar } from './case.js'; + +describe('case utilities', () => { + describe('lowercaseFirstChar', () => { + it('lowercases the first character of a normal word', () => { + // Arrange + const input = 'HelloWorld'; + + // Act + const result = lowercaseFirstChar(input); + + // Assert + expect(result).toBe('helloWorld'); + }); + + it('returns the same string if already lowercased first char', () => { + const input = 'helloWorld'; + const result = lowercaseFirstChar(input); + expect(result).toBe('helloWorld'); + }); + + it('handles empty string', () => { + const input = ''; + const result = lowercaseFirstChar(input); + expect(result).toBe(''); + }); + + it('leaves non-alphabetic first characters unchanged', () => { + const input = '1World'; + const result = lowercaseFirstChar(input); + expect(result).toBe('1World'); + }); + + it('handles unicode characters (emoji) at start', () => { + const input = '😀Smile'; + const result = lowercaseFirstChar(input); + expect(result).toBe('😀Smile'); + }); + }); + + describe('uppercaseFirstChar', () => { + it('uppercases the first character of a normal word', () => { + const input = 'helloWorld'; + const result = uppercaseFirstChar(input); + expect(result).toBe('HelloWorld'); + }); + + it('returns the same string if already uppercased first char', () => { + const input = 'HelloWorld'; + const result = uppercaseFirstChar(input); + expect(result).toBe('HelloWorld'); + }); + + it('handles empty string', () => { + const input = ''; + const result = uppercaseFirstChar(input); + expect(result).toBe(''); + }); + + it('leaves non-alphabetic first characters unchanged', () => { + const input = '#tag'; + const result = uppercaseFirstChar(input); + expect(result).toBe('#tag'); + }); + + it('handles unicode characters (emoji) at start', () => { + const input = '😀smile'; + const result = uppercaseFirstChar(input); + expect(result).toBe('😀smile'); + }); + }); +}); diff --git a/packages/utils/src/string/index.ts b/packages/utils/src/string/index.ts index bc9576b61..c2fcaff56 100644 --- a/packages/utils/src/string/index.ts +++ b/packages/utils/src/string/index.ts @@ -1,3 +1,4 @@ +export * from './case.js'; export * from './convert-case-with-prefix.js'; export * from './find-closest-match.js'; export * from './quot.js'; diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/generated/ts-import-providers.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/generated/ts-import-providers.ts index 3b62f684c..550226803 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/generated/ts-import-providers.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { LOCAL_AUTH_CORE_AUTH_EMAIL_PASSWORD_PATHS } from './template-paths.js'; -const authEmailPasswordImportsSchema = createTsImportMapSchema({ +export const authEmailPasswordImportsSchema = createTsImportMapSchema({ authenticateUserWithEmailAndPassword: {}, createUserWithEmailAndPassword: {}, PASSWORD_MIN_LENGTH: {}, diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-hooks/generated/ts-import-providers.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-hooks/generated/ts-import-providers.ts index 0171b04fe..66b883887 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-hooks/generated/ts-import-providers.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-hooks/generated/ts-import-providers.ts @@ -16,7 +16,7 @@ import { import { LOCAL_AUTH_CORE_AUTH_HOOKS_PATHS } from './template-paths.js'; -const localAuthHooksImportsSchema = createTsImportMapSchema({ +export const localAuthHooksImportsSchema = createTsImportMapSchema({ AuthSessionContext: {}, }); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-module/generated/ts-import-providers.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-module/generated/ts-import-providers.ts index 4e838bdd4..e9c946cd0 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-module/generated/ts-import-providers.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-module/generated/ts-import-providers.ts @@ -16,7 +16,7 @@ import { import { LOCAL_AUTH_CORE_AUTH_MODULE_PATHS } from './template-paths.js'; -const authModuleImportsSchema = createTsImportMapSchema({ +export const authModuleImportsSchema = createTsImportMapSchema({ userSessionPayload: {}, }); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/react-session/generated/ts-import-providers.ts b/plugins/plugin-auth/src/local-auth/core/generators/react-session/generated/ts-import-providers.ts index 473a5baf7..4a6b858dd 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/react-session/generated/ts-import-providers.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/react-session/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { LOCAL_AUTH_CORE_REACT_SESSION_PATHS } from './template-paths.js'; -const reactSessionImportsSchema = createTsImportMapSchema({ +export const reactSessionImportsSchema = createTsImportMapSchema({ userSessionClient: {}, UserSessionClient: {}, }); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/seed-initial-user/generated/ts-import-providers.ts b/plugins/plugin-auth/src/local-auth/core/generators/seed-initial-user/generated/ts-import-providers.ts index f5450c2f3..1841d170d 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/seed-initial-user/generated/ts-import-providers.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/seed-initial-user/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { LOCAL_AUTH_CORE_SEED_INITIAL_USER_PATHS } from './template-paths.js'; -const seedInitialUserImportsSchema = createTsImportMapSchema({ +export const seedInitialUserImportsSchema = createTsImportMapSchema({ seedInitialUser: {}, }); diff --git a/plugins/plugin-queue/src/queue/core/generators/queues/generated/ts-import-providers.ts b/plugins/plugin-queue/src/queue/core/generators/queues/generated/ts-import-providers.ts index 6a806347f..c0d911d35 100644 --- a/plugins/plugin-queue/src/queue/core/generators/queues/generated/ts-import-providers.ts +++ b/plugins/plugin-queue/src/queue/core/generators/queues/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { QUEUE_CORE_QUEUES_PATHS } from './template-paths.js'; -const queuesImportsSchema = createTsImportMapSchema({ +export const queuesImportsSchema = createTsImportMapSchema({ EnqueueOptions: { isTypeOnly: true }, Queue: { isTypeOnly: true }, QUEUE_REGISTRY: {}, diff --git a/plugins/plugin-storage/src/generators/fastify/file-data-field/file-data-field.generator.ts b/plugins/plugin-storage/src/generators/fastify/file-data-field/file-data-field.generator.ts new file mode 100644 index 000000000..83e3eb3be --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/file-data-field/file-data-field.generator.ts @@ -0,0 +1,129 @@ +import type { + PrismaOutputRelationField, + ServiceOutputDtoNestedField, +} from '@baseplate-dev/fastify-generators'; + +import { TsCodeUtils, tsTemplate } from '@baseplate-dev/core-generators'; +import { + prismaDataServiceSetupProvider, + prismaOutputProvider, +} from '@baseplate-dev/fastify-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { quot } from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import { fileCategoriesProvider } from '#src/storage/core/generators/file-categories/file-categories.generator.js'; + +import { storageModuleImportsProvider } from '../storage-module/index.js'; + +const descriptorSchema = z.object({ + /** Name of the parent model */ + modelName: z.string().min(1), + /** Name of the file relation */ + relationName: z.string().min(1), + /** File category name */ + category: z.string().min(1), + /** Feature ID for file categories lookup */ + featureId: z.string().min(1), +}); + +/** + * Generator for fastify/file-data-field + * + * Creates a virtual input field for file uploads in data services. + * This replaces the old transformer-based approach with a field-based approach + * that integrates with the new data operations system. + */ +export const fileDataFieldGenerator = createGenerator({ + name: 'fastify/file-data-field', + generatorFileUrl: import.meta.url, + descriptorSchema, + getInstanceName: (descriptor) => descriptor.relationName, + buildTasks: ({ modelName, relationName, category, featureId }) => ({ + main: createGeneratorTask({ + dependencies: { + prismaOutput: prismaOutputProvider, + storageModuleImports: storageModuleImportsProvider, + prismaDataServiceSetup: prismaDataServiceSetupProvider, + fileCategories: fileCategoriesProvider + .dependency() + .reference(featureId), + }, + exports: {}, + run({ + prismaOutput, + storageModuleImports, + prismaDataServiceSetup, + fileCategories, + }) { + const model = prismaOutput.getPrismaModel(modelName); + + // Find the file relation + const relation = model.fields.find( + (f): f is PrismaOutputRelationField => + f.type === 'relation' && f.name === relationName, + ); + + if (!relation) { + throw new Error( + `Could not find relation ${relationName} in model ${modelName}`, + ); + } + + // Validate this is a file relation (should have exactly one field) + if (!relation.fields || relation.fields.length !== 1) { + throw new Error( + `File relation ${relationName} in model ${modelName} must have exactly one field (the file ID field)`, + ); + } + + const fileIdFieldName = relation.fields[0]; + + // Get the file category fragment + const fileCategoryFragment = + fileCategories.getFileCategoryImportFragment(category); + + // Create the field configuration object + const fieldConfig = TsCodeUtils.mergeFragmentsAsObject({ + category: fileCategoryFragment, + fileIdFieldName: quot(fileIdFieldName), + optional: relation.isOptional ? 'true' : undefined, + }); + + // Create the field fragment using storageModuleImports.fileField + const fieldFragment = tsTemplate`${storageModuleImports.fileField.fragment()}(${fieldConfig})`; + + // Create the DTO field for FileUploadInput + const outputDtoField = { + name: relationName, + type: 'nested', + isPrismaType: false, + isOptional: relation.isOptional, + isNullable: relation.isOptional, + nestedType: { + name: 'FileUploadInput', + fields: [ + { + type: 'scalar', + scalarType: 'string', + name: 'id', + }, + ], + }, + schemaFieldName: 'FileUploadInput', + } satisfies ServiceOutputDtoNestedField; + + return { + build: () => { + // Add the file field to virtual input fields + prismaDataServiceSetup.virtualInputFields.add({ + name: relationName, + fragment: fieldFragment, + outputDtoField, + }); + }, + }; + }, + }), + }), +}); diff --git a/plugins/plugin-storage/src/generators/fastify/file-data-field/index.ts b/plugins/plugin-storage/src/generators/fastify/file-data-field/index.ts new file mode 100644 index 000000000..1391468fb --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/file-data-field/index.ts @@ -0,0 +1 @@ +export * from './file-data-field.generator.js'; diff --git a/plugins/plugin-storage/src/generators/fastify/index.ts b/plugins/plugin-storage/src/generators/fastify/index.ts index 0d6f25814..6cced13e7 100644 --- a/plugins/plugin-storage/src/generators/fastify/index.ts +++ b/plugins/plugin-storage/src/generators/fastify/index.ts @@ -1,2 +1,2 @@ -export * from './prisma-file-transformer/index.js'; +export * from './file-data-field/index.js'; export * from './storage-module/index.js'; diff --git a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/index.ts b/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/index.ts deleted file mode 100644 index 145220df0..000000000 --- a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './prisma-file-transformer.generator.js'; diff --git a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts b/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts deleted file mode 100644 index 639be08c5..000000000 --- a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { PrismaOutputRelationField } from '@baseplate-dev/fastify-generators'; - -import { - tsCodeFragment, - tsTemplate, - tsTemplateWithImports, -} from '@baseplate-dev/core-generators'; -import { - prismaCrudServiceSetupProvider, - prismaOutputProvider, - prismaUtilsImportsProvider, -} from '@baseplate-dev/fastify-generators'; -import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; -import { z } from 'zod'; - -import { fileCategoriesProvider } from '#src/storage/core/generators/file-categories/file-categories.generator.js'; - -import { storageModuleImportsProvider } from '../storage-module/index.js'; - -const descriptorSchema = z.object({ - name: z.string(), - category: z.string(), - featureId: z.string(), -}); - -export const prismaFileTransformerGenerator = createGenerator({ - name: 'fastify/prisma-file-transformer', - generatorFileUrl: import.meta.url, - descriptorSchema, - getInstanceName: (descriptor) => descriptor.name, - buildTasks: ({ name, category, featureId }) => ({ - main: createGeneratorTask({ - dependencies: { - prismaCrudServiceSetup: prismaCrudServiceSetupProvider, - storageModuleImports: storageModuleImportsProvider, - prismaOutput: prismaOutputProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - fileCategories: fileCategoriesProvider - .dependency() - .reference(featureId), - }, - exports: {}, - run({ - prismaOutput, - prismaCrudServiceSetup, - storageModuleImports, - prismaUtilsImports, - fileCategories, - }) { - const modelName = prismaCrudServiceSetup.getModelName(); - const model = prismaOutput.getPrismaModel(modelName); - - const foreignRelation = model.fields.find( - (f): f is PrismaOutputRelationField => - f.type === 'relation' && f.name === name, - ); - - if (!foreignRelation) { - throw new Error( - `Could not find relation ${name} in model ${modelName}`, - ); - } - - if (foreignRelation.fields?.length !== 1) { - throw new Error( - `Foreign relation for file transformer must only have one field in model ${modelName}`, - ); - } - - const foreignRelationFieldName = foreignRelation.fields[0]; - - prismaCrudServiceSetup.addTransformer(name, { - buildTransformer: ({ operationType }) => { - const isFieldOptional = - operationType === 'update' || foreignRelation.isOptional; - const transformer = tsTemplateWithImports([ - storageModuleImports.validateFileInput.declaration(), - ])`await validateFileInput(${name}, ${fileCategories.getFileCategoryImportFragment(category)}, context${ - operationType === 'create' - ? '' - : `, existingItem${ - operationType === 'upsert' ? '?' : '' - }.${foreignRelationFieldName}` - })`; - - const prefix = isFieldOptional - ? `${name} == null ? ${name} : ` - : ''; - - return { - inputFields: [ - { - type: tsCodeFragment( - `FileUploadInput${foreignRelation.isOptional ? '| null' : ''}`, - storageModuleImports.FileUploadInput.typeDeclaration(), - ), - dtoField: { - name, - type: 'nested', - isOptional: isFieldOptional, - isNullable: foreignRelation.isOptional, - schemaFieldName: 'FileUploadInput', - nestedType: { - name: 'FileUploadInput', - fields: [ - { type: 'scalar', scalarType: 'string', name: 'id' }, - ], - }, - }, - }, - ], - outputFields: [ - { - name, - transformer: tsTemplate`const ${name}Output = ${prefix}${transformer}`, - pipeOutputName: `${name}Output`, - createExpression: isFieldOptional - ? `${name}Output?.data` - : undefined, - updateExpression: foreignRelation.isOptional - ? tsCodeFragment( - `createPrismaDisconnectOrConnectData(${name}Output?.data)`, - prismaUtilsImports.createPrismaDisconnectOrConnectData.declaration(), - ) - : `${name}Output${operationType === 'upsert' ? '' : '?'}.data`, - }, - ], - isAsync: true, - needsExistingItem: true, - needsContext: true, - }; - }, - }); - return { - providers: { - prismaFileTransformer: {}, - }, - }; - }, - }), - }), -}); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json index 57d8efb40..281d6c6f2 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json @@ -181,6 +181,40 @@ "sourceFile": "module/services/download-file.ts", "variables": { "TPL_FILE_MODEL": {} } }, + "services-file-field": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "main", + "importMapProviders": { + "dataUtilsImportsProvider": { + "importName": "dataUtilsImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/data-utils/generated/ts-import-providers.ts" + }, + "errorHandlerServiceImportsProvider": { + "importName": "errorHandlerServiceImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/error-handler-service/generated/ts-import-providers.ts" + }, + "prismaGeneratedImportsProvider": { + "importName": "prismaGeneratedImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" + }, + "prismaImportsProvider": { + "importName": "prismaImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{module-root}/services/file-field.ts", + "projectExports": { + "fileField": { "isTypeOnly": false, "name": "fileField" }, + "FileInput": { "isTypeOnly": true, "name": "FileInput" } + }, + "referencedGeneratorTemplates": [ + "config-adapters", + "types-file-category" + ], + "sourceFile": "module/services/file-field.ts", + "variables": {} + }, "services-get-public-url": { "type": "ts", "fileOptions": { "kind": "singleton" }, @@ -211,36 +245,6 @@ "sourceFile": "module/services/upload-file.ts", "variables": { "TPL_FILE_MODEL": {}, "TPL_FILE_MODEL_TYPE": {} } }, - "services-validate-file-input": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "group": "main", - "importMapProviders": { - "errorHandlerServiceImportsProvider": { - "importName": "errorHandlerServiceImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/error-handler-service/generated/ts-import-providers.ts" - }, - "prismaUtilsImportsProvider": { - "importName": "prismaUtilsImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/prisma-utils/generated/ts-import-providers.ts" - }, - "serviceContextImportsProvider": { - "importName": "serviceContextImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" - } - }, - "pathRootRelativePath": "{module-root}/services/validate-file-input.ts", - "projectExports": { - "FileUploadInput": { "isTypeOnly": true }, - "validateFileInput": {} - }, - "referencedGeneratorTemplates": [ - "config-adapters", - "types-file-category" - ], - "sourceFile": "module/services/validate-file-input.ts", - "variables": { "TPL_FILE_MODEL": {} } - }, "types-adapter": { "type": "ts", "fileOptions": { "kind": "singleton" }, @@ -274,13 +278,18 @@ "projectExports": { "FileCategory": { "isTypeOnly": true } }, "referencedGeneratorTemplates": ["config-adapters"], "sourceFile": "module/types/file-category.ts", - "variables": { "TPL_FILE_COUNT_OUTPUT_TYPE": {} } + "variables": {} }, "utils-create-file-category": { "type": "ts", "fileOptions": { "kind": "singleton" }, "group": "main", - "importMapProviders": {}, + "importMapProviders": { + "prismaGeneratedImportsProvider": { + "importName": "prismaGeneratedImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/prisma/_providers/prisma-generated-imports.ts" + } + }, "pathRootRelativePath": "{module-root}/utils/create-file-category.ts", "projectExports": { "createFileCategory": {}, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts index 1838605f4..8436da6f3 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts @@ -13,9 +13,9 @@ export interface FastifyStorageModulePaths { servicesCreatePresignedDownloadUrl: string; servicesCreatePresignedUploadUrl: string; servicesDownloadFile: string; + servicesFileField: string; servicesGetPublicUrl: string; servicesUploadFile: string; - servicesValidateFileInput: string; typesAdapter: string; typesFileCategory: string; utilsCreateFileCategory: string; @@ -47,9 +47,9 @@ const fastifyStorageModulePathsTask = createGeneratorTask({ servicesCreatePresignedDownloadUrl: `${moduleRoot}/services/create-presigned-download-url.ts`, servicesCreatePresignedUploadUrl: `${moduleRoot}/services/create-presigned-upload-url.ts`, servicesDownloadFile: `${moduleRoot}/services/download-file.ts`, + servicesFileField: `${moduleRoot}/services/file-field.ts`, servicesGetPublicUrl: `${moduleRoot}/services/get-public-url.ts`, servicesUploadFile: `${moduleRoot}/services/upload-file.ts`, - servicesValidateFileInput: `${moduleRoot}/services/validate-file-input.ts`, typesAdapter: `${moduleRoot}/types/adapter.ts`, typesFileCategory: `${moduleRoot}/types/file-category.ts`, utilsCreateFileCategory: `${moduleRoot}/utils/create-file-category.ts`, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts index f5087e113..5c00c7e35 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts @@ -6,10 +6,11 @@ import type { BuilderAction } from '@baseplate-dev/sync'; import { typescriptFileProvider } from '@baseplate-dev/core-generators'; import { + dataUtilsImportsProvider, errorHandlerServiceImportsProvider, pothosImportsProvider, prismaGeneratedImportsProvider, - prismaUtilsImportsProvider, + prismaImportsProvider, serviceContextImportsProvider, } from '@baseplate-dev/fastify-generators'; import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; @@ -77,11 +78,12 @@ const fastifyStorageModuleRenderers = const fastifyStorageModuleRenderersTask = createGeneratorTask({ dependencies: { + dataUtilsImports: dataUtilsImportsProvider, errorHandlerServiceImports: errorHandlerServiceImportsProvider, paths: FASTIFY_STORAGE_MODULE_PATHS.provider, pothosImports: pothosImportsProvider, prismaGeneratedImports: prismaGeneratedImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, + prismaImports: prismaImportsProvider, serviceContextImports: serviceContextImportsProvider, typescriptFile: typescriptFileProvider, }, @@ -89,11 +91,12 @@ const fastifyStorageModuleRenderersTask = createGeneratorTask({ fastifyStorageModuleRenderers: fastifyStorageModuleRenderers.export(), }, run({ + dataUtilsImports, errorHandlerServiceImports, paths, pothosImports, prismaGeneratedImports, - prismaUtilsImports, + prismaImports, serviceContextImports, typescriptFile, }) { @@ -123,9 +126,10 @@ const fastifyStorageModuleRenderersTask = createGeneratorTask({ group: FASTIFY_STORAGE_MODULE_TEMPLATES.mainGroup, paths, importMapProviders: { + dataUtilsImports, errorHandlerServiceImports, prismaGeneratedImports, - prismaUtilsImports, + prismaImports, serviceContextImports, }, generatorPaths: paths, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts index b1745c567..aada03192 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { FASTIFY_STORAGE_MODULE_PATHS } from './template-paths.js'; -const storageModuleImportsSchema = createTsImportMapSchema({ +export const storageModuleImportsSchema = createTsImportMapSchema({ createFileCategory: {}, createPresignedDownloadUrl: {}, CreatePresignedUploadOptions: { isTypeOnly: true }, @@ -23,10 +23,11 @@ const storageModuleImportsSchema = createTsImportMapSchema({ FILE_CATEGORIES: {}, FileCategory: { isTypeOnly: true }, FileCategoryName: { isTypeOnly: true }, + fileField: {}, + FileInput: { isTypeOnly: true }, fileInputInputType: {}, FileMetadata: { isTypeOnly: true }, FileSize: {}, - FileUploadInput: { isTypeOnly: true }, FileUploadOptions: { isTypeOnly: true }, getCategoryByName: {}, getCategoryByNameOrThrow: {}, @@ -39,7 +40,6 @@ const storageModuleImportsSchema = createTsImportMapSchema({ StorageAdapter: { isTypeOnly: true }, StorageAdapterKey: { isTypeOnly: true }, validateFileExtensionWithMimeType: {}, - validateFileInput: {}, validateFileUploadOptions: {}, }); @@ -73,10 +73,11 @@ const fastifyStorageModuleImportsTask = createGeneratorTask({ FILE_CATEGORIES: paths.configCategories, FileCategory: paths.typesFileCategory, FileCategoryName: paths.configCategories, + fileField: paths.servicesFileField, + FileInput: paths.servicesFileField, fileInputInputType: paths.schemaFileInput, FileMetadata: paths.typesAdapter, FileSize: paths.utilsCreateFileCategory, - FileUploadInput: paths.servicesValidateFileInput, FileUploadOptions: paths.utilsValidateFileUploadOptions, getCategoryByName: paths.configCategories, getCategoryByNameOrThrow: paths.configCategories, @@ -89,7 +90,6 @@ const fastifyStorageModuleImportsTask = createGeneratorTask({ StorageAdapter: paths.typesAdapter, StorageAdapterKey: paths.configAdapters, validateFileExtensionWithMimeType: paths.utilsMime, - validateFileInput: paths.servicesValidateFileInput, validateFileUploadOptions: paths.utilsValidateFileUploadOptions, }), }, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts index a35d4055d..fda4aac0b 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts @@ -1,9 +1,10 @@ import { createTsTemplateFile } from '@baseplate-dev/core-generators'; import { + dataUtilsImportsProvider, errorHandlerServiceImportsProvider, pothosImportsProvider, prismaGeneratedImportsProvider, - prismaUtilsImportsProvider, + prismaImportsProvider, serviceContextImportsProvider, } from '@baseplate-dev/fastify-generators'; import path from 'node:path'; @@ -129,43 +130,44 @@ const servicesDownloadFile = createTsTemplateFile({ variables: { TPL_FILE_MODEL: {} }, }); -const servicesUploadFile = createTsTemplateFile({ +const servicesFileField = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'main', - importMapProviders: { serviceContextImports: serviceContextImportsProvider }, - name: 'services-upload-file', - projectExports: {}, - referencedGeneratorTemplates: { utilsValidateFileUploadOptions: {} }, + importMapProviders: { + dataUtilsImports: dataUtilsImportsProvider, + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + prismaGeneratedImports: prismaGeneratedImportsProvider, + prismaImports: prismaImportsProvider, + }, + name: 'services-file-field', + projectExports: { + fileField: { isTypeOnly: false }, + FileInput: { isTypeOnly: true }, + }, + referencedGeneratorTemplates: { configAdapters: {}, typesFileCategory: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/upload-file.ts', + '../templates/module/services/file-field.ts', ), }, - variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, + variables: {}, }); -const servicesValidateFileInput = createTsTemplateFile({ +const servicesUploadFile = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'main', - importMapProviders: { - errorHandlerServiceImports: errorHandlerServiceImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, - name: 'services-validate-file-input', - projectExports: { - FileUploadInput: { isTypeOnly: true }, - validateFileInput: {}, - }, - referencedGeneratorTemplates: { configAdapters: {}, typesFileCategory: {} }, + importMapProviders: { serviceContextImports: serviceContextImportsProvider }, + name: 'services-upload-file', + projectExports: {}, + referencedGeneratorTemplates: { utilsValidateFileUploadOptions: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/validate-file-input.ts', + '../templates/module/services/upload-file.ts', ), }, - variables: { TPL_FILE_MODEL: {} }, + variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, }); const typesAdapter = createTsTemplateFile({ @@ -204,13 +206,15 @@ const typesFileCategory = createTsTemplateFile({ '../templates/module/types/file-category.ts', ), }, - variables: { TPL_FILE_COUNT_OUTPUT_TYPE: {} }, + variables: {}, }); const utilsCreateFileCategory = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'main', - importMapProviders: {}, + importMapProviders: { + prismaGeneratedImports: prismaGeneratedImportsProvider, + }, name: 'utils-create-file-category', projectExports: { createFileCategory: {}, FileSize: {}, MimeTypes: {} }, referencedGeneratorTemplates: { typesFileCategory: {} }, @@ -274,8 +278,8 @@ export const mainGroup = { servicesCreatePresignedDownloadUrl, servicesCreatePresignedUploadUrl, servicesDownloadFile, + servicesFileField, servicesUploadFile, - servicesValidateFileInput, typesAdapter, typesFileCategory, utilsCreateFileCategory, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts index bf35b3ff7..2762a97ee 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts @@ -226,15 +226,6 @@ export const storageModuleGenerator = createGenerator({ TPL_FILE_MODEL: model, TPL_FILE_MODEL_TYPE: modelType, }, - servicesValidateFileInput: { - TPL_FILE_MODEL: model, - }, - typesFileCategory: { - TPL_FILE_COUNT_OUTPUT_TYPE: tsCodeFragment( - `Prisma.${STORAGE_MODELS.file}CountOutputType`, - prismaGeneratedImports.Prisma.typeDeclaration(), - ), - }, utilsValidateFileUploadOptions: { TPL_FILE_CREATE_INPUT: tsCodeFragment( `Prisma.${STORAGE_MODELS.file}CreateInput`, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts new file mode 100644 index 000000000..957ac5380 --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts @@ -0,0 +1,199 @@ +// @ts-nocheck + +import type { FileCategory } from '$typesFileCategory'; +import type { FieldDefinition } from '%dataUtilsImports'; +import type { Prisma } from '%prismaGeneratedImports'; + +import { STORAGE_ADAPTERS } from '$configAdapters'; +import { BadRequestError } from '%errorHandlerServiceImports'; +import { prisma } from '%prismaImports'; + +/** + * File input type - accepts a file ID string + */ +export interface FileInput { + id: string; +} + +/** + * Configuration for file field handler + */ +interface FileFieldConfig< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +> { + /** + * The category of files this field accepts + */ + category: TFileCategory; + /** + * The field name of the file ID in the existing model + */ + fileIdFieldName: keyof Prisma.$FilePayload['objects'][TFileCategory['referencedByRelation']][number]['scalars'] & + string; + + /** + * Whether the file is optional + */ + optional?: TOptional; +} + +/** + * Create a file field handler with validation and authorization + * + * This helper creates a field definition for managing file uploads. + * It validates that: + * - The file exists + * - The user is authorized to use the file (must be uploader or system role) + * - The file hasn't been referenced by another entity + * - The file category matches what's expected + * - The file was successfully uploaded + * + * After validation, it marks the file as referenced and returns a Prisma connect object. + * + * For create operations: + * - Returns connect object if file ID is provided and valid + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns connect object if file ID is provided and valid + * - Returns disconnect if input is null (removes file reference) + * - Returns undefined if input is not provided (no change) + * - Skips validation if the file ID hasn't changed from existing + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * avatar: fileField({ + * category: avatarFileCategory, + * }), + * }; + * ``` + */ +export function fileField< + TFileCategory extends FileCategory, + TOptional extends boolean = false, +>( + config: FileFieldConfig, +): FieldDefinition< + TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? { connect: { id: string } } | undefined + : { connect: { id: string } }, + TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined +> { + return { + processInput: async (value: FileInput | null | undefined, processCtx) => { + const { serviceContext } = processCtx; + + // Handle null - disconnect the file + if (value === null) { + return { + data: { + create: undefined, + update: { disconnect: true } as TOptional extends true + ? { connect: { id: string } } | { disconnect: true } | undefined + : { connect: { id: string } } | undefined, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + // Get existing file ID to check if we're changing it + const existingModel = (await processCtx.loadExisting()) as + | Record + | undefined; + + if (existingModel && !(config.fileIdFieldName in existingModel)) { + throw new BadRequestError( + `File ID field "${config.fileIdFieldName}" not found in existing model`, + ); + } + + const existingFileId = existingModel?.[config.fileIdFieldName]; + + // If we're updating and not changing the ID, skip checks + if (existingFileId === value.id) { + return { + data: { + create: { connect: { id: value.id } }, + update: { connect: { id: value.id } }, + }, + }; + } + + // Validate the file input + const { id } = value; + const isSystemUser = serviceContext.auth.roles.includes('system'); + const uploaderId = isSystemUser ? undefined : serviceContext.auth.userId; + const file = await prisma.file.findUnique({ + where: { id, uploaderId }, + }); + + // Check if file exists + if (!file) { + throw new BadRequestError( + `File with ID "${id}" not found. Please make sure the file exists and you were the original uploader.`, + ); + } + + // Check if file is already referenced + if (file.referencedAt) { + throw new BadRequestError( + `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, + ); + } + + // Check category match + if (file.category !== config.category.name) { + throw new BadRequestError( + `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${config.category.name}". Please upload a file of the correct type.`, + ); + } + + // Validate file was uploaded + if (!(file.adapter in STORAGE_ADAPTERS)) { + throw new BadRequestError( + `Unknown file adapter "${file.adapter}" configured for file "${id}".`, + ); + } + + const adapter = + STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; + + const fileMetadata = await adapter.getFileMetadata(file.storagePath); + if (!fileMetadata) { + throw new BadRequestError(`File "${id}" was not uploaded correctly.`); + } + + return { + data: { + create: { connect: { id } }, + update: { connect: { id } }, + }, + hooks: { + afterExecute: [ + async ({ tx }) => { + await tx.file.update({ + where: { id, referencedAt: null }, + data: { + referencedAt: new Date(), + size: fileMetadata.size, + }, + }); + }, + ], + }, + }; + }, + }; +} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts deleted file mode 100644 index 910c304d7..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @ts-nocheck - -import type { FileCategory } from '$typesFileCategory'; -import type { DataPipeOutput } from '%prismaUtilsImports'; -import type { ServiceContext } from '%serviceContextImports'; - -import { STORAGE_ADAPTERS } from '$configAdapters'; -import { BadRequestError } from '%errorHandlerServiceImports'; - -export interface FileUploadInput { - id: string; -} - -/** - * Validates a file input and checks the upload is authorized - * @param input - The file input - * @param category - The category of the file - * @param context - The service context - * @param existingId - The existing ID of the file (if any) - * @returns The data pipe output - */ -export async function validateFileInput( - { id }: FileUploadInput, - category: FileCategory, - context: ServiceContext, - existingId?: string | null, -): Promise> { - // if we're updating and not changing the ID, skip checks - if (existingId === id) { - return { data: { connect: { id } } }; - } - - const file = await TPL_FILE_MODEL.findUnique({ - where: { id }, - }); - - // Check if file exists - if (!file) { - throw new BadRequestError(`File with ID "${id}" does not exist`); - } - - // Check authorization: must be system role or the uploader - const isSystemUser = context.auth.roles.includes('system'); - const isUploader = file.uploaderId === context.auth.userId; - - if (!isSystemUser && !isUploader) { - throw new BadRequestError( - `Access denied: You can only use files that you uploaded. File "${id}" was uploaded by a different user.`, - ); - } - - // Check if file is already referenced - if (file.referencedAt) { - throw new BadRequestError( - `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, - ); - } - - // Check category match - if (file.category !== category.name) { - throw new BadRequestError( - `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${category.name}". Please upload a file of the correct type.`, - ); - } - - // Validate file was uploaded - const adapter = - STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; - const fileMetadata = await adapter.getFileMetadata(file.storagePath); - if (!fileMetadata) { - throw new BadRequestError(`File "${id}" was not uploaded correctly.`); - } - - return { - data: { connect: { id } }, - operations: { - afterPrismaPromises: [ - TPL_FILE_MODEL.update({ - where: { id }, - data: { - referencedAt: new Date(), - size: fileMetadata.size, - }, - }), - ], - }, - }; -} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts index 6846a52d9..4f3f32c6f 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts @@ -1,14 +1,18 @@ // @ts-nocheck import type { StorageAdapterKey } from '$configAdapters'; -import type { File } from '%prismaGeneratedImports'; +import type { File, Prisma } from '%prismaGeneratedImports'; import type { ServiceContext } from '%serviceContextImports'; /** * Configuration for a file category that specifies how files for a * particular model relation to File model should be handled. */ -export interface FileCategory { +export interface FileCategory< + TName extends string = string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +> { /** Name of category (must be CONSTANT_CASE) */ readonly name: TName; @@ -49,5 +53,5 @@ export interface FileCategory { /** * The relation that references this file category. */ - readonly referencedByRelation: keyof TPL_FILE_COUNT_OUTPUT_TYPE; + readonly referencedByRelation: TReferencedByRelation; } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts index 8c27d8bcb..15a529a6e 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts @@ -1,6 +1,7 @@ // @ts-nocheck import type { FileCategory } from '$typesFileCategory'; +import type { Prisma } from '%prismaGeneratedImports'; // Helper for common file size constraints export const FileSize = { @@ -19,9 +20,13 @@ export const MimeTypes = { ], } as const; -export function createFileCategory( - config: FileCategory, -): FileCategory { +export function createFileCategory< + TName extends string, + TReferencedByRelation extends + keyof Prisma.FileCountOutputType = keyof Prisma.FileCountOutputType, +>( + config: FileCategory, +): FileCategory { if (!/^[A-Z][A-Z0-9_]*$/.test(config.name)) { throw new Error( 'File category name must be CONSTANT_CASE (e.g., USER_AVATAR, POST_IMAGE)', diff --git a/plugins/plugin-storage/src/generators/react/upload-components/generated/ts-import-providers.ts b/plugins/plugin-storage/src/generators/react/upload-components/generated/ts-import-providers.ts index fbfa0fa87..eb6023663 100644 --- a/plugins/plugin-storage/src/generators/react/upload-components/generated/ts-import-providers.ts +++ b/plugins/plugin-storage/src/generators/react/upload-components/generated/ts-import-providers.ts @@ -12,7 +12,7 @@ import { import { REACT_UPLOAD_COMPONENTS_PATHS } from './template-paths.js'; -const uploadComponentsImportsSchema = createTsImportMapSchema({ +export const uploadComponentsImportsSchema = createTsImportMapSchema({ FileInput: { exportedAs: 'default' }, FileInputField: {}, FileInputFieldController: {}, diff --git a/plugins/plugin-storage/src/storage/transformers/node.ts b/plugins/plugin-storage/src/storage/transformers/node.ts index 55a4553ee..8737fe9e6 100644 --- a/plugins/plugin-storage/src/storage/transformers/node.ts +++ b/plugins/plugin-storage/src/storage/transformers/node.ts @@ -5,14 +5,14 @@ import { modelTransformerCompilerSpec, } from '@baseplate-dev/project-builder-lib'; -import { prismaFileTransformerGenerator } from '#src/generators/fastify/index.js'; +import { fileDataFieldGenerator } from '#src/generators/fastify/index.js'; import type { FileTransformerDefinition } from './schema/file-transformer.schema.js'; function buildFileTransformerCompiler(): ModelTransformerCompiler { return { name: 'file', - compileTransformer(definition, { model }) { + compileField(definition, { model }) { const { fileRelationRef, category } = definition; const foreignRelation = model.model.relations?.find( @@ -21,13 +21,14 @@ function buildFileTransformerCompiler(): ModelTransformerCompiler=16.8.0' - '@emnapi/core@1.4.5': - resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} - '@emnapi/runtime@1.4.5': - resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@emnapi/wasi-threads@1.0.4': - resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} '@esbuild/aix-ppc64@0.25.8': resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} @@ -2295,6 +2298,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2311,14 +2317,29 @@ packages: resolution: {integrity: sha512-tHLMjdMJFPFMSJrUuJJiv8l7OFRvM19E9O1B9dhbk+04i3RnYwE9A6oNtSUM1dnvkalzCLwZIuMpti28/tnh8g==} engines: {node: '>=14.0.0', pnpm: '>=7.0.1'} + '@oxc-resolver/binding-android-arm-eabi@11.13.2': + resolution: {integrity: sha512-vWd1NEaclg/t2DtEmYzRRBNQOueMI8tixw/fSNZ9XETXLRJiAjQMYpYeflQdRASloGze6ZelHE/wIBNt4S+pkw==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.13.2': + resolution: {integrity: sha512-jxZrYcxgpI6IuQpguQVAQNrZfUyiYfMVqR4pKVU3PRLCM7AsfXNKp0TIgcvp+l6dYVdoZ1MMMMa5Ayjd09rNOw==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.13.2': + resolution: {integrity: sha512-RDS3HUe1FvgjNS1xfBUqiEJ8938Zb5r7iKABwxEblp3K4ufZZNAtoaHjdUH2TJ0THDmuf0OxxVUO/Y+4Ep4QfQ==} + cpu: [arm64] + os: [darwin] + '@oxc-resolver/binding-darwin-arm64@5.2.0': resolution: {integrity: sha512-3v2eS1swAUZ/OPrBpTB5Imn4Xhbz4zKPa/mugnYCAC4pVt/miBQLBNciBRZG8oyHiGmLtjw/qanZC36uB6MITQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-arm64@9.0.2': - resolution: {integrity: sha512-MVyRgP2gzJJtAowjG/cHN3VQXwNLWnY+FpOEsyvDepJki1SdAX/8XDijM1yN6ESD1kr9uhBKjGelC6h3qtT+rA==} - cpu: [arm64] + '@oxc-resolver/binding-darwin-x64@11.13.2': + resolution: {integrity: sha512-tDcyWtkUzkt6auJLP2dOjL84BxqHkKW4mz2lNRIGPTq7b+HBraB+m8RdRH6BgqTvbnNECOxR3XAMaKBKC8J51g==} + cpu: [x64] os: [darwin] '@oxc-resolver/binding-darwin-x64@5.2.0': @@ -2326,42 +2347,48 @@ packages: cpu: [x64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@9.0.2': - resolution: {integrity: sha512-7kV0EOFEZ3sk5Hjy4+bfA6XOQpCwbDiDkkHN4BHHyrBHsXxUR05EcEJPPL1WjItefg+9+8hrBmoK0xRoDs41+A==} + '@oxc-resolver/binding-freebsd-x64@11.13.2': + resolution: {integrity: sha512-fpaeN8Q0kWvKns9uSMg6CcKo7cdgmWt6J91stPf8sdM+EKXzZ0YcRnWWyWF8SM16QcLUPCy5Iwt5Z8aYBGaZYA==} cpu: [x64] - os: [darwin] + os: [freebsd] '@oxc-resolver/binding-freebsd-x64@5.2.0': resolution: {integrity: sha512-6TCXw/rPnhBLlS/Rg7QHO9lBjwJSbUJMhd9POpVpQEK1S9viEAl8JPdxXuNCEDPJHSmpMrGt6+DTjQxQ5J1kpQ==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-freebsd-x64@9.0.2': - resolution: {integrity: sha512-6OvkEtRXrt8sJ4aVfxHRikjain9nV1clIsWtJ1J3J8NG1ZhjyJFgT00SCvqxbK+pzeWJq6XzHyTCN78ML+lY2w==} - cpu: [x64] - os: [freebsd] + '@oxc-resolver/binding-linux-arm-gnueabihf@11.13.2': + resolution: {integrity: sha512-idBgJU5AvSsGOeaIWiFBKbNBjpuduHsJmrG4CBbEUNW/Ykx+ISzcuj1PHayiYX6R9stVsRhj3d2PyymfC5KWRg==} + cpu: [arm] + os: [linux] '@oxc-resolver/binding-linux-arm-gnueabihf@5.2.0': resolution: {integrity: sha512-egjFYBKixAjekmiImCYkpwSo0bnZJOieJIc6cXePuCih2R5nFjkS1F8tSlJ18GdRZ1MmYveM6THmIHJCpnDqaQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-gnueabihf@9.0.2': - resolution: {integrity: sha512-aYpNL6o5IRAUIdoweW21TyLt54Hy/ZS9tvzNzF6ya1ckOQ8DLaGVPjGpmzxdNja9j/bbV6aIzBH7lNcBtiOTkQ==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.13.2': + resolution: {integrity: sha512-BlBvQUhvvIM/7s96KlKhMk0duR2sj8T7Hyii46/5QnwfN/pHwobvOL5czZ6/SKrHNB/F/qDY4hGsBuB1y7xgTg==} cpu: [arm] os: [linux] + '@oxc-resolver/binding-linux-arm64-gnu@11.13.2': + resolution: {integrity: sha512-lUmDTmYOGpbIK+FBfZ0ySaQTo7g1Ia/WnDnQR2wi/0AtehZIg/ZZIgiT/fD0iRvKEKma612/0PVo8dXdAKaAGA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-resolver/binding-linux-arm64-gnu@5.2.0': resolution: {integrity: sha512-Cizb3uHnEc2MYZeRnp+BxmDyAKo7szJxbTW4BgPvs+XicYZI0kc/qcZlHRoJImalBqvve+ZObasRqCS1zqub9A==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-gnu@9.0.2': - resolution: {integrity: sha512-RGFW4vCfKMFEIzb9VCY0oWyyY9tR1/o+wDdNePhiUXZU4SVniRPQaZ1SJ0sUFI1k25pXZmzQmIP6cBmazi/Dew==} + '@oxc-resolver/binding-linux-arm64-musl@11.13.2': + resolution: {integrity: sha512-dkGzOxo+I9lA4Er6qzFgkFevl3JvwyI9i0T/PkOJHva04rb1p9dz8GPogTO9uMK4lrwLWzm/piAu+tHYC7v7+w==} cpu: [arm64] os: [linux] - libc: [glibc] + libc: [musl] '@oxc-resolver/binding-linux-arm64-musl@5.2.0': resolution: {integrity: sha512-rDiRuIvQXa9MI8oiEbCVnU7dBVDuo74456dN3Bf30/Joz6FVBhYrhoOTxtxH+WgC38qCUWWuBjhFaLRLDLaMRw==} @@ -2369,11 +2396,17 @@ packages: os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-arm64-musl@9.0.2': - resolution: {integrity: sha512-lxx/PibBfzqYvut2Y8N2D0Ritg9H8pKO+7NUSJb9YjR/bfk2KRmP8iaUz3zB0JhPtf/W3REs65oKpWxgflGToA==} - cpu: [arm64] + '@oxc-resolver/binding-linux-ppc64-gnu@11.13.2': + resolution: {integrity: sha512-53kWsjLkVFnoSA7COdps38pBssN48zI8LfsOvupsmQ0/4VeMYb+0Ao9O6r52PtmFZsGB3S1Qjqbjl/Pswj1a3g==} + cpu: [ppc64] os: [linux] - libc: [musl] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.13.2': + resolution: {integrity: sha512-MfxN6DMpvmdCbGlheJ+ihy11oTcipqDfcEIQV9ah3FGXBRCZtBOHJpQDk8qI2Y+nCXVr3Nln7OSsOzoC4+rSYQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@5.2.0': resolution: {integrity: sha512-QRdE2DOO9e4oYzYyf/iRnLiomvs3bRedRTvFHbTAcL0JJfsicLLK4T7J5BP76sVum0QUAVJm+JqgEUmk8ETGXw==} @@ -2381,10 +2414,16 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@9.0.2': - resolution: {integrity: sha512-yD28ptS/OuNhwkpXRPNf+/FvrO7lwURLsEbRVcL1kIE0GxNJNMtKgIE4xQvtKDzkhk6ZRpLho5VSrkkF+3ARTQ==} + '@oxc-resolver/binding-linux-riscv64-musl@11.13.2': + resolution: {integrity: sha512-WXrm4YiRU0ijqb72WHSjmfYaQZ7t6/kkQrFc4JtU+pUE4DZA/DEdxOuQEd4Q43VqmLvICTJWSaZMlCGQ4PSRUg==} cpu: [riscv64] os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.13.2': + resolution: {integrity: sha512-4pISWIlOFRUhWyvGCB3XUhtcwyvwGGhlXhHz7IXCXuGufaQtvR05trvw8U1ZnaPhsdPBkRhOMIedX11ayi5uXw==} + cpu: [s390x] + os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-s390x-gnu@5.2.0': @@ -2393,9 +2432,9 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-s390x-gnu@9.0.2': - resolution: {integrity: sha512-WBwEJdspoga2w+aly6JVZeHnxuPVuztw3fPfWrei2P6rNM5hcKxBGWKKT6zO1fPMCB4sdDkFohGKkMHVV1eryQ==} - cpu: [s390x] + '@oxc-resolver/binding-linux-x64-gnu@11.13.2': + resolution: {integrity: sha512-DVo6jS8n73yNAmCsUOOk2vBeC60j2RauDXQM8p7RDl0afsEaA2le22vD8tky7iNoM5tsxfBmE4sOJXEKgpwWRw==} + cpu: [x64] os: [linux] libc: [glibc] @@ -2405,11 +2444,11 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@9.0.2': - resolution: {integrity: sha512-a2z3/cbOOTUq0UTBG8f3EO/usFcdwwXnCejfXv42HmV/G8GjrT4fp5+5mVDoMByH3Ce3iVPxj1LmS6OvItKMYQ==} + '@oxc-resolver/binding-linux-x64-musl@11.13.2': + resolution: {integrity: sha512-6WqrE+hQBFP35KdwQjWcZpldbTq6yJmuTVThISu+rY3+j6MaDp2ciLHTr1X68r2H/7ocOIl4k3NnOVIzeRJE3w==} cpu: [x64] os: [linux] - libc: [glibc] + libc: [musl] '@oxc-resolver/binding-linux-x64-musl@5.2.0': resolution: {integrity: sha512-iojrjytDOdg4aWm25ak7qpTQwWj+D7O+duHBL2rQhDxIY1K4eysJwobWck0yzJ6VlONaQF6RLt+YeDpGoKV+ww==} @@ -2417,39 +2456,38 @@ packages: os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-x64-musl@9.0.2': - resolution: {integrity: sha512-bHZF+WShYQWpuswB9fyxcgMIWVk4sZQT0wnwpnZgQuvGTZLkYJ1JTCXJMtaX5mIFHf69ngvawnwPIUA4Feil0g==} - cpu: [x64] - os: [linux] - libc: [musl] + '@oxc-resolver/binding-wasm32-wasi@11.13.2': + resolution: {integrity: sha512-YpxvQmP2D+mNUkLQZbBjGz20g/pY8XoOBdPPoWMl9X68liFFjXxkPQTrZxWw4zzG/UkTM5z6dPRTyTePRsMcjw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] '@oxc-resolver/binding-wasm32-wasi@5.2.0': resolution: {integrity: sha512-Lgv3HjKUXRa/xMCgBAkwKQcPljAn5IRicjgoPBXGUhghzK/9yF2DTf7aXdVPvRxFKjvcyWtzpzPV2pzYCuBaBA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-wasm32-wasi@9.0.2': - resolution: {integrity: sha512-I5cSgCCh5nFozGSHz+PjIOfrqW99eUszlxKLgoNNzQ1xQ2ou9ZJGzcZ94BHsM9SpyYHLtgHljmOZxCT9bgxYNA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] + '@oxc-resolver/binding-win32-arm64-msvc@11.13.2': + resolution: {integrity: sha512-1SKBw6KcCmvPBdEw1/Qdpv6eSDf23lCXTWz9VxTe6QUQ/1wR+HZR2uS4q6C8W6jnIswMTQbxpTvVwdRXl+ufeA==} + cpu: [arm64] + os: [win32] '@oxc-resolver/binding-win32-arm64-msvc@5.2.0': resolution: {integrity: sha512-VK5yEOdGbIrb89gUtVIw2IVP4r0rEhiwVLQOD37vZhvrt5iY0FHOTtMz9ZsWI0anZ0swt26U2wRcJYT0/AsBfw==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-arm64-msvc@9.0.2': - resolution: {integrity: sha512-5IhoOpPr38YWDWRCA5kP30xlUxbIJyLAEsAK7EMyUgqygBHEYLkElaKGgS0X5jRXUQ6l5yNxuW73caogb2FYaw==} - cpu: [arm64] + '@oxc-resolver/binding-win32-ia32-msvc@11.13.2': + resolution: {integrity: sha512-KEVV7wggDucxRn3vvyHnmTCPXoCT7vWpH18UVLTygibHJvNRP2zl5lBaQcCIdIaYYZjKt1aGI/yZqxZvHoiCdg==} + cpu: [ia32] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@5.2.0': - resolution: {integrity: sha512-BhIcyjr/gTafUrdOhd1EC5H4LeUSKK9uQIG2RSyMMH0Cq1yBacTb1yvLowhP/6e4ncCGByXEkW7sWGowCfSY8A==} + '@oxc-resolver/binding-win32-x64-msvc@11.13.2': + resolution: {integrity: sha512-6AAdN9v/wO5c3td1yidgNLKYlzuNgfOtEqBq60WE469bJWR7gHgG/S5aLR2pH6/gyPLs9UXtItxi934D+0Estg==} cpu: [x64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@9.0.2': - resolution: {integrity: sha512-Qc40GDkaad9rZksSQr2l/V9UubigIHsW69g94Gswc2sKYB3XfJXfIfyV8WTJ67u6ZMXsZ7BH1msSC6Aen75mCg==} + '@oxc-resolver/binding-win32-x64-msvc@5.2.0': + resolution: {integrity: sha512-BhIcyjr/gTafUrdOhd1EC5H4LeUSKK9uQIG2RSyMMH0Cq1yBacTb1yvLowhP/6e4ncCGByXEkW7sWGowCfSY8A==} cpu: [x64] os: [win32] @@ -3714,8 +3752,8 @@ packages: '@tsconfig/vite-react@7.0.0': resolution: {integrity: sha512-fiuTviENxttMlo8BHuVWgPe/DRwcuU722oVvQ/HLfI3pxXfX4uBjvj9tHm1fbj5+iYbcPmdGENXOUks6yKF2Ug==} - '@tybys/wasm-util@0.10.0': - resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -5157,8 +5195,8 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fd-package-json@1.2.0: - resolution: {integrity: sha512-45LSPmWf+gC5tdCQMNH4s9Sr00bIkiD9aN7dc5hqkrEw1geRYyDQS1v1oMHAW3ysfxfndqGsrDREHHjNNbKUfA==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -5233,8 +5271,8 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} - formatly@0.2.3: - resolution: {integrity: sha512-WH01vbXEjh9L3bqn5V620xUAWs32CmK4IzWRRY6ep5zpa/mrisL4d9+pRVuETORVDTQw8OycSO1WC68PL51RaA==} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} hasBin: true @@ -5739,8 +5777,8 @@ packages: resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} engines: {node: 20 || >=22} - jiti@2.5.1: - resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true joycon@3.1.1: @@ -5757,8 +5795,8 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsdom@26.0.0: @@ -5820,13 +5858,13 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - knip@5.59.0: - resolution: {integrity: sha512-fpkvLIcw2xqcisJpFZRt/BG2rol1znSwzUB5AR6uj+tTgurC1iUEudx8mSlaxsLaeWx5zvHmlW0dlfq7CfKWJQ==} + knip@5.70.0: + resolution: {integrity: sha512-ZRO7GzegusadOqR0ICxEQfbM1RS+1Uu/LtATpzO71pHXZQnoj4K47/QtuCtfvJVjWb2R4a7YwHv+Ey9xoxjQCw==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: '@types/node': '>=18' - typescript: '>=5.0.4' + typescript: '>=5.0.4 <7' language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} @@ -6266,12 +6304,12 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-resolver@11.13.2: + resolution: {integrity: sha512-1SXVyYQ9bqMX3uZo8Px81EG7jhZkO9PvvR5X9roY5TLYVm4ZA7pbPDNlYaDBBeF9U+YO3OeMNoHde52hrcCu8w==} + oxc-resolver@5.2.0: resolution: {integrity: sha512-ce0rdG5Y0s1jhcvh2Zc6sD+fTw/WA4pUKWrPmjbniZjC/m6pPob2I2Pkz8T0YzdWsbAC98E00Bc7KNB1B6Tolg==} - oxc-resolver@9.0.2: - resolution: {integrity: sha512-w838ygc1p7rF+7+h5vR9A+Y9Fc4imy6C3xPthCMkdFUgFvUWkmABeNB8RBDQ6+afk44Q60/UMMQ+gfDUW99fBA==} - p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -6806,8 +6844,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true @@ -7015,8 +7053,8 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} - smol-toml@1.3.1: - resolution: {integrity: sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==} + smol-toml@1.5.2: + resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} snake-case@3.0.4: @@ -7185,8 +7223,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.1: - resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} strip-literal@3.0.0: @@ -7628,8 +7666,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - walk-up-path@3.0.1: - resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -7783,15 +7822,12 @@ packages: peerDependencies: zod: ^3.24.1 - zod-validation-error@3.4.0: - resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.18.0 - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@5.0.3: resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'} @@ -8237,18 +8273,18 @@ snapshots: react: 19.1.0 tslib: 2.8.1 - '@emnapi/core@1.4.5': + '@emnapi/core@1.7.1': dependencies: - '@emnapi/wasi-threads': 1.0.4 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.5': + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.4': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true @@ -8331,9 +8367,9 @@ snapshots: '@esbuild/win32-x64@0.25.8': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0(jiti@2.6.1))': dependencies: - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -8360,7 +8396,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8633,12 +8669,12 @@ snapshots: dependencies: minipass: 7.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: glob: 10.4.5 magic-string: 0.30.19 react-docgen-typescript: 2.2.2(typescript@5.8.3) - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) optionalDependencies: typescript: 5.8.3 @@ -8722,9 +8758,16 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 - '@tybys/wasm-util': 0.10.0 + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 optional: true '@nodelib/fs.scandir@2.1.5': @@ -8744,64 +8787,84 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.27.0 + '@oxc-resolver/binding-android-arm-eabi@11.13.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.13.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.13.2': + optional: true + '@oxc-resolver/binding-darwin-arm64@5.2.0': optional: true - '@oxc-resolver/binding-darwin-arm64@9.0.2': + '@oxc-resolver/binding-darwin-x64@11.13.2': optional: true '@oxc-resolver/binding-darwin-x64@5.2.0': optional: true - '@oxc-resolver/binding-darwin-x64@9.0.2': + '@oxc-resolver/binding-freebsd-x64@11.13.2': optional: true '@oxc-resolver/binding-freebsd-x64@5.2.0': optional: true - '@oxc-resolver/binding-freebsd-x64@9.0.2': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.13.2': optional: true '@oxc-resolver/binding-linux-arm-gnueabihf@5.2.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@9.0.2': + '@oxc-resolver/binding-linux-arm-musleabihf@11.13.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.13.2': optional: true '@oxc-resolver/binding-linux-arm64-gnu@5.2.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@9.0.2': + '@oxc-resolver/binding-linux-arm64-musl@11.13.2': optional: true '@oxc-resolver/binding-linux-arm64-musl@5.2.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@9.0.2': + '@oxc-resolver/binding-linux-ppc64-gnu@11.13.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.13.2': optional: true '@oxc-resolver/binding-linux-riscv64-gnu@5.2.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@9.0.2': + '@oxc-resolver/binding-linux-riscv64-musl@11.13.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.13.2': optional: true '@oxc-resolver/binding-linux-s390x-gnu@5.2.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@9.0.2': + '@oxc-resolver/binding-linux-x64-gnu@11.13.2': optional: true '@oxc-resolver/binding-linux-x64-gnu@5.2.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@9.0.2': + '@oxc-resolver/binding-linux-x64-musl@11.13.2': optional: true '@oxc-resolver/binding-linux-x64-musl@5.2.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@9.0.2': + '@oxc-resolver/binding-wasm32-wasi@11.13.2': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 optional: true '@oxc-resolver/binding-wasm32-wasi@5.2.0': @@ -8809,21 +8872,19 @@ snapshots: '@napi-rs/wasm-runtime': 0.2.12 optional: true - '@oxc-resolver/binding-wasm32-wasi@9.0.2': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 + '@oxc-resolver/binding-win32-arm64-msvc@11.13.2': optional: true '@oxc-resolver/binding-win32-arm64-msvc@5.2.0': optional: true - '@oxc-resolver/binding-win32-arm64-msvc@9.0.2': + '@oxc-resolver/binding-win32-ia32-msvc@11.13.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@5.2.0': + '@oxc-resolver/binding-win32-x64-msvc@11.13.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@9.0.2': + '@oxc-resolver/binding-win32-x64-msvc@5.2.0': optional: true '@pkgjs/parseargs@0.11.0': @@ -9787,12 +9848,12 @@ snapshots: storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2) ts-dedent: 2.2.0 - '@storybook/builder-vite@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/builder-vite@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@storybook/csf-plugin': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2)) storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2) ts-dedent: 2.2.0 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) '@storybook/csf-plugin@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))': dependencies: @@ -9812,21 +9873,21 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2) - '@storybook/react-vite@9.0.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/react-vite@9.0.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) '@rollup/pluginutils': 5.3.0(rollup@4.50.1) - '@storybook/builder-vite': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/builder-vite': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) '@storybook/react': 9.0.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3) find-up: 7.0.0 magic-string: 0.30.19 react: 19.1.0 react-docgen: 8.0.0 react-dom: 19.1.0(react@19.1.0) - resolve: 1.22.10 + resolve: 1.22.11 storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2) tsconfig-paths: 4.2.0 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color @@ -9916,7 +9977,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 - jiti: 2.5.1 + jiti: 2.6.1 lightningcss: 1.30.1 magic-string: 0.30.19 source-map-js: 1.2.1 @@ -9976,12 +10037,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.13 '@tailwindcss/oxide-win32-x64-msvc': 4.1.13 - '@tailwindcss/vite@4.1.13(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.13(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.1.13 '@tailwindcss/oxide': 4.1.13 tailwindcss: 4.1.13 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) '@tanstack/history@1.129.7': {} @@ -10026,7 +10087,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.130.8(@tanstack/react-router@1.130.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@tanstack/router-plugin@1.130.8(@tanstack/react-router@1.130.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) @@ -10044,7 +10105,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.130.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -10117,7 +10178,7 @@ snapshots: '@tsconfig/vite-react@7.0.0': {} - '@tybys/wasm-util@0.10.0': + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true @@ -10233,15 +10294,15 @@ snapshots: dependencies: '@types/node': 18.19.123 - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -10250,14 +10311,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -10280,13 +10341,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -10310,13 +10371,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -10385,7 +10446,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.0.2(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.2(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -10393,17 +10454,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.3.4(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.5.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/eslint-plugin@1.3.4(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.32.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.32.0(jiti@2.6.1) optionalDependencies: typescript: 5.8.3 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.5.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -10415,13 +10476,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -10776,7 +10837,7 @@ snapshots: dotenv: 16.6.1 exsolve: 1.0.7 giget: 2.0.0 - jiti: 2.5.1 + jiti: 2.6.1 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 1.0.0 @@ -10841,7 +10902,7 @@ snapshots: commander: 12.1.0 edit-json-file: 1.8.0 globby: 14.0.2 - js-yaml: 4.1.0 + js-yaml: 4.1.1 semver: 7.7.2 table: 6.8.2 type-fest: 4.41.0 @@ -11001,7 +11062,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.8.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -11024,7 +11085,7 @@ snapshots: ignore: 6.0.2 minimatch: 10.0.3 p-map: 7.0.3 - resolve: 1.22.10 + resolve: 1.22.11 safe-buffer: 5.2.1 shell-quote: 1.8.2 subarg: 1.0.0 @@ -11419,9 +11480,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.32.0(jiti@2.5.1)): + eslint-config-prettier@10.1.8(eslint@9.32.0(jiti@2.6.1)): dependencies: - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -11434,15 +11495,15 @@ snapshots: dependencies: debug: 3.2.7 is-core-module: 2.16.1 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - supports-color optional: true - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.6.1)))(eslint@9.32.0(jiti@2.6.1)): dependencies: debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 @@ -11450,16 +11511,16 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.38.0 comment-parser: 1.4.1 debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.0.3 @@ -11467,12 +11528,12 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.8 @@ -11482,7 +11543,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -11491,21 +11552,21 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-perfectionist@4.15.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3): + eslint-plugin-perfectionist@4.15.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3): dependencies: '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.32.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.32.0(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-react-hooks@5.2.0(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0(jiti@2.6.1)): dependencies: - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) - eslint-plugin-react@7.37.5(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-react@7.37.5(eslint@9.32.0(jiti@2.6.1)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -11513,7 +11574,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -11527,25 +11588,25 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.0.18(eslint@9.32.0(jiti@2.5.1))(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3): + eslint-plugin-storybook@9.0.18(eslint@9.32.0(jiti@2.6.1))(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3): dependencies: - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.32.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.32.0(jiti@2.6.1) storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.2) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-unicorn@60.0.0(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-unicorn@60.0.0(eslint@9.32.0(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.27.1 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.6.1)) '@eslint/plugin-kit': 0.3.4 change-case: 5.4.4 ci-info: 4.3.0 clean-regexp: 1.0.0 core-js-compat: 3.44.0 - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) esquery: 1.6.0 find-up-simple: 1.0.1 globals: 16.4.0 @@ -11558,11 +11619,11 @@ snapshots: semver: 7.7.2 strip-indent: 4.0.0 - eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1)): dependencies: - eslint: 9.32.0(jiti@2.5.1) + eslint: 9.32.0(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) eslint-scope@8.4.0: dependencies: @@ -11573,9 +11634,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.32.0(jiti@2.5.1): + eslint@9.32.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.0 @@ -11611,7 +11672,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.5.1 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -11792,9 +11853,9 @@ snapshots: dependencies: reusify: 1.1.0 - fd-package-json@1.2.0: + fd-package-json@2.0.0: dependencies: - walk-up-path: 3.0.1 + walk-up-path: 4.0.0 fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -11881,9 +11942,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - formatly@0.2.3: + formatly@0.3.0: dependencies: - fd-package-json: 1.2.0 + fd-package-json: 2.0.0 forwarded@0.2.0: {} @@ -12360,7 +12421,7 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 - jiti@2.5.1: {} + jiti@2.6.1: {} joycon@3.1.1: {} @@ -12373,7 +12434,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -12448,23 +12509,22 @@ snapshots: kleur@3.0.3: {} - knip@5.59.0(@types/node@22.17.2)(typescript@5.8.3): + knip@5.70.0(@types/node@22.17.2)(typescript@5.8.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 22.17.2 fast-glob: 3.3.3 - formatly: 0.2.3 - jiti: 2.5.1 - js-yaml: 4.1.0 + formatly: 0.3.0 + jiti: 2.6.1 + js-yaml: 4.1.1 minimist: 1.2.8 - oxc-resolver: 9.0.2 + oxc-resolver: 11.13.2 picocolors: 1.1.1 picomatch: 4.0.3 - smol-toml: 1.3.1 - strip-json-comments: 5.0.1 + smol-toml: 1.5.2 + strip-json-comments: 5.0.3 typescript: 5.8.3 - zod: 3.25.76 - zod-validation-error: 3.4.0(zod@3.25.76) + zod: 4.1.12 language-subtag-registry@0.3.22: {} @@ -12864,6 +12924,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-resolver@11.13.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.13.2 + '@oxc-resolver/binding-android-arm64': 11.13.2 + '@oxc-resolver/binding-darwin-arm64': 11.13.2 + '@oxc-resolver/binding-darwin-x64': 11.13.2 + '@oxc-resolver/binding-freebsd-x64': 11.13.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.13.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.13.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.13.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.13.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.13.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.13.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.13.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.13.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.13.2 + '@oxc-resolver/binding-linux-x64-musl': 11.13.2 + '@oxc-resolver/binding-wasm32-wasi': 11.13.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.13.2 + '@oxc-resolver/binding-win32-ia32-msvc': 11.13.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.13.2 + oxc-resolver@5.2.0: optionalDependencies: '@oxc-resolver/binding-darwin-arm64': 5.2.0 @@ -12880,22 +12962,6 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 5.2.0 '@oxc-resolver/binding-win32-x64-msvc': 5.2.0 - oxc-resolver@9.0.2: - optionalDependencies: - '@oxc-resolver/binding-darwin-arm64': 9.0.2 - '@oxc-resolver/binding-darwin-x64': 9.0.2 - '@oxc-resolver/binding-freebsd-x64': 9.0.2 - '@oxc-resolver/binding-linux-arm-gnueabihf': 9.0.2 - '@oxc-resolver/binding-linux-arm64-gnu': 9.0.2 - '@oxc-resolver/binding-linux-arm64-musl': 9.0.2 - '@oxc-resolver/binding-linux-riscv64-gnu': 9.0.2 - '@oxc-resolver/binding-linux-s390x-gnu': 9.0.2 - '@oxc-resolver/binding-linux-x64-gnu': 9.0.2 - '@oxc-resolver/binding-linux-x64-musl': 9.0.2 - '@oxc-resolver/binding-wasm32-wasi': 9.0.2 - '@oxc-resolver/binding-win32-arm64-msvc': 9.0.2 - '@oxc-resolver/binding-win32-x64-msvc': 9.0.2 - p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -13277,7 +13343,7 @@ snapshots: '@types/doctrine': 0.0.9 '@types/resolve': 1.20.6 doctrine: 3.0.0 - resolve: 1.22.10 + resolve: 1.22.11 strip-indent: 4.0.0 transitivePeerDependencies: - supports-color @@ -13433,7 +13499,7 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 @@ -13685,7 +13751,7 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 - smol-toml@1.3.1: {} + smol-toml@1.5.2: {} snake-case@3.0.4: dependencies: @@ -13898,7 +13964,7 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.1: {} + strip-json-comments@5.0.3: {} strip-literal@3.0.0: dependencies: @@ -14174,13 +14240,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3): + typescript-eslint@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.32.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.32.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -14282,13 +14348,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -14303,18 +14369,18 @@ snapshots: - tsx - yaml - vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.8.3)(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.50.1) '@svgr/core': 8.1.0(typescript@5.8.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.8.3)) - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): + vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.8 fdir: 6.5.0(picomatch@4.0.3) @@ -14325,16 +14391,16 @@ snapshots: optionalDependencies: '@types/node': 22.17.2 fsevents: 2.3.3 - jiti: 2.5.1 + jiti: 2.6.1 lightningcss: 1.30.1 tsx: 4.20.6 yaml: 2.8.1 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.5.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.0.0)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -14352,8 +14418,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -14379,7 +14445,7 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - walk-up-path@3.0.1: {} + walk-up-path@4.0.0: {} webidl-conversions@3.0.1: {} @@ -14462,7 +14528,7 @@ snapshots: commander: 14.0.0 fast-glob: 3.3.3 find-workspaces: 0.3.1 - jiti: 2.5.1 + jiti: 2.6.1 micromatch: 4.0.8 pkg-types: 2.3.0 transitivePeerDependencies: @@ -14544,12 +14610,10 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@3.4.0(zod@3.25.76): - dependencies: - zod: 3.25.76 - zod@3.25.76: {} + zod@4.1.12: {} + zustand@5.0.3(@types/react@19.1.3)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)): optionalDependencies: '@types/react': 19.1.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 32b569d44..8a3e4bcf8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,6 @@ catalog: '@types/react': 19.1.3 '@types/react-dom': 19.1.3 '@vitejs/plugin-react': 5.0.2 - autoprefixer: 10.4.20 cpx2: 8.0.0 eslint: 9.32.0 prettier: 3.6.2 diff --git a/tests/simple/apps/backend/baseplate/file-id-map.json b/tests/simple/apps/backend/baseplate/file-id-map.json index 487a25127..ef93d7bf9 100644 --- a/tests/simple/apps/backend/baseplate/file-id-map.json +++ b/tests/simple/apps/backend/baseplate/file-id-map.json @@ -47,6 +47,12 @@ "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-schema-builder": "src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts", "@baseplate-dev/fastify-generators#pothos/pothos:field-with-input-types": "src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts", "@baseplate-dev/fastify-generators#pothos/pothos:strip-query-mutation-plugin": "src/plugins/graphql/strip-query-mutation-plugin.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:define-operations": "src/utils/data-operations/define-operations.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:field-definitions": "src/utils/data-operations/field-definitions.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-types": "src/utils/data-operations/prisma-types.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:prisma-utils": "src/utils/data-operations/prisma-utils.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:relation-helpers": "src/utils/data-operations/relation-helpers.ts", + "@baseplate-dev/fastify-generators#prisma/data-utils:types": "src/utils/data-operations/types.ts", "@baseplate-dev/fastify-generators#prisma/prisma:client": "src/generated/prisma/client.ts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-config": "prisma.config.mts", "@baseplate-dev/fastify-generators#prisma/prisma:prisma-schema": "prisma/schema.prisma", diff --git a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index fc69143b7..0e5f0ce72 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -9,6 +9,7 @@ import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosFieldWithInputPayloadPlugin } from './FieldWithInputPayloadPlugin/index.js'; @@ -49,6 +50,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/tests/simple/apps/backend/baseplate/generated/vitest.config.ts b/tests/simple/apps/backend/baseplate/generated/vitest.config.ts index e7b9216c2..1bc85c7b8 100644 --- a/tests/simple/apps/backend/baseplate/generated/vitest.config.ts +++ b/tests/simple/apps/backend/baseplate/generated/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', }, diff --git a/tests/simple/apps/backend/src/plugins/graphql/builder.ts b/tests/simple/apps/backend/src/plugins/graphql/builder.ts index fc69143b7..0e5f0ce72 100644 --- a/tests/simple/apps/backend/src/plugins/graphql/builder.ts +++ b/tests/simple/apps/backend/src/plugins/graphql/builder.ts @@ -9,6 +9,7 @@ import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; import type { RequestServiceContext } from '@src/utils/request-service-context.js'; import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js'; +import { config } from '@src/services/config.js'; import { prisma } from '@src/services/prisma.js'; import { pothosFieldWithInputPayloadPlugin } from './FieldWithInputPayloadPlugin/index.js'; @@ -49,6 +50,7 @@ export const builder = new SchemaBuilder<{ dmmf: getDatamodel(), exposeDescriptions: false, filterConnectionTotalCount: true, + onUnusedQuery: config.APP_ENVIRONMENT === 'dev' ? 'warn' : null, }, relay: { clientMutationId: 'omit', diff --git a/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts b/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts new file mode 100644 index 000000000..b571281de --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts @@ -0,0 +1,932 @@ +import type { Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; + +import { prisma } from '@src/services/prisma.js'; + +import type { ServiceContext } from '../service-context.js'; +import type { + GetPayload, + ModelPropName, + ModelQuery, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + AnyOperationHooks, + DataOperationType, + InferFieldOutput, + InferFieldsCreateOutput, + InferFieldsOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + OperationHooks, + PrismaTransaction, + TransactionalOperationContext, +} from './types.js'; + +import { NotFoundError } from '../http-errors.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Invokes an array of hooks with the provided context. + * + * All hooks are executed in parallel using `Promise.all`. If no hooks are provided + * or the array is empty, this function returns immediately. + * + * @template TContext - The context type passed to each hook + * @param hooks - Optional array of async hook functions to invoke + * @param context - The context object passed to each hook + * @returns Promise that resolves when all hooks have completed + * + * @example + * ```typescript + * await invokeHooks(config.hooks?.beforeExecute, { + * operation: 'create', + * serviceContext: ctx, + * tx: transaction, + * }); + * ``` + */ +export async function invokeHooks( + hooks: ((ctx: TContext) => Promise)[] | undefined, + context: TContext, +): Promise { + if (!hooks || hooks.length === 0) return; + await Promise.all(hooks.map((hook) => hook(context))); +} + +type FieldDataOrFunction = + | InferFieldOutput + | ((tx: PrismaTransaction) => Promise>); + +/** + * Transforms field definitions into Prisma create/update data structures. + * + * This function processes each field definition by: + * 1. Validating the input value against the field's schema + * 2. Transforming the value into Prisma-compatible create/update data + * 3. Collecting hooks from each field for execution during the operation lifecycle + * + * The function supports both synchronous and asynchronous field transformations. + * If any field returns an async transformation function, the entire data object + * becomes async and will be resolved inside the transaction. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to process + * @param input - Input data to validate and transform + * @param options - Transformation options + * @param options.serviceContext - Service context with user, request info + * @param options.operation - Type of operation (create, update, upsert, delete) + * @param options.allowOptionalFields - Whether to allow undefined field values + * @param options.loadExisting - Function to load existing model data + * @returns Object containing transformed data and collected hooks + * + * @example + * ```typescript + * const { data, hooks } = await transformFields( + * { name: scalarField(z.string()), email: scalarField(z.string().email()) }, + * { name: 'John', email: 'john@example.com' }, + * { + * serviceContext: ctx, + * operation: 'create', + * allowOptionalFields: false, + * loadExisting: () => Promise.resolve(undefined), + * }, + * ); + * ``` + */ +export async function transformFields< + TFields extends Record, +>( + fields: TFields, + input: InferInput, + { + serviceContext, + operation, + allowOptionalFields, + loadExisting, + }: { + serviceContext: ServiceContext; + operation: DataOperationType; + allowOptionalFields: boolean; + loadExisting: () => Promise; + }, +): Promise<{ + data: + | InferFieldsOutput + | ((tx: PrismaTransaction) => Promise>); + hooks: AnyOperationHooks; +}> { + const hooks: Required = { + beforeExecute: [], + afterExecute: [], + afterCommit: [], + }; + + const data = {} as { + [K in keyof TFields]: FieldDataOrFunction; + }; + + for (const [key, field] of Object.entries(fields)) { + const fieldKey = key as keyof typeof input; + const value = input[fieldKey]; + + if (allowOptionalFields && value === undefined) continue; + + const result = await field.processInput(value, { + operation, + serviceContext, + fieldName: fieldKey as string, + loadExisting, + }); + + if (result.data) { + data[fieldKey as keyof TFields] = result.data as FieldDataOrFunction< + TFields[keyof TFields] + >; + } + + if (result.hooks) { + hooks.beforeExecute.push(...(result.hooks.beforeExecute ?? [])); + hooks.afterExecute.push(...(result.hooks.afterExecute ?? [])); + hooks.afterCommit.push(...(result.hooks.afterCommit ?? [])); + } + } + + function splitCreateUpdateData(data: { + [K in keyof TFields]: InferFieldOutput; + }): { + create: InferFieldsCreateOutput; + update: InferFieldsUpdateOutput; + } { + const create = {} as InferFieldsCreateOutput; + const update = {} as InferFieldsUpdateOutput; + for (const [key, value] of Object.entries< + InferFieldOutput + >(data)) { + if (value.create !== undefined) { + create[key as keyof TFields] = + value.create as InferFieldsCreateOutput[keyof TFields]; + } + if (value.update) { + update[key as keyof TFields] = + value.update as InferFieldsUpdateOutput[keyof TFields]; + } + } + return { create, update }; + } + + const transformedData = Object.values(data).some( + (value) => typeof value === 'function', + ) + ? async (tx: PrismaTransaction) => { + const awaitedData = Object.fromEntries( + await Promise.all( + Object.entries(data).map( + async ([key, value]: [ + keyof TFields, + FieldDataOrFunction, + ]): Promise< + [keyof TFields, InferFieldOutput] + > => [key, typeof value === 'function' ? await value(tx) : value], + ), + ), + ) as { + [K in keyof TFields]: InferFieldOutput; + }; + return splitCreateUpdateData(awaitedData); + } + : splitCreateUpdateData( + data as { [K in keyof TFields]: InferFieldOutput }, + ); + + return { data: transformedData, hooks }; +} + +/** + * ========================================= + * Create Operation + * ========================================= + */ + +/** + * Configuration for defining a create operation. + * + * Create operations insert new records into the database with support for: + * - Field-level validation and transformation + * - Authorization checks before creation + * - Computed fields based on raw input + * - Transaction management with lifecycle hooks + * - Nested relation creation + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepareComputedFields + */ +export interface CreateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the create operation + */ + fields: TFields; + + /** + * Optional authorization check before creating + */ + authorize?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional step to prepare computed fields based off the raw input + */ + prepareComputedFields?: ( + data: InferInput, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the create operation. This function receives validated field data + * and must return a Prisma create operation. It runs inside the transaction. + */ + create: >(input: { + tx: PrismaTransaction; + data: InferFieldsCreateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the create call + { include: NonNullable }, + 'create' + > + >; + + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a create operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface CreateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Data to create the new record with */ + data: InferInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe create operation for a Prisma model. + * + * Creates a reusable function for inserting new records with built-in: + * - Input validation via field definitions + * - Authorization checks + * - Computed field preparation + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared computed fields + * @param config - Operation configuration + * @returns Async function that executes the create operation + * + * @example + * ```typescript + * const createUser = defineCreateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * // Check if user has permission to create + * }, + * create: ({ tx, data, query }) => + * tx.user.create({ + * data: { + * name: data.name, + * email: data.email, + * }, + * ...query, + * }), + * }); + * + * // Usage + * const user = await createUser({ + * data: { name: 'John', email: 'john@example.com' }, + * context: serviceContext, + * }); + * ``` + */ +export function defineCreateOperation< + TModelName extends Prisma.TypeMap['meta']['modelProps'], + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: CreateOperationConfig, +): >( + input: CreateOperationInput, +) => Promise> { + return async >({ + data, + query, + context, + }: CreateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for create operations. Use include instead.', + ); + } + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'create' as const, + serviceContext: context, + loadExisting: () => Promise.resolve(undefined), + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(data, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(config.fields, data, { + operation: 'create', + serviceContext: context, + allowOptionalFields: false, + loadExisting: () => Promise.resolve(undefined), + }), + config.prepareComputedFields + ? config.prepareComputedFields(data, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async create data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.create({ + tx, + data: { ...awaitedFieldsData.create, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * ========================================= + * Update Operation + * ========================================= + */ + +/** + * Configuration for defining an update operation. + * + * Update operations modify existing database records with support for: + * - Partial updates (only specified fields are updated) + * - Authorization checks before modification + * - Pre-transaction preparation step for heavy I/O + * - Field-level validation and transformation + * - Transaction management with lifecycle hooks + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of data returned by prepare function + */ +export interface UpdateOperationConfig< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = undefined, +> { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Field definitions for the update operation + */ + fields: TFields; + + /** + * Optional authorization check before updating + */ + authorize?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => Promise; + + /** + * Optional prepare step - runs BEFORE transaction + * For heavy I/O, validation, data enrichment + */ + prepareComputedFields?: ( + data: Partial>, + ctx: OperationContext, { hasResult: false }>, + ) => TPrepareResult | Promise; + + /** + * Execute the update operation. This function receives validated field data + * and must return a Prisma update operation. It runs inside the transaction. + */ + update: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + data: InferFieldsUpdateOutput & TPrepareResult; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the update call + { include: NonNullable }, + 'update' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing an update operation. + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface UpdateOperationInput< + TModelName extends ModelPropName, + TFields extends Record, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to update */ + where: WhereUniqueInput; + /** Partial data containing only the fields to update */ + data: Partial>; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe update operation for a Prisma model. + * + * Creates a reusable function for modifying existing records with built-in: + * - Partial input validation (only specified fields are processed) + * - Authorization checks with access to existing data + * - Pre-transaction preparation for heavy I/O + * - Transaction management + * - Hook execution at each lifecycle phase + * + * @template TModelName - Prisma model name + * @template TFields - Record of field definitions + * @template TPrepareResult - Type of prepared data + * @param config - Operation configuration + * @returns Async function that executes the update operation + * @throws {NotFoundError} If the record to update doesn't exist + * + * @example + * ```typescript + * const updateUser = defineUpdateOperation({ + * model: 'user', + * fields: { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }, + * authorize: async (data, ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user owns this record + * }, + * update: ({ tx, where, data, query }) => + * tx.user.update({ + * where, + * data, + * ...query, + * }), + * }); + * + * // Usage + * const user = await updateUser({ + * where: { id: userId }, + * data: { name: 'Jane' }, // Only update name + * context: serviceContext, + * }); + * ``` + */ +export function defineUpdateOperation< + TModelName extends ModelPropName, + TFields extends Record, + TPrepareResult extends Record | undefined = Record< + string, + never + >, +>( + config: UpdateOperationConfig, +): >( + input: UpdateOperationInput, +) => Promise> { + return async >({ + where, + data: inputData, + query, + context, + }: UpdateOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for update operations. Use include instead.', + ); + } + + let existingItem: GetPayload | undefined; + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'update' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ + where, + }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + // Authorization + if (config.authorize) { + await config.authorize(inputData, baseOperationContext); + } + + // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Only transform fields provided in input + const fieldsToTransform = Object.fromEntries( + Object.entries(config.fields).filter(([key]) => key in inputData), + ) as TFields; + + const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = + await Promise.all([ + transformFields(fieldsToTransform, inputData as InferInput, { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }), + config.prepareComputedFields + ? config.prepareComputedFields(inputData, baseOperationContext) + : Promise.resolve(undefined as TPrepareResult), + ]); + + // Combine config hooks with field hooks + const allHooks: AnyOperationHooks = { + beforeExecute: [ + ...(config.hooks?.beforeExecute ?? []), + ...(fieldsHooks.beforeExecute ?? []), + ], + afterExecute: [ + ...(config.hooks?.afterExecute ?? []), + ...(fieldsHooks.afterExecute ?? []), + ], + afterCommit: [ + ...(config.hooks?.afterCommit ?? []), + ...(fieldsHooks.afterCommit ?? []), + ], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Run all async update data transformations + const awaitedFieldsData = + typeof fieldsData === 'function' ? await fieldsData(tx) : fieldsData; + + const result = await config.update({ + tx, + where, + data: { ...awaitedFieldsData.update, ...preparedData }, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} + +/** + * Configuration for defining a delete operation. + * + * Delete operations remove records from the database with support for: + * - Authorization checks before deletion + * - Transaction management + * - Lifecycle hooks for cleanup operations + * - Access to the record being deleted + * + * @template TModelName - Prisma model name (e.g., 'user', 'post') + */ +export interface DeleteOperationConfig { + /** + * Prisma model name + */ + model: TModelName; + + /** + * Optional authorization check before deleting + */ + authorize?: ( + ctx: OperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => Promise; + + /** + * Execute the delete operation. This function receives the where clause + * and must return a Prisma delete operation. It runs inside the transaction. + */ + delete: >(input: { + tx: PrismaTransaction; + where: WhereUniqueInput; + query: { include: NonNullable }; + }) => Promise< + Result< + (typeof prisma)[TModelName], + // We type the query parameter to ensure that the user always includes ...query into the delete call + { include: NonNullable }, + 'delete' + > + >; + + /** + * Optional hooks for the operation + */ + hooks?: OperationHooks>; +} + +/** + * Input parameters for executing a delete operation. + * + * @template TModelName - Prisma model name + * @template TQueryArgs - Prisma query arguments (select/include) + */ +export interface DeleteOperationInput< + TModelName extends ModelPropName, + TQueryArgs extends ModelQuery, +> { + /** Unique identifier to locate the record to delete */ + where: WhereUniqueInput; + /** Optional Prisma query arguments to shape the returned data */ + query?: TQueryArgs; + /** Service context containing user info, request details, etc. */ + context: ServiceContext; +} + +/** + * Defines a type-safe delete operation for a Prisma model. + * + * Creates a reusable function for removing records with built-in: + * - Authorization checks with access to the record being deleted + * - Transaction management + * - Hook execution for cleanup operations (e.g., deleting associated files) + * - Returns the deleted record + * + * @template TModelName - Prisma model name + * @param config - Operation configuration + * @returns Async function that executes the delete operation + * @throws {NotFoundError} If the record to delete doesn't exist + * + * @example + * ```typescript + * const deleteUser = defineDeleteOperation({ + * model: 'user', + * authorize: async (ctx) => { + * const existing = await ctx.loadExisting(); + * // Check if user has permission to delete + * }, + * delete: ({ tx, where, query }) => + * tx.user.delete({ + * where, + * ...query, + * }), + * hooks: { + * afterCommit: [ + * async (ctx) => { + * // Clean up user's files, sessions, etc. + * await cleanupUserResources(ctx.result.id); + * }, + * ], + * }, + * }); + * + * // Usage + * const deletedUser = await deleteUser({ + * where: { id: userId }, + * context: serviceContext, + * }); + * ``` + */ +export function defineDeleteOperation( + config: DeleteOperationConfig, +): >( + input: DeleteOperationInput, +) => Promise> { + return async >({ + where, + query, + context, + }: DeleteOperationInput) => { + // Throw error if query select is provided since we will not necessarily have a full result to return + if (query?.select) { + throw new Error( + 'Query select is not supported for delete operations. Use include instead.', + ); + } + + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + let existingItem: GetPayload | undefined; + const baseOperationContext: OperationContext< + GetPayload, + { hasResult: false } + > = { + operation: 'delete' as const, + serviceContext: context, + loadExisting: async () => { + if (existingItem) return existingItem; + const result = await delegate.findUnique({ where }); + if (!result) throw new NotFoundError(`${config.model} not found`); + existingItem = result; + return result; + }, + result: undefined, + }; + + // Authorization + if (config.authorize) { + await config.authorize(baseOperationContext); + } + + const allHooks: AnyOperationHooks = { + beforeExecute: config.hooks?.beforeExecute ?? [], + afterExecute: config.hooks?.afterExecute ?? [], + afterCommit: config.hooks?.afterCommit ?? [], + }; + + // Execute in transaction + return prisma + .$transaction(async (tx) => { + const txContext: TransactionalOperationContext< + GetPayload, + { hasResult: false } + > = { + ...baseOperationContext, + tx, + }; + + // Run beforeExecute hooks + await invokeHooks(allHooks.beforeExecute, txContext); + + // Execute delete operation + const result = await config.delete({ + tx, + where, + query: (query ?? {}) as { + include: NonNullable; + }, + }); + + // Run afterExecute hooks + await invokeHooks(allHooks.afterExecute, { + ...txContext, + result, + }); + + return result; + }) + .then(async (result) => { + // Run afterCommit hooks (outside transaction) + await invokeHooks(allHooks.afterCommit, { + ...baseOperationContext, + result, + }); + return result as GetPayload; + }); + }; +} diff --git a/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts b/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts new file mode 100644 index 000000000..ab87233ae --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts @@ -0,0 +1,748 @@ +import type { Payload } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; + +import { prisma } from '@src/services/prisma.js'; + +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { + AnyFieldDefinition, + FieldDefinition, + InferFieldsCreateOutput, + InferFieldsUpdateOutput, + InferInput, + OperationContext, + TransactionalOperationContext, +} from './types.js'; + +import { invokeHooks, transformFields } from './define-operations.js'; +import { makeGenericPrismaDelegate } from './prisma-utils.js'; + +/** + * Create a simple scalar field with validation only + * + * This helper creates a field definition that validates input using a Zod schema. + * The validated value is passed through unchanged to the transform step. + * + * For relation fields (e.g., `userId`), use this helper to validate the ID, + * then use relation helpers in the transform step to create Prisma connect/disconnect objects. + * + * @param schema - Zod schema for validation + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * title: scalarField(z.string()), + * ownerId: scalarField(z.string()), // Validated as string + * }; + * + * // In transform, convert IDs to relations: + * transform: (data) => ({ + * title: data.title, + * owner: relation.required(data.ownerId), + * }) + * ``` + */ +export function scalarField( + schema: TSchema, +): FieldDefinition, z.output, z.output> { + return { + processInput: (value) => { + const validated = schema.parse(value) as z.output; + return { + data: { create: validated, update: validated }, + }; + }, + }; +} + +/** + * ========================================= + * Nested Field Handlers + * ========================================= + */ + +/** + * Configuration for a parent model in nested field definitions. + * + * Used to establish the relationship between a parent and child model + * in nested one-to-one and one-to-many field handlers. + * + * @template TModelName - Prisma model name + */ +export interface ParentModelConfig { + /** Prisma model name of the parent */ + model: TModelName; + /** Function to extract unique identifier from parent model instance */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; +} + +/** + * Creates a parent model configuration for use in nested field definitions. + * + * @template TModelName - Prisma model name + * @param model - Prisma model name + * @param getWhereUnique - Function to extract unique identifier from parent model + * @returns Parent model configuration object + * + * @example + * ```typescript + * const parentModel = createParentModelConfig('user', (user) => ({ + * id: user.id, + * })); + * ``` + */ +export function createParentModelConfig( + model: TModelName, + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput, +): ParentModelConfig { + return { + model, + getWhereUnique, + }; +} + +type RelationName = keyof Payload< + (typeof prisma)[TModelName] +>['objects']; + +interface PrismaFieldData { + create: CreateInput; + update: UpdateInput; +} + +/** + * Configuration for defining a nested one-to-one relationship field. + * + * One-to-one fields represent a single related entity that can be created, + * updated, or deleted along with the parent entity. The field handler manages + * the lifecycle of the nested entity automatically. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for the nested entity + */ +export interface NestedOneToOneFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Extract where unique from parent model + */ + getWhereUnique: ( + parentModel: GetPayload, + ) => WhereUniqueInput; + + /** + * Transform validated field data into final Prisma structure + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ) => PrismaFieldData | Promise>; +} + +/** + * Create a nested one-to-one relationship field handler + * + * This helper creates a field definition for managing one-to-one nested relationships. + * It handles nested field validation, transformation, and supports both create and update operations. + * + * For create operations: + * - Returns nested create data if input is provided + * - Returns undefined if input is not provided + * + * For update operations: + * - Returns upsert if input has a unique identifier (via getWhereUnique) + * - Returns create if input doesn't have a unique identifier + * - Deletes the relation if input is null (requires deleteRelation) + * - Returns undefined if input is not provided (no change) + * + * @param config - Configuration object + * @returns Field definition + * + * @example + * ```typescript + * const fields = { + * userProfile: nestedOneToOneField({ + * fields: { + * bio: scalarField(z.string()), + * avatar: fileField(avatarFileCategory), + * }, + * buildData: (data) => ({ + * bio: data.bio, + * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * }), + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * deleteRelation: async () => { + * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); + * }, + * }), + * }; + * ``` + */ +export function nestedOneToOneField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToOneFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput | null | undefined, + undefined, + undefined | { delete: true } +> { + return { + processInput: async (value, processCtx) => { + // Handle null - delete the relation + if (value === null) { + return { + data: { + create: undefined, + update: { delete: true }, + }, + }; + } + + // Handle undefined - no change + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + let cachedExisting: GetPayload | undefined; + async function loadExisting(): Promise< + GetPayload | undefined + > { + if (cachedExisting) return cachedExisting; + const existingParent = await processCtx.loadExisting(); + if (!existingParent) return undefined; + const whereUnique = config.getWhereUnique( + existingParent as GetPayload, + ); + const prismaDelegate = makeGenericPrismaDelegate(prisma, config.model); + cachedExisting = + (await prismaDelegate.findUnique({ + where: whereUnique, + })) ?? undefined; + return cachedExisting; + } + + // Process nested fields + const { data, hooks } = await transformFields(config.fields, value, { + serviceContext: processCtx.serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: loadExisting as () => Promise, + }); + + let newModelResult: GetPayload | undefined; + + return { + data: {}, + hooks: { + beforeExecute: [ + (ctx) => invokeHooks(hooks.beforeExecute, { ...ctx, loadExisting }), + ], + afterExecute: [ + async (ctx) => { + const awaitedData = + typeof data === 'function' ? await data(ctx.tx) : data; + const whereUnique = config.getWhereUnique( + ctx.result as GetPayload, + ); + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result as GetPayload, + ); + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result as GetPayload, + { + ...ctx, + operation: 'upsert', + loadExisting, + }, + ); + const prismaDelegate = makeGenericPrismaDelegate( + ctx.tx, + config.model, + ); + + newModelResult = await prismaDelegate.upsert({ + where: whereUnique, + create: builtData.create, + update: builtData.update, + }); + + await invokeHooks(hooks.afterExecute, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + afterCommit: [ + async (ctx) => { + await invokeHooks(hooks.afterCommit, { + ...ctx, + loadExisting, + result: newModelResult, + }); + }, + ], + }, + }; + }, + }; +} + +/** + * Configuration for defining a nested one-to-many relationship field. + * + * One-to-many fields represent a collection of related entities that are synchronized + * with the input array. The handler automatically: + * - Creates new items without unique identifiers + * - Updates existing items with unique identifiers + * - Deletes items not present in the input array + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on the child model + * @template TFields - Field definitions for each item in the collection + */ +export interface NestedOneToManyFieldConfig< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +> { + /** + * Prisma model name of parent model + */ + parentModel: ParentModelConfig; + + /** + * Prisma model name of the child model + */ + model: TModelName; + + /** + * Relation name of the parent model from the child model + */ + relationName: TRelationName; + + /** + * Field definitions for the nested entity + */ + fields: TFields; + + /** + * Function to extract a unique where clause from the input data for a child item and + * the parent model. + * If it returns undefined, the item is considered a new item to be created. + */ + getWhereUnique: ( + input: InferInput, + originalModel: GetPayload, + ) => WhereUniqueInput | undefined; + + /** + * Transform validated field data into final Prisma structure for a single item. + * The returned payload should not include the parent relation field, as it will be added automatically. + */ + buildData: ( + data: { + create: InferFieldsCreateOutput & + Record }>; + update: InferFieldsUpdateOutput; + }, + parentModel: GetPayload, + ctx: TransactionalOperationContext< + GetPayload | undefined, + { hasResult: false } + >, + ) => + | Promise<{ + create: CreateInput; + update: UpdateInput; + }> + | { + create: CreateInput; + update: UpdateInput; + }; +} + +/** + * Converts a Prisma `WhereUniqueInput` into a plain `WhereInput`. + * + * Compound unique constraints arrive as synthetic keys (e.g., `userId_role: { userId, role }`), + * while generic `where` filters need the flattened field structure. This normalization allows + * composing unique constraints with parent-level filters when constructing delete conditions + * in one-to-many relationships. + * + * @template TModelName - Prisma model name + * @param whereUnique - Unique filter returned by `getWhereUnique`, or undefined for new items + * @returns Normalized where filter or undefined if no usable fields exist + * + * @internal This function is used internally by nestedOneToManyField + */ +function expandWhereUnique( + whereUnique: WhereUniqueInput | undefined, +): WhereInput | undefined { + if (!whereUnique) return undefined; + + const entries = Object.entries(whereUnique as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + + if (entries.length === 0) return undefined; + + const [[key, value]] = entries; + + if (typeof value === 'object' && !Array.isArray(value)) { + return value as WhereInput; + } + + return { [key]: value as unknown } as WhereInput; +} + +/** + * Creates a nested one-to-many relationship field handler. + * + * This helper manages collections of child entities by synchronizing them with the input array. + * The synchronization logic: + * - **Update**: Items with unique identifiers (from `getWhereUnique`) are updated + * - **Create**: Items without unique identifiers are created as new records + * - **Delete**: Existing items not present in the input array are removed + * - **No Change**: Passing `undefined` leaves the collection unchanged + * + * All operations are performed atomically within the parent operation's transaction, + * ensuring data consistency even if the operation fails. + * + * @template TParentModelName - Parent model name + * @template TModelName - Child model name + * @template TRelationName - Relation field name on child model + * @template TFields - Field definitions for each child item + * @param config - Configuration object for the one-to-many relationship + * @returns Field definition for use in `defineCreateOperation` or `defineUpdateOperation` + * + * @example + * ```typescript + * const fields = { + * images: nestedOneToManyField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userImage', + * relationName: 'user', + * fields: { + * id: scalarField(z.string()), + * caption: scalarField(z.string()), + * }, + * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, + * buildData: (data) => ({ + * create: { caption: data.caption }, + * update: { caption: data.caption }, + * }), + * }), + * }; + * + * // Create user with images + * await createUser({ + * data: { + * name: 'John', + * images: [ + * { caption: 'First image' }, + * { caption: 'Second image' }, + * ], + * }, + * context: ctx, + * }); + * + * // Update user images (creates new, updates existing, deletes removed) + * await updateUser({ + * where: { id: userId }, + * data: { + * images: [ + * { id: 'img-1', caption: 'Updated caption' }, // Updates existing + * { caption: 'New image' }, // Creates new + * // img-2 not in array, will be deleted + * ], + * }, + * context: ctx, + * }); + * ``` + */ +export function nestedOneToManyField< + TParentModelName extends ModelPropName, + TModelName extends ModelPropName, + TRelationName extends RelationName, + TFields extends Record, +>( + config: NestedOneToManyFieldConfig< + TParentModelName, + TModelName, + TRelationName, + TFields + >, +): FieldDefinition< + InferInput[] | undefined, + undefined, + undefined | { deleteMany: Record } +> { + const getWhereUnique = ( + input: InferInput, + originalModel: GetPayload, + ): WhereUniqueInput | undefined => { + const whereUnique = config.getWhereUnique(input, originalModel); + if (whereUnique && Object.values(whereUnique).includes(undefined)) { + throw new Error( + 'getWhereUnique cannot return any undefined values in the object', + ); + } + return whereUnique; + }; + + return { + processInput: async (value, processCtx) => { + const { serviceContext, loadExisting } = processCtx; + + if (value === undefined) { + return { data: { create: undefined, update: undefined } }; + } + + const existingModel = (await loadExisting()) as + | GetPayload + | undefined; + + // Filter objects that relate to parent model only + const whereFromOriginalModel = existingModel && { + [config.relationName]: expandWhereUnique( + config.parentModel.getWhereUnique(existingModel), + ), + }; + // Handle list of items + const delegate = makeGenericPrismaDelegate(prisma, config.model); + + const cachedLoadExisting = value.map((itemInput) => { + let cachedExisting: GetPayload | undefined; + const whereUnique = + existingModel && getWhereUnique(itemInput, existingModel); + + return async (): Promise | undefined> => { + if (cachedExisting) return cachedExisting; + if (!whereUnique) return undefined; + cachedExisting = + (await delegate.findUnique({ + where: { ...whereUnique, ...whereFromOriginalModel }, + })) ?? undefined; + return cachedExisting; + }; + }); + + const processedItems = await Promise.all( + value.map(async (itemInput, idx) => { + const whereUnique = + existingModel && config.getWhereUnique(itemInput, existingModel); + + const { data, hooks } = await transformFields( + config.fields, + itemInput, + { + serviceContext, + operation: 'upsert', + allowOptionalFields: false, + loadExisting: cachedLoadExisting[idx] as () => Promise< + object | undefined + >, + }, + ); + + return { whereUnique, data, hooks }; + }), + ); + + const beforeExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: false } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.beforeExecute, { + ...ctx, + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + const results: (GetPayload | undefined)[] = Array.from( + { length: value.length }, + () => undefined, + ); + const afterExecuteHook = async ( + ctx: TransactionalOperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + const prismaDelegate = makeGenericPrismaDelegate(ctx.tx, config.model); + + // Delete items not in the input + if (whereFromOriginalModel) { + const keepFilters = processedItems + .map((item) => expandWhereUnique(item.whereUnique)) + .filter( + (where): where is WhereInput => where !== undefined, + ) + .map((where) => ({ NOT: where })); + + const deleteWhere = + keepFilters.length === 0 + ? whereFromOriginalModel + : ({ + AND: [whereFromOriginalModel, ...keepFilters], + } as WhereInput); + + await prismaDelegate.deleteMany({ where: deleteWhere }); + } + + // Upsert items + await Promise.all( + processedItems.map(async (item, idx) => { + const awaitedData = + typeof item.data === 'function' + ? await item.data(ctx.tx) + : item.data; + + const parentWhereUnique = config.parentModel.getWhereUnique( + ctx.result, + ); + + const builtData = await config.buildData( + { + create: { + ...awaitedData.create, + ...({ + [config.relationName]: { connect: parentWhereUnique }, + } as Record< + TRelationName, + { connect: WhereUniqueInput } + >), + }, + update: awaitedData.update, + }, + ctx.result, + { + ...ctx, + operation: item.whereUnique ? 'update' : 'create', + loadExisting: cachedLoadExisting[idx], + result: undefined, + }, + ); + + results[idx] = item.whereUnique + ? await prismaDelegate.upsert({ + where: item.whereUnique, + create: builtData.create, + update: builtData.update, + }) + : await prismaDelegate.create({ + data: builtData.create, + }); + + await invokeHooks(item.hooks.afterExecute, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }); + }), + ); + }; + + const afterCommitHook = async ( + ctx: OperationContext< + GetPayload, + { hasResult: true } + >, + ): Promise => { + await Promise.all( + processedItems.map((item, idx) => + invokeHooks(item.hooks.afterCommit, { + ...ctx, + result: results[idx], + loadExisting: cachedLoadExisting[idx], + }), + ), + ); + }; + + return { + data: {}, + hooks: { + beforeExecute: [beforeExecuteHook], + afterExecute: [afterExecuteHook], + afterCommit: [afterCommitHook], + }, + }; + }, + }; +} diff --git a/tests/simple/apps/backend/src/utils/data-operations/prisma-types.ts b/tests/simple/apps/backend/src/utils/data-operations/prisma-types.ts new file mode 100644 index 000000000..44169a726 --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/prisma-types.ts @@ -0,0 +1,163 @@ +import type { Args, Result } from '@prisma/client/runtime/client'; + +import type { Prisma } from '@src/generated/prisma/client.js'; +import type { prisma } from '@src/services/prisma.js'; + +/** + * Union type of all Prisma model names (e.g., 'user', 'post', 'comment'). + * + * Used as a constraint for generic type parameters to ensure only valid + * Prisma model names are used. + */ +export type ModelPropName = Prisma.TypeMap['meta']['modelProps']; + +/** + * Infers the return type of a Prisma query for a given model and query arguments. + * + * This type extracts the shape of data returned from a Prisma query, respecting + * any `select` or `include` arguments provided. + * + * @template TModelName - The Prisma model name + * @template TQueryArgs - Optional query arguments (select/include) + * + * @example + * ```typescript + * // Basic user type + * type User = GetPayload<'user'>; + * + * // User with posts included + * type UserWithPosts = GetPayload<'user', { include: { posts: true } }>; + * + * // User with only specific fields + * type UserNameEmail = GetPayload<'user', { select: { name: true, email: true } }>; + * ``` + */ +export type GetPayload< + TModelName extends ModelPropName, + TQueryArgs = undefined, +> = Result<(typeof prisma)[TModelName], TQueryArgs, 'findUniqueOrThrow'>; + +/** + * Type for Prisma query arguments (select/include) for a given model. + * + * Used to shape the returned data from database operations by specifying + * which fields to select or which relations to include. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const query: ModelQuery<'user'> = { + * select: { id: true, name: true, email: true }, + * }; + * + * const queryWithInclude: ModelQuery<'user'> = { + * include: { posts: true, profile: true }, + * }; + * ``` + */ +export type ModelQuery = Pick< + { select?: unknown; include?: unknown } & Args< + (typeof prisma)[TModelName], + 'findUnique' + >, + 'select' | 'include' +>; + +/** + * Type for Prisma where clauses for filtering records. + * + * Used in `findMany`, `updateMany`, `deleteMany` operations to specify + * which records to operate on. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const where: WhereInput<'user'> = { + * email: { contains: '@example.com' }, + * AND: [{ isActive: true }, { createdAt: { gt: new Date('2024-01-01') } }], + * }; + * ``` + */ +export type WhereInput = Args< + (typeof prisma)[TModelName], + 'findMany' +>['where']; + +/** + * Type for Prisma unique where clauses for finding a single record. + * + * Used in `findUnique`, `update`, `delete`, and `upsert` operations + * to specify which record to operate on using unique fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * // By ID + * const where1: WhereUniqueInput<'user'> = { id: 'user-123' }; + * + * // By unique email + * const where2: WhereUniqueInput<'user'> = { email: 'user@example.com' }; + * + * // By compound unique constraint + * const where3: WhereUniqueInput<'membership'> = { + * userId_teamId: { userId: 'user-1', teamId: 'team-1' }, + * }; + * ``` + */ +export type WhereUniqueInput = Args< + (typeof prisma)[TModelName], + 'findUnique' +>['where']; + +/** + * Type for Prisma create input data for a given model. + * + * Represents the shape of data accepted by `create` operations. + * Includes all required fields and optional fields, with support for + * nested creates via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const createData: CreateInput<'user'> = { + * name: 'John Doe', + * email: 'john@example.com', + * posts: { + * create: [{ title: 'First post', content: '...' }], + * }, + * }; + * ``` + */ +export type CreateInput = Args< + (typeof prisma)[TModelName], + 'create' +>['data']; + +/** + * Type for Prisma update input data for a given model. + * + * Represents the shape of data accepted by `update` operations. + * All fields are optional (partial updates), with support for + * nested updates, creates, and deletes via relation fields. + * + * @template TModelName - The Prisma model name + * + * @example + * ```typescript + * const updateData: UpdateInput<'user'> = { + * name: 'Jane Doe', // Only updating name + * posts: { + * update: [{ where: { id: 'post-1' }, data: { title: 'Updated title' } }], + * create: [{ title: 'New post', content: '...' }], + * }, + * }; + * ``` + */ +export type UpdateInput = Args< + (typeof prisma)[TModelName], + 'update' +>['data']; diff --git a/tests/simple/apps/backend/src/utils/data-operations/prisma-utils.ts b/tests/simple/apps/backend/src/utils/data-operations/prisma-utils.ts new file mode 100644 index 000000000..5d1e12baa --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/prisma-utils.ts @@ -0,0 +1,78 @@ +import type { + CreateInput, + GetPayload, + ModelPropName, + UpdateInput, + WhereInput, + WhereUniqueInput, +} from './prisma-types.js'; +import type { PrismaTransaction } from './types.js'; + +/** + * Generic interface for Prisma model delegates. + * + * Provides a type-safe way to interact with any Prisma model through + * a common set of operations. Used internally by the data operations + * system to perform database operations on models determined at runtime. + * + * @template TModelName - The Prisma model name + * + * @internal This interface is used internally by the data operations system + */ +interface GenericPrismaDelegate { + findUnique: (args: { + where: WhereUniqueInput; + }) => Promise | null>; + findMany: (args: { + where: WhereInput; + }) => Promise[]>; + create: (args: { + data: CreateInput; + }) => Promise>; + update: (args: { + where: WhereUniqueInput; + data: UpdateInput; + }) => Promise>; + upsert: (args: { + where: WhereUniqueInput; + create: CreateInput; + update: UpdateInput; + }) => Promise>; + delete: (args: { + where: WhereUniqueInput; + }) => Promise>; + deleteMany: (args: { + where: WhereInput; + }) => Promise<{ count: number }>; +} + +/** + * Creates a type-safe generic delegate for a Prisma model. + * + * This function allows accessing Prisma model operations (findUnique, create, update, etc.) + * in a type-safe way when the model name is only known at runtime. It's used internally + * by nested field handlers and other generic operations. + * + * @template TModelName - The Prisma model name + * @param tx - Prisma transaction client + * @param modelName - The name of the model to create a delegate for (e.g., 'user', 'post') + * @returns A generic delegate providing type-safe access to model operations + * + * @example + * ```typescript + * const delegate = makeGenericPrismaDelegate(tx, 'user'); + * + * // Type-safe operations + * const user = await delegate.findUnique({ where: { id: userId } }); + * const users = await delegate.findMany({ where: { isActive: true } }); + * const newUser = await delegate.create({ data: { name: 'John', email: 'john@example.com' } }); + * ``` + * + * @internal This function is used internally by nested field handlers + */ +export function makeGenericPrismaDelegate( + tx: PrismaTransaction, + modelName: TModelName, +): GenericPrismaDelegate { + return tx[modelName] as unknown as GenericPrismaDelegate; +} diff --git a/tests/simple/apps/backend/src/utils/data-operations/relation-helpers.ts b/tests/simple/apps/backend/src/utils/data-operations/relation-helpers.ts new file mode 100644 index 000000000..aef6f8931 --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/relation-helpers.ts @@ -0,0 +1,194 @@ +/** + * Type helper to check if a record type has any undefined values. + * + * @template T - Record type to check + */ +type HasUndefinedValues> = + undefined extends T[keyof T] ? true : false; + +/** + * Type helper to check if a record type has any null values. + * + * @template T - Record type to check + */ +type HasNullValues> = null extends T[keyof T] + ? true + : false; + +/** + * Type helper to check if a record type has any undefined or null values. + * Used for conditional return types in relation helpers. + * + * @template T - Record type to check + */ +type HasUndefinedOrNullValues> = + HasUndefinedValues extends true + ? true + : HasNullValues extends true + ? true + : false; + +/** + * Creates a Prisma connect object for create operations + * + * Returns undefined if any value in the data object is null or undefined, + * allowing optional relations in create operations. + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect object or undefined if any value is null/undefined + * + * @example + * // Single field - required relation + * todoList: relationHelpers.connectCreate({ id: todoListId }) + * + * @example + * // Single field - optional relation (returns undefined if assigneeId is null/undefined) + * assignee: relationHelpers.connectCreate({ id: assigneeId }) + * + * @example + * // Composite key - required relation + * owner: relationHelpers.connectCreate({ userId, tenantId }) + * + * @example + * // Composite key - optional relation (returns undefined if any field is null/undefined) + * assignee: relationHelpers.connectCreate({ userId, organizationId }) + */ +function connectCreate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedOrNullValues extends true ? undefined : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + // Return undefined if any value is null or undefined (for optional relations) + if (values.some((value) => value === undefined || value === null)) { + return undefined as HasUndefinedOrNullValues extends true + ? undefined + : never; + } + return { + connect: data as { [K in keyof TUniqueWhere]: string }, + }; +} + +/** + * Creates a Prisma connect/disconnect object for update operations + * + * Handles three cases: + * - All values present: returns connect object to update the relation + * - Any value is null: returns { disconnect: true } to remove the relation + * - Any value is undefined: returns undefined to leave the relation unchanged + * + * @template TUniqueWhere - Object containing the unique identifier(s) for the relation + * @param data - Object with one or more key-value pairs representing the unique identifier(s) + * @returns Prisma connect/disconnect object, or undefined if no change + * + * @example + * // Single field - update to a new assignee + * assignee: relationHelpers.connectUpdate({ id: 'user-2' }) + * + * @example + * // Single field - disconnect the assignee (set to null) + * assignee: relationHelpers.connectUpdate({ id: null }) + * + * @example + * // Single field - leave the assignee unchanged + * assignee: relationHelpers.connectUpdate({ id: undefined }) + * + * @example + * // Composite key - update to a new relation + * owner: relationHelpers.connectUpdate({ userId: 'user-2', tenantId: 'tenant-1' }) + * + * @example + * // Composite key - disconnect (if any field is null) + * owner: relationHelpers.connectUpdate({ userId: null, tenantId: 'tenant-1' }) + * + * @example + * // Composite key - no change (if any field is undefined) + * owner: relationHelpers.connectUpdate({ userId: undefined, tenantId: 'tenant-1' }) + */ +function connectUpdate< + TUniqueWhere extends Record, +>( + data: TUniqueWhere, +): + | (HasUndefinedValues extends true ? undefined : never) + | (HasNullValues extends true ? { disconnect: true } : never) + | { connect: { [K in keyof TUniqueWhere]: string } } { + const values = Object.values(data); + const hasUndefined = values.includes(undefined); + const hasNull = values.includes(null); + + // If any value is undefined, leave relation unchanged + if (hasUndefined) { + return undefined as HasUndefinedValues extends true + ? undefined + : never; + } + + // If any value is null, disconnect the relation + if (hasNull) { + return { + disconnect: true, + } as HasNullValues extends true + ? { disconnect: true } + : never; + } + + // All values are present, connect to the new relation + return { connect: data as { [K in keyof TUniqueWhere]: string } }; +} + +/** + * Relation helpers for transforming scalar IDs into Prisma relation objects + * + * These helpers provide type-safe ways to connect and disconnect relations + * in Prisma operations, with proper handling of optional relations and + * support for both single-field and composite key relations. + * + * @example + * // In a create operation with single field relations + * buildData: ({ todoListId, assigneeId, ...rest }) => ({ + * todoList: relationHelpers.connectCreate({ id: todoListId }), + * assignee: relationHelpers.connectCreate({ id: assigneeId }), // undefined if assigneeId is null + * ...rest, + * }) + * + * @example + * // In a create operation with composite key relation + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectCreate({ userId, tenantId }), // undefined if any field is null + * ...rest, + * }) + * + * @example + * // In an update operation with single field + * buildData: ({ assigneeId, ...rest }) => ({ + * assignee: relationHelpers.connectUpdate({ id: assigneeId }), + * ...rest, + * }) + * + * @example + * // In an update operation with composite key + * buildData: ({ userId, tenantId, ...rest }) => ({ + * owner: relationHelpers.connectUpdate({ userId, tenantId }), + * ...rest, + * }) + */ +export const relationHelpers = { + /** + * Creates a connect object for create operations + * Returns undefined if the ID is null or undefined + */ + connectCreate, + + /** + * Creates a connect/disconnect object for update operations + * - null: disconnects the relation + * - undefined: no change to the relation + * - value: connects to the new relation + */ + connectUpdate, +}; diff --git a/tests/simple/apps/backend/src/utils/data-operations/types.ts b/tests/simple/apps/backend/src/utils/data-operations/types.ts new file mode 100644 index 000000000..aba7c5e6b --- /dev/null +++ b/tests/simple/apps/backend/src/utils/data-operations/types.ts @@ -0,0 +1,342 @@ +import type { ITXClientDenyList } from '@prisma/client/runtime/client'; + +import type { PrismaClient } from '@src/generated/prisma/client.js'; + +import type { ServiceContext } from '../service-context.js'; + +/** + * Prisma transaction client type for data operations. + * + * This is the Prisma client type available within transaction callbacks, + * with operations that cannot be used inside transactions excluded. + */ +export type PrismaTransaction = Omit; + +/** + * Type of data operation being performed. + * + * - **create**: Inserting a new record + * - **update**: Modifying an existing record + * - **upsert**: Creating or updating a record (used internally for nested relations) + * - **delete**: Removing a record + */ +export type DataOperationType = 'create' | 'update' | 'upsert' | 'delete'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- to allow any generic types */ + +/** + * ========================================= + * Operation Contexts and Hooks + * ========================================= + */ + +/** + * Context object provided to operation hooks and authorization functions. + * + * Contains information about the operation being performed and provides + * access to the service context and existing data. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface OperationContext< + TModel, + TConfig extends { + hasResult: boolean; + }, +> { + /** Type of operation being performed */ + operation: DataOperationType; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load the existing model data (for update/delete operations) */ + loadExisting: () => Promise; + /** The operation result (only available after execution) */ + result: TConfig['hasResult'] extends true ? TModel : undefined; +} + +/** + * Context object provided to hooks that run inside a transaction. + * + * Extends {@link OperationContext} with access to the Prisma transaction client, + * allowing hooks to perform additional database operations within the same transaction. + * + * @template TModel - The Prisma model type + * @template TConfig - Configuration object with hasResult flag + */ +export interface TransactionalOperationContext< + TModel, + TConfig extends { hasResult: boolean }, +> extends OperationContext { + /** Prisma transaction client for performing database operations */ + tx: PrismaTransaction; +} + +/** + * Lifecycle hooks for data operations. + * + * Hooks allow you to execute custom logic at different points in the operation lifecycle: + * - **beforeExecute**: Runs inside transaction, before the database operation + * - **afterExecute**: Runs inside transaction, after the database operation (has access to result) + * - **afterCommit**: Runs outside transaction, after successful commit (for side effects) + * + * @template TModel - The Prisma model type + * + * @example + * ```typescript + * const hooks: OperationHooks = { + * beforeExecute: [ + * async (ctx) => { + * // Validate business rules before saving + * }, + * ], + * afterExecute: [ + * async (ctx) => { + * // Update related records within same transaction + * await ctx.tx.auditLog.create({ + * data: { action: 'user_created', userId: ctx.result.id }, + * }); + * }, + * ], + * afterCommit: [ + * async (ctx) => { + * // Send email notification (outside transaction) + * await emailService.sendWelcome(ctx.result.email); + * }, + * ], + * }; + * ``` + */ +export interface OperationHooks { + /** Hooks that run inside transaction, before the database operation */ + beforeExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run inside transaction, after the database operation */ + afterExecute?: (( + context: TransactionalOperationContext, + ) => Promise)[]; + /** Hooks that run outside transaction, after successful commit */ + afterCommit?: (( + context: OperationContext, + ) => Promise)[]; +} + +export type AnyOperationHooks = OperationHooks; + +/** + * ========================================= + * Field Types + * ========================================= + */ + +/** + * Context provided to field `processInput` functions. + * + * Contains information about the operation and provides access to + * existing data and service context. + */ +export interface FieldContext { + /** Type of operation being performed */ + operation: DataOperationType; + /** Name of the field being processed */ + fieldName: string; + /** Service context with user info, request details, etc. */ + serviceContext: ServiceContext; + /** Function to load existing model data (for updates) */ + loadExisting: () => Promise; +} + +/** + * Transformed field data for create and update operations. + * + * Fields can produce different data structures for create vs. update operations. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformData { + /** Data to use when creating a new record */ + create?: TCreateOutput; + /** Data to use when updating an existing record */ + update?: TUpdateOutput; +} + +/** + * Result of field processing, including transformed data and optional hooks. + * + * The data can be either synchronous or asynchronous (resolved inside transaction). + * Hooks allow fields to perform side effects during the operation lifecycle. + * + * @template TCreateOutput - Data type for create operations + * @template TUpdateOutput - Data type for update operations + */ +export interface FieldTransformResult { + /** + * Transformed field data or an async function that resolves to field data. + * Async functions are resolved inside the transaction, allowing access to tx client. + */ + data?: + | FieldTransformData + | (( + tx: PrismaTransaction, + ) => Promise>); + + /** Optional hooks to execute during operation lifecycle */ + hooks?: AnyOperationHooks; +} + +/** + * Field definition for validating and transforming input values. + * + * A field definition specifies how to process a single input field: + * - Validate the input value + * - Transform it into Prisma-compatible create/update data + * - Optionally attach hooks for side effects + * + * @template TInput - The expected input type + * @template TCreateOutput - Output type for create operations + * @template TUpdateOutput - Output type for update operations + * + * @example + * ```typescript + * const nameField: FieldDefinition = { + * processInput: (value, ctx) => { + * const validated = z.string().min(1).parse(value); + * return { + * data: { + * create: validated, + * update: validated, + * }, + * }; + * }, + * }; + * ``` + */ +export interface FieldDefinition { + /** + * Processes and transforms an input value. + * + * @param value - The input value to process + * @param ctx - Context about the operation + * @returns Transformed data and optional hooks + */ + processInput: ( + value: TInput, + ctx: FieldContext, + ) => + | Promise> + | FieldTransformResult; +} + +/** Type alias for any field definition (used for generic constraints) */ +export type AnyFieldDefinition = FieldDefinition; + +/** + * ========================================= + * Type Inference Utilities + * ========================================= + */ + +/** Extracts keys from T where the value type does not include undefined */ +type RequiredKeys = { + [P in keyof T]: T[P] extends Exclude ? P : never; +}[keyof T]; + +/** Makes properties with undefined values optional, keeps others required */ +type OptionalForUndefinedKeys = Partial & Pick>; + +/** Identity type that expands type aliases for better IDE tooltips */ +type Identity = T extends object + ? {} & { + [P in keyof T]: T[P]; + } + : T; + +/** + * Infers the input type from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's input type + * - Fields accepting undefined become optional properties + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type Input = InferInput; + * // { name: string; email?: string | undefined } + * ``` + */ +export type InferInput> = + Identity< + OptionalForUndefinedKeys<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInput, + any, + any + > + ? TInput + : never; + }> + >; + +/** + * Infers the output type (create and update) from a single field definition. + * + * @template TField - A field definition + */ +export type InferFieldOutput> = + TField extends FieldDefinition + ? { + create: TCreateOutput; + update: TUpdateOutput; + } + : never; + +/** + * Infers the create output type from a record of field definitions. + * + * Creates an object type where each property is the field's create output type. + * + * @template TFields - Record of field definitions + */ +export type InferFieldsCreateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['create']; +}>; + +/** + * Infers the update output type from a record of field definitions. + * + * Creates an object type where each property is the field's update output type + * or undefined (since updates are partial). + * + * @template TFields - Record of field definitions + */ +export type InferFieldsUpdateOutput< + TFields extends Record, +> = Identity<{ + [K in keyof TFields]: InferFieldOutput['update'] | undefined; +}>; + +/** + * Combined create and update output types for a set of fields. + * + * @template TFields - Record of field definitions + */ +export interface InferFieldsOutput< + TFields extends Record, +> { + /** Field outputs for create operations */ + create: InferFieldsCreateOutput; + /** Field outputs for update operations */ + update: InferFieldsUpdateOutput; +} diff --git a/tests/simple/apps/backend/vitest.config.ts b/tests/simple/apps/backend/vitest.config.ts index e7b9216c2..1bc85c7b8 100644 --- a/tests/simple/apps/backend/vitest.config.ts +++ b/tests/simple/apps/backend/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { clearMocks: true, globalSetup: './tests/scripts/global-setup.ts', + maxWorkers: 1, passWithNoTests: true, root: './src', },