diff --git a/.changeset/bright-seals-rescue.md b/.changeset/bright-seals-rescue.md new file mode 100644 index 000000000..65309da83 --- /dev/null +++ b/.changeset/bright-seals-rescue.md @@ -0,0 +1,9 @@ +--- +'@halfdomelabs/fastify-generators': patch +'@halfdomelabs/react-generators': patch +'@halfdomelabs/core-generators': patch +'@halfdomelabs/utils': patch +'@halfdomelabs/sync': patch +--- + +Introduce new generator concept of output providers that replace task dependencies diff --git a/.cursor/rules/writing-vitest-tests.mdc b/.cursor/rules/writing-vitest-tests.mdc index d474b8944..398512121 100644 --- a/.cursor/rules/writing-vitest-tests.mdc +++ b/.cursor/rules/writing-vitest-tests.mdc @@ -3,4 +3,4 @@ description: globs: alwaysApply: true --- -We use vitest and colocate unit/integration tests next to the file that is written with the convention of: *.unit.test.ts (for unit tests) or *.int.test.ts (for integration tests). We also use Node 16 module resolution so files must end with .js. \ No newline at end of file +We use vitest and colocate unit/integration tests next to the file that is written with the convention of: *.unit.test.ts (for unit tests) or *.int.test.ts (for integration tests). We also use Node 16 module resolution so filenames in import statements must end with .js. \ No newline at end of file diff --git a/packages/core-generators/src/generators/docker/docker-compose/index.ts b/packages/core-generators/src/generators/docker/docker-compose/index.ts index 9cb3cd873..1a503689e 100644 --- a/packages/core-generators/src/generators/docker/docker-compose/index.ts +++ b/packages/core-generators/src/generators/docker/docker-compose/index.ts @@ -87,7 +87,6 @@ ${volumeEntries.join('\n')}`.trim(); } return { - providers: {}, build: (builder) => { builder.writeFile({ id: 'docker-compose', diff --git a/packages/core-generators/src/generators/node/node/index.ts b/packages/core-generators/src/generators/node/node/index.ts index 34a481a7b..e0e94aeef 100644 --- a/packages/core-generators/src/generators/node/node/index.ts +++ b/packages/core-generators/src/generators/node/node/index.ts @@ -2,6 +2,7 @@ import { createGenerator, createNonOverwriteableMap, createProviderType, + createSetupTask, POST_WRITE_COMMAND_PRIORITY, writeJsonAction, } from '@halfdomelabs/sync'; @@ -27,13 +28,6 @@ const descriptorSchema = z.object({ pnpmVersion: z.string().default('10.6.5'), }); -export interface NodeSetupProvider { - setIsEsm(isEsm: boolean): void; -} - -export const nodeSetupProvider = - createProviderType('node-setup'); - export interface NodeProvider { addPackage(name: string, version: string): void; addPackages(packages: Record): void; @@ -57,42 +51,34 @@ interface NodeDependencyEntry { type: NodeDependencyType; } +const [setupTask, nodeSetupProvider, nodeSetupOutputProvider] = createSetupTask( + (t) => ({ isEsm: t.boolean(false) }), + { + prefix: 'node', + configScope: projectScope, + }, +); + +export { nodeSetupProvider }; + export const nodeGenerator = createGenerator({ name: 'node/node', generatorFileUrl: import.meta.url, descriptorSchema, scopes: [projectScope], buildTasks(taskBuilder, descriptor) { - const setupTask = taskBuilder.addTask({ - name: 'setup', - exports: { - nodeSetup: nodeSetupProvider.export(projectScope), - }, - run() { - let isEsm = false; - return { - providers: { - nodeSetup: { - setIsEsm(value) { - isEsm = value; - }, - }, - }, - build: () => ({ isEsm }), - }; - }, - }); + taskBuilder.addTask(setupTask); taskBuilder.addTask({ name: 'main', + dependencies: { + setup: nodeSetupOutputProvider, + }, exports: { node: nodeProvider.export(projectScope), project: projectProvider.export(projectScope), }, - taskDependencies: { - setup: setupTask, - }, - run(deps, { setup: { isEsm } }) { + run({ setup: { isEsm } }) { const dependencies = new Map(); const extraProperties = createNonOverwriteableMap( { type: isEsm ? 'module' : 'commonjs' }, diff --git a/packages/core-generators/src/generators/node/typescript/index.ts b/packages/core-generators/src/generators/node/typescript/index.ts index f5ac0ad0e..a7941f1d8 100644 --- a/packages/core-generators/src/generators/node/typescript/index.ts +++ b/packages/core-generators/src/generators/node/typescript/index.ts @@ -1,15 +1,18 @@ -import type { BuilderAction, WriteFileOptions } from '@halfdomelabs/sync'; -import type { CompilerOptions } from 'ts-morph'; +import type { + BuilderAction, + InferProviderType, + WriteFileOptions, +} from '@halfdomelabs/sync'; +import type { CompilerOptions, ts } from 'ts-morph'; import { createGenerator, - createNonOverwriteableMap, createProviderType, + createSetupTask, writeJsonAction, } from '@halfdomelabs/sync'; import { safeMergeAll } from '@halfdomelabs/utils'; import path from 'node:path'; -import { ts } from 'ts-morph'; import { z } from 'zod'; import type { CopyTypescriptFilesOptions } from '@src/actions/copy-typescript-files-action.js'; @@ -73,19 +76,6 @@ export interface TypescriptConfigReference { path: string; } -export interface TypescriptConfigProvider { - setTypescriptVersion(version: string): void; - setTypescriptCompilerOptions(json: TypescriptCompilerOptions): void; - getCompilerOptions(): CompilerOptions; - addInclude(path: string): void; - addExclude(path: string): void; - addReference(reference: TypescriptConfigReference): void; - addExtraSection(section: Record): void; -} - -export const typescriptConfigProvider = - createProviderType('typescript-config'); - export interface TypescriptProvider { createTemplate( config: Config, @@ -112,7 +102,6 @@ export interface TypescriptProvider { options?: WriteFileOptions, ): BuilderAction; resolveModule(moduleSpecifier: string, from: string): string; - getCompilerOptions(): CompilerOptions; } export const typescriptProvider = @@ -138,116 +127,72 @@ export interface TypescriptFileProvider { export const typescriptFileProvider = createProviderType('typescript-file'); -interface TypescriptConfig { - version: string; - compilerOptions: TypescriptCompilerOptions; - include: string[]; - exclude: string[]; - references: TypescriptConfigReference[]; - extraSections: Record[]; -} - -const DEFAULT_CONFIG: TypescriptConfig = { - version: CORE_PACKAGES.typescript, - compilerOptions: { - outDir: 'dist', - declaration: true, - baseUrl: './src', - target: 'es2022', - lib: ['es2023'], - esModuleInterop: true, - module: 'node16', - moduleResolution: 'node16', - strict: true, - removeComments: true, - forceConsistentCasingInFileNames: true, - resolveJsonModule: true, - sourceMap: true, - }, - include: ['src'], - exclude: ['**/node_modules', '**/dist', '**/lib'], - references: [], - extraSections: [], +const DEFAULT_COMPILER_OPTIONS: TypescriptCompilerOptions = { + outDir: 'dist', + declaration: true, + baseUrl: './src', + target: 'es2022', + lib: ['es2023'], + esModuleInterop: true, + module: 'node16', + moduleResolution: 'node16', + strict: true, + removeComments: true, + forceConsistentCasingInFileNames: true, + resolveJsonModule: true, + sourceMap: true, }; +const [setupTask, typescriptSetupProvider, typescriptConfigProvider] = + createSetupTask( + (t) => ({ + version: t.string(CORE_PACKAGES.typescript), + compilerOptions: t.scalar( + DEFAULT_COMPILER_OPTIONS, + ), + include: t.array(['src'], { stripDuplicates: true }), + exclude: t.array(['**/node_modules', '**/dist', '**/lib']), + references: t.array(), + extraSections: t.array>(), + }), + { + prefix: 'typescript', + configScope: projectScope, + outputScope: projectScope, + }, + ); + +export { typescriptConfigProvider, typescriptSetupProvider }; + +export type TypescriptConfigProvider = InferProviderType< + typeof typescriptConfigProvider +>; + +export type TypescriptSetupProvider = InferProviderType< + typeof typescriptSetupProvider +>; + export const typescriptGenerator = createGenerator({ name: 'node/typescript', generatorFileUrl: import.meta.url, descriptorSchema: typescriptGeneratorDescriptorSchema, buildTasks(taskBuilder, descriptor) { - const configTask = taskBuilder.addTask({ - name: 'config', - exports: { - typescriptConfig: typescriptConfigProvider.export(projectScope), - }, - run() { - const config = createNonOverwriteableMap( - DEFAULT_CONFIG, - { - name: 'typescript', - defaultsOverwriteable: true, - }, - ); - - function getCompilerOptions(): CompilerOptions { - const result = ts.convertCompilerOptionsFromJson( - config.get('compilerOptions'), - '.', - ); - if (result.errors.length > 0) { - throw new Error( - `Unable to extract compiler options: ${JSON.stringify( - result.errors, - )}`, - ); - } - return result.options; - } - - return { - providers: { - typescriptConfig: { - setTypescriptVersion(version) { - config.merge({ version }); - }, - setTypescriptCompilerOptions(options) { - config.merge({ compilerOptions: options }); - }, - getCompilerOptions, - addInclude(path) { - config.appendUnique('include', [path]); - }, - addExclude(path) { - config.appendUnique('exclude', [path]); - }, - addReference(reference) { - config.appendUnique('references', [reference]); - }, - addExtraSection(section) { - config.appendUnique('extraSections', [section]); - }, - }, - }, - build: () => ({ config, getCompilerOptions }), - }; - }, - }); + taskBuilder.addTask(setupTask); taskBuilder.addTask({ name: 'main', - dependencies: { node: nodeProvider }, + dependencies: { + node: nodeProvider, + typescriptConfig: typescriptConfigProvider, + }, exports: { typescript: typescriptProvider.export(projectScope) }, - taskDependencies: { configTask }, - run({ node }, { configTask: { config, getCompilerOptions } }) { + run({ node, typescriptConfig }) { + const { compilerOptions } = typescriptConfig; let cachedPathEntries: PathMapEntry[] | undefined; function getPathEntries(): PathMapEntry[] { if (!cachedPathEntries) { - // { "baseUrl": "./src", "paths": { "@src/*": ["./*"] } } - // would be { from: "src", to: "@src" } - const configMap = config.value(); - - const { baseUrl, paths } = configMap.compilerOptions; + const { baseUrl, paths } = compilerOptions; if (!paths && (baseUrl === './' || baseUrl === '.')) { // TODO: Support other source folders cachedPathEntries = [{ from: 'src', to: 'src' }]; @@ -276,8 +221,7 @@ export const typescriptGenerator = createGenerator({ return cachedPathEntries; } - const moduleResolution = - config.value().compilerOptions.moduleResolution ?? 'node'; + const moduleResolution = compilerOptions.moduleResolution ?? 'node'; return { providers: { @@ -320,18 +264,11 @@ export const typescriptGenerator = createGenerator({ pathMapEntries: getPathEntries(), moduleResolution, }), - getCompilerOptions, } as TypescriptProvider, }, async build(builder) { - const { - compilerOptions, - include, - exclude, - version, - references, - extraSections, - } = config.value(); + const { include, exclude, version, references, extraSections } = + typescriptConfig; node.addDevPackage('typescript', version); await builder.apply( @@ -356,11 +293,13 @@ export const typescriptGenerator = createGenerator({ exports: { typescriptFile: typescriptFileProvider.export(projectScope), }, - taskDependencies: { configTask }, - run(_, { configTask: { config } }) { - const moduleResolution = - config.value().compilerOptions.moduleResolution ?? 'node'; - const { baseUrl = '.', paths = {} } = config.value().compilerOptions; + dependencies: { typescriptConfig: typescriptConfigProvider }, + run({ typescriptConfig: { compilerOptions } }) { + const { + baseUrl = '.', + paths = {}, + moduleResolution = 'node', + } = compilerOptions; const pathMapEntries = generatePathMapEntries(baseUrl, paths); const internalPatterns = pathMapEntriesToRegexes(pathMapEntries); diff --git a/packages/fastify-generators/src/generators/auth/auth-context/index.ts b/packages/fastify-generators/src/generators/auth/auth-context/index.ts index fd8f4a5ab..39e947e22 100644 --- a/packages/fastify-generators/src/generators/auth/auth-context/index.ts +++ b/packages/fastify-generators/src/generators/auth/auth-context/index.ts @@ -21,7 +21,7 @@ import { requestServiceContextSetupProvider } from '@src/generators/core/request import { serviceContextSetupProvider } from '@src/generators/core/service-context/index.js'; import { authRolesProvider } from '../auth-roles/index.js'; -import { authSetupProvider } from '../auth/index.js'; +import { authConfigProvider } from '../auth/index.js'; const descriptorSchema = z.object({}); @@ -39,7 +39,7 @@ const createMainTask = createTaskConfigBuilder(() => ({ appModule: appModuleProvider, typescript: typescriptProvider, errorHandlerService: errorHandlerServiceProvider, - authSetup: authSetupProvider, + authConfig: authConfigProvider, }, exports: { authContext: authContextProvider.export(projectScope), @@ -51,7 +51,7 @@ const createMainTask = createTaskConfigBuilder(() => ({ typescript, errorHandlerService, authRoles, - authSetup, + authConfig, }) { const [authContextTypesImport, authContextTypesFile] = makeImportAndFilePath( @@ -84,10 +84,13 @@ const createMainTask = createTaskConfigBuilder(() => ({ }, }; - authSetup.getConfig().set('contextUtilsImport', { - path: authContextUtilsImport, - allowedImports: ['createAuthContextFromSessionInfo'], - }); + authConfig.contextUtilsImport.set( + { + path: authContextUtilsImport, + allowedImports: ['createAuthContextFromSessionInfo'], + }, + 'auth/auth-context', + ); return { providers: { diff --git a/packages/fastify-generators/src/generators/auth/auth-plugin/index.ts b/packages/fastify-generators/src/generators/auth/auth-plugin/index.ts index 8290c395a..0942244bf 100644 --- a/packages/fastify-generators/src/generators/auth/auth-plugin/index.ts +++ b/packages/fastify-generators/src/generators/auth/auth-plugin/index.ts @@ -57,9 +57,6 @@ export const authPluginGenerator = createGenerator({ ); return { - providers: { - authPlugin: {}, - }, build: async (builder) => { await builder.apply( typescript.createCopyAction({ diff --git a/packages/fastify-generators/src/generators/auth/auth-roles/index.ts b/packages/fastify-generators/src/generators/auth/auth-roles/index.ts index 8fc316257..534e5725e 100644 --- a/packages/fastify-generators/src/generators/auth/auth-roles/index.ts +++ b/packages/fastify-generators/src/generators/auth/auth-roles/index.ts @@ -12,7 +12,7 @@ import { z } from 'zod'; import { appModuleProvider } from '@src/generators/core/root-module/index.js'; -import { authSetupProvider } from '../auth/index.js'; +import { authConfigProvider } from '../auth/index.js'; const descriptorSchema = z.object({ // Note: Public and user roles are automatically added @@ -44,12 +44,12 @@ export const authRolesGenerator = createGenerator({ dependencies: { typescript: typescriptProvider, appModule: appModuleProvider, - authSetup: authSetupProvider, + authConfig: authConfigProvider, }, exports: { authRoles: authRolesProvider.export(projectScope), }, - run({ typescript, appModule, authSetup }) { + run({ typescript, appModule, authConfig }) { if ( !['public', 'user', 'system'].every((name) => roles.some((r) => r.name === name), @@ -75,7 +75,7 @@ export const authRolesGenerator = createGenerator({ ], }; - authSetup.getConfig().set('authRolesImport', authRolesImport); + authConfig.authRolesImport.set(authRolesImport, 'auth/auth-roles'); return { providers: { diff --git a/packages/fastify-generators/src/generators/auth/auth/index.ts b/packages/fastify-generators/src/generators/auth/auth/index.ts index 1699a0fe2..81af515a2 100644 --- a/packages/fastify-generators/src/generators/auth/auth/index.ts +++ b/packages/fastify-generators/src/generators/auth/auth/index.ts @@ -1,5 +1,3 @@ -import type { NonOverwriteableMap } from '@halfdomelabs/sync'; - import { type ImportEntry, type ImportMapper, @@ -7,30 +5,30 @@ import { } from '@halfdomelabs/core-generators'; import { createGenerator, - createNonOverwriteableMap, createProviderType, + createSetupTask, } from '@halfdomelabs/sync'; import { z } from 'zod'; const descriptorSchema = z.object({}); -export interface AuthGeneratorConfig { - userModelName?: string; - authRolesImport?: ImportEntry; - userSessionServiceImport?: ImportEntry; - contextUtilsImport?: ImportEntry; -} - -export interface AuthSetupProvider { - getConfig(): NonOverwriteableMap; -} +const [setupTask, authConfigProvider, authSetupProvider] = createSetupTask( + (t) => ({ + userModelName: t.scalar(), + authRolesImport: t.scalar(), + userSessionServiceImport: t.scalar(), + contextUtilsImport: t.scalar(), + }), + { + prefix: 'auth', + configScope: projectScope, + outputScope: projectScope, + }, +); -export const authSetupProvider = - createProviderType('auth-setup'); +export { authConfigProvider, authSetupProvider }; -export interface AuthProvider extends ImportMapper { - getConfig(): AuthGeneratorConfig; -} +export type AuthProvider = ImportMapper; export const authProvider = createProviderType('auth', { isReadOnly: true, @@ -41,45 +39,32 @@ export const authGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder) { - const setupTask = taskBuilder.addTask({ - name: 'setup', - exports: { - authSetup: authSetupProvider.export(projectScope), - }, - run() { - const config = createNonOverwriteableMap( - {}, - { name: 'auth-config' }, - ); - return { - providers: { - authSetup: { - getConfig: () => config, - }, - }, - build: () => ({ config }), - }; - }, - }); + taskBuilder.addTask(setupTask); taskBuilder.addTask({ name: 'main', + dependencies: { authSetup: authSetupProvider }, exports: { auth: authProvider.export(projectScope), }, - taskDependencies: { setupTask }, - run(deps, { setupTask: { config } }) { - if (!config.value().authRolesImport) { + run({ + authSetup: { + authRolesImport, + userSessionServiceImport, + contextUtilsImport, + }, + }) { + if (!authRolesImport) { throw new Error( 'authRolesImport is required for auth module to work', ); } - if (!config.value().userSessionServiceImport) { + if (!userSessionServiceImport) { throw new Error( 'userSessionServiceImport is required for auth module to work', ); } - if (!config.value().contextUtilsImport) { + if (!contextUtilsImport) { throw new Error( 'contextUtilsImport is required for auth module to work', ); @@ -87,14 +72,11 @@ export const authGenerator = createGenerator({ return { providers: { auth: { - getConfig: () => config.value(), getImportMap() { - const settings = config.value(); return { - '%auth/auth-roles': settings.authRolesImport, - '%auth/user-session-service': - settings.userSessionServiceImport, - '%auth/context-utils': settings.contextUtilsImport, + '%auth/auth-roles': authRolesImport, + '%auth/user-session-service': userSessionServiceImport, + '%auth/context-utils': contextUtilsImport, }; }, }, diff --git a/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts b/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts index b28af0d49..04a4a46b4 100644 --- a/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts +++ b/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { authContextProvider } from '@src/generators/auth/auth-context/index.js'; import { authRolesProvider } from '@src/generators/auth/auth-roles/index.js'; -import { authSetupProvider } from '@src/generators/auth/auth/index.js'; +import { authConfigProvider } from '@src/generators/auth/auth/index.js'; import { userSessionServiceProvider } from '@src/generators/auth/index.js'; import { userSessionTypesProvider } from '@src/generators/auth/user-session-types/index.js'; import { configServiceProvider } from '@src/generators/core/config-service/index.js'; @@ -48,7 +48,7 @@ const createMainTask = createTaskConfigBuilder( appModule: appModuleProvider, configService: configServiceProvider, prismaOutput: prismaOutputProvider, - authSetup: authSetupProvider, + authConfig: authConfigProvider, userSessionTypes: userSessionTypesProvider, authContext: authContextProvider, }, @@ -63,7 +63,7 @@ const createMainTask = createTaskConfigBuilder( prismaOutput, configService, appModule, - authSetup, + authConfig, userSessionTypes, authContext, }) { @@ -96,10 +96,13 @@ const createMainTask = createTaskConfigBuilder( `${appModule.getModuleFolder()}/services/management.ts`, ); - authSetup.getConfig().set('userSessionServiceImport', { - path: userSessionServiceImport, - allowedImports: ['userSessionService'], - }); + authConfig.userSessionServiceImport.set( + { + path: userSessionServiceImport, + allowedImports: ['userSessionService'], + }, + 'auth0/auth0-module', + ); if (includeManagement) { configService.getConfigEntries().set('AUTH0_TENANT_DOMAIN', { diff --git a/packages/fastify-generators/src/generators/core/app-module/index.ts b/packages/fastify-generators/src/generators/core/app-module/index.ts index db70166ce..09e79f6f5 100644 --- a/packages/fastify-generators/src/generators/core/app-module/index.ts +++ b/packages/fastify-generators/src/generators/core/app-module/index.ts @@ -27,7 +27,7 @@ export const appModuleGenerator = createGenerator({ taskBuilder.addTask({ name: 'main', dependencies: { - appModule: appModuleProvider, + appModule: appModuleProvider.dependency().parentScopeOnly(), typescript: typescriptProvider, }, exports: { diff --git a/packages/fastify-generators/src/generators/core/fastify-server/index.ts b/packages/fastify-generators/src/generators/core/fastify-server/index.ts index 3d14f1efd..b0fb1ce86 100644 --- a/packages/fastify-generators/src/generators/core/fastify-server/index.ts +++ b/packages/fastify-generators/src/generators/core/fastify-server/index.ts @@ -21,7 +21,10 @@ import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { configServiceProvider } from '../config-service/index.js'; import { loggerServiceProvider } from '../logger-service/index.js'; -import { rootModuleProvider } from '../root-module/index.js'; +import { + rootModuleConfigProvider, + rootModuleImportProvider, +} from '../root-module/index.js'; const descriptorSchema = z.object({ defaultPort: z.number().default(7001), @@ -53,13 +56,29 @@ export const fastifyServerGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder, descriptor) { + taskBuilder.addTask({ + name: 'root-module-config', + dependencies: { + rootModuleConfig: rootModuleConfigProvider, + }, + run({ rootModuleConfig }) { + rootModuleConfig.moduleFields.set( + 'plugins', + TypescriptCodeUtils.createExpression( + '(FastifyPluginCallback | FastifyPluginAsync)', + "import { FastifyPluginAsync, FastifyPluginCallback } from 'fastify';", + ), + taskBuilder.generatorName, + ); + }, + }); taskBuilder.addTask({ name: 'main', dependencies: { node: nodeProvider, loggerService: loggerServiceProvider, configService: configServiceProvider, - rootModule: rootModuleProvider, + rootModule: rootModuleImportProvider, typescript: typescriptProvider, }, exports: { @@ -115,14 +134,6 @@ export const fastifyServerGenerator = createGenerator({ }, }); - rootModule.addModuleField( - 'plugins', - TypescriptCodeUtils.createExpression( - '(FastifyPluginCallback | FastifyPluginAsync)', - "import { FastifyPluginAsync, FastifyPluginCallback } from 'fastify';", - ), - ); - return { providers: { fastifyServer: { diff --git a/packages/fastify-generators/src/generators/core/fastify/index.ts b/packages/fastify-generators/src/generators/core/fastify/index.ts index 5ad073c28..ba0ef2319 100644 --- a/packages/fastify-generators/src/generators/core/fastify/index.ts +++ b/packages/fastify-generators/src/generators/core/fastify/index.ts @@ -5,11 +5,12 @@ import { nodeProvider, nodeSetupProvider, projectScope, - typescriptConfigProvider, + typescriptSetupProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -56,7 +57,7 @@ export interface FastifyOutputProvider { } export const fastifyOutputProvider = - createProviderType('fastify-output'); + createOutputProviderType('fastify-output'); export const fastifyGenerator = createGenerator({ name: 'core/fastify', @@ -69,7 +70,7 @@ export const fastifyGenerator = createGenerator({ nodeSetup: nodeSetupProvider, }, run({ nodeSetup }) { - nodeSetup.setIsEsm(false); + nodeSetup.isEsm.set(false, taskBuilder.generatorName); return {}; }, }); @@ -78,15 +79,15 @@ export const fastifyGenerator = createGenerator({ name: 'typescript', dependencies: { node: nodeProvider, - typescriptConfig: typescriptConfigProvider, + typescriptSetup: typescriptSetupProvider, }, - run({ node, typescriptConfig }) { - setupFastifyTypescript(node, typescriptConfig); + run({ node, typescriptSetup }) { + setupFastifyTypescript(node, typescriptSetup); return {}; }, }); - const mainTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'main', dependencies: { node: nodeProvider, @@ -95,6 +96,9 @@ export const fastifyGenerator = createGenerator({ exports: { fastify: fastifyProvider.export(projectScope), }, + outputs: { + fastifyOutput: fastifyOutputProvider.export(projectScope), + }, run({ node, nodeGitIgnore }) { const config = createNonOverwriteableMap( { nodeFlags: [] }, @@ -145,41 +149,28 @@ export const fastifyGenerator = createGenerator({ dev: devCommand, }); - return { nodeFlags, devOutputFormatter }; - }, - }; - }, - }); - - taskBuilder.addTask({ - name: 'output', - taskDependencies: { mainTask }, - exports: { - fastifyOutput: fastifyOutputProvider.export(projectScope), - }, - run(deps, { mainTask: { nodeFlags, devOutputFormatter } }) { - return { - providers: { - fastifyOutput: { - getNodeFlags: () => nodeFlags, - getNodeFlagsDev: (useCase) => - nodeFlags - .filter( - (f) => - f.targetEnvironment === 'dev' && - (!useCase || f.useCase === useCase), - ) - .map((f) => f.flag), - getNodeFlagsProd: (useCase) => - nodeFlags - .filter( - (f) => - f.targetEnvironment === 'prod' && - (!useCase || f.useCase === useCase), - ) - .map((f) => f.flag), - getDevOutputFormatter: () => devOutputFormatter, - }, + return { + fastifyOutput: { + getNodeFlags: () => nodeFlags, + getNodeFlagsDev: (useCase) => + nodeFlags + .filter( + (f) => + f.targetEnvironment === 'dev' && + (!useCase || f.useCase === useCase), + ) + .map((f) => f.flag), + getNodeFlagsProd: (useCase) => + nodeFlags + .filter( + (f) => + f.targetEnvironment === 'prod' && + (!useCase || f.useCase === useCase), + ) + .map((f) => f.flag), + getDevOutputFormatter: () => devOutputFormatter, + }, + }; }, }; }, diff --git a/packages/fastify-generators/src/generators/core/fastify/setup-fastify-typescript.ts b/packages/fastify-generators/src/generators/core/fastify/setup-fastify-typescript.ts index 2afa195e4..c0cd9f7f4 100644 --- a/packages/fastify-generators/src/generators/core/fastify/setup-fastify-typescript.ts +++ b/packages/fastify-generators/src/generators/core/fastify/setup-fastify-typescript.ts @@ -1,34 +1,37 @@ import type { NodeProvider, - TypescriptConfigProvider, + TypescriptSetupProvider, } from '@halfdomelabs/core-generators'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; export function setupFastifyTypescript( node: NodeProvider, - typescriptConfig: TypescriptConfigProvider, + typescriptConfig: TypescriptSetupProvider, ): void { - typescriptConfig.setTypescriptVersion('5.5.4'); - typescriptConfig.setTypescriptCompilerOptions({ - outDir: 'dist', - declaration: true, - baseUrl: './', - paths: { - '@src/*': ['./src/*'], + typescriptConfig.version.set('5.5.4', 'fastify'); + typescriptConfig.compilerOptions.set( + { + outDir: 'dist', + declaration: true, + baseUrl: './', + paths: { + '@src/*': ['./src/*'], + }, + target: 'es2022', + lib: ['es2023'], + esModuleInterop: true, + module: 'node16', + moduleResolution: 'node16', + strict: true, + removeComments: true, + forceConsistentCasingInFileNames: true, + resolveJsonModule: true, + sourceMap: true, + skipLibCheck: true, }, - target: 'es2022', - lib: ['es2023'], - esModuleInterop: true, - module: 'node16', - moduleResolution: 'node16', - strict: true, - removeComments: true, - forceConsistentCasingInFileNames: true, - resolveJsonModule: true, - sourceMap: true, - skipLibCheck: true, - }); + 'fastify', + ); node.addDevPackages({ 'tsc-alias': FASTIFY_PACKAGES['tsc-alias'], diff --git a/packages/fastify-generators/src/generators/core/readme/index.ts b/packages/fastify-generators/src/generators/core/readme/index.ts index 588796255..98a8bd288 100644 --- a/packages/fastify-generators/src/generators/core/readme/index.ts +++ b/packages/fastify-generators/src/generators/core/readme/index.ts @@ -47,7 +47,6 @@ In order to set up the backend, you must do the following steps: 2. **Run Server**: \`pnpm dev\``; return { - providers: {}, build: (builder) => { builder.writeFile({ id: 'readme', diff --git a/packages/fastify-generators/src/generators/core/request-service-context/index.ts b/packages/fastify-generators/src/generators/core/request-service-context/index.ts index c279f859e..b13d1911d 100644 --- a/packages/fastify-generators/src/generators/core/request-service-context/index.ts +++ b/packages/fastify-generators/src/generators/core/request-service-context/index.ts @@ -13,6 +13,7 @@ import { import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; import { mapValues } from 'es-toolkit'; @@ -53,16 +54,16 @@ export interface RequestServiceContextProvider extends ImportMapper { } export const requestServiceContextProvider = - createProviderType('request-service-context', { - isReadOnly: true, - }); + createOutputProviderType( + 'request-service-context', + ); export const requestServiceContextGenerator = createGenerator({ name: 'core/request-service-context', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder) { - const setupTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'setup', dependencies: { typescript: typescriptProvider, @@ -73,6 +74,10 @@ export const requestServiceContextGenerator = createGenerator({ requestServiceContextSetup: requestServiceContextSetupProvider.export(projectScope), }, + outputs: { + requestServiceContext: + requestServiceContextProvider.export(projectScope), + }, run({ typescript, requestContext, serviceContextSetup }) { const contextPassthroughMap = createNonOverwriteableMap< Record @@ -162,29 +167,12 @@ export const requestServiceContextGenerator = createGenerator({ ), ); - return { importMap, contextPath }; - }, - }; - }, - }); - - taskBuilder.addTask({ - name: 'output', - exports: { - requestServiceContext: - requestServiceContextProvider.export(projectScope), - }, - taskDependencies: { setupTask }, - run(deps, { setupTask: { importMap, contextPath } }) { - return { - providers: { - requestServiceContext: { - getImportMap: () => importMap, - getContextPath: () => contextPath, - }, - }, - build: async () => { - // do nothing + return { + requestServiceContext: { + getImportMap: () => importMap, + getContextPath: () => contextPath, + }, + }; }, }; }, diff --git a/packages/fastify-generators/src/generators/core/root-module/index.ts b/packages/fastify-generators/src/generators/core/root-module/index.ts index a117fdcd1..44d9b0b74 100644 --- a/packages/fastify-generators/src/generators/core/root-module/index.ts +++ b/packages/fastify-generators/src/generators/core/root-module/index.ts @@ -10,8 +10,8 @@ import { } from '@halfdomelabs/core-generators'; import { createGenerator, - createNonOverwriteableMap, createProviderType, + createSetupTask, } from '@halfdomelabs/sync'; import { safeMergeAllWithOptions } from '@halfdomelabs/utils'; import { mapValues } from 'es-toolkit'; @@ -29,6 +29,19 @@ export interface RootModuleProvider { export const rootModuleProvider = createProviderType('root-module'); +const [setupTask, rootModuleConfigProvider, rootModuleSetupProvider] = + createSetupTask( + (t) => ({ + moduleFields: t.map(), + }), + { + prefix: 'root-module', + configScope: projectScope, + }, + ); + +export { rootModuleConfigProvider }; + export interface RootModuleImport extends ImportMapper { getRootModule: () => TypescriptCodeExpression; getRootModuleImport: () => string; @@ -56,33 +69,7 @@ export const rootModuleGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder) { - const rootModuleTask = taskBuilder.addTask({ - name: 'rootModule', - exports: { - rootModule: rootModuleProvider.export(projectScope), - }, - run() { - const moduleFieldMap = createNonOverwriteableMap< - Record - >({}, { name: 'root-module-fields' }); - - return { - providers: { - rootModule: { - addModuleField: (name, type) => { - moduleFieldMap.set(name, type); - }, - getRootModule: () => - TypescriptCodeUtils.createExpression( - 'RootModule', - "import { RootModule } from '@/src/modules/index.js'", - ), - }, - }, - build: () => ({ moduleFieldMap }), - }; - }, - }); + taskBuilder.addTask(setupTask); taskBuilder.addTask({ name: 'rootModuleImport', @@ -113,31 +100,29 @@ export const rootModuleGenerator = createGenerator({ taskBuilder.addTask({ name: 'appModule', - dependencies: { typescript: typescriptProvider }, + dependencies: { + typescript: typescriptProvider, + rootModuleSetup: rootModuleSetupProvider, + }, exports: { appModule: appModuleProvider.export(projectScope) }, - taskDependencies: { rootModuleTask }, - run({ typescript }, { rootModuleTask: { moduleFieldMap } }) { - const rootModuleEntries = createNonOverwriteableMap< - Record - >({}, { name: 'root-module-entries' }); + run({ typescript, rootModuleSetup: { moduleFields: moduleFieldsMap } }) { + const rootModuleEntries = new Map(); const moduleImports: string[] = []; return { providers: { appModule: { getModuleFolder: () => 'src/modules', - getValidFields: () => [ - 'children', - ...Object.keys(moduleFieldMap.value()), - ], + getValidFields: () => ['children', ...moduleFieldsMap.keys()], addModuleImport(name) { moduleImports.push(name); }, registerFieldEntry: (name, type) => { - if (name !== 'children' && !moduleFieldMap.get(name)) { + if (name !== 'children' && !moduleFieldsMap.get(name)) { throw new Error(`Unknown field entry: ${name}`); } - rootModuleEntries.appendUnique(name, [type]); + const existing = rootModuleEntries.get(name) ?? []; + rootModuleEntries.set(name, [...existing, type]); }, }, }, @@ -149,7 +134,7 @@ export const rootModuleGenerator = createGenerator({ rootModule.addCodeExpression( 'ROOT_MODULE_CONTENTS', TypescriptCodeUtils.mergeExpressionsAsObject( - mapValues(rootModuleEntries.value(), (types) => + mapValues(Object.fromEntries(rootModuleEntries), (types) => TypescriptCodeUtils.mergeExpressionsAsArray(types), ), ), @@ -164,14 +149,8 @@ export const rootModuleGenerator = createGenerator({ MODULE_MERGER: { type: 'code-expression' }, }); - const moduleFields = Object.keys(moduleFieldMap.value()).map( - (name) => { - const field = moduleFieldMap.get(name); - if (!field) { - throw new Error(`Unknown field entry: ${name}`); - } - return { name, field }; - }, + const moduleFields = [...moduleFieldsMap.entries()].map( + ([name, field]) => ({ name, field }), ); moduleHelper.addCodeAddition({ diff --git a/packages/fastify-generators/src/generators/core/service-context/index.ts b/packages/fastify-generators/src/generators/core/service-context/index.ts index 8299d591b..80f11aa54 100644 --- a/packages/fastify-generators/src/generators/core/service-context/index.ts +++ b/packages/fastify-generators/src/generators/core/service-context/index.ts @@ -12,6 +12,7 @@ import { import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; import { mapValues } from 'es-toolkit'; @@ -45,21 +46,24 @@ export interface ServiceContextProvider extends ImportMapper { } export const serviceContextProvider = - createProviderType('service-context'); + createOutputProviderType('service-context'); export const serviceContextGenerator = createGenerator({ name: 'core/service-context', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder) { - const setupTask = taskBuilder.addTask({ - name: 'setup', + taskBuilder.addTask({ + name: 'main', dependencies: { typescript: typescriptProvider, }, exports: { serviceContextSetup: serviceContextSetupProvider.export(projectScope), }, + outputs: { + serviceContext: serviceContextProvider.export(projectScope), + }, run({ typescript }) { const contextFieldsMap = createNonOverwriteableMap< Record @@ -172,30 +176,17 @@ export const serviceContextGenerator = createGenerator({ ), ); - return { importMap, contextPath, contextImport }; - }, - }; - }, - }); - - taskBuilder.addTask({ - name: 'main', - taskDependencies: { setupTask }, - exports: { - serviceContext: serviceContextProvider.export(projectScope), - }, - run(deps, { setupTask: { importMap, contextPath, contextImport } }) { - return { - providers: { - serviceContext: { - getImportMap: () => importMap, - getContextPath: () => contextPath, - getServiceContextType: () => - TypescriptCodeUtils.createExpression( - 'ServiceContext', - `import {ServiceContext} from '${contextImport}'`, - ), - }, + return { + serviceContext: { + getImportMap: () => importMap, + getContextPath: () => contextPath, + getServiceContextType: () => + TypescriptCodeUtils.createExpression( + 'ServiceContext', + `import {ServiceContext} from '${contextImport}'`, + ), + }, + }; }, }; }, diff --git a/packages/fastify-generators/src/generators/core/service-file/index.ts b/packages/fastify-generators/src/generators/core/service-file/index.ts index 521f4b278..e9521eaa1 100644 --- a/packages/fastify-generators/src/generators/core/service-file/index.ts +++ b/packages/fastify-generators/src/generators/core/service-file/index.ts @@ -9,6 +9,7 @@ import { import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; import { kebabCase } from 'change-case'; @@ -47,20 +48,25 @@ export interface ServiceFileOutputProvider { } export const serviceFileOutputProvider = - createProviderType('service-file-output'); + createOutputProviderType('service-file-output'); export const serviceFileGenerator = createGenerator({ name: 'core/service-file', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder, descriptor) { - const mainTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'main', dependencies: { appModule: appModuleProvider, typescript: typescriptProvider, }, exports: { serviceFile: serviceFileProvider.export() }, + outputs: { + serviceFileOutput: descriptor.id + ? serviceFileOutputProvider.export(projectScope, descriptor.id) + : serviceFileOutputProvider.export(), + }, run({ appModule, typescript }) { const methodMap = createNonOverwriteableMap< Record @@ -111,25 +117,7 @@ export const serviceFileGenerator = createGenerator({ servicesFile.renderToActionFromText('METHODS;', servicesPath), ); } - return { outputMap }; - }, - }; - }, - }); - - if (descriptor.id) { - taskBuilder.addTask({ - name: 'output', - exports: { - serviceFileOutput: serviceFileOutputProvider.export( - projectScope, - descriptor.id, - ), - }, - taskDependencies: { mainTask }, - run(deps, { mainTask: { outputMap } }) { - return { - providers: { + return { serviceFileOutput: { getServiceMethod(key) { const output = outputMap.get(key); @@ -139,10 +127,10 @@ export const serviceFileGenerator = createGenerator({ return output; }, }, - }, - }; - }, - }); - } + }; + }, + }; + }, + }); }, }); diff --git a/packages/fastify-generators/src/generators/pothos/pothos-sentry/index.ts b/packages/fastify-generators/src/generators/pothos/pothos-sentry/index.ts index 91823c04f..1a90294e3 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos-sentry/index.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos-sentry/index.ts @@ -10,7 +10,7 @@ import { z } from 'zod'; import { errorHandlerServiceProvider } from '@src/generators/core/error-handler-service/index.js'; import { fastifySentryProvider } from '@src/generators/core/fastify-sentry/index.js'; -import { yogaPluginSetupProvider } from '@src/generators/yoga/yoga-plugin/index.js'; +import { yogaPluginConfigProvider } from '@src/generators/yoga/yoga-plugin/index.js'; import { pothosSetupProvider } from '../pothos/index.js'; @@ -19,23 +19,21 @@ const descriptorSchema = z.object({}); const createMainTask = createTaskConfigBuilder(() => ({ name: 'main', dependencies: { - yogaPluginSetup: yogaPluginSetupProvider, + yogaPluginConfig: yogaPluginConfigProvider, errorHandlerService: errorHandlerServiceProvider, typescript: typescriptProvider, node: nodeProvider, }, - run({ yogaPluginSetup, typescript, errorHandlerService, node }) { + run({ yogaPluginConfig, typescript, errorHandlerService, node }) { const [pluginImport, pluginPath] = makeImportAndFilePath( 'src/plugins/graphql/useSentry.ts', ); - yogaPluginSetup - .getConfig() - .appendUnique('envelopPlugins', [ - new TypescriptCodeExpression(`useSentry()`, [ - `import { useSentry } from '${pluginImport}'`, - ]), - ]); + yogaPluginConfig.envelopPlugins.push( + new TypescriptCodeExpression(`useSentry()`, [ + `import { useSentry } from '${pluginImport}'`, + ]), + ); node.addPackages({ '@pothos/plugin-tracing': '1.1.0', @@ -43,7 +41,6 @@ const createMainTask = createTaskConfigBuilder(() => ({ }); return { - providers: {}, build: async (builder) => { await builder.apply( typescript.createCopyAction({ diff --git a/packages/fastify-generators/src/generators/pothos/pothos/index.ts b/packages/fastify-generators/src/generators/pothos/pothos/index.ts index fef932221..0d0b83a45 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/index.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/index.ts @@ -18,6 +18,7 @@ import { import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, POST_WRITE_COMMAND_PRIORITY, } from '@halfdomelabs/sync'; @@ -27,7 +28,7 @@ import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { fastifyOutputProvider } from '@src/generators/core/fastify/index.js'; import { requestServiceContextProvider } from '@src/generators/core/request-service-context/index.js'; import { rootModuleImportProvider } from '@src/generators/core/root-module/index.js'; -import { yogaPluginSetupProvider } from '@src/generators/yoga/yoga-plugin/index.js'; +import { yogaPluginConfigProvider } from '@src/generators/yoga/yoga-plugin/index.js'; import { PothosTypeReferenceContainer } from '@src/writers/pothos/index.js'; const descriptorSchema = z.object({}); @@ -47,6 +48,12 @@ export interface PothosSetupProvider extends ImportMapper { export const pothosSetupProvider = createProviderType('pothos-setup'); +const pothosSetupOutputProvider = createOutputProviderType<{ + config: NonOverwriteableMap; + schemaFiles: string[]; + pothosTypes: PothosTypeReferenceContainer; +}>('pothos-setup-output'); + export interface PothosSchemaProvider extends ImportMapper { registerSchemaFile: (filePath: string) => void; getTypeReferences: () => PothosTypeReferenceContainer; @@ -55,6 +62,10 @@ export interface PothosSchemaProvider extends ImportMapper { export const pothosSchemaProvider = createProviderType('pothos-schema'); +const pothosSchemaOutputProvider = createOutputProviderType<{ + schemaFiles: string[]; +}>('pothos-schema-output'); + export type PothosProvider = unknown; export const pothosProvider = createProviderType('pothos'); @@ -64,12 +75,13 @@ export const pothosGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder) { - const setupTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'setup', dependencies: {}, exports: { pothosSetup: pothosSetupProvider.export(projectScope), }, + outputs: { pothosSetupOutput: pothosSetupOutputProvider.export() }, run() { const config = createNonOverwriteableMap({ pothosPlugins: [], @@ -99,19 +111,23 @@ export const pothosGenerator = createGenerator({ getTypeReferences: () => pothosTypes, }, }, - build: () => ({ config, schemaFiles, pothosTypes }), + build: () => ({ + pothosSetupOutput: { config, schemaFiles, pothosTypes }, + }), }; }, }); - const schemaTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'schema', - dependencies: {}, + dependencies: { + pothosSetupOutput: pothosSetupOutputProvider, + }, exports: { pothosSchema: pothosSchemaProvider.export(projectScope), }, - taskDependencies: { setupTask }, - run(deps, { setupTask: { schemaFiles, pothosTypes } }) { + outputs: { pothosSchemaOutput: pothosSchemaOutputProvider.export() }, + run({ pothosSetupOutput: { schemaFiles, pothosTypes } }) { return { providers: { pothosSchema: { @@ -129,7 +145,9 @@ export const pothosGenerator = createGenerator({ }, }, }, - build: () => ({ schemaFiles }), + build: () => ({ + pothosSchemaOutput: { schemaFiles }, + }), }; }, }); @@ -143,28 +161,25 @@ export const pothosGenerator = createGenerator({ requestServiceContext: requestServiceContextProvider, prettier: prettierProvider, rootModuleImport: rootModuleImportProvider, - yogaPluginSetup: yogaPluginSetupProvider, + yogaPluginConfig: yogaPluginConfigProvider, tsUtils: tsUtilsProvider, + pothosSetupOutput: pothosSetupOutputProvider, + pothosSchemaOutput: pothosSchemaOutputProvider, }, - taskDependencies: { setupTask, schemaTask }, exports: { pothos: pothosProvider.export(projectScope), }, - run( - { - node, - typescript, - requestServiceContext, - prettier, - rootModuleImport, - yogaPluginSetup, - tsUtils, - }, - { - setupTask: { config: configMap, pothosTypes }, - schemaTask: { schemaFiles }, - }, - ) { + run({ + node, + typescript, + requestServiceContext, + prettier, + rootModuleImport, + yogaPluginConfig, + tsUtils, + pothosSetupOutput: { config: configMap, pothosTypes }, + pothosSchemaOutput: { schemaFiles }, + }) { node.addPackages({ '@pothos/core': FASTIFY_PACKAGES['@pothos/core'], '@pothos/plugin-simple-objects': @@ -260,7 +275,7 @@ export const pothosGenerator = createGenerator({ SCHEMA_TYPE_OPTIONS: schemaTypeOptions, SCHEMA_BUILDER_OPTIONS: schemaOptions, 'SUBSCRIPTION_TYPE;': new TypescriptStringReplacement( - yogaPluginSetup.isSubscriptionEnabled() + yogaPluginConfig.isSubscriptionEnabled() ? `builder.subscriptionType();` : '', ), @@ -278,11 +293,9 @@ export const pothosGenerator = createGenerator({ { importMappers: [rootModuleImport] }, ); - const yogaConfig = yogaPluginSetup.getConfig(); + yogaPluginConfig.schema.set(schemaExpression, 'pothos/pothos'); - yogaConfig.set('schema', schemaExpression); - - yogaConfig.appendUnique('postSchemaBlocks', [ + yogaPluginConfig.postSchemaBlocks.push( TypescriptCodeUtils.createBlock( ` async function writeSchemaToFile(): Promise { @@ -308,7 +321,7 @@ if (IS_DEVELOPMENT) { `import fs from 'fs/promises';`, ], ), - ]); + ); await builder.apply( typescript.createCopyFilesAction({ 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 index f450440ee..ce72c733a 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-create/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-crud-create/index.ts @@ -165,7 +165,6 @@ export const prismaCrudCreateGenerator = createGenerator({ ) ?? []; return { - providers: {}, build: () => { const methodOptions: PrismaDataMethodOptions = { name: methodName, 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 index ebeab8af2..8a05f61cc 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-crud-delete/index.ts @@ -141,11 +141,7 @@ export const prismaCrudDeleteGenerator = createGenerator({ getMethodDefinition(methodOptions), ); - return { - providers: { - prismaDeleteMethod: {}, - }, - }; + 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 index e3b5ff57e..2ec2e657f 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-service/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-crud-service/index.ts @@ -2,6 +2,7 @@ import { projectScope } from '@halfdomelabs/core-generators'; import { createGenerator, createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -27,15 +28,21 @@ export interface PrismaCrudServiceProvider { } export const prismaCrudServiceProvider = - createProviderType('prisma-crud-service'); + createOutputProviderType('prisma-crud-service'); export const prismaCrudServiceGenerator = createGenerator({ name: 'prisma/prisma-crud-service', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder, { modelName }) { - const setupTask = taskBuilder.addTask({ - name: 'setup', + taskBuilder.addTask({ + name: 'main', + outputs: { + prismaCrudService: prismaCrudServiceProvider + // export to children and project under model name + .export() + .andExport(projectScope, modelName), + }, exports: { prismaCrudServiceSetup: prismaCrudServiceSetupProvider.export(), }, @@ -55,23 +62,7 @@ export const prismaCrudServiceGenerator = createGenerator({ }, }, }, - build: () => ({ transformers }), - }; - }, - }); - - taskBuilder.addTask({ - name: 'main', - taskDependencies: { setupTask }, - exports: { - prismaCrudService: prismaCrudServiceProvider - // export to children and project under model name - .export() - .andExport(projectScope, modelName), - }, - run(deps, { setupTask: { transformers } }) { - return { - providers: { + build: () => ({ prismaCrudService: { getTransformerByName(name) { const transformer = transformers.get(name); @@ -81,7 +72,7 @@ export const prismaCrudServiceGenerator = createGenerator({ 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 index 7549536e6..52e8ad028 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-crud-update/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-crud-update/index.ts @@ -184,7 +184,6 @@ export const prismaCrudUpdateGenerator = createGenerator({ ) ?? []; return { - providers: {}, build: () => { const model = prismaOutput.getPrismaModel(modelName); const primaryKey = getPrimaryKeyExpressions(model); diff --git a/packages/fastify-generators/src/generators/prisma/prisma-enum/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-enum/index.ts index 5fffecea0..2996669d0 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-enum/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-enum/index.ts @@ -24,11 +24,7 @@ export const prismaEnumGenerator = createGenerator({ values: values.map((v) => ({ name: v.name })), }); - return { - providers: { - prismaEnum: {}, - }, - }; + return {}; }, }); }, diff --git a/packages/fastify-generators/src/generators/prisma/prisma-field/index.ts b/packages/fastify-generators/src/generators/prisma/prisma-field/index.ts index ada089859..fc711257b 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-field/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-field/index.ts @@ -79,11 +79,7 @@ export const prismaFieldGenerator = createGenerator({ }); prismaModel.addField(prismaField); - return { - providers: { - prismaField: {}, - }, - }; + return {}; }, }); }, diff --git a/packages/fastify-generators/src/generators/prisma/prisma/index.ts b/packages/fastify-generators/src/generators/prisma/prisma/index.ts index 07945b803..f4d13a686 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma/index.ts @@ -13,6 +13,7 @@ import { } from '@halfdomelabs/core-generators'; import { createGenerator, + createOutputProviderType, createProviderType, POST_WRITE_COMMAND_PRIORITY, } from '@halfdomelabs/sync'; @@ -61,7 +62,7 @@ export interface PrismaOutputProvider extends ImportMapper { } export const prismaOutputProvider = - createProviderType('prisma-output'); + createOutputProviderType('prisma-output'); export type PrismaCrudServiceTypesProvider = ImportMapper; @@ -77,7 +78,7 @@ export const prismaGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks(taskBuilder, descriptor) { - const schemaTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'schema', dependencies: { node: nodeProvider, @@ -88,6 +89,7 @@ export const prismaGenerator = createGenerator({ typescript: typescriptProvider, }, exports: { prismaSchema: prismaSchemaProvider.export(projectScope) }, + outputs: { prismaOutput: prismaOutputProvider.export(projectScope) }, run({ node, configService, @@ -170,7 +172,10 @@ export const prismaGenerator = createGenerator({ }, }, }, - build: async (builder) => { + build: async ( + builder, + addTaskOutput: (output: { schemaFile: PrismaSchemaFile }) => void, + ) => { const schemaText = schemaFile.toText(); const { formatSchema: format } = internalRequire( '@prisma/internals', @@ -213,67 +218,59 @@ export const prismaGenerator = createGenerator({ }), ); - return { schemaFile }; - }, - }; - }, - }); + addTaskOutput({ schemaFile }); - taskBuilder.addTask({ - name: 'output', - exports: { prismaOutput: prismaOutputProvider.export(projectScope) }, - taskDependencies: { schemaTask }, - run(deps, { schemaTask: { schemaFile } }) { - return { - providers: { - prismaOutput: { - getImportMap: () => ({ - '%prisma-service': { - path: '@/src/services/prisma.js', - allowedImports: ['prisma'], + return { + prismaOutput: { + getImportMap: () => ({ + '%prisma-service': { + path: '@/src/services/prisma.js', + allowedImports: ['prisma'], + }, + }), + getPrismaServicePath: () => '@/src/services/prisma.js', + getPrismaClient: () => + TypescriptCodeUtils.createExpression( + 'prisma', + "import { prisma } from '@/src/services/prisma.js'", + ), + getPrismaModel: (modelName) => { + const modelBlock = schemaFile.getModelBlock(modelName); + if (!modelBlock) { + throw new Error(`Model ${modelName} not found`); + } + return modelBlock; }, - }), - getPrismaServicePath: () => '@/src/services/prisma.js', - getPrismaClient: () => - TypescriptCodeUtils.createExpression( - 'prisma', - "import { prisma } from '@/src/services/prisma.js'", - ), - getPrismaModel: (modelName) => { - const modelBlock = schemaFile.getModelBlock(modelName); - if (!modelBlock) { - throw new Error(`Model ${modelName} not found`); - } - return modelBlock; - }, - getServiceEnum: (name) => { - const block = schemaFile.getEnum(name); - if (!block) { - throw new Error(`Enum ${name} not found`); - } - return { - name: block.name, - values: block.values, - expression: TypescriptCodeUtils.createExpression( - block.name, - `import { ${block.name} } from '@prisma/client'`, + getServiceEnum: (name) => { + const block = schemaFile.getEnum(name); + if (!block) { + throw new Error(`Enum ${name} not found`); + } + return { + name: block.name, + values: block.values, + expression: TypescriptCodeUtils.createExpression( + block.name, + `import { ${block.name} } from '@prisma/client'`, + ), + }; + }, + getPrismaModelExpression: (modelName) => { + const modelExport = + modelName.charAt(0).toLocaleLowerCase() + + modelName.slice(1); + return TypescriptCodeUtils.createExpression( + `prisma.${modelExport}`, + "import { prisma } from '@/src/services/prisma.js'", + ); + }, + getModelTypeExpression: (modelName) => + TypescriptCodeUtils.createExpression( + modelName, + `import { ${modelName} } from '@prisma/client'`, ), - }; - }, - getPrismaModelExpression: (modelName) => { - const modelExport = - modelName.charAt(0).toLocaleLowerCase() + modelName.slice(1); - return TypescriptCodeUtils.createExpression( - `prisma.${modelExport}`, - "import { prisma } from '@/src/services/prisma.js'", - ); }, - getModelTypeExpression: (modelName) => - TypescriptCodeUtils.createExpression( - modelName, - `import { ${modelName} } from '@prisma/client'`, - ), - }, + }; }, }; }, diff --git a/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts b/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts index 217c32f5f..38fca4b24 100644 --- a/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts +++ b/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts @@ -1,5 +1,8 @@ import type { TypescriptCodeBlock } from '@halfdomelabs/core-generators'; -import type { NonOverwriteableMap } from '@halfdomelabs/sync'; +import type { + FieldMapValues, + InferFieldMapSchemaFromBuilder, +} from '@halfdomelabs/utils'; import { makeImportAndFilePath, @@ -11,9 +14,13 @@ import { } from '@halfdomelabs/core-generators'; import { createGenerator, - createNonOverwriteableMap, + createOutputProviderType, createProviderType, } from '@halfdomelabs/sync'; +import { + createFieldMap, + createFieldMapSchemaBuilder, +} from '@halfdomelabs/utils'; import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/index.js'; @@ -36,17 +43,38 @@ export interface YogaPluginConfig { customImports: TypescriptCodeBlock[]; } -export interface YogaPluginSetupProvider { - getConfig(): NonOverwriteableMap; +const schemaBuilder = createFieldMapSchemaBuilder((t) => ({ + envelopPlugins: t.array([ + TypescriptCodeUtils.createExpression('useGraphLogger()', [ + "import { useGraphLogger } from './useGraphLogger.js'", + ]), + TypescriptCodeUtils.createExpression( + 'useDisableIntrospection({ disableIf: () => !IS_DEVELOPMENT })', + "import { useDisableIntrospection } from '@envelop/disable-introspection';", + ), + ]), + postSchemaBlocks: t.array(), + schema: t.scalar( + new TypescriptCodeExpression( + `new GraphQLSchema({})`, + `import { GraphQLSchema } from 'graphql';`, + ), + ), + customImports: t.array(), +})); + +export interface YogaPluginConfigProvider + extends InferFieldMapSchemaFromBuilder { isSubscriptionEnabled(): boolean; } -export const yogaPluginSetupProvider = - createProviderType('yoga-plugin-setup'); -export type YogaPluginProvider = unknown; +export const yogaPluginConfigProvider = + createProviderType('yoga-plugin-config'); -export const yogaPluginProvider = - createProviderType('yoga-plugin'); +export const yogaPluginSetupProvider = + createOutputProviderType< + FieldMapValues> + >(`yoga-plugin-setup`); export const yogaPluginGenerator = createGenerator({ name: 'yoga/yoga-plugin', @@ -54,50 +82,28 @@ export const yogaPluginGenerator = createGenerator({ descriptorSchema, buildTasks(taskBuilder, { enableSubscriptions }) { // Setup Task - const setupTask = taskBuilder.addTask({ + taskBuilder.addTask({ name: 'setup', - dependencies: {}, exports: { - yogaPluginSetup: yogaPluginSetupProvider.export(projectScope), + yogaPluginConfig: yogaPluginConfigProvider.export(projectScope), + }, + outputs: { + yogaPluginSetup: yogaPluginSetupProvider.export(), }, run() { - const configMap = createNonOverwriteableMap( - { - envelopPlugins: [], - postSchemaBlocks: [], - customImports: [], - schema: new TypescriptCodeExpression( - `new GraphQLSchema({})`, - `import { GraphQLSchema } from 'graphql';`, - ), - }, - { - defaultsOverwriteable: true, - }, - ); + const configMap = createFieldMap(schemaBuilder); return { providers: { - yogaPluginSetup: { - getConfig: () => configMap, + yogaPluginConfig: { + ...configMap, isSubscriptionEnabled: () => !!enableSubscriptions, }, }, build() { - configMap.prepend( - 'envelopPlugins', - new TypescriptCodeExpression( - 'useDisableIntrospection({ disableIf: () => !IS_DEVELOPMENT })', - "import { useDisableIntrospection } from '@envelop/disable-introspection';", - ), - ); - configMap.prepend( - 'envelopPlugins', - TypescriptCodeUtils.createExpression('useGraphLogger()', [ - "import { useGraphLogger } from './useGraphLogger.js'", - ]), - ); - return { configMap }; + return { + yogaPluginSetup: configMap.getValues(), + }; }, }; }, @@ -122,7 +128,6 @@ export const yogaPluginGenerator = createGenerator({ taskBuilder.addTask({ name: 'main', - taskDependencies: { setupTask }, dependencies: { node: nodeProvider, typescript: typescriptProvider, @@ -130,21 +135,17 @@ export const yogaPluginGenerator = createGenerator({ errorHandlerService: errorHandlerServiceProvider, requestServiceContext: requestServiceContextProvider, loggerService: loggerServiceProvider, + yogaPluginSetup: yogaPluginSetupProvider, }, - exports: { - yogaPlugin: yogaPluginProvider.export(projectScope), - }, - run( - { - node, - typescript, - configService, - requestServiceContext, - loggerService, - errorHandlerService, - }, - { setupTask: { configMap } }, - ) { + run({ + node, + typescript, + configService, + requestServiceContext, + loggerService, + errorHandlerService, + yogaPluginSetup: config, + }) { node.addPackages({ 'altair-fastify-plugin': FASTIFY_PACKAGES['altair-fastify-plugin'], graphql: FASTIFY_PACKAGES.graphql, @@ -161,12 +162,7 @@ export const yogaPluginGenerator = createGenerator({ }); return { - providers: { - yogaPlugin: { getConfig: () => configMap }, - }, async build(builder) { - const config = configMap.value(); - const pluginFile = typescript.createTemplate( { SCHEMA: { type: 'code-expression' }, diff --git a/packages/react-generators/src/generators/admin/admin-crud-embedded-form/index.ts b/packages/react-generators/src/generators/admin/admin-crud-embedded-form/index.ts index 03bf287ea..e09fbdbf2 100644 --- a/packages/react-generators/src/generators/admin/admin-crud-embedded-form/index.ts +++ b/packages/react-generators/src/generators/admin/admin-crud-embedded-form/index.ts @@ -1,5 +1,3 @@ -import type { InferTaskBuilderMap } from '@halfdomelabs/sync'; - import { makeImportAndFilePath, TypescriptCodeBlock, @@ -10,8 +8,8 @@ import { } from '@halfdomelabs/core-generators'; import { createGenerator, + createOutputProviderType, createProviderType, - createTaskConfigBuilder, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -48,8 +46,6 @@ const descriptorSchema = z.object({ idField: z.string().optional(), }); -type Descriptor = z.infer; - interface AdminCrudEmbeddedComponent { expression: TypescriptCodeExpression; extraProps: string; @@ -132,294 +128,294 @@ function getComponentProps({ }); } -const createSetupFormTask = createTaskConfigBuilder( - ({ modelName, isList }: Descriptor) => ({ - name: 'setupForm', - dependencies: {}, - exports: { - adminCrudInputContainer: adminCrudInputContainerProvider.export(), - adminCrudColumnContainer: adminCrudColumnContainerProvider.export(), - }, - run() { - const inputFields: AdminCrudInput[] = []; - const tableColumns: AdminCrudColumn[] = []; +const adminCrudEmbeddedFormSetupProvider = createOutputProviderType<{ + inputFields: AdminCrudInput[]; + tableColumns: AdminCrudColumn[]; +}>('admin-crud-embedded-form-setup'); - return { - providers: { - adminCrudInputContainer: { - addInput: (input) => inputFields.push(input), - getModelName: () => modelName, - isInModal: () => true, - }, - adminCrudColumnContainer: { - addColumn: (column) => { - if (!isList) { - throw new Error( - 'Cannot add columns to a non-list embedded form', - ); - } - tableColumns.push(column); +export const adminCrudEmbeddedFormGenerator = createGenerator({ + name: 'admin/admin-crud-embedded-form', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks(taskBuilder, { id, name, modelName, isList, idField }) { + taskBuilder.addTask({ + name: 'setupForm', + dependencies: {}, + exports: { + adminCrudInputContainer: adminCrudInputContainerProvider.export(), + adminCrudColumnContainer: adminCrudColumnContainerProvider.export(), + }, + outputs: { + adminCrudEmbeddedFormSetup: adminCrudEmbeddedFormSetupProvider.export(), + }, + run() { + const inputFields: AdminCrudInput[] = []; + const tableColumns: AdminCrudColumn[] = []; + + return { + providers: { + adminCrudInputContainer: { + addInput: (input) => inputFields.push(input), + getModelName: () => modelName, + isInModal: () => true, + }, + adminCrudColumnContainer: { + addColumn: (column) => { + if (!isList) { + throw new Error( + 'Cannot add columns to a non-list embedded form', + ); + } + tableColumns.push(column); + }, + getModelName: () => modelName, }, - getModelName: () => modelName, }, - }, - build: () => ({ inputFields, tableColumns }), - }; - }, - }), -); - -const createMainTask = createTaskConfigBuilder( - ( - { id, isList, name, idField }: Descriptor, - taskDependencies?: InferTaskBuilderMap<{ - setupTask: typeof createSetupFormTask; - }>, - ) => ({ - name: 'main', - dependencies: { - adminCrudEdit: adminCrudEditProvider, - adminComponents: adminComponentsProvider, - reactComponents: reactComponentsProvider, - reactError: reactErrorProvider, - typescript: typescriptProvider, - }, - exports: { - adminCrudEmbeddedForm: adminCrudEmbeddedFormProvider.export( - adminCrudSectionScope, - id, - ), - }, - taskDependencies, - run( - { + build: () => ({ + adminCrudEmbeddedFormSetup: { inputFields, tableColumns }, + }), + }; + }, + }); + taskBuilder.addTask({ + name: 'main', + dependencies: { + adminCrudEdit: adminCrudEditProvider, + adminComponents: adminComponentsProvider, + reactComponents: reactComponentsProvider, + reactError: reactErrorProvider, + typescript: typescriptProvider, + adminCrudEmbeddedFormSetup: adminCrudEmbeddedFormSetupProvider, + }, + exports: { + adminCrudEmbeddedForm: adminCrudEmbeddedFormProvider.export( + adminCrudSectionScope, + id, + ), + }, + run({ adminCrudEdit, reactComponents, reactError, typescript, adminComponents, - }, - { setupTask: { inputFields, tableColumns } }, - ) { - const capitalizedName = upperCaseFirst(name); - const formName = `Embedded${capitalizedName}Form`; - const formDataType = `Embedded${capitalizedName}FormData`; - const formSchema = `embedded${capitalizedName}FormSchema`; - - const [formImport, formPath] = makeImportAndFilePath( - `${adminCrudEdit.getDirectoryBase()}/${formName}.tsx`, - ); + adminCrudEmbeddedFormSetup: { inputFields, tableColumns }, + }) { + const capitalizedName = upperCaseFirst(name); + const formName = `Embedded${capitalizedName}Form`; + const formDataType = `Embedded${capitalizedName}FormData`; + const formSchema = `embedded${capitalizedName}FormSchema`; - const inputDataDependencies = inputFields.flatMap( - (f) => f.dataDependencies ?? [], - ); + const [formImport, formPath] = makeImportAndFilePath( + `${adminCrudEdit.getDirectoryBase()}/${formName}.tsx`, + ); - const tableName = `Embedded${capitalizedName}Table`; - const tableDataDependencies = tableColumns.flatMap( - (f) => f.display.dataDependencies ?? [], - ); + const inputDataDependencies = inputFields.flatMap( + (f) => f.dataDependencies ?? [], + ); - const allDataDependencies = mergeAdminCrudDataDependencies([ - ...inputDataDependencies, - ...tableDataDependencies, - ]); + const tableName = `Embedded${capitalizedName}Table`; + const tableDataDependencies = tableColumns.flatMap( + (f) => f.display.dataDependencies ?? [], + ); - const graphQLFields: GraphQLField[] = [ - ...(idField ? [{ name: idField }] : []), - ...inputFields.flatMap((f) => f.graphQLFields), - ...tableColumns.flatMap((f) => f.display.graphQLFields), - ]; + const allDataDependencies = mergeAdminCrudDataDependencies([ + ...inputDataDependencies, + ...tableDataDependencies, + ]); - // Create schema - const validations: AdminCrudInputValidation[] = [ - ...(idField && - !inputFields.some((f) => f.validation.some((v) => v.key === idField)) - ? [ - { - key: idField, - // TODO: Allow non-string IDs - expression: new TypescriptCodeExpression( - 'z.string().nullish()', - ), - }, - ] - : []), - ...inputFields.flatMap((f) => f.validation), - ]; - const embeddedBlock = TypescriptCodeUtils.formatBlock( - ` -export const SCHEMA_NAME = z.object(SCHEMA_OBJECT); + const graphQLFields: GraphQLField[] = [ + ...(idField ? [{ name: idField }] : []), + ...inputFields.flatMap((f) => f.graphQLFields), + ...tableColumns.flatMap((f) => f.display.graphQLFields), + ]; -export type SCHEMA_TYPE = z.infer; -`, - { - SCHEMA_NAME: formSchema, - SCHEMA_TYPE: formDataType, - SCHEMA_OBJECT: TypescriptCodeUtils.mergeExpressionsAsObject( - Object.fromEntries(validations.map((v) => [v.key, v.expression])), - ), - }, - ).withHeaderKey(formSchema); - - const validationExpression = TypescriptCodeUtils.createExpression( - isList ? `z.array(${formSchema})` : formSchema, - undefined, - { headerBlocks: [embeddedBlock] }, - ); - - return { - providers: { - adminCrudEmbeddedForm: { - getEmbeddedFormInfo: () => { - const sharedData = { - embeddedFormComponent: { - expression: TypescriptCodeUtils.createExpression( - formName, - `import { ${formName} } from '${formImport}'`, + // Create schema + const validations: AdminCrudInputValidation[] = [ + ...(idField && + !inputFields.some((f) => f.validation.some((v) => v.key === idField)) + ? [ + { + key: idField, + // TODO: Allow non-string IDs + expression: new TypescriptCodeExpression( + 'z.string().nullish()', ), - extraProps: getPassthroughExtraProps(inputDataDependencies), }, - dataDependencies: allDataDependencies, - graphQLFields, - validationExpression, - }; - if (isList) { - return { - type: 'list', - ...sharedData, - embeddedTableComponent: { + ] + : []), + ...inputFields.flatMap((f) => f.validation), + ]; + const embeddedBlock = TypescriptCodeUtils.formatBlock( + ` + export const SCHEMA_NAME = z.object(SCHEMA_OBJECT); + + export type SCHEMA_TYPE = z.infer; + `, + { + SCHEMA_NAME: formSchema, + SCHEMA_TYPE: formDataType, + SCHEMA_OBJECT: TypescriptCodeUtils.mergeExpressionsAsObject( + Object.fromEntries(validations.map((v) => [v.key, v.expression])), + ), + }, + ).withHeaderKey(formSchema); + + const validationExpression = TypescriptCodeUtils.createExpression( + isList ? `z.array(${formSchema})` : formSchema, + undefined, + { headerBlocks: [embeddedBlock] }, + ); + + return { + providers: { + adminCrudEmbeddedForm: { + getEmbeddedFormInfo: () => { + const sharedData = { + embeddedFormComponent: { expression: TypescriptCodeUtils.createExpression( - tableName, - `import { ${tableName} } from '${formImport}'`, + formName, + `import { ${formName} } from '${formImport}'`, ), - extraProps: getPassthroughExtraProps(tableDataDependencies), + extraProps: getPassthroughExtraProps(inputDataDependencies), }, + dataDependencies: allDataDependencies, + graphQLFields, + validationExpression, }; - } - return { - type: 'object', - ...sharedData, - }; + if (isList) { + return { + type: 'list', + ...sharedData, + embeddedTableComponent: { + expression: TypescriptCodeUtils.createExpression( + tableName, + `import { ${tableName} } from '${formImport}'`, + ), + extraProps: getPassthroughExtraProps( + tableDataDependencies, + ), + }, + }; + } + return { + type: 'object', + ...sharedData, + }; + }, }, }, - }, - build: async (builder) => { - const headers = tableColumns.map((column) => - TypescriptCodeUtils.createExpression( - `${column.label}`, - ), - ); - const cells = tableColumns.map((column) => - column.display - .content('item') - .wrap((content) => `${content}`), - ); - const tableComponent = isList - ? TypescriptCodeUtils.formatBlock( - ` - export function COMPONENT_NAME({ - items, - edit, - remove, - EXTRA_PROP_SPREAD - }: PROPS): JSX.Element { - return ( - - - - HEADERS - Actions - - - - {items.map((item, idx) => ( - - CELLS - - edit(idx)}>Edit - remove(idx)}> - Remove - - - - ))} - -
- ); - } -`, - { - COMPONENT_NAME: tableName, - EXTRA_PROP_SPREAD: new TypescriptStringReplacement( - tableDataDependencies.map((d) => d.propName).join(',\n'), - ), - PROPS: getComponentProps({ - inputType: 'List', - componentType: 'Table', - formDataType, - dataDependencies: tableDataDependencies, - adminComponents, - }), - HEADERS: TypescriptCodeUtils.mergeExpressions(headers, '\n'), - CELLS: TypescriptCodeUtils.mergeExpressions(cells, '\n'), - }, - { - importText: [ - 'import {Table, LinkButton} from "%react-components"', - ], - importMappers: [reactComponents], - }, - ) - : new TypescriptCodeBlock(''); - - const formFile = typescript.createTemplate( - { - EMBEDDED_FORM_DATA_TYPE: TypescriptCodeUtils.createExpression( - formDataType, - `import { ${formDataType} } from "${adminCrudEdit.getSchemaImport()}`, - ), - EMBEDDED_FORM_DATA_SCHEMA: TypescriptCodeUtils.createExpression( - formSchema, - `import { ${formSchema} } from "${adminCrudEdit.getSchemaImport()}`, - ), - COMPONENT_NAME: new TypescriptStringReplacement(formName), - INPUTS: TypescriptCodeUtils.mergeExpressions( - inputFields.map((input) => input.content), - '\n', + build: async (builder) => { + const headers = tableColumns.map((column) => + TypescriptCodeUtils.createExpression( + `${column.label}`, ), - HEADER: TypescriptCodeUtils.mergeBlocks( - inputFields.map((field) => field.header).filter(notEmpty), - ), - 'EXTRA_PROP_SPREAD,': new TypescriptStringReplacement( - inputDataDependencies.map((d) => d.propName).join(',\n'), - ), - PROPS: getComponentProps({ - inputType: isList ? 'List' : 'Object', - componentType: 'Form', - formDataType, - dataDependencies: inputDataDependencies, - adminComponents, - }), - TABLE_COMPONENT: tableComponent, - }, - { importMappers: [reactComponents, reactError] }, - ); + ); + const cells = tableColumns.map((column) => + column.display + .content('item') + .wrap((content) => `${content}`), + ); + const tableComponent = isList + ? TypescriptCodeUtils.formatBlock( + ` + export function COMPONENT_NAME({ + items, + edit, + remove, + EXTRA_PROP_SPREAD + }: PROPS): JSX.Element { + return ( + + + + HEADERS + Actions + + + + {items.map((item, idx) => ( + + CELLS + + edit(idx)}>Edit + remove(idx)}> + Remove + + + + ))} + +
+ ); + } + `, + { + COMPONENT_NAME: tableName, + EXTRA_PROP_SPREAD: new TypescriptStringReplacement( + tableDataDependencies.map((d) => d.propName).join(',\n'), + ), + PROPS: getComponentProps({ + inputType: 'List', + componentType: 'Table', + formDataType, + dataDependencies: tableDataDependencies, + adminComponents, + }), + HEADERS: TypescriptCodeUtils.mergeExpressions( + headers, + '\n', + ), + CELLS: TypescriptCodeUtils.mergeExpressions(cells, '\n'), + }, + { + importText: [ + 'import {Table, LinkButton} from "%react-components"', + ], + importMappers: [reactComponents], + }, + ) + : new TypescriptCodeBlock(''); - await builder.apply( - formFile.renderToAction('EmbeddedForm.tsx', formPath), - ); - }, - }; - }, - }), -); + const formFile = typescript.createTemplate( + { + EMBEDDED_FORM_DATA_TYPE: TypescriptCodeUtils.createExpression( + formDataType, + `import { ${formDataType} } from "${adminCrudEdit.getSchemaImport()}`, + ), + EMBEDDED_FORM_DATA_SCHEMA: TypescriptCodeUtils.createExpression( + formSchema, + `import { ${formSchema} } from "${adminCrudEdit.getSchemaImport()}`, + ), + COMPONENT_NAME: new TypescriptStringReplacement(formName), + INPUTS: TypescriptCodeUtils.mergeExpressions( + inputFields.map((input) => input.content), + '\n', + ), + HEADER: TypescriptCodeUtils.mergeBlocks( + inputFields.map((field) => field.header).filter(notEmpty), + ), + 'EXTRA_PROP_SPREAD,': new TypescriptStringReplacement( + inputDataDependencies.map((d) => d.propName).join(',\n'), + ), + PROPS: getComponentProps({ + inputType: isList ? 'List' : 'Object', + componentType: 'Form', + formDataType, + dataDependencies: inputDataDependencies, + adminComponents, + }), + TABLE_COMPONENT: tableComponent, + }, + { importMappers: [reactComponents, reactError] }, + ); -export const adminCrudEmbeddedFormGenerator = createGenerator({ - name: 'admin/admin-crud-embedded-form', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks(taskBuilder, descriptor) { - const setupTask = taskBuilder.addTask(createSetupFormTask(descriptor)); - taskBuilder.addTask(createMainTask(descriptor, { setupTask })); + await builder.apply( + formFile.renderToAction('EmbeddedForm.tsx', formPath), + ); + }, + }; + }, + }); }, }); diff --git a/packages/react-generators/src/generators/core/react-routes/index.ts b/packages/react-generators/src/generators/core/react-routes/index.ts index 6476c7eef..8c10d11ed 100644 --- a/packages/react-generators/src/generators/core/react-routes/index.ts +++ b/packages/react-generators/src/generators/core/react-routes/index.ts @@ -32,7 +32,7 @@ export const reactRoutesGenerator = createGenerator({ taskBuilder.addTask({ name: 'main', dependencies: { - reactRoutes: reactRoutesProvider.dependency(), + reactRoutes: reactRoutesProvider.dependency().parentScopeOnly(), typescript: typescriptProvider, reactNotFound: reactNotFoundProvider.dependency().optional(), }, diff --git a/packages/react-generators/src/generators/core/react-typescript/index.ts b/packages/react-generators/src/generators/core/react-typescript/index.ts index 8e8f80ce6..a4a06e8b3 100644 --- a/packages/react-generators/src/generators/core/react-typescript/index.ts +++ b/packages/react-generators/src/generators/core/react-typescript/index.ts @@ -1,6 +1,6 @@ import { eslintProvider, - typescriptConfigProvider, + typescriptSetupProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, writeJsonAction } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -15,42 +15,45 @@ export const reactTypescriptGenerator = createGenerator({ taskBuilder.addTask({ name: 'main', dependencies: { - typescriptConfig: typescriptConfigProvider, + typescriptSetup: typescriptSetupProvider, eslint: eslintProvider, }, - run({ typescriptConfig, eslint }) { - typescriptConfig.setTypescriptVersion('5.5.4'); - typescriptConfig.setTypescriptCompilerOptions({ - /* Compilation */ - lib: ['dom', 'dom.iterable', 'esnext'], - module: 'esnext', - target: 'esnext', - skipLibCheck: true, - esModuleInterop: false, - allowJs: false, - jsx: 'react-jsx', + run({ typescriptSetup, eslint }) { + typescriptSetup.version.set('5.5.4', 'react'); + typescriptSetup.compilerOptions.set( + { + /* Compilation */ + lib: ['dom', 'dom.iterable', 'esnext'], + module: 'esnext', + target: 'esnext', + skipLibCheck: true, + esModuleInterop: false, + allowJs: false, + jsx: 'react-jsx', - /* Linting */ - strict: true, + /* Linting */ + strict: true, - /* Resolution */ - allowSyntheticDefaultImports: true, - forceConsistentCasingInFileNames: true, - resolveJsonModule: true, - moduleResolution: 'bundler', + /* Resolution */ + allowSyntheticDefaultImports: true, + forceConsistentCasingInFileNames: true, + resolveJsonModule: true, + moduleResolution: 'bundler', - /* Output */ - isolatedModules: true, - noEmit: true, + /* Output */ + isolatedModules: true, + noEmit: true, - /* Paths */ - baseUrl: './', - paths: { - '@src/*': ['./src/*'], + /* Paths */ + baseUrl: './', + paths: { + '@src/*': ['./src/*'], + }, }, - }); - typescriptConfig.addInclude('src'); - typescriptConfig.addReference({ + 'react', + ); + typescriptSetup.include.push('src'); + typescriptSetup.references.push({ path: './tsconfig.node.json', }); eslint diff --git a/packages/react-generators/src/generators/core/react/index.ts b/packages/react-generators/src/generators/core/react/index.ts index d72987246..e374f6ecb 100644 --- a/packages/react-generators/src/generators/core/react/index.ts +++ b/packages/react-generators/src/generators/core/react/index.ts @@ -196,7 +196,7 @@ export const reactGenerator = createGenerator({ nodeSetup: nodeSetupProvider, }, run: ({ nodeSetup }) => { - nodeSetup.setIsEsm(true); + nodeSetup.isEsm.set(true, taskBuilder.generatorName); return {}; }, }); diff --git a/packages/sync/src/generators/build-generator-entry.ts b/packages/sync/src/generators/build-generator-entry.ts index 45c9408a6..78d6e7546 100644 --- a/packages/sync/src/generators/build-generator-entry.ts +++ b/packages/sync/src/generators/build-generator-entry.ts @@ -26,6 +26,10 @@ export interface GeneratorTaskEntry { * The exports of the task entry */ exports: ProviderExportMap; + /** + * The outputs of the task entry + */ + outputs: ProviderExportMap; /** * The task that the task entry represents */ @@ -94,6 +98,7 @@ async function buildGeneratorEntryRecursive( id: `${id}#${task.name}`, dependencies: task.dependencies ?? {}, exports: task.exports ?? {}, + outputs: task.outputs ?? {}, task, generatorBaseDirectory: directory, dependentTaskIds: task.taskDependencies.map((t) => `${id}#${t}`), diff --git a/packages/sync/src/generators/build-generator-entry.unit.test.ts b/packages/sync/src/generators/build-generator-entry.unit.test.ts index 052611da5..44eed8480 100644 --- a/packages/sync/src/generators/build-generator-entry.unit.test.ts +++ b/packages/sync/src/generators/build-generator-entry.unit.test.ts @@ -47,7 +47,10 @@ describe('buildGeneratorEntry', () => { test: testProviderType.export(), }, taskDependencies: [], - run: () => ({}), + run: () => ({ + providers: { test: {} }, + build: () => ({}), + }), }, ], }); @@ -93,7 +96,10 @@ describe('buildGeneratorEntry', () => { { name: 'child-task', taskDependencies: [], - run: () => ({}), + run: () => ({ + providers: {}, + build: () => ({}), + }), }, ], }; @@ -149,39 +155,6 @@ describe('buildGeneratorEntry', () => { }); }); - it('supports task dependencies', async () => { - // Set up test package.json - const testFs = { - '/test/package.json': JSON.stringify({ - name: 'test-package', - }), - }; - vol.fromJSON(testFs, '/'); - - const bundle = { - name: 'test-generator', - directory: '/test', - scopes: [], - children: {}, - tasks: [ - { - name: 'task1', - taskDependencies: [], - run: () => ({}), - }, - { - name: 'task2', - taskDependencies: ['task1'], - run: () => ({}), - }, - ], - }; - - const entry = await buildGeneratorEntry(bundle, { logger }); - - expect(entry.tasks[1].dependentTaskIds).toEqual(['root#task1']); - }); - it('preserves scopes from bundle', async () => { // Set up test package.json const testFs = { diff --git a/packages/sync/src/generators/generators.ts b/packages/sync/src/generators/generators.ts index 348914d66..db08ee2de 100644 --- a/packages/sync/src/generators/generators.ts +++ b/packages/sync/src/generators/generators.ts @@ -43,48 +43,89 @@ export type ProviderDependencyMap> = { /** * Infer the map of the initialized providers from the provider export map */ -export type InferExportProviderMap = - T extends ProviderExportMap ? P : never; +export type InferExportProviderMap = T extends undefined + ? undefined + : T extends ProviderExportMap + ? P + : never; /** * Infer the map of the initialized providers from the provider dependency map */ -export type InferDependencyProviderMap = - T extends ProviderDependencyMap ? P : never; +export type InferDependencyProviderMap = T extends undefined + ? undefined + : T extends ProviderDependencyMap + ? P + : never; -/** - * The result of a generator task without any exported providers - */ -export interface GeneratorTaskResultWithNoExports { +interface GeneratorTaskResultProviders< + ExportMap extends Record | undefined = + | Record + | undefined, +> { /** - * The function to build the output for the generator task + * The providers that are exported by this generator task */ - build?: (builder: GeneratorTaskOutputBuilder) => Promise | void; + providers: ExportMap; } -/** - * The result of a generator task with exported providers - */ -export interface GeneratorTaskResult< - ExportMap extends Record = Record, -> extends GeneratorTaskResultWithNoExports { +interface GeneratorTaskResultBuildersWithOutputs< + OutputMap extends Record | undefined = + | Record + | undefined, +> { /** - * The providers that are exported by this generator task + * The function to build the output for the generator task */ - providers?: ExportMap; + build: ( + builder: GeneratorTaskOutputBuilder, + ) => Promise | OutputMap; +} + +interface GeneratorTaskResultBuildersWithNoOutputs { /** * The function to build the output for the generator task */ build?: (builder: GeneratorTaskOutputBuilder) => Promise | void; } +type IsEmpty = T extends undefined + ? true + : keyof T extends never + ? true + : false; + +/** + * The result of a generator task with exported providers + */ +export type GeneratorTaskResult< + ExportMap extends Record | undefined = + | Record + | undefined, + OutputMap extends Record | undefined = + | Record + | undefined, +> = (IsEmpty extends true + ? Record + : GeneratorTaskResultProviders) & + (OutputMap extends true + ? GeneratorTaskResultBuildersWithNoOutputs + : IsEmpty extends true + ? GeneratorTaskResultBuildersWithNoOutputs + : GeneratorTaskResultBuildersWithOutputs); + /** * A generator task that has been initialized by the generator config with * the descriptor of the generator. */ export interface GeneratorTask< - ExportMap extends ProviderExportMap = ProviderExportMap, + ExportMap extends ProviderExportMap | undefined = + | ProviderExportMap + | undefined, DependencyMap extends ProviderDependencyMap = ProviderDependencyMap, + OutputMap extends ProviderExportMap | undefined = + | ProviderExportMap + | undefined, > { /** * The name of the generator task (must be unique within the generator) @@ -94,6 +135,10 @@ export interface GeneratorTask< * The providers that are exported by this generator task */ exports?: ExportMap; + /** + * The providers that are outputs from this generator task + */ + outputs?: OutputMap; /** * The providers that are required by this generator task */ @@ -109,9 +154,10 @@ export interface GeneratorTask< */ run: ( dependencies: InferDependencyProviderMap, - ) => keyof ExportMap extends never - ? GeneratorTaskResultWithNoExports - : GeneratorTaskResult>; + ) => GeneratorTaskResult< + InferExportProviderMap, + InferExportProviderMap + >; } export type ChildDescriptorOrReference = BaseGeneratorDescriptor | string; diff --git a/packages/sync/src/generators/generators.unit.test.ts b/packages/sync/src/generators/generators.unit.test.ts new file mode 100644 index 000000000..923368f47 --- /dev/null +++ b/packages/sync/src/generators/generators.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { GeneratorTaskOutputBuilder } from '@src/output/generator-task-output.js'; + +import { createProviderType } from '@src/providers/index.js'; + +import type { + GeneratorTask, + InferDependencyProviderMap, + InferExportProviderMap, + ProviderDependencyMap, + ProviderExportMap, +} from './generators.js'; + +function createTask< + ExportMap extends ProviderExportMap | undefined = undefined, + DependencyMap extends ProviderDependencyMap = ProviderDependencyMap, + OutputMap extends ProviderExportMap | undefined = undefined, +>( + taskResult: GeneratorTask, +): GeneratorTask { + return taskResult; +} + +describe('generators type definitions', () => { + it('should correctly infer provider maps from export and dependency maps', () => { + // Define test provider types + interface TestProvider { + test: () => string; + } + interface ConfigProvider { + getConfig: () => object; + } + + // Test ProviderExportMap + type TestExportMap = ProviderExportMap<{ + test: TestProvider; + config: ConfigProvider; + }>; + + // Test ProviderDependencyMap + type TestDependencyMap = ProviderDependencyMap<{ + test: TestProvider; + config: ConfigProvider; + }>; + + // Test InferExportProviderMap + type InferredExportMap = InferExportProviderMap; + expectTypeOf().toMatchTypeOf<{ + test: TestProvider; + config: ConfigProvider; + }>(); + + // Test InferDependencyProviderMap + type InferredDependencyMap = InferDependencyProviderMap; + expectTypeOf().toMatchTypeOf<{ + test: TestProvider; + config: ConfigProvider; + }>(); + }); + + it('should correctly type generator task results no exports', () => { + const taskOutput = createTask({ + name: 'test', + taskDependencies: [], + run: () => ({}), + }); + + expectTypeOf>().toMatchTypeOf<{ + build?: (builder: GeneratorTaskOutputBuilder) => Promise | void; + }>(); + expect(taskOutput.name).toBe('test'); + }); + + it('should correctly type generator task results no exports but output providers', () => { + const taskOutput = createTask({ + name: 'test', + taskDependencies: [], + outputs: { + test: createProviderType<{ value: string }>('test').export(), + }, + run: () => ({ + build: () => ({ test: { value: 'test' } }), + }), + }); + + expectTypeOf>().toHaveProperty( + 'build', + ); + expect(taskOutput.name).toBe('test'); + }); + + it('should correctly type generator tasks with exports and outputs', () => { + const taskOutput = createTask({ + name: 'test', + taskDependencies: [], + exports: { + test: createProviderType<{ value: string }>('test').export(), + }, + outputs: { + test: createProviderType<{ value: string }>('test').export(), + }, + run: () => ({ + providers: { + test: { value: 'test' }, + }, + build: () => ({ test: { value: 'test' } }), + }), + }); + + expect(taskOutput.name).toBe('test'); + }); +}); diff --git a/packages/sync/src/providers/providers.ts b/packages/sync/src/providers/providers.ts index 92eb27b6e..3c86ae0e5 100644 --- a/packages/sync/src/providers/providers.ts +++ b/packages/sync/src/providers/providers.ts @@ -5,10 +5,10 @@ import { KEBAB_CASE_REGEX } from '@src/utils/validation.js'; import type { ProviderExportScope } from './export-scopes.js'; /** - * A provider is a dictionary of functions that allow generator tasks + * A provider is a dictionary of functions/values that allow generator tasks * to communicate with other tasks */ -export type Provider = Record unknown>; +export type Provider = Record; /** * A provider type is a typed tag for a provider so that it can @@ -30,6 +30,10 @@ export interface ProviderType

{ * between the build step of dependent task and the build step of the export task. */ readonly isReadOnly?: boolean; + /** + * Whether the provider is used as an output provider (i.e. read-only) + */ + readonly isOutput?: boolean; /** * Creates a dependency config for the provider that can be used in dependency maps */ @@ -40,6 +44,8 @@ export interface ProviderType

{ export(scope?: ProviderExportScope, exportName?: string): ProviderExport

; } +export type InferProviderType = T extends ProviderType ? P : never; + export interface ProviderDependencyOptions { /** * Whether the dependency is optional or not @@ -49,10 +55,23 @@ export interface ProviderDependencyOptions { * The export name of the provider to resolve to (if empty string, forces the dependency to resolve to undefined) */ exportName?: string; + + /** + * Whether resolution should skip the current task and look for the provider in the parent task + * + * This is useful for recursive providers where the same generator might be used as a dependency of itself + */ + useParentScope?: boolean; + /** * Whether the provider is read-only or not (i.e. cannot modify any state in the generator task) */ readonly isReadOnly?: boolean; + /** + * Whether the provider is an output provider (i.e. read-only). Only output providers + * can be used in the outputs of a task. + */ + readonly isOutput?: boolean; } /** @@ -84,6 +103,10 @@ export interface ProviderDependency

{ optionalReference( exportName: string | undefined, ): ProviderDependency

; + /** + * Specifies that the dependency should only be resolved from the parent task + */ + parentScopeOnly(): ProviderDependency

; } /** @@ -92,6 +115,7 @@ export interface ProviderDependency

{ export interface ProviderExport

{ readonly type: 'export'; readonly name: string; + readonly isOutput: boolean; /** * The scope/name pairs that the provider will be available in */ @@ -115,6 +139,11 @@ export interface ProviderExport

{ * Options for a provider type */ interface ProviderTypeOptions { + /** + * Whether the provider is an output provider (i.e. read-only). Only output providers + * can be used in the outputs of a task. + */ + isOutput?: boolean; /** * Whether the functions in the provider are read-only such that they cannot * modify any state in the generator task @@ -150,7 +179,10 @@ export function createProviderType( return { ...this, type: 'dependency', - options: options?.isReadOnly ? { isReadOnly: true } : {}, + options: { + isReadOnly: options?.isReadOnly, + isOutput: options?.isOutput, + }, optional() { return toMerged(this, { options: { optional: true } }); }, @@ -171,12 +203,16 @@ export function createProviderType( options: { exportName: exportName ?? '', optional: true }, }); }, + parentScopeOnly() { + return toMerged(this, { options: { useParentScope: true } }); + }, }; }, export(scope, exportName) { return { ...this, type: 'export', + isOutput: options?.isOutput ?? false, exports: [{ scope, exportName }], andExport(scope, exportName) { return toMerged(this, { @@ -187,3 +223,17 @@ export function createProviderType( }, }; } + +/** + * Creates an output provider type + * + * @param name The name of the provider type + * @param options The options for the provider type + * @returns The provider type + */ +export function createOutputProviderType( + name: string, + options?: Omit, +): ProviderType { + return createProviderType(name, { ...options, isOutput: true }); +} diff --git a/packages/sync/src/runner/dependency-map.ts b/packages/sync/src/runner/dependency-map.ts index 7c0ca8d9e..630442298 100644 --- a/packages/sync/src/runner/dependency-map.ts +++ b/packages/sync/src/runner/dependency-map.ts @@ -8,15 +8,17 @@ import type { } from '../generators/index.js'; import type { ProviderDependencyOptions } from '../providers/index.js'; -type GeneratorIdToScopesMap = Record< - string, - { - // scopes offered by the generator - scopes: string[]; - // providers within the scopes - // key is JSON.encode([providerName(, exportName)]) - providers: Map; - } +type GeneratorIdToScopesMap = Partial< + Record< + string, + { + // scopes offered by the generator + scopes: string[]; + // providers within the scopes + // key is JSON.encode([providerName(, exportName)]) + providers: Map; + } + > >; function makeProviderId(providerName: string, exportName?: string): string { @@ -35,27 +37,51 @@ function buildGeneratorIdToScopesMapRecursive( }; const newParentTaskIds = [...parentTaskIds, entry.id]; - // add scoped exports of the entry to cache + // add scoped exports and outputs of the entry to cache for (const task of entry.tasks) { const taskExports = Object.values(task.exports); - for (const taskExport of taskExports) { + const taskOutputs = Object.values(task.outputs); + const invalidTaskExports = taskExports.filter( + (taskExport) => taskExport.isOutput, + ); + if (invalidTaskExports.length > 0) { + throw new Error( + `All providers in task exports must be non-output providers in ${task.id}: ${invalidTaskExports + .map((taskExport) => taskExport.name) + .join(', ')}`, + ); + } + const invalidTaskOutputs = taskOutputs.filter( + (taskOutput) => !taskOutput.isOutput, + ); + if (invalidTaskOutputs.length > 0) { + throw new Error( + `All providers in task outputs must be output providers in ${task.id}: ${invalidTaskOutputs + .map((output) => output.name) + .join(', ')}`, + ); + } + for (const taskExport of [...taskExports, ...taskOutputs]) { const { exports } = taskExport; for (const { scope, exportName } of exports) { // find the parent task ID that offers the scope (if undefined, it is the default scope of the entry itself) const parentTaskId = scope ? newParentTaskIds.findLast((id) => - generatorIdToScopesMap[id].scopes.includes(scope.name), + generatorIdToScopesMap[id]?.scopes.includes(scope.name), ) : entry.id; - if (!parentTaskId) { + const generatorEntry = + parentTaskId && generatorIdToScopesMap[parentTaskId]; + + if (!generatorEntry) { throw new Error( `Could not find parent generator with scope ${scope?.name} at ${entry.id}`, ); } - const { providers } = generatorIdToScopesMap[parentTaskId]; + const { providers } = generatorEntry; const providerId = makeProviderId(taskExport.name, exportName); const existingProviderId = providers.get(providerId); @@ -103,7 +129,7 @@ function mergeAllWithoutDuplicates>( */ function buildTaskDependencyMap( entry: GeneratorTaskEntry, - parentEntryIds: string[], + parentEntryIdsWithSelf: string[], generatorIdToScopesMap: GeneratorIdToScopesMap, ): Record< string, @@ -112,7 +138,8 @@ function buildTaskDependencyMap( return mapValues(entry.dependencies, (dep) => { const normalizedDep = dep.type === 'type' ? dep.dependency() : dep; const provider = normalizedDep.name; - const { optional, exportName, isReadOnly } = normalizedDep.options; + const { optional, exportName, isReadOnly, isOutput, useParentScope } = + normalizedDep.options; // if the export name is empty and the dependency is optional, we can skip it if (exportName === '' && optional) { @@ -121,13 +148,16 @@ function buildTaskDependencyMap( const providerId = makeProviderId(provider, exportName); // find the closest parent task ID that offers the provider - const parentEntryId = parentEntryIds.findLast((id) => - generatorIdToScopesMap[id].providers.has(providerId), + const entryIdsToCheck = useParentScope + ? parentEntryIdsWithSelf.slice(0, -1) + : parentEntryIdsWithSelf; + const parentEntryId = entryIdsToCheck.findLast((id) => + generatorIdToScopesMap[id]?.providers.has(providerId), ); const resolvedTaskId = parentEntryId && - generatorIdToScopesMap[parentEntryId].providers.get(providerId); + generatorIdToScopesMap[parentEntryId]?.providers.get(providerId); if (!resolvedTaskId) { if (!optional || exportName) { @@ -138,9 +168,19 @@ function buildTaskDependencyMap( return; } + if (resolvedTaskId === entry.id) { + throw new Error( + `Circular dependency detected for ${provider}${exportName ? ` (${exportName})` : ''} for ${entry.id} (generator ${entry.generatorName}). + You can use the .parentScopeOnly() method to create a dependency that only resolves providers from the parent generator entry.`, + ); + } + return { id: resolvedTaskId, - options: { isReadOnly: isReadOnly ? true : undefined }, + options: { + isReadOnly: isReadOnly ? true : undefined, + isOutput: isOutput ? true : undefined, + }, }; }); } @@ -149,7 +189,10 @@ export type EntryDependencyMap = Record< string, Record< string, - | { id: string; options?: Pick } + | { + id: string; + options?: Pick; + } | null | undefined > @@ -169,11 +212,12 @@ function buildEntryDependencyMapRecursive( generatorIdToScopesMap: GeneratorIdToScopesMap, logger: Logger, ): EntryDependencyMap { + const parentChildIdsWithSelf = [...parentEntryIds, entry.id]; const entryDependencyMaps = mergeAllWithoutDuplicates( entry.tasks.map((task) => { const taskDependencyMap = buildTaskDependencyMap( task, - parentEntryIds, + parentChildIdsWithSelf, generatorIdToScopesMap, ); @@ -183,8 +227,6 @@ function buildEntryDependencyMapRecursive( }), ); - const parentChildIdsWithSelf = [...parentEntryIds, entry.id]; - const childDependencyMaps = mergeAllWithoutDuplicates( entry.children.map((childEntry) => buildEntryDependencyMapRecursive( diff --git a/packages/sync/src/runner/dependency-map.unit.test.ts b/packages/sync/src/runner/dependency-map.unit.test.ts index 6124fecaa..b822bfdae 100644 --- a/packages/sync/src/runner/dependency-map.unit.test.ts +++ b/packages/sync/src/runner/dependency-map.unit.test.ts @@ -7,13 +7,19 @@ import { createProviderType, } from '../providers/index.js'; import { resolveTaskDependencies } from './dependency-map.js'; -import { buildTestGeneratorEntry } from './tests/factories.test-helper.js'; +import { + buildTestGeneratorEntry, + buildTestGeneratorTaskEntry, +} from './tests/factories.test-helper.js'; const providerOne = createProviderType('provider-one'); const providerTwo = createProviderType('provider-two'); const readOnlyProvider = createProviderType('read-only-provider', { isReadOnly: true, }); +const outputOnlyProvider = createProviderType('output-only-provider', { + isOutput: true, +}); const testLogger = createEventedLogger({ noConsole: true }); // Create test scopes @@ -358,6 +364,52 @@ describe('resolveTaskDependencies', () => { ); }); + it('should handle recursive dependencies', () => { + // Arrange + const rootEntry = buildTestGeneratorEntry( + { id: 'root' }, + { + exports: { + exportOne: providerOne.export(), + }, + }, + ); + + const middleEntry = buildTestGeneratorEntry( + { id: 'middle' }, + { + dependencies: { dep: providerOne.dependency().parentScopeOnly() }, + exports: { + exportOne: providerOne.export(), + }, + }, + ); + + const leafEntry = buildTestGeneratorEntry( + { id: 'leaf' }, + { + dependencies: { dep: providerOne.dependency() }, + }, + ); + + middleEntry.children.push(leafEntry); + rootEntry.children.push(middleEntry); + + // Act + const dependencyMap = resolveTaskDependencies(rootEntry, testLogger); + + // Assert + expect(dependencyMap).toEqual({ + 'root#main': {}, + 'middle#main': { + dep: { id: 'root#main', options: {} }, + }, + 'leaf#main': { + dep: { id: 'middle#main', options: {} }, + }, + }); + }); + it('should handle nested scope inheritance', () => { // Arrange const rootEntry = buildTestGeneratorEntry( @@ -408,4 +460,114 @@ describe('resolveTaskDependencies', () => { }, }); }); + + it('should handle output-only providers correctly', () => { + // Arrange + const entry = buildTestGeneratorEntry( + { + id: 'root', + scopes: [defaultScope], + }, + { + outputs: { + outputProvider: outputOnlyProvider.export(defaultScope), + }, + }, + ); + + const childEntry = buildTestGeneratorEntry( + { + id: 'child', + scopes: [defaultScope], + }, + { + dependencies: { dep: outputOnlyProvider.dependency() }, + }, + ); + + entry.children.push(childEntry); + + // Act + const dependencyMap = resolveTaskDependencies(entry, testLogger); + + // Assert + expect(dependencyMap).toEqual({ + 'root#main': {}, + 'child#main': { + dep: { id: 'root#main', options: { isOutput: true } }, + }, + }); + }); + + it('should resolve dependencies between tasks in the same generator entry', () => { + // Arrange + const entry = buildTestGeneratorEntry({ + id: 'root', + tasks: [ + buildTestGeneratorTaskEntry({ + id: 'root#producer', + outputs: { + outputProvider: outputOnlyProvider.export(), + }, + }), + buildTestGeneratorTaskEntry({ + id: 'root#consumer', + dependencies: { dep: outputOnlyProvider.dependency() }, + exports: {}, + outputs: {}, + }), + ], + }); + // Act + const dependencyMap = resolveTaskDependencies(entry, testLogger); + + // Assert + expect(dependencyMap).toEqual({ + 'root#producer': {}, + 'root#consumer': { + dep: { id: 'root#producer', options: { isOutput: true } }, + }, + }); + }); + + it('should throw error when non-output provider is used in task outputs', () => { + // Arrange + const entry = buildTestGeneratorEntry( + { + id: 'root', + scopes: [defaultScope], + }, + { + outputs: { + // Using a regular provider in outputs should throw + invalidOutput: providerOne.export(defaultScope), + }, + }, + ); + + // Act & Assert + expect(() => resolveTaskDependencies(entry, testLogger)).toThrow( + /All providers in task outputs must be output providers/, + ); + }); + + it('should throw error when non-output provider is used in task exports', () => { + // Arrange + const entry = buildTestGeneratorEntry( + { + id: 'root', + scopes: [defaultScope], + }, + { + exports: { + invalidExport: outputOnlyProvider.export(defaultScope), + }, + }, + ); + + // Act & Assert + expect(() => resolveTaskDependencies(entry, testLogger)).toThrow( + /All providers in task exports must be non-output providers/, + ); + }); }); diff --git a/packages/sync/src/runner/dependency-sort.ts b/packages/sync/src/runner/dependency-sort.ts index 973e19153..7b5d8dcb4 100644 --- a/packages/sync/src/runner/dependency-sort.ts +++ b/packages/sync/src/runner/dependency-sort.ts @@ -37,6 +37,14 @@ export function getSortedRunSteps( .flatMap((dependent): [string, string][] => { const dependentInit = `init|${dependent.id}`; const dependentBuild = `build|${dependent.id}`; + + // check if the dependency is to an output provider and if so, + // we need to wait until the dependent task has been built before + // we can build the current task + if (dependent.options?.isOutput) { + return [[dependentBuild, entryInit] as [string, string]]; + } + return [ [dependentInit, entryInit], // we don't attach a build step dependency if the provider is a read-only provider diff --git a/packages/sync/src/runner/dependency-sort.unit.test.ts b/packages/sync/src/runner/dependency-sort.unit.test.ts index 1486e716d..82cc2fc16 100644 --- a/packages/sync/src/runner/dependency-sort.unit.test.ts +++ b/packages/sync/src/runner/dependency-sort.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { createProviderType } from '../providers/index.js'; +import { + createOutputProviderType, + createProviderType, +} from '../providers/index.js'; import { getSortedRunSteps } from './dependency-sort.js'; import { buildTestGeneratorTaskEntry } from './tests/factories.test-helper.js'; @@ -50,6 +53,158 @@ describe('getSortedRunSteps', () => { ]); }); + describe('with provider dependencies', () => { + const providerOne = createProviderType('provider-one'); + const providerTwo = createProviderType('provider-two'); + const outputProvider = createOutputProviderType('output-provider'); + const readOnlyProvider = createProviderType('readonly-provider', { + isReadOnly: true, + }); + + it('sorts tasks with output provider dependencies correctly', () => { + const entries = [ + buildTestGeneratorTaskEntry({ + id: 'producer', + outputs: { out: outputProvider.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'consumer', + dependencies: { dep: outputProvider.dependency() }, + }), + ]; + + const dependencyMap = { + producer: {}, + consumer: { dep: { id: 'producer', options: { isOutput: true } } }, + }; + + const result = getSortedRunSteps(entries, dependencyMap); + expect(result.steps).toEqual([ + 'init|producer', + 'build|producer', // Must complete build before consumer can start + 'init|consumer', + 'build|consumer', + ]); + }); + + it('sorts tasks with mixed output and regular provider dependencies', () => { + const entries = [ + buildTestGeneratorTaskEntry({ + id: 'outputProducer', + outputs: { out: outputProvider.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'normalProducer', + exports: { exp: providerOne.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'consumer', + dependencies: { + outDep: outputProvider.dependency(), + normalDep: providerOne.dependency(), + }, + }), + ]; + + const dependencyMap = { + outputProducer: {}, + normalProducer: {}, + consumer: { + outDep: { id: 'outputProducer', options: { isOutput: true } }, + normalDep: { id: 'normalProducer', options: {} }, + }, + }; + + const result = getSortedRunSteps(entries, dependencyMap); + expect(result.steps).toEqual([ + 'init|outputProducer', + 'build|outputProducer', + 'init|normalProducer', + 'init|consumer', + 'build|consumer', + 'build|normalProducer', + ]); + }); + + it('sorts tasks with read-only provider dependencies', () => { + const entries = [ + buildTestGeneratorTaskEntry({ + id: 'producer', + exports: { exp: readOnlyProvider.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'consumer', + dependencies: { dep: readOnlyProvider.dependency() }, + }), + ]; + + const dependencyMap = { + producer: {}, + consumer: { dep: { id: 'producer', options: { isReadOnly: true } } }, + }; + + const result = getSortedRunSteps(entries, dependencyMap); + expect(result.steps).toEqual([ + 'init|producer', + 'build|producer', // Build order doesn't matter for read-only providers + 'init|consumer', + 'build|consumer', + ]); + }); + + it('handles complex dependency chains with mixed provider types', () => { + const entries = [ + buildTestGeneratorTaskEntry({ + id: 'outputProducer', + outputs: { out: outputProvider.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'middleConsumer', + dependencies: { outDep: outputProvider.dependency() }, + exports: { exp: providerTwo.export() }, + }), + buildTestGeneratorTaskEntry({ + id: 'finalConsumer', + dependencies: { + normalDep: providerTwo.dependency(), + readonlyDep: readOnlyProvider.dependency(), + }, + }), + buildTestGeneratorTaskEntry({ + id: 'readonlyProducer', + exports: { exp: readOnlyProvider.export() }, + }), + ]; + + const dependencyMap = { + outputProducer: {}, + middleConsumer: { + outDep: { id: 'outputProducer', options: { isOutput: true } }, + }, + finalConsumer: { + normalDep: { id: 'middleConsumer', options: {} }, + readonlyDep: { + id: 'readonlyProducer', + options: { isReadOnly: true }, + }, + }, + readonlyProducer: {}, + }; + + const result = getSortedRunSteps(entries, dependencyMap); + expect(result.steps).toEqual([ + 'init|outputProducer', + 'build|outputProducer', + 'init|middleConsumer', + 'init|readonlyProducer', + 'init|finalConsumer', + 'build|finalConsumer', + 'build|middleConsumer', + 'build|readonlyProducer', + ]); + }); + }); + describe('with inter-dependent tasks', () => { const providerOne = createProviderType('provider-one'); const providerTwo = createProviderType('provider-two'); diff --git a/packages/sync/src/runner/generator-runner.ts b/packages/sync/src/runner/generator-runner.ts index 1439c889f..2f3160c9b 100644 --- a/packages/sync/src/runner/generator-runner.ts +++ b/packages/sync/src/runner/generator-runner.ts @@ -37,9 +37,9 @@ export async function executeGeneratorEntry( for (const runStep of sortedRunSteps) { const [action, taskId] = runStep.split('|'); try { + const { task, dependencies, exports, outputs } = taskEntriesById[taskId]; if (action === 'init') { // run through init step - const { task, dependencies, exports } = taskEntriesById[taskId]; const resolvedDependencies = mapValues( dependencies, @@ -102,7 +102,31 @@ export async function executeGeneratorEntry( }); if (generator.build) { - await Promise.resolve(generator.build(outputBuilder)); + const outputResult = + ((await Promise.resolve(generator.build(outputBuilder))) as + | Record + | undefined) ?? {}; + + const outputKeys = Object.keys(outputs); + if (outputKeys.length > 0) { + const missingProvider = Object.keys(outputs).find( + (key) => !(key in outputResult), + ); + if (missingProvider) { + throw new Error( + `Task ${taskId} did not export provider ${missingProvider}`, + ); + } + providerMapById[taskId] = { + ...providerMapById[taskId], + ...Object.fromEntries( + Object.entries(outputs).map(([key, value]) => [ + value.name, + outputResult[key], + ]), + ), + }; + } } generatorOutputs.push(outputBuilder.output); diff --git a/packages/sync/src/runner/generator-runner.unit.test.ts b/packages/sync/src/runner/generator-runner.unit.test.ts index d954f58c0..7de9a64b2 100644 --- a/packages/sync/src/runner/generator-runner.unit.test.ts +++ b/packages/sync/src/runner/generator-runner.unit.test.ts @@ -12,7 +12,10 @@ import type { } from '../generators/index.js'; import type { Provider } from '../providers/index.js'; -import { createProviderType } from '../providers/index.js'; +import { + createOutputProviderType, + createProviderType, +} from '../providers/index.js'; import { executeGeneratorEntry } from './generator-runner.js'; import { buildTestGeneratorEntry } from './tests/factories.test-helper.js'; @@ -25,22 +28,28 @@ function buildGeneratorEntry( dependencyMap?: ProviderDependencyMap; exportMap?: ProviderExportMap; exports?: Record; + outputMap?: ProviderExportMap; build?: ( builder: GeneratorTaskOutputBuilder, deps: Record, - ) => void; + ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- allow no returns for build + | void + | Record + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- allow no returns for build + | Promise>; generatorName?: string; } = {}, ): GeneratorEntry { const { id, build = () => { - /* empty */ + /*void*/ }, children = [], exports: entryExports = {}, dependencyMap = {}, exportMap = {}, + outputMap = {}, } = options; return buildTestGeneratorEntry( { @@ -51,17 +60,17 @@ function buildGeneratorEntry( ...(id && { id: `${id}#main` }), dependencies: dependencyMap, exports: exportMap, + outputs: outputMap, generatorName: options.generatorName, task: { name: 'main', dependencies: dependencyMap, exports: exportMap, + outputs: outputMap, taskDependencies: [], run: (deps) => ({ providers: entryExports, - build: (builder) => { - build(builder, deps); - }, + build: (builder) => build(builder, deps) as undefined, }), }, }, @@ -134,7 +143,7 @@ describe('executeGeneratorEntry', () => { dependencyMap: { simpleDep: simpleProviderType }, generatorName: 'nested-generator', build: (builder, deps) => { - deps.simpleDep.hello(); + (deps.simpleDep as { hello: () => void }).hello(); builder.writeFile({ id: 'nested', filePath: '/nested/file.txt', @@ -192,4 +201,123 @@ describe('executeGeneratorEntry', () => { ]); expect(simpleProvider.hello).toHaveBeenCalled(); }); + + it('handles output providers correctly', async () => { + const outputProviderType = createOutputProviderType<{ + generate: () => void; + }>('output-provider'); + const outputProvider = { generate: vi.fn() }; + const entry = buildGeneratorEntry({ + id: 'root', + outputMap: { + outputProv: outputProviderType.export(), + }, + build: (builder) => { + builder.writeFile({ + id: 'output', + filePath: '/output/file.txt', + contents: 'output', + }); + return { outputProv: outputProvider }; + }, + children: [ + buildGeneratorEntry({ + id: 'root:consumer', + dependencyMap: { outputDep: outputProviderType }, + build: (builder, deps) => { + (deps.outputDep as { generate: () => void }).generate(); + builder.writeFile({ + id: 'consumer', + filePath: '/consumer/file.txt', + contents: 'consumer', + }); + }, + }), + ], + }); + + const result = await executeGeneratorEntry(entry, logger); + expect(Object.fromEntries(result.files.entries())).toEqual({ + '/output/file.txt': { + id: 'simple:output', + contents: 'output', + options: undefined, + }, + '/consumer/file.txt': { + id: 'simple:consumer', + contents: 'consumer', + options: undefined, + }, + }); + expect(outputProvider.generate).toHaveBeenCalled(); + }); + + it('handles multiple exports and dependencies correctly', async () => { + const providerTypeA = createProviderType('provider-a'); + const providerTypeB = createProviderType('provider-b'); + const providerA = { methodA: vi.fn() }; + const providerB = { methodB: vi.fn() }; + + const entry = buildGeneratorEntry({ + id: 'root', + exportMap: { + provA: providerTypeA.export(), + provB: providerTypeB.export(), + }, + exports: { + provA: providerA, + provB: providerB, + }, + children: [ + buildGeneratorEntry({ + id: 'root:consumer', + generatorName: 'test-generator', + dependencyMap: { + depA: providerTypeA, + depB: providerTypeB, + }, + build: (builder, deps) => { + (deps.depA as { methodA: () => void }).methodA(); + (deps.depB as { methodB: () => void }).methodB(); + builder.writeFile({ + id: 'consumer', + filePath: '/consumer/file.txt', + contents: 'consumer', + }); + }, + }), + ], + }); + + const result = await executeGeneratorEntry(entry, logger); + expect(Object.fromEntries(result.files.entries())).toEqual({ + '/consumer/file.txt': { + id: 'test-generator:consumer', + contents: 'consumer', + options: undefined, + }, + }); + expect(providerA.methodA).toHaveBeenCalled(); + expect(providerB.methodB).toHaveBeenCalled(); + }); + + it('throws error when required provider is not exported', async () => { + const providerType = createProviderType('missing-provider'); + const entry = buildGeneratorEntry({ + id: 'root', + children: [ + buildGeneratorEntry({ + id: 'root:consumer', + dependencyMap: { dep: providerType }, + build: () => { + /*void*/ + }, + }), + ], + }); + + await expect(executeGeneratorEntry(entry, logger)).rejects.toThrow( + /Could not resolve dependency/, + ); + }); }); diff --git a/packages/sync/src/runner/tests/factories.test-helper.ts b/packages/sync/src/runner/tests/factories.test-helper.ts index 56d7e1e90..3cb1578f4 100644 --- a/packages/sync/src/runner/tests/factories.test-helper.ts +++ b/packages/sync/src/runner/tests/factories.test-helper.ts @@ -31,12 +31,14 @@ export function buildTestGeneratorTaskEntry( id: lastTaskId.toString(), dependencies: {}, exports: {}, + outputs: {}, dependentTaskIds: [], task: { name: `task-${lastTaskId.toString()}`, exports: {}, dependencies: {}, taskDependencies: [], + outputs: {}, run: vi.fn(), }, generatorBaseDirectory: '/', diff --git a/packages/sync/src/utils/create-generator-types.ts b/packages/sync/src/utils/create-generator-types.ts index dcf1ee4ba..10814d025 100644 --- a/packages/sync/src/utils/create-generator-types.ts +++ b/packages/sync/src/utils/create-generator-types.ts @@ -45,13 +45,22 @@ export interface SimpleGeneratorTaskOutput { } interface SimpleGeneratorTaskInstance< - ExportMap extends Record = Record, + ExportMap extends Record | undefined = Record< + string, + Provider + >, + OutputMap extends Record | undefined = + | Record + | undefined, TaskOutput = unknown, > { providers?: ExportMap; build?: ( builder: GeneratorTaskOutputBuilder, - ) => Promise | TaskOutput; + addTaskOutput: (output: TaskOutput) => void, + ) => OutputMap extends undefined + ? void | Promise + : Promise | OutputMap; } export type TaskOutputDependencyMap> = { @@ -62,33 +71,45 @@ export type InferTaskOutputDependencyMap = T extends TaskOutputDependencyMap ? P : never; export interface SimpleGeneratorTaskConfig< - ExportMap extends ProviderExportMap = Record, - DependencyMap extends ProviderDependencyMap = Record, - TaskDependencyMap extends TaskOutputDependencyMap = Record, + ExportMap extends ProviderExportMap | undefined = + | ProviderExportMap + | undefined, + DependencyMap extends ProviderDependencyMap = ProviderDependencyMap, + OutputMap extends ProviderExportMap | undefined = + | ProviderExportMap + | undefined, + TaskDependencyMap extends TaskOutputDependencyMap = TaskOutputDependencyMap, TaskOutput = unknown, > { name: string; exports?: ExportMap; dependencies?: DependencyMap; + outputs?: OutputMap; taskDependencies?: TaskDependencyMap; run: ( dependencies: InferDependencyProviderMap, taskDependencies: InferTaskOutputDependencyMap, - ) => ExportMap extends Record + ) => { + exports: ExportMap; + outputs: OutputMap; + } extends { exports: undefined; outputs: undefined } ? // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- we want to allow empty returns for tasks that don't need to return anything void | SimpleGeneratorTaskInstance< InferExportProviderMap, + InferExportProviderMap, TaskOutput > : SimpleGeneratorTaskInstance< InferExportProviderMap, + InferExportProviderMap, TaskOutput >; } type TaskConfigBuilder< - ExportMap extends ProviderExportMap, + ExportMap extends ProviderExportMap | undefined, DependencyMap extends ProviderDependencyMap, + OutputMap extends ProviderExportMap | undefined, TaskDependencyMap extends TaskOutputDependencyMap, TaskOutput = unknown, Input = never, @@ -98,6 +119,7 @@ type TaskConfigBuilder< ) => SimpleGeneratorTaskConfig< ExportMap, DependencyMap, + OutputMap, TaskDependencyMap, TaskOutput >; @@ -106,6 +128,7 @@ export type ExtractTaskOutputFromBuilder = T extends TaskConfigBuilder< ProviderExportMap, ProviderDependencyMap, + ProviderExportMap, TaskOutputDependencyMap, infer TaskOutput > @@ -116,6 +139,7 @@ type TaskBuilderMap = { [key in keyof T]: TaskConfigBuilder< ProviderExportMap, ProviderDependencyMap, + ProviderExportMap, TaskOutputDependencyMap, T[key] >; @@ -127,15 +151,17 @@ export type InferTaskBuilderMap = : never; export function createTaskConfigBuilder< - ExportMap extends ProviderExportMap, - DependencyMap extends ProviderDependencyMap, - TaskDependencyMap extends TaskOutputDependencyMap, + ExportMap extends ProviderExportMap | undefined = undefined, + DependencyMap extends ProviderDependencyMap = Record, + OutputMap extends ProviderExportMap | undefined = undefined, + TaskDependencyMap extends TaskOutputDependencyMap = Record, TaskOutput = unknown, Input = unknown, >( builder: TaskConfigBuilder< ExportMap, DependencyMap, + OutputMap, TaskDependencyMap, TaskOutput, Input @@ -146,6 +172,7 @@ export function createTaskConfigBuilder< ) => SimpleGeneratorTaskConfig< ExportMap, DependencyMap, + OutputMap, TaskDependencyMap, TaskOutput > { @@ -153,9 +180,11 @@ export function createTaskConfigBuilder< } export interface GeneratorTaskBuilder { + generatorName: string; addTask: < - ExportMap extends ProviderExportMap = Record, - DependencyMap extends ProviderDependencyMap = Record, + ExportMap extends ProviderExportMap | undefined = undefined, + DependencyMap extends ProviderDependencyMap = Record, + OutputMap extends ProviderExportMap | undefined = undefined, TaskDependencyMap extends TaskOutputDependencyMap = Record, TaskOutput = unknown, >( @@ -163,6 +192,7 @@ export interface GeneratorTaskBuilder { | SimpleGeneratorTaskConfig< ExportMap, DependencyMap, + OutputMap, TaskDependencyMap, TaskOutput > @@ -171,6 +201,7 @@ export interface GeneratorTaskBuilder { ) => SimpleGeneratorTaskConfig< ExportMap, DependencyMap, + OutputMap, TaskDependencyMap, TaskOutput >), diff --git a/packages/sync/src/utils/create-generator.ts b/packages/sync/src/utils/create-generator.ts index 1534517c4..0c79651bb 100644 --- a/packages/sync/src/utils/create-generator.ts +++ b/packages/sync/src/utils/create-generator.ts @@ -8,15 +8,12 @@ import { fileURLToPath } from 'node:url'; import type { GeneratorBundle, GeneratorTask, - ProviderDependencyMap, - ProviderExportMap, } from '@src/generators/generators.js'; -import type { ProviderExportScope } from '@src/providers/index.js'; +import type { Provider, ProviderExportScope } from '@src/providers/index.js'; import type { GeneratorTaskBuilder, SimpleGeneratorTaskConfig, - TaskOutputDependencyMap, } from './create-generator-types.js'; /** @@ -96,13 +93,10 @@ export function createGenerator( const validatedDescriptor = (config.descriptorSchema?.parse(rest) as unknown) ?? {}; - const taskConfigs: SimpleGeneratorTaskConfig< - ProviderExportMap, - ProviderDependencyMap, - TaskOutputDependencyMap - >[] = []; + const taskConfigs: SimpleGeneratorTaskConfig[] = []; const taskOutputs: Record = {}; const taskBuilder: GeneratorTaskBuilder> = { + generatorName: config.name, addTask: (task) => { taskConfigs.push( task instanceof Function ? task(validatedDescriptor) : task, @@ -121,37 +115,41 @@ export function createGenerator( }; config.buildTasks(taskBuilder, validatedDescriptor); - const tasks: GeneratorTask[] = - taskConfigs.map((task) => { - const taskDependencies = task.taskDependencies ?? {}; - return { - name: task.name, - dependencies: task.dependencies, - exports: task.exports, - taskDependencies: Object.values(taskDependencies).map( - (dep) => dep.name, - ), - run(dependencies) { - const resolvedTaskOutputs = mapValues(taskDependencies, (dep) => - dep.getOutput(), - ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it can return undefined if there are no exports - const runResult = task.run(dependencies, resolvedTaskOutputs) ?? {}; - return { - providers: runResult.providers, - async build(builder) { - if (!runResult.build) { - return; - } - const taskOutput = await Promise.resolve( - runResult.build(builder), - ); - taskOutputs[task.name] = taskOutput; - }, - }; - }, - }; - }); + const tasks: GeneratorTask[] = taskConfigs.map((task) => { + const taskDependencies = task.taskDependencies ?? {}; + return { + name: task.name, + dependencies: task.dependencies, + exports: task.exports, + outputs: task.outputs, + taskDependencies: Object.values(taskDependencies).map( + (dep) => dep.name, + ), + run(dependencies) { + const resolvedTaskOutputs = mapValues(taskDependencies, (dep) => + dep.getOutput(), + ); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it can return undefined if there are no exports + const runResult = task.run(dependencies, resolvedTaskOutputs) ?? {}; + return { + providers: runResult.providers, + async build(builder) { + if (!runResult.build) { + return {}; + } + let taskOutput: unknown; + const output = await Promise.resolve( + runResult.build(builder, (output) => { + taskOutput = output; + }), + ); + taskOutputs[task.name] = taskOutput; + return output as Record; + }, + }; + }, + }; + }); return { name: config.name, diff --git a/packages/sync/src/utils/create-generator.unit.test.ts b/packages/sync/src/utils/create-generator.unit.test.ts index 5054bdb2d..61d563328 100644 --- a/packages/sync/src/utils/create-generator.unit.test.ts +++ b/packages/sync/src/utils/create-generator.unit.test.ts @@ -117,7 +117,9 @@ describe('createGenerator', () => { const task1 = taskBuilder.addTask({ name: 'task1', run: () => ({ - build: () => 'task1-output', + build: (_, addTaskOutput: (output: string) => void) => { + addTaskOutput('task1-output'); + }, }), }); diff --git a/packages/sync/src/utils/create-setup-task.ts b/packages/sync/src/utils/create-setup-task.ts new file mode 100644 index 000000000..18746c7f0 --- /dev/null +++ b/packages/sync/src/utils/create-setup-task.ts @@ -0,0 +1,92 @@ +import { + createFieldMap, + type FieldMapSchema, + type FieldMapSchemaBuilder, + type FieldMapValues, +} from '@halfdomelabs/utils'; + +import type { ProviderExportScope } from '@src/providers/export-scopes.js'; + +import { + createOutputProviderType, + createProviderType, + type ProviderType, +} from '@src/providers/providers.js'; + +import type { SimpleGeneratorTaskConfig } from './create-generator-types.js'; + +/** + * Options for creating a setup task builder + */ +export interface CreateSetupTaskOptions { + /** + * The prefix for the providers + */ + prefix: string; + /** + * The name of the task + * + * @default 'setup' + */ + taskName?: string; + /** + * The scope for the config provider + */ + configScope?: ProviderExportScope; + /** + * The scope for the output provider + */ + outputScope?: ProviderExportScope; +} + +export type SetupTaskResult = [ + // Setup task + SimpleGeneratorTaskConfig, + // Config provider + ProviderType, + // Output provider + ProviderType>, +]; + +/** + * Creates a setup task + * + * @param schemaBuilder - The schema builder for the setup task + * @param options - The options for the setup task + * @returns The setup task builder + */ +export function createSetupTask( + schemaBuilder: (t: FieldMapSchemaBuilder) => TSchema, + { + prefix, + taskName = 'setup', + configScope, + outputScope, + }: CreateSetupTaskOptions, +): SetupTaskResult { + const configProvider = createProviderType( + `${prefix}-${taskName}-config`, + ); + const outputProvider = createOutputProviderType>( + `${prefix}-${taskName}-output`, + ); + + return [ + { + name: taskName, + exports: { config: configProvider.export(configScope) }, + outputs: { output: outputProvider.export(outputScope) }, + run() { + const fieldMap = createFieldMap(schemaBuilder); + return { + providers: { config: fieldMap }, + build() { + return { output: fieldMap.getValues() }; + }, + }; + }, + }, + configProvider, + outputProvider, + ]; +} diff --git a/packages/sync/src/utils/index.ts b/packages/sync/src/utils/index.ts index da48d99fc..2c8143b2f 100644 --- a/packages/sync/src/utils/index.ts +++ b/packages/sync/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './create-generator-types.js'; export * from './create-generator.js'; +export * from './create-setup-task.js'; export * from './evented-logger.js'; export * from './non-overwriteable-map.js'; export * from './ordered-list.js'; diff --git a/packages/tools/eslint-configs/typescript.js b/packages/tools/eslint-configs/typescript.js index 28580890d..31ecb89d3 100644 --- a/packages/tools/eslint-configs/typescript.js +++ b/packages/tools/eslint-configs/typescript.js @@ -213,6 +213,11 @@ export function generateTypescriptEslintConfig(options = []) { files: ['**/*.test.{ts,js,tsx,jsx}', 'tests/**'], plugins: { vitest }, rules: vitest.configs.recommended.rules, + settings: { + vitest: { + typecheck: true, + }, + }, }, // Global Ignores diff --git a/packages/utils/src/field-map/field-map.ts b/packages/utils/src/field-map/field-map.ts new file mode 100644 index 000000000..97e0bcf93 --- /dev/null +++ b/packages/utils/src/field-map/field-map.ts @@ -0,0 +1,246 @@ +// Base field container class +export abstract class FieldContainer { + private _value: T | undefined; + protected readonly defaultValue: T; + protected isSet = false; + + constructor(defaultValue: T) { + this.defaultValue = defaultValue; + } + + get value(): T { + return this._value === undefined ? this.defaultValue : this._value; + } + + protected setValue(value: T): void { + this._value = value; + this.isSet = true; + } +} + +// Scalar field container +export class ScalarContainer extends FieldContainer { + protected setBySource: string | undefined; + + set(value: T, source: string): void { + if (this.isSet) { + throw new Error( + `Value has already been set by ${this.setBySource} and cannot be overwritten by ${source}`, + ); + } + this.setValue(value); + this.setBySource = source; + } +} + +// Array field container +export class ArrayContainer extends FieldContainer { + private readonly stripDuplicates: boolean; + + constructor(defaultValue?: T[], options?: { stripDuplicates?: boolean }) { + super(defaultValue ?? []); + this.stripDuplicates = options?.stripDuplicates ?? false; + } + + push(...items: T[]): void { + let currentValue = this.value; + + if (this.stripDuplicates) { + // Add items without duplicates + const set = new Set([...currentValue, ...items]); + currentValue = [...set]; + } else { + // Add all items + currentValue = [...currentValue, ...items]; + } + + this.setValue(currentValue); + } +} + +export class ObjectContainer< + T extends Record, +> extends FieldContainer { + private readonly map: Map< + keyof T, + { value: unknown; setBySource: string | undefined } + >; + + constructor(defaultValue: T) { + super(defaultValue); + this.map = new Map( + Object.entries(defaultValue).map(([key, value]) => [ + key, + { value, setBySource: undefined }, + ]), + ); + } + + set(key: keyof T, value: T[keyof T], source: string): void { + const existingValue = this.map.get(key); + if (existingValue?.setBySource) { + throw new Error( + `Value for key ${key as string} has already been set by ${existingValue.setBySource} and cannot be overwritten by ${source}`, + ); + } + this.map.set(key, { value, setBySource: source }); + } + + merge(value: Partial, source: string): void { + for (const [key, val] of Object.entries(value)) { + this.set(key as keyof T, val as T[keyof T], source); + } + } + + get value(): T { + return Object.fromEntries( + [...this.map.entries()].map(([key, value]) => [key, value.value]), + ) as T; + } +} + +// Map field container +export class MapContainer< + K extends string | number | symbol, + V, +> extends FieldContainer> { + private readonly map: Map; + + constructor(defaultValue?: Map) { + const initialMap = defaultValue ?? new Map(); + super(initialMap); + this.map = new Map( + [...initialMap.entries()].map(([key, value]) => [ + key, + { value, setBySource: undefined }, + ]), + ); + } + + set(key: K, value: V, source: string): void { + const existingValue = this.map.get(key); + if (existingValue?.setBySource) { + throw new Error( + `Value for key ${key as string} has already been set by ${existingValue.setBySource} and cannot be overwritten by ${source}`, + ); + } + this.map.set(key, { value, setBySource: source }); + } + + merge(value: Map, source: string): void { + for (const [key, val] of value.entries()) { + this.set(key, val, source); + } + } + + mergeObj(value: Record, source: string): void { + for (const [key, val] of Object.entries(value)) { + this.set(key as K, val as V, source); + } + } + + get value(): Map { + return new Map( + [...this.map.entries()].map(([key, value]) => [key, value.value]), + ); + } +} + +type InferFieldContainer = T extends FieldContainer ? U : never; + +// Schema type +export type FieldMapSchema = Record>; + +// Type for values returned by getValues() +export type FieldMapValues = { + [K in keyof S]: InferFieldContainer; +}; + +// FieldMap type based on schema +export type FieldMap = S & { + getValues(): FieldMapValues; +}; + +// Schema builder class +export class FieldMapSchemaBuilder { + scalar(): ScalarContainer; + scalar(defaultValue: T): ScalarContainer; + scalar(defaultValue?: T): ScalarContainer { + return new ScalarContainer(defaultValue); + } + + string(): ScalarContainer; + string(defaultValue: string): ScalarContainer; + string(defaultValue?: string): ScalarContainer { + return new ScalarContainer(defaultValue); + } + + number(): ScalarContainer; + number(defaultValue: number): ScalarContainer; + number(defaultValue?: number): ScalarContainer { + return new ScalarContainer(defaultValue); + } + + boolean(): ScalarContainer; + boolean(defaultValue: boolean): ScalarContainer; + boolean(defaultValue?: boolean): ScalarContainer { + return new ScalarContainer(defaultValue); + } + + array( + defaultValue?: T[], + options?: { stripDuplicates?: boolean }, + ): ArrayContainer { + return new ArrayContainer(defaultValue ?? [], options); + } + + object>( + defaultValue: T, + ): ObjectContainer { + return new ObjectContainer(defaultValue); + } + + map( + defaultValue?: Map, + ): MapContainer { + return new MapContainer(defaultValue ?? new Map()); + } + + mapFromObj(defaultValue?: Record): MapContainer { + return new MapContainer(new Map(Object.entries(defaultValue ?? {}))); + } +} + +export function createFieldMapSchemaBuilder( + schemaBuilder: (t: FieldMapSchemaBuilder) => T, +): (t: FieldMapSchemaBuilder) => T { + return schemaBuilder; +} + +export type InferFieldMapSchemaFromBuilder< + T extends (t: FieldMapSchemaBuilder) => FieldMapSchema, +> = T extends (t: FieldMapSchemaBuilder) => infer U ? U : never; + +/** + * Creates a field map with type-safe field definitions + */ +export function createFieldMap( + schemaBuilder: (t: FieldMapSchemaBuilder) => S, +): FieldMap { + const schema = schemaBuilder(new FieldMapSchemaBuilder()); + + // Add getValues method + return { + ...schema, + getValues: () => { + const values = {} as FieldMapValues; + + for (const key of Object.keys(schema)) { + const container = schema[key]; + (values as Record)[key] = container.value; + } + + return values; + }, + }; +} diff --git a/packages/utils/src/field-map/field-map.unit.test.ts b/packages/utils/src/field-map/field-map.unit.test.ts new file mode 100644 index 000000000..1efc750e0 --- /dev/null +++ b/packages/utils/src/field-map/field-map.unit.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { createFieldMap } from './field-map.js'; + +describe('FieldMap', () => { + describe('ScalarContainer', () => { + it('should handle string fields with default values', () => { + const fieldMap = createFieldMap((t) => ({ + name: t.string('default'), + })); + + expect(fieldMap.getValues()).toEqual({ name: 'default' }); + }); + + it('should allow setting string value once', () => { + const fieldMap = createFieldMap((t) => ({ + name: t.string(), + })); + + fieldMap.name.set('test', 'source1'); + expect(fieldMap.getValues()).toEqual({ name: 'test' }); + }); + + it('should throw error when setting scalar value multiple times', () => { + const fieldMap = createFieldMap((t) => ({ + name: t.string(), + })); + + fieldMap.name.set('test', 'source1'); + expect(() => { + fieldMap.name.set('another', 'source2'); + }).toThrow( + 'Value has already been set by source1 and cannot be overwritten by source2', + ); + }); + }); + + describe('ArrayContainer', () => { + it('should handle arrays with default values', () => { + const fieldMap = createFieldMap((t) => ({ + tags: t.array(['default']), + })); + + expect(fieldMap.getValues()).toEqual({ tags: ['default'] }); + }); + + it('should allow pushing values to array', () => { + const fieldMap = createFieldMap((t) => ({ + tags: t.array(), + })); + + fieldMap.tags.push('tag1', 'tag2'); + expect(fieldMap.getValues()).toEqual({ tags: ['tag1', 'tag2'] }); + }); + + it('should handle stripDuplicates option', () => { + const fieldMap = createFieldMap((t) => ({ + tags: t.array([], { stripDuplicates: true }), + })); + + fieldMap.tags.push('tag1', 'tag2', 'tag1'); + expect(fieldMap.getValues()).toEqual({ tags: ['tag1', 'tag2'] }); + }); + }); + + describe('ObjectContainer', () => { + it('should handle objects with default values', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.object({ key1: 'value1' }), + })); + + expect(fieldMap.getValues()).toEqual({ settings: { key1: 'value1' } }); + }); + + it('should allow setting object value once', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.object({ key1: 'value1' }), + })); + + fieldMap.settings.set('key1', 'value2', 'source1'); + expect(fieldMap.getValues()).toEqual({ settings: { key1: 'value2' } }); + }); + }); + + describe('MapContainer', () => { + it('should handle maps with default values', () => { + const defaultMap = new Map([['key1', 'value1']]); + const fieldMap = createFieldMap((t) => ({ + settings: t.map(defaultMap), + })); + + expect(fieldMap.getValues().settings).toEqual(defaultMap); + }); + + it('should allow setting individual values', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.map( + new Map([ + ['key1', 'value0'], + ['key2', 'value1'], + ]), + ), + })); + + fieldMap.settings.set('key1', 'value1', 'source1'); + expect(fieldMap.getValues().settings).toEqual( + new Map([ + ['key1', 'value1'], + ['key2', 'value1'], + ]), + ); + }); + + it('should throw error when setting map value multiple times', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.map(), + })); + + fieldMap.settings.set('key1', 'value1', 'source1'); + expect(() => { + fieldMap.settings.set('key1', 'value2', 'source2'); + }).toThrow( + 'Value for key key1 has already been set by source1 and cannot be overwritten by source2', + ); + }); + + it('should handle merging maps', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.map(), + })); + + const newMap = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]); + fieldMap.settings.merge(newMap, 'source1'); + expect(fieldMap.getValues().settings).toEqual(newMap); + }); + + it('should handle merging objects', () => { + const fieldMap = createFieldMap((t) => ({ + settings: t.map(), + })); + + fieldMap.settings.mergeObj({ key1: 'value1', key2: 'value2' }, 'source1'); + expect(fieldMap.getValues().settings).toEqual( + new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]), + ); + }); + }); + + describe('Mixed field types', () => { + it('should handle multiple field types together', () => { + const fieldMap = createFieldMap((t) => ({ + name: t.string('default'), + age: t.number(25), + isActive: t.boolean(true), + tags: t.array(['tag1']), + settings: t.mapFromObj({ key1: 'value1' }), + })); + + expect(fieldMap.getValues()).toEqual({ + name: 'default', + age: 25, + isActive: true, + tags: ['tag1'], + settings: new Map([['key1', 'value1']]), + }); + }); + }); +}); diff --git a/packages/utils/src/field-map/index.ts b/packages/utils/src/field-map/index.ts new file mode 100644 index 000000000..3451d33d6 --- /dev/null +++ b/packages/utils/src/field-map/index.ts @@ -0,0 +1 @@ +export * from './field-map.js'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index fabf68440..211da7ce1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,5 @@ export * from './crypto/index.js'; export * from './events/index.js'; +export * from './field-map/index.js'; export * from './maps/index.js'; export * from './objects/index.js';