diff --git a/packages/core-generators/src/generators/node/typescript/index.ts b/packages/core-generators/src/generators/node/typescript/index.ts index a07c5938f..f5aa70a5c 100644 --- a/packages/core-generators/src/generators/node/typescript/index.ts +++ b/packages/core-generators/src/generators/node/typescript/index.ts @@ -1,7 +1,8 @@ import type { BuilderAction, - GeneratorTask, + GeneratorTaskOutputBuilder, InferProviderType, + ProviderType, WriteFileOptions, } from '@halfdomelabs/sync'; @@ -10,7 +11,6 @@ import { createGenerator, createGeneratorTask, createProviderType, - createTaskPhase, } from '@halfdomelabs/sync'; import { safeMergeAll } from '@halfdomelabs/utils'; import path from 'node:path'; @@ -18,6 +18,7 @@ import { z } from 'zod'; import type { CopyTypescriptFilesOptions } from '@src/actions/copy-typescript-files-action.js'; import type { + InferImportMapProvidersFromProviderTypeMap, InferTsCodeTemplateVariablesFromMap, TsCodeFileTemplate, TsCodeTemplateVariableMap, @@ -91,18 +92,30 @@ export const typescriptProvider = interface WriteTemplatedFilePayload< TVariables extends TsCodeTemplateVariableMap, + TImportMapProviders extends Record = Record< + never, + ProviderType + >, > { - template: TsCodeFileTemplate; + id: string; + template: TsCodeFileTemplate; destination: string; variables: InferTsCodeTemplateVariablesFromMap; - fileId: string; + importMapProviders: InferImportMapProvidersFromProviderTypeMap; options?: WriteFileOptions; } export interface TypescriptFileProvider { - writeTemplatedFile( - payload: WriteTemplatedFilePayload, - ): void; + writeTemplatedFile< + TVariables extends TsCodeTemplateVariableMap, + TImportMapProviders extends Record = Record< + never, + ProviderType + >, + >( + builder: GeneratorTaskOutputBuilder, + payload: WriteTemplatedFilePayload, + ): Promise<{ destination: string }>; } export const typescriptFileProvider = @@ -157,61 +170,10 @@ export type TypescriptSetupProvider = InferProviderType< typeof typescriptSetupProvider >; -export const typescriptFileTaskPhase = createTaskPhase('typescript-file'); - -export function createTypescriptFileTask< - TVariables extends TsCodeTemplateVariableMap, ->(payload: WriteTemplatedFilePayload): GeneratorTask { - const task = createGeneratorTask({ - phase: typescriptFileTaskPhase, - dependencies: { typescriptConfig: typescriptConfigProvider }, - run({ typescriptConfig: { compilerOptions, includeMetadata } }) { - const { - baseUrl = '.', - paths = {}, - moduleResolution = 'node', - } = compilerOptions; - const { fileId, template, destination, variables, options } = payload; - const pathMapEntries = generatePathMapEntries(baseUrl, paths); - const internalPatterns = pathMapEntriesToRegexes(pathMapEntries); - - return { - async build(builder) { - const directory = path.dirname(destination); - const file = await renderTsCodeFileTemplate(template, variables, { - resolveModule(moduleSpecifier) { - return resolveModule(moduleSpecifier, directory, { - pathMapEntries, - moduleResolution, - }); - }, - importSortOptions: { - internalPatterns, - }, - includeMetadata, - }); - - builder.writeFile({ - id: fileId, - filePath: destination, - contents: file, - options: { - ...options, - shouldFormat: true, - }, - }); - }, - }; - }, - }); - return task; -} - export const typescriptGenerator = createGenerator({ name: 'node/typescript', generatorFileUrl: import.meta.url, descriptorSchema: typescriptGeneratorDescriptorSchema, - preRegisteredPhases: [typescriptFileTaskPhase], buildTasks: (descriptor) => ({ setup: createGeneratorTask(setupTask(descriptor)), nodePackages: createNodePackagesTask({ @@ -321,5 +283,65 @@ export const typescriptGenerator = createGenerator({ }; }, }), + file: createGeneratorTask({ + dependencies: { typescriptConfig: typescriptConfigProvider }, + exports: { typescriptFile: typescriptFileProvider.export(projectScope) }, + run({ typescriptConfig: { compilerOptions, includeMetadata } }) { + const { + baseUrl = '.', + paths = {}, + moduleResolution = 'node', + } = compilerOptions; + const pathMapEntries = generatePathMapEntries(baseUrl, paths); + const internalPatterns = pathMapEntriesToRegexes(pathMapEntries); + + return { + providers: { + typescriptFile: { + writeTemplatedFile: async (builder, payload) => { + const { + id, + template, + destination, + variables, + options, + importMapProviders, + } = payload; + const directory = path.dirname(destination); + const file = await renderTsCodeFileTemplate( + template, + variables, + { + resolveModule(moduleSpecifier) { + return resolveModule(moduleSpecifier, directory, { + pathMapEntries, + moduleResolution, + }); + }, + importSortOptions: { + internalPatterns, + }, + includeMetadata, + importMapProviders, + }, + ); + + builder.writeFile({ + id, + filePath: destination, + contents: file, + options: { + ...options, + shouldFormat: true, + }, + }); + + return { destination }; + }, + }, + }, + }; + }, + }), }), }); diff --git a/packages/core-generators/src/providers/project.ts b/packages/core-generators/src/providers/project.ts index 59bb59092..b186e867e 100644 --- a/packages/core-generators/src/providers/project.ts +++ b/packages/core-generators/src/providers/project.ts @@ -1,8 +1,8 @@ -import { createOutputProviderType } from '@halfdomelabs/sync'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; export interface ProjectProvider { getProjectName(): string; } export const projectProvider = - createOutputProviderType('project'); + createReadOnlyProviderType('project'); diff --git a/packages/core-generators/src/renderers/typescript/import-maps/index.ts b/packages/core-generators/src/renderers/typescript/import-maps/index.ts new file mode 100644 index 000000000..5d9fa98c5 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/import-maps/index.ts @@ -0,0 +1,3 @@ +export * from './transform-ts-imports-with-map.js'; +export * from './ts-import-map.js'; +export type * from './types.js'; diff --git a/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.ts b/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.ts new file mode 100644 index 000000000..6748cdd89 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.ts @@ -0,0 +1,56 @@ +import type { TsImportDeclaration } from '../imports/types.js'; +import type { TsImportMap } from './types.js'; + +/** + * Transform import declarations with an import map. + * + * Import declarations whose module specifiers begin with `%` are transformed + * using the import map. We first look up the correct import map using the + * module specifier without the `%` prefix. If the import map is not found, an + * error is thrown. + * + * Then we transform each named import using the import map entry found in the + * import map. + * + * @param imports - The import declarations to transform. + * @param importMaps - The import maps to use to transform the imports. + * @returns The transformed import declarations. + */ +export function transformTsImportsWithMap( + imports: TsImportDeclaration[], + importMaps: Map, +): TsImportDeclaration[] { + return imports.flatMap((importDeclaration) => { + if (!importDeclaration.source.startsWith('%')) { + return [importDeclaration]; + } + + const importMapKey = importDeclaration.source.slice(1); + + const importMap = importMaps.get(importMapKey); + + if (!importMap) { + throw new Error(`Import map not found for ${importDeclaration.source}`); + } + + if (importDeclaration.namespaceImport || importDeclaration.defaultImport) { + throw new Error( + `Import map does not support namespace or default imports: ${importDeclaration.source}`, + ); + } + + return ( + importDeclaration.namedImports?.map((namedImport) => { + if (!(namedImport.name in importMap)) { + throw new Error(`Import map entry not found for ${namedImport.name}`); + } + + const entry = importMap[namedImport.name]; + + return importDeclaration.isTypeOnly + ? entry.typeDeclaration() + : entry.declaration(); + }) ?? [] + ); + }); +} diff --git a/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.unit.test.ts b/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.unit.test.ts new file mode 100644 index 000000000..15a21457b --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/import-maps/transform-ts-imports-with-map.unit.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import type { TsImportDeclaration } from '../imports/types.js'; +import type { TsImportMap } from './types.js'; + +import { transformTsImportsWithMap } from './transform-ts-imports-with-map.js'; +import { createTsImportMap, createTsImportMapSchema } from './ts-import-map.js'; + +describe('transformImportsWithMap', () => { + it('should return unchanged imports when no $ prefix is present', () => { + const imports: TsImportDeclaration[] = [ + { + source: 'react', + namedImports: [{ name: 'useState' }], + }, + ]; + + const result = transformTsImportsWithMap(imports, new Map()); + expect(result).toEqual(imports); + }); + + it('should throw error when import map is not found', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%unknown', + namedImports: [{ name: 'test' }], + }, + ]; + + expect(() => transformTsImportsWithMap(imports, new Map())).toThrow( + 'Import map not found for %unknown', + ); + }); + + describe('for a test import map', () => { + const testSchema = createTsImportMapSchema({ + test: { name: 'test' }, + }); + + const testImportMaps = new Map([ + [ + 'test', + createTsImportMap(testSchema, { test: 'test-package' }) as TsImportMap, + ], + ]); + + it('should throw error for namespace imports', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + namespaceImport: 'test', + }, + ]; + + expect(() => transformTsImportsWithMap(imports, testImportMaps)).toThrow( + 'Import map does not support namespace or default imports: %test', + ); + }); + + it('should throw error for default imports', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + defaultImport: 'test', + }, + ]; + + expect(() => transformTsImportsWithMap(imports, testImportMaps)).toThrow( + 'Import map does not support namespace or default imports: %test', + ); + }); + + it('should throw error when import map entry is not found', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + namedImports: [{ name: 'unknown' }], + }, + ]; + + expect(() => transformTsImportsWithMap(imports, testImportMaps)).toThrow( + 'Import map entry not found for unknown', + ); + }); + + it('should transform named imports using import map', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + namedImports: [{ name: 'test' }], + }, + ]; + + const result = transformTsImportsWithMap(imports, testImportMaps); + expect(result).toEqual([ + { + source: 'test-package', + namedImports: [{ name: 'test' }], + }, + ]); + }); + + it('should handle type-only imports correctly', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + namedImports: [{ name: 'test' }], + isTypeOnly: true, + }, + ]; + + const result = transformTsImportsWithMap(imports, testImportMaps); + expect(result).toEqual([ + { + source: 'test-package', + namedImports: [{ name: 'test' }], + isTypeOnly: true, + }, + ]); + }); + }); + + it('should handle multiple named imports', () => { + const imports: TsImportDeclaration[] = [ + { + source: '%test', + namedImports: [{ name: 'test1' }, { name: 'test2' }], + }, + ]; + + const schema = createTsImportMapSchema({ + test1: { name: 'test1' }, + test2: { name: 'test2' }, + }); + + const importMaps = new Map([ + [ + 'test', + createTsImportMap(schema, { + test1: 'test-package1', + test2: 'test-package2', + }) as TsImportMap, + ], + ]); + + const result = transformTsImportsWithMap(imports, importMaps); + expect(result).toEqual([ + { + source: 'test-package1', + namedImports: [{ name: 'test1' }], + }, + { + source: 'test-package2', + namedImports: [{ name: 'test2' }], + }, + ]); + }); +}); diff --git a/packages/core-generators/src/renderers/typescript/import-maps/ts-import-map.ts b/packages/core-generators/src/renderers/typescript/import-maps/ts-import-map.ts new file mode 100644 index 000000000..81595cb6b --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/import-maps/ts-import-map.ts @@ -0,0 +1,74 @@ +import type { TsImportDeclaration } from '../imports/types.js'; +import type { + InferTsImportMapFromSchema, + TsImportMapProviderFromSchema, + TsImportMapSchema, + TsImportMapSchemaEntry, +} from './types.js'; + +export function createTsImportMapSchema< + T extends Record, +>(importSchema: T): T { + return importSchema; +} + +type ImportMapInputFromSchema = { + [K in keyof T]: + | string + | { + source: string; + }; +}; + +export function createTsImportMap< + T extends Record, +>( + importSchema: T, + imports: ImportMapInputFromSchema, +): InferTsImportMapFromSchema { + return Object.fromEntries( + Object.entries(importSchema).map(([key, value]) => { + const name = value.name ?? key; + const source = + typeof imports[key] === 'string' ? imports[key] : imports[key].source; + + const makeDeclaration = ( + alias?: string, + isTypeOnly?: boolean, + ): TsImportDeclaration => ({ + source, + ...(name === 'default' + ? { defaultImport: alias ?? key } + : { + namedImports: [{ name, alias }], + }), + isTypeOnly, + }); + + return [ + key, + { + name, + source, + isTypeOnly: value.isTypeOnly, + declaration: (alias) => { + if (value.isTypeOnly) { + throw new Error( + 'Type only imports cannot be marked as named imports', + ); + } + return makeDeclaration(alias); + }, + typeDeclaration: (alias) => makeDeclaration(alias, true), + }, + ]; + }), + ) as InferTsImportMapFromSchema; +} + +export function createTsImportMapProvider( + importSchema: T, + importMap: ImportMapInputFromSchema, +): TsImportMapProviderFromSchema { + return { importMap: createTsImportMap(importSchema, importMap) }; +} diff --git a/packages/core-generators/src/renderers/typescript/import-maps/types.ts b/packages/core-generators/src/renderers/typescript/import-maps/types.ts new file mode 100644 index 000000000..dc1d240f9 --- /dev/null +++ b/packages/core-generators/src/renderers/typescript/import-maps/types.ts @@ -0,0 +1,41 @@ +import type { TsImportDeclaration } from '../imports/types.js'; + +export interface TsImportMapSchemaEntry< + IsTypeOnly extends boolean | undefined = boolean | undefined, +> { + /** + * The name of the import (if not the key of the entry). + */ + name?: string; + /** + * If the import can only be type only. + */ + isTypeOnly?: IsTypeOnly; +} + +export type TsImportMapSchema = Record; + +export interface TsImportMapEntry< + IsTypeOnly extends boolean | undefined = boolean | undefined, +> { + name: string; + source: string; + isTypeOnly: IsTypeOnly; + declaration( + alias?: string, + ): IsTypeOnly extends true ? never : TsImportDeclaration; + typeDeclaration(alias?: string): TsImportDeclaration; +} + +export type TsImportMap = Record; + +export type InferTsImportMapFromSchema = { + [K in keyof T]: TsImportMapEntry; +}; + +export interface TsImportMapProvider { + importMap: T; +} + +export type TsImportMapProviderFromSchema = + TsImportMapProvider>; diff --git a/packages/core-generators/src/renderers/typescript/index.ts b/packages/core-generators/src/renderers/typescript/index.ts index 6888962c5..4520ead20 100644 --- a/packages/core-generators/src/renderers/typescript/index.ts +++ b/packages/core-generators/src/renderers/typescript/index.ts @@ -1,4 +1,5 @@ export * from './fragments/index.js'; +export * from './import-maps/index.js'; export * from './imports/index.js'; export * from './renderers/index.js'; export * from './templates/index.js'; diff --git a/packages/core-generators/src/renderers/typescript/renderers/file.ts b/packages/core-generators/src/renderers/typescript/renderers/file.ts index 5ba209e07..c17989bbd 100644 --- a/packages/core-generators/src/renderers/typescript/renderers/file.ts +++ b/packages/core-generators/src/renderers/typescript/renderers/file.ts @@ -1,9 +1,11 @@ +import type { InferProviderType, ProviderType } from '@halfdomelabs/sync'; import type { SourceFile } from 'ts-morph'; import { readFile } from 'node:fs/promises'; import { Project } from 'ts-morph'; import type { TsHoistedFragment } from '../fragments/types.js'; +import type { TsImportMap, TsImportMapProvider } from '../import-maps/types.js'; import type { SortImportDeclarationsOptions } from '../imports/index.js'; import type { TsImportDeclaration } from '../imports/types.js'; import type { @@ -13,6 +15,7 @@ import type { } from '../templates/types.js'; import type { RenderTsTemplateOptions } from './template.js'; +import { transformTsImportsWithMap } from '../import-maps/transform-ts-imports-with-map.js'; import { sortImportDeclarations } from '../imports/index.js'; import { mergeTsImportDeclarations } from '../imports/merge-ts-import-declarations.js'; import { @@ -22,17 +25,29 @@ import { } from '../imports/ts-morph-operations.js'; import { renderTsTemplateToTsCodeFragment } from './template.js'; -export interface RenderTsCodeFileTemplateOptions - extends RenderTsTemplateOptions { +interface RenderTsCodeFileTemplateOptionsBase extends RenderTsTemplateOptions { importSortOptions?: Partial; resolveModule?: (moduleSpecifier: string) => string; } +export type InferImportMapProvidersFromProviderTypeMap< + T extends Record, +> = { + [K in keyof T]: InferProviderType; +}; + +export interface RenderTsCodeFileTemplateOptions< + T extends Record = Record, +> extends RenderTsCodeFileTemplateOptionsBase { + importMapProviders: InferImportMapProvidersFromProviderTypeMap; +} + function mergeImportsAndHoistedFragments( file: SourceFile, imports: TsImportDeclaration[], hoistedFragments: TsHoistedFragment[], - { resolveModule, importSortOptions }: RenderTsCodeFileTemplateOptions, + importMaps: Map, + { resolveModule, importSortOptions }: RenderTsCodeFileTemplateOptionsBase, ): void { // Get the import declarations from the source file const importDeclarationsFromFile = @@ -40,8 +55,11 @@ function mergeImportsAndHoistedFragments( // Convert the import declarations to TsImportDeclaration const tsImportDeclarations = [ - ...importDeclarationsFromFile.map( - convertTsMorphImportDeclarationToTsImportDeclaration, + ...transformTsImportsWithMap( + importDeclarationsFromFile.map( + convertTsMorphImportDeclarationToTsImportDeclaration, + ), + importMaps, ), ...imports, ]; @@ -98,10 +116,14 @@ function mergeImportsAndHoistedFragments( export async function renderTsCodeFileTemplate< TVariables extends TsCodeTemplateVariableMap, + TImportMapProviders extends Record = Record< + never, + ProviderType + >, >( - template: TsCodeFileTemplate, + template: TsCodeFileTemplate, variables: InferTsCodeTemplateVariablesFromMap, - options: RenderTsCodeFileTemplateOptions, + options: RenderTsCodeFileTemplateOptions, ): Promise { const rawTemplate = 'path' in template.source @@ -115,7 +137,11 @@ export async function renderTsCodeFileTemplate< ...options, }); - if (!imports?.length && !hoistedFragments?.length) { + if ( + !imports?.length && + !hoistedFragments?.length && + Object.keys(options.importMapProviders).length === 0 + ) { return contents; } @@ -125,12 +151,26 @@ export async function renderTsCodeFileTemplate< }); const file = project.createSourceFile('./file.ts', contents); + const { importMapProviders, ...restOptions } = options; + + const importMapProvidersMap = new Map( + Object.entries(importMapProviders).map(([key, value]) => { + const provider = value as TsImportMapProvider; + if (!('importMap' in provider)) { + throw new Error('Import map provider must have an importMap property'); + } + + return [key, provider.importMap]; + }), + ); + // Merge in imports and hoisted fragments mergeImportsAndHoistedFragments( file, imports ?? [], hoistedFragments ?? [], - options, + importMapProvidersMap, + restOptions, ); return file.getText(); diff --git a/packages/core-generators/src/renderers/typescript/renderers/file.unit.test.ts b/packages/core-generators/src/renderers/typescript/renderers/file.unit.test.ts index 9004accf1..8741cd514 100644 --- a/packages/core-generators/src/renderers/typescript/renderers/file.unit.test.ts +++ b/packages/core-generators/src/renderers/typescript/renderers/file.unit.test.ts @@ -18,7 +18,9 @@ describe('renderTsCodeFileTemplate', () => { TPL_CONTENT: tsCodeFragment('42'), }; - const result = await renderTsCodeFileTemplate(template, variables, {}); + const result = await renderTsCodeFileTemplate(template, variables, { + importMapProviders: {}, + }); expect(result).toBe('const value = 42;'); }); @@ -42,7 +44,9 @@ describe('renderTsCodeFileTemplate', () => { ), }; - const result = await renderTsCodeFileTemplate(template, variables, {}); + const result = await renderTsCodeFileTemplate(template, variables, { + importMapProviders: {}, + }); expect(result).toMatchInlineSnapshot(` "import type { type MyType } from "./types"; @@ -73,6 +77,7 @@ describe('renderTsCodeFileTemplate', () => { const result = await renderTsCodeFileTemplate(template, variables, { resolveModule: (moduleSpecifier) => `@project/${moduleSpecifier}`, + importMapProviders: {}, }); expect(result).toMatchInlineSnapshot(` @@ -112,7 +117,9 @@ describe('renderTsCodeFileTemplate', () => { ), }; - const result = await renderTsCodeFileTemplate(template, variables, {}); + const result = await renderTsCodeFileTemplate(template, variables, { + importMapProviders: {}, + }); expect(result).toMatchInlineSnapshot( ` diff --git a/packages/core-generators/src/renderers/typescript/templates/creators.ts b/packages/core-generators/src/renderers/typescript/templates/creators.ts index 412da06a1..805f88f7b 100644 --- a/packages/core-generators/src/renderers/typescript/templates/creators.ts +++ b/packages/core-generators/src/renderers/typescript/templates/creators.ts @@ -1,3 +1,4 @@ +import { ProviderType } from '@halfdomelabs/sync'; import { TsCodeTemplateVariableMap } from './types.js'; import { TsCodeFileTemplate } from './types.js'; @@ -9,6 +10,12 @@ import { TsCodeFileTemplate } from './types.js'; */ export function tsCodeFileTemplate< TVariables extends TsCodeTemplateVariableMap, ->(template: TsCodeFileTemplate): TsCodeFileTemplate { + TImportProviders extends Record = Record< + never, + ProviderType + >, +>( + template: TsCodeFileTemplate, +): TsCodeFileTemplate { return template; } diff --git a/packages/core-generators/src/renderers/typescript/templates/types.ts b/packages/core-generators/src/renderers/typescript/templates/types.ts index 03c5834c5..cc922668a 100644 --- a/packages/core-generators/src/renderers/typescript/templates/types.ts +++ b/packages/core-generators/src/renderers/typescript/templates/types.ts @@ -1,4 +1,6 @@ +import { ProviderType } from '@halfdomelabs/sync'; import { TsCodeFragment } from '../fragments/types.js'; +import { TsImportMapProvider } from '../import-maps/types.js'; export interface TsCodeTemplateVariable { description?: string; @@ -16,6 +18,10 @@ export type TsCodeFileTemplateSource = export interface TsCodeFileTemplate< TVariables extends TsCodeTemplateVariableMap, + TImportMapProviders extends Record = Record< + never, + ProviderType + >, > { name: string; variables: TVariables; @@ -25,6 +31,10 @@ export interface TsCodeFileTemplate< * @default 'TPL_' */ prefix?: string; + /** + * Import map providers that will be used to resolve imports for the template. + */ + importMapProviders?: TImportMapProviders; } export type InferTsCodeTemplateVariablesFromMap< 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 3bd49e26a..ebd2f45b0 100644 --- a/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts +++ b/packages/fastify-generators/src/generators/auth0/auth0-module/index.ts @@ -23,7 +23,7 @@ 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'; import { fastifyServerProvider } from '@src/generators/core/index.js'; -import { loggerServiceSetupProvider } from '@src/generators/core/logger-service/index.js'; +import { loggerServiceSetupProvider } from '@src/generators/core/logger-service/logger-service.generator.js'; import { appModuleProvider } from '@src/generators/core/root-module/index.js'; import { prismaOutputProvider } from '@src/generators/prisma/prisma/index.js'; diff --git a/packages/fastify-generators/src/generators/bull/bull-mq/index.ts b/packages/fastify-generators/src/generators/bull/bull-mq/index.ts index e597f7082..308b531ef 100644 --- a/packages/fastify-generators/src/generators/bull/bull-mq/index.ts +++ b/packages/fastify-generators/src/generators/bull/bull-mq/index.ts @@ -17,7 +17,7 @@ import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { errorHandlerServiceProvider } from '@src/generators/core/error-handler-service/index.js'; import { fastifyRedisProvider } from '@src/generators/core/fastify-redis/index.js'; import { fastifyOutputProvider } from '@src/generators/core/fastify/index.js'; -import { loggerServiceProvider } from '@src/generators/core/logger-service/index.js'; +import { loggerServiceProvider } from '@src/generators/core/logger-service/logger-service.generator.js'; const descriptorSchema = z.object({}); diff --git a/packages/fastify-generators/src/generators/core/_composers/fastify-composer.ts b/packages/fastify-generators/src/generators/core/_composers/fastify-composer.ts index 4adb67553..2c713b861 100644 --- a/packages/fastify-generators/src/generators/core/_composers/fastify-composer.ts +++ b/packages/fastify-generators/src/generators/core/_composers/fastify-composer.ts @@ -13,7 +13,7 @@ import { fastifyHealthCheckGenerator } from '../fastify-health-check/index.js'; import { fastifyScriptsGenerator } from '../fastify-scripts/index.js'; import { fastifyServerGenerator } from '../fastify-server/index.js'; import { fastifyGenerator } from '../fastify/index.js'; -import { loggerServiceGenerator } from '../logger-service/index.js'; +import { loggerServiceGenerator } from '../logger-service/logger-service.generator.js'; import { requestContextGenerator } from '../request-context/index.js'; import { requestServiceContextGenerator } from '../request-service-context/index.js'; import { rootModuleGenerator } from '../root-module/index.js'; diff --git a/packages/fastify-generators/src/generators/core/config-service/generated/import-maps.ts b/packages/fastify-generators/src/generators/core/config-service/generated/import-maps.ts new file mode 100644 index 000000000..edafa5bf4 --- /dev/null +++ b/packages/fastify-generators/src/generators/core/config-service/generated/import-maps.ts @@ -0,0 +1,32 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMapProvider, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; + +const configServiceImportMapSchema = createTsImportMapSchema({ + config: {}, +}); + +type ConfigServiceImportMapProvider = TsImportMapProviderFromSchema< + typeof configServiceImportMapSchema +>; + +export const configServiceImportsProvider = + createReadOnlyProviderType( + 'config-service-imports', + ); + +interface ConfigServiceFileMap { + service: string; +} + +export function createConfigServiceImportMap( + filePaths: ConfigServiceFileMap, +): ConfigServiceImportMapProvider { + return createTsImportMapProvider(configServiceImportMapSchema, { + config: filePaths.service, + }); +} diff --git a/packages/fastify-generators/src/generators/core/config-service/index.ts b/packages/fastify-generators/src/generators/core/config-service/index.ts index 16ca479b4..3f9525826 100644 --- a/packages/fastify-generators/src/generators/core/config-service/index.ts +++ b/packages/fastify-generators/src/generators/core/config-service/index.ts @@ -26,6 +26,10 @@ import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { fastifyProvider } from '../fastify/index.js'; +import { + configServiceImportsProvider, + createConfigServiceImportMap, +} from './generated/import-maps.js'; const descriptorSchema = z.object({ placeholder: z.string().optional(), @@ -77,7 +81,10 @@ export const configServiceGenerator = createGenerator({ nodeGitIgnore: nodeGitIgnoreProvider, typescript: typescriptProvider, }, - exports: { configService: configServiceProvider.export(projectScope) }, + exports: { + configService: configServiceProvider.export(projectScope), + configServiceImports: configServiceImportsProvider.export(projectScope), + }, run({ nodeGitIgnore, typescript }) { const configEntries = createNonOverwriteableMap< Record @@ -114,6 +121,9 @@ export const configServiceGenerator = createGenerator({ }, }), }, + configServiceImports: createConfigServiceImportMap({ + service: '@/src/services/config.js', + }), }, build: async (builder) => { const configFile = typescript.createTemplate({ @@ -196,3 +206,5 @@ export const configServiceGenerator = createGenerator({ }), }), }); + +export { configServiceImportsProvider } from './generated/import-maps.js'; diff --git a/packages/fastify-generators/src/generators/core/error-handler-service/generated/templates.ts b/packages/fastify-generators/src/generators/core/error-handler-service/generated/templates.ts new file mode 100644 index 000000000..cd28b386e --- /dev/null +++ b/packages/fastify-generators/src/generators/core/error-handler-service/generated/templates.ts @@ -0,0 +1,18 @@ +import { tsCodeFileTemplate } from '@halfdomelabs/core-generators'; +import path from 'node:path'; + +import { configServiceImportsProvider } from '../../config-service/index.js'; + +export const errorHandlerPluginFileTemplate = tsCodeFileTemplate({ + name: 'error-handler-plugin', + source: { + path: path.join( + import.meta.dirname, + '../templates/plugins/error-handler.ts', + ), + }, + variables: {}, + importMapProviders: { + configService: configServiceImportsProvider, + }, +}); diff --git a/packages/fastify-generators/src/generators/core/error-handler-service/index.ts b/packages/fastify-generators/src/generators/core/error-handler-service/index.ts index 290cfc328..2f3856c54 100644 --- a/packages/fastify-generators/src/generators/core/error-handler-service/index.ts +++ b/packages/fastify-generators/src/generators/core/error-handler-service/index.ts @@ -8,6 +8,7 @@ import { createTypescriptTemplateConfig, projectScope, TypescriptCodeUtils, + typescriptFileProvider, typescriptProvider, } from '@halfdomelabs/core-generators'; import { @@ -18,9 +19,10 @@ import { } from '@halfdomelabs/sync'; import { z } from 'zod'; -import { configServiceProvider } from '../config-service/index.js'; +import { configServiceImportsProvider } from '../config-service/index.js'; import { fastifyServerProvider } from '../fastify-server/index.js'; -import { loggerServiceProvider } from '../logger-service/index.js'; +import { loggerServiceProvider } from '../logger-service/logger-service.generator.js'; +import { errorHandlerPluginFileTemplate } from './generated/templates.js'; const descriptorSchema = z.object({}); @@ -65,16 +67,23 @@ export const errorHandlerServiceGenerator = createGenerator({ buildTasks: () => ({ setup: createGeneratorTask({ dependencies: { + configServiceImports: configServiceImportsProvider, loggerService: loggerServiceProvider, fastifyServer: fastifyServerProvider, typescript: typescriptProvider, - configService: configServiceProvider, + typescriptFile: typescriptFileProvider, }, exports: { errorHandlerServiceSetup: errorHandlerServiceSetupProvider.export(projectScope), }, - run({ loggerService, fastifyServer, typescript, configService }) { + run({ + loggerService, + fastifyServer, + typescript, + configServiceImports, + typescriptFile, + }) { const errorLoggerFile = typescript.createTemplate( errorHandlerFileConfig, ); @@ -112,13 +121,15 @@ export const errorHandlerServiceGenerator = createGenerator({ ), ); - await builder.apply( - typescript.createCopyAction({ - source: 'plugins/error-handler.ts', - destination: 'src/plugins/error-handler.ts', - importMappers: [configService], - }), - ); + await typescriptFile.writeTemplatedFile(builder, { + template: errorHandlerPluginFileTemplate, + id: 'error-handler-plugin', + destination: 'src/plugins/error-handler.ts', + variables: {}, + importMapProviders: { + configService: configServiceImports, + }, + }); await builder.apply( errorLoggerFile.renderToAction( diff --git a/packages/fastify-generators/src/generators/core/error-handler-service/templates/plugins/error-handler.ts b/packages/fastify-generators/src/generators/core/error-handler-service/templates/plugins/error-handler.ts index c9194a8ec..f9365ab7e 100644 --- a/packages/fastify-generators/src/generators/core/error-handler-service/templates/plugins/error-handler.ts +++ b/packages/fastify-generators/src/generators/core/error-handler-service/templates/plugins/error-handler.ts @@ -3,7 +3,7 @@ import fp from 'fastify-plugin'; import { logError } from '../services/error-logger.js'; import { HttpError, NotFoundError } from '../utils/http-errors.js'; -import { config } from '%config'; +import { config } from '%configService'; const IS_DEVELOPMENT = config.APP_ENVIRONMENT === 'development'; diff --git a/packages/fastify-generators/src/generators/core/fastify-graceful-shutdown/index.ts b/packages/fastify-generators/src/generators/core/fastify-graceful-shutdown/index.ts index 7575b8fbf..5b3da1021 100644 --- a/packages/fastify-generators/src/generators/core/fastify-graceful-shutdown/index.ts +++ b/packages/fastify-generators/src/generators/core/fastify-graceful-shutdown/index.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { errorHandlerServiceProvider } from '../error-handler-service/index.js'; import { fastifyServerProvider } from '../fastify-server/index.js'; -import { loggerServiceProvider } from '../logger-service/index.js'; +import { loggerServiceProvider } from '../logger-service/logger-service.generator.js'; const descriptorSchema = z.object({ placeholder: z.string().optional(), 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 823313df5..2a6e870bf 100644 --- a/packages/fastify-generators/src/generators/core/fastify-server/index.ts +++ b/packages/fastify-generators/src/generators/core/fastify-server/index.ts @@ -22,7 +22,7 @@ import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { configServiceProvider } from '../config-service/index.js'; -import { loggerServiceProvider } from '../logger-service/index.js'; +import { loggerServiceProvider } from '../logger-service/logger-service.generator.js'; import { rootModuleConfigProvider, rootModuleImportProvider, diff --git a/packages/fastify-generators/src/generators/core/fastify/index.ts b/packages/fastify-generators/src/generators/core/fastify/index.ts index 74bd4a3f7..d79c0c166 100644 --- a/packages/fastify-generators/src/generators/core/fastify/index.ts +++ b/packages/fastify-generators/src/generators/core/fastify/index.ts @@ -12,8 +12,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -61,7 +61,7 @@ export interface FastifyOutputProvider { } export const fastifyOutputProvider = - createOutputProviderType('fastify-output'); + createReadOnlyProviderType('fastify-output'); export const fastifyGenerator = createGenerator({ name: 'core/fastify', diff --git a/packages/fastify-generators/src/generators/core/index.ts b/packages/fastify-generators/src/generators/core/index.ts index b1d9eec5a..851115a6c 100644 --- a/packages/fastify-generators/src/generators/core/index.ts +++ b/packages/fastify-generators/src/generators/core/index.ts @@ -11,7 +11,7 @@ export * from './fastify-scripts/index.js'; export * from './fastify-sentry/index.js'; export * from './fastify-server/index.js'; export * from './fastify/index.js'; -export * from './logger-service/index.js'; +export * from './logger-service/logger-service.generator.js'; export * from './readme/index.js'; export * from './request-context/index.js'; export * from './request-service-context/index.js'; diff --git a/packages/fastify-generators/src/generators/core/logger-service/generated/import-maps.ts b/packages/fastify-generators/src/generators/core/logger-service/generated/import-maps.ts new file mode 100644 index 000000000..9cc43e227 --- /dev/null +++ b/packages/fastify-generators/src/generators/core/logger-service/generated/import-maps.ts @@ -0,0 +1,32 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMapProvider, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; + +const loggerServiceImportMapSchema = createTsImportMapSchema({ + logger: {}, +}); + +type LoggerServiceImportMapProvider = TsImportMapProviderFromSchema< + typeof loggerServiceImportMapSchema +>; + +export const loggerServiceImportsProvider = + createReadOnlyProviderType( + 'logger-service-imports', + ); + +interface LoggerServiceFileMap { + logger: string; +} + +export function createLoggerServiceImportMap( + filePaths: LoggerServiceFileMap, +): LoggerServiceImportMapProvider { + return createTsImportMapProvider(loggerServiceImportMapSchema, { + logger: filePaths.logger, + }); +} diff --git a/packages/fastify-generators/src/generators/core/logger-service/index.ts b/packages/fastify-generators/src/generators/core/logger-service/logger-service.generator.ts similarity index 72% rename from packages/fastify-generators/src/generators/core/logger-service/index.ts rename to packages/fastify-generators/src/generators/core/logger-service/logger-service.generator.ts index 937bb95d2..0ca4c7dcb 100644 --- a/packages/fastify-generators/src/generators/core/logger-service/index.ts +++ b/packages/fastify-generators/src/generators/core/logger-service/logger-service.generator.ts @@ -6,11 +6,11 @@ import type { import { createNodePackagesTask, - createTypescriptFileTask, extractPackageVersions, projectScope, TsCodeUtils, TypescriptCodeUtils, + typescriptFileProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, @@ -18,17 +18,16 @@ import { createNonOverwriteableMap, createProviderType, } from '@halfdomelabs/sync'; -import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { fastifyProvider } from '../fastify/index.js'; +import { + createLoggerServiceImportMap, + loggerServiceImportsProvider, +} from './generated/import-maps.js'; import { loggerFileTemplate } from './generated/templates.js'; -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); - export interface LoggerServiceSetupProvider extends ImportMapper { addMixin(key: string, expression: TsCodeFragment): void; } @@ -56,7 +55,6 @@ export const loggerServiceProvider = createProviderType( export const loggerServiceGenerator = createGenerator({ name: 'core/logger-service', generatorFileUrl: import.meta.url, - descriptorSchema, buildTasks: () => ({ nodePackages: createNodePackagesTask({ prod: extractPackageVersions(FASTIFY_PACKAGES, ['pino']), @@ -65,12 +63,16 @@ export const loggerServiceGenerator = createGenerator({ main: createGeneratorTask({ dependencies: { fastify: fastifyProvider, + typescriptFile: typescriptFileProvider, }, exports: { loggerServiceSetup: loggerServiceSetupProvider.export(projectScope), loggerService: loggerServiceProvider.export(projectScope), }, - run({ fastify }) { + outputs: { + loggerServiceImports: loggerServiceImportsProvider.export(projectScope), + }, + run({ fastify, typescriptFile }) { const mixins = createNonOverwriteableMap< Record >({}, { name: 'logger-service-mixins' }); @@ -104,7 +106,7 @@ export const loggerServiceGenerator = createGenerator({ }, }, }, - build: (builder) => { + build: async (builder) => { const loggerOptions: Record = {}; // log level vs. number for better log parsing @@ -121,28 +123,36 @@ export const loggerServiceGenerator = createGenerator({ }`; } - builder.addDynamicTask( - 'logger-service-file', - createTypescriptFileTask({ - template: loggerFileTemplate, - variables: { - TPL_LOGGER_OPTIONS: - Object.keys(loggerOptions).length > 0 - ? TsCodeUtils.mergeFragmentsAsObject(loggerOptions) - : '', - }, - destination: 'src/services/logger.ts', - fileId: 'logger', - options: { - alternateFullIds: [ - '@halfdomelabs/fastify-generators#core/logger-service:src/services/logger.ts', - ], - }, - }), - ); + const fileMap = { + logger: 'src/services/logger.ts', + }; + + await typescriptFile.writeTemplatedFile(builder, { + template: loggerFileTemplate, + id: 'logger', + variables: { + TPL_LOGGER_OPTIONS: + Object.keys(loggerOptions).length > 0 + ? TsCodeUtils.mergeFragmentsAsObject(loggerOptions) + : '', + }, + destination: fileMap.logger, + importMapProviders: {}, + options: { + alternateFullIds: [ + '@halfdomelabs/fastify-generators#core/logger-service:src/services/logger.ts', + ], + }, + }); + + return { + loggerServiceImports: createLoggerServiceImportMap(fileMap), + }; }, }; }, }), }), }); + +export { loggerServiceImportsProvider } from './generated/import-maps.js'; diff --git a/packages/fastify-generators/src/generators/core/request-context/index.ts b/packages/fastify-generators/src/generators/core/request-context/index.ts index b383bba8b..cecf61ea2 100644 --- a/packages/fastify-generators/src/generators/core/request-context/index.ts +++ b/packages/fastify-generators/src/generators/core/request-context/index.ts @@ -21,7 +21,7 @@ import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { fastifyServerProvider } from '../fastify-server/index.js'; -import { loggerServiceSetupProvider } from '../logger-service/index.js'; +import { loggerServiceSetupProvider } from '../logger-service/logger-service.generator.js'; const descriptorSchema = z.object({}); 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 c2ded0e43..155d192fa 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 @@ -14,8 +14,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { mapValues } from 'es-toolkit'; import { z } from 'zod'; @@ -55,7 +55,7 @@ export interface RequestServiceContextProvider extends ImportMapper { } export const requestServiceContextProvider = - createOutputProviderType( + createReadOnlyProviderType( 'request-service-context', ); 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 4136a7dab..0865424d0 100644 --- a/packages/fastify-generators/src/generators/core/service-context/index.ts +++ b/packages/fastify-generators/src/generators/core/service-context/index.ts @@ -13,8 +13,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { mapValues } from 'es-toolkit'; import { z } from 'zod'; @@ -47,7 +47,7 @@ export interface ServiceContextProvider extends ImportMapper { } export const serviceContextProvider = - createOutputProviderType('service-context'); + createReadOnlyProviderType('service-context'); export const serviceContextGenerator = createGenerator({ name: 'core/service-context', 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 8adcd2c49..a4e213df4 100644 --- a/packages/fastify-generators/src/generators/core/service-file/index.ts +++ b/packages/fastify-generators/src/generators/core/service-file/index.ts @@ -10,8 +10,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { kebabCase } from 'change-case'; import path from 'node:path'; @@ -49,7 +49,7 @@ export interface ServiceFileOutputProvider { } export const serviceFileOutputProvider = - createOutputProviderType('service-file-output'); + createReadOnlyProviderType('service-file-output'); export const serviceFileGenerator = createGenerator({ name: 'core/service-file', diff --git a/packages/fastify-generators/src/generators/email/fastify-sendgrid/index.ts b/packages/fastify-generators/src/generators/email/fastify-sendgrid/index.ts index 342d61be4..0c09615f2 100644 --- a/packages/fastify-generators/src/generators/email/fastify-sendgrid/index.ts +++ b/packages/fastify-generators/src/generators/email/fastify-sendgrid/index.ts @@ -13,7 +13,7 @@ import { z } from 'zod'; import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { configServiceProvider } from '@src/generators/core/config-service/index.js'; -import { loggerServiceProvider } from '@src/generators/core/logger-service/index.js'; +import { loggerServiceProvider } from '@src/generators/core/logger-service/logger-service.generator.js'; const descriptorSchema = z.object({}); diff --git a/packages/fastify-generators/src/generators/pothos/pothos/index.ts b/packages/fastify-generators/src/generators/pothos/pothos/index.ts index fa3996c4d..3ddbd2eef 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/index.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/index.ts @@ -21,8 +21,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, POST_WRITE_COMMAND_PRIORITY, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -51,7 +51,7 @@ export interface PothosSetupProvider extends ImportMapper { export const pothosSetupProvider = createProviderType('pothos-setup'); -const pothosSetupOutputProvider = createOutputProviderType<{ +const pothosSetupOutputProvider = createReadOnlyProviderType<{ config: NonOverwriteableMap; schemaFiles: string[]; pothosTypes: PothosTypeReferenceContainer; @@ -65,7 +65,7 @@ export interface PothosSchemaProvider extends ImportMapper { export const pothosSchemaProvider = createProviderType('pothos-schema'); -const pothosSchemaOutputProvider = createOutputProviderType<{ +const pothosSchemaOutputProvider = createReadOnlyProviderType<{ schemaFiles: string[]; }>('pothos-schema-output'); 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 92416ab34..8c887e707 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 @@ -3,8 +3,8 @@ import { createGenerator, createGeneratorTask, createNonOverwriteableMap, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -29,7 +29,7 @@ export interface PrismaCrudServiceProvider { } export const prismaCrudServiceProvider = - createOutputProviderType('prisma-crud-service'); + createReadOnlyProviderType('prisma-crud-service'); export const prismaCrudServiceGenerator = createGenerator({ name: 'prisma/prisma-crud-service', diff --git a/packages/fastify-generators/src/generators/prisma/prisma/index.ts b/packages/fastify-generators/src/generators/prisma/prisma/index.ts index bd8a5b73c..f3dcbac99 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma/index.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma/index.ts @@ -15,8 +15,8 @@ import { import { createGenerator, createGeneratorTask, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, POST_WRITE_COMMAND_PRIORITY, } from '@halfdomelabs/sync'; import { createRequire } from 'node:module'; @@ -64,7 +64,7 @@ export interface PrismaOutputProvider extends ImportMapper { } export const prismaOutputProvider = - createOutputProviderType('prisma-output'); + createReadOnlyProviderType('prisma-output'); export type PrismaCrudServiceTypesProvider = ImportMapper; diff --git a/packages/fastify-generators/src/generators/stripe/fastify-stripe/index.ts b/packages/fastify-generators/src/generators/stripe/fastify-stripe/index.ts index 4ce672edc..16d28016a 100644 --- a/packages/fastify-generators/src/generators/stripe/fastify-stripe/index.ts +++ b/packages/fastify-generators/src/generators/stripe/fastify-stripe/index.ts @@ -16,7 +16,7 @@ import { FASTIFY_PACKAGES } from '@src/constants/fastify-packages.js'; import { configServiceProvider } from '@src/generators/core/config-service/index.js'; import { errorHandlerServiceProvider } from '@src/generators/core/error-handler-service/index.js'; import { fastifyServerProvider } from '@src/generators/core/fastify-server/index.js'; -import { loggerServiceProvider } from '@src/generators/core/logger-service/index.js'; +import { loggerServiceProvider } from '@src/generators/core/logger-service/logger-service.generator.js'; const descriptorSchema = z.object({ placeholder: z.string().optional(), 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 a9c7f9c99..b51a06d91 100644 --- a/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts +++ b/packages/fastify-generators/src/generators/yoga/yoga-plugin/index.ts @@ -17,8 +17,8 @@ import { import { createGenerator, createGeneratorTask, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { createFieldMap, @@ -32,7 +32,7 @@ import { configServiceProvider } from '@src/generators/core/config-service/index import { errorHandlerServiceProvider } from '@src/generators/core/error-handler-service/index.js'; import { fastifyRedisProvider } from '@src/generators/core/fastify-redis/index.js'; import { fastifyServerProvider } from '@src/generators/core/fastify-server/index.js'; -import { loggerServiceProvider } from '@src/generators/core/logger-service/index.js'; +import { loggerServiceProvider } from '@src/generators/core/logger-service/logger-service.generator.js'; import { requestServiceContextProvider } from '@src/generators/core/request-service-context/index.js'; const descriptorSchema = z.object({ @@ -75,7 +75,7 @@ export const yogaPluginConfigProvider = createProviderType('yoga-plugin-config'); export const yogaPluginSetupProvider = - createOutputProviderType< + createReadOnlyProviderType< FieldMapValues> >(`yoga-plugin-setup`); 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 ddff6dba5..195bfe3c6 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 @@ -9,8 +9,8 @@ import { import { createGenerator, createGeneratorTask, - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -129,7 +129,7 @@ function getComponentProps({ }); } -const adminCrudEmbeddedFormSetupProvider = createOutputProviderType<{ +const adminCrudEmbeddedFormSetupProvider = createReadOnlyProviderType<{ inputFields: AdminCrudInput[]; tableColumns: AdminCrudColumn[]; }>('admin-crud-embedded-form-setup'); diff --git a/packages/sync/src/providers/providers.ts b/packages/sync/src/providers/providers.ts index 40e411ae1..de4610f1c 100644 --- a/packages/sync/src/providers/providers.ts +++ b/packages/sync/src/providers/providers.ts @@ -24,16 +24,11 @@ export interface ProviderType

{ */ readonly name: string; /** - * Whether the provider is read-only or not such that it cannot modify any state in the generator task + * Whether the provider is read-only or not such that it cannot modify any state in the generator task. * - * This allows the sync engine to optimize the dependency graph by not including dependencies - * between the build step of dependent task and the build step of the export task. + * Only read-only providers can be used as build outputs. */ 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 */ @@ -62,16 +57,6 @@ export interface ProviderDependencyOptions { * 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; } /** @@ -80,6 +65,7 @@ export interface ProviderDependencyOptions { export interface ProviderDependency

{ readonly type: 'dependency'; readonly name: string; + readonly isReadOnly: boolean; readonly options: ProviderDependencyOptions; /** * Creates an optional dependency @@ -115,7 +101,7 @@ export interface ProviderDependency

{ export interface ProviderExport

{ readonly type: 'export'; readonly name: string; - readonly isOutput: boolean; + readonly isReadOnly: boolean; /** * The scope/name pairs that the provider will be available in */ @@ -139,17 +125,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 * - * This allows the sync engine to optimize the dependency graph by not including dependencies - * between the build step of dependent task and the build step of the export task. + * Only read-only providers can be used as build outputs. */ isReadOnly?: boolean; } @@ -174,16 +154,13 @@ export function createProviderType( return { type: 'type', name, - isReadOnly: options?.isReadOnly, - isOutput: options?.isOutput, + isReadOnly: options?.isReadOnly ?? false, dependency() { return { ...this, type: 'dependency', - options: { - isReadOnly: options?.isReadOnly, - isOutput: options?.isOutput, - }, + isReadOnly: options?.isReadOnly ?? false, + options: {}, optional() { return toMerged(this, { options: { optional: true } }); }, @@ -213,7 +190,7 @@ export function createProviderType( return { ...this, type: 'export', - isOutput: options?.isOutput ?? false, + isReadOnly: options?.isReadOnly ?? false, exports: [{ scope, exportName }], andExport(scope, exportName) { return toMerged(this, { @@ -226,15 +203,15 @@ export function createProviderType( } /** - * Creates an output provider type + * Creates a read-only 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( +export function createReadOnlyProviderType( name: string, - options?: Omit, + options?: Omit, ): ProviderType { - return createProviderType(name, { ...options, isOutput: true }); + return createProviderType(name, { ...options, isReadOnly: true }); } diff --git a/packages/sync/src/runner/dependency-map.ts b/packages/sync/src/runner/dependency-map.ts index 32537d257..1a48d288a 100644 --- a/packages/sync/src/runner/dependency-map.ts +++ b/packages/sync/src/runner/dependency-map.ts @@ -7,7 +7,7 @@ import type { GeneratorEntry, GeneratorTaskEntry, } from '../generators/index.js'; -import type { ProviderDependencyOptions } from '../providers/index.js'; +import type { ProviderExport } from '../providers/index.js'; type GeneratorIdToScopesMap = Partial< Record< @@ -17,7 +17,7 @@ type GeneratorIdToScopesMap = Partial< scopes: string[]; // providers within the scopes // key is JSON.encode([providerName(, exportName)]) - providers: Map; + providers: Map; } > >; @@ -26,6 +26,18 @@ function makeProviderId(providerName: string, exportName?: string): string { return JSON.stringify([providerName, exportName]); } +export interface EntryDependencyRecord { + id: string; + providerName: string; + isReadOnly: boolean; + isOutput: boolean; +} + +export type EntryDependencyMap = Record< + string, + Record +>; + function buildGeneratorIdToScopesMapRecursive( entry: GeneratorEntry, parentTaskIds: string[], @@ -43,27 +55,20 @@ function buildGeneratorIdToScopesMapRecursive( const { task } = taskEntry; const taskExports = Object.values(task.exports ?? {}); 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 ${taskEntry.id}: ${invalidTaskExports - .map((taskExport) => taskExport.name) - .join(', ')}`, - ); - } const invalidTaskOutputs = taskOutputs.filter( - (taskOutput) => !taskOutput.isOutput, + (taskOutput) => !taskOutput.isReadOnly, ); if (invalidTaskOutputs.length > 0) { throw new Error( - `All providers in task outputs must be output providers in ${taskEntry.id}: ${invalidTaskOutputs + `All providers in task outputs must be read-only providers in ${taskEntry.id}: ${invalidTaskOutputs .map((output) => output.name) .join(', ')}`, ); } - for (const taskExport of [...taskExports, ...taskOutputs]) { + function addTaskExport( + taskExport: ProviderExport, + isOutput: boolean, + ): void { const { exports } = taskExport; for (const { scope, exportName } of exports) { @@ -86,18 +91,27 @@ function buildGeneratorIdToScopesMapRecursive( const { providers } = generatorEntry; const providerId = makeProviderId(taskExport.name, exportName); - const existingProviderId = providers.get(providerId); + const existingProvider = providers.get(providerId); - if (existingProviderId) { + if (existingProvider) { throw new Error( - `Duplicate scoped provider export detected between ${entry.id} and ${existingProviderId} ` + + `Duplicate scoped provider export detected between ${entry.id} and ${existingProvider.taskId} ` + `in scope (${scope?.name ?? 'default'}) at ${parentTaskId} for provider ${taskExport.name}. ` + `Please make sure that the provider export names are unique within the scope (and any other scopes at that level).`, ); } - providers.set(providerId, taskEntry.id); + providers.set(providerId, { + taskId: taskEntry.id, + isOutput, + }); } } + for (const taskExport of taskExports) { + addTaskExport(taskExport, false); + } + for (const taskOutput of taskOutputs) { + addTaskExport(taskOutput, true); + } } for (const child of entry.children) { @@ -136,9 +150,8 @@ function buildTaskDependencyMap( ): Record { return mapValues(entry.task.dependencies ?? {}, (dep) => { const normalizedDep = dep.type === 'type' ? dep.dependency() : dep; - const provider = normalizedDep.name; - const { optional, exportName, isReadOnly, isOutput, useParentScope } = - normalizedDep.options; + const { name: provider, isReadOnly } = normalizedDep; + const { optional, exportName, useParentScope } = normalizedDep.options; // if the export name is empty and the dependency is optional, we can skip it if (exportName === '' && optional) { @@ -154,11 +167,11 @@ function buildTaskDependencyMap( generatorIdToScopesMap[id]?.providers.has(providerId), ); - const resolvedTaskId = + const resolvedTask = parentEntryId && generatorIdToScopesMap[parentEntryId]?.providers.get(providerId); - if (!resolvedTaskId) { + if (!resolvedTask) { if (!optional || exportName) { throw new Error( `Could not resolve dependency ${provider}${exportName ? ` (${exportName})` : ''} for ${entry.id} (generator ${entry.generatorInfo.name})`, @@ -167,6 +180,8 @@ function buildTaskDependencyMap( return; } + const resolvedTaskId = resolvedTask.taskId; + if (resolvedTaskId === entry.id) { throw new Error( `Circular dependency detected for ${provider}${exportName ? ` (${exportName})` : ''} for ${entry.id} (generator ${entry.generatorInfo.name}). @@ -177,25 +192,12 @@ function buildTaskDependencyMap( return { id: resolvedTaskId, providerName: provider, - options: { - isReadOnly: isReadOnly ? true : undefined, - isOutput: isOutput ? true : undefined, - }, + isOutput: resolvedTask.isOutput, + isReadOnly, }; }); } -interface EntryDependencyRecord { - id: string; - providerName: string; - options?: Pick; -} - -export type EntryDependencyMap = Record< - string, - Record ->; - /** * Builds a map of task entry ID to resolved providers for that entry recursively from the generator root entry * diff --git a/packages/sync/src/runner/dependency-map.unit.test.ts b/packages/sync/src/runner/dependency-map.unit.test.ts index 33673ac99..913171a6a 100644 --- a/packages/sync/src/runner/dependency-map.unit.test.ts +++ b/packages/sync/src/runner/dependency-map.unit.test.ts @@ -14,11 +14,17 @@ import type { EntryDependencyMap } from './dependency-map.js'; import { createProviderExportScope, createProviderType, + createReadOnlyProviderType, } from '../providers/index.js'; import { buildGeneratorIdToScopesMap, resolveTaskDependenciesForPhase, } from './dependency-map.js'; +import { + createDependencyEntry, + createOutputDependencyEntry, + createReadOnlyDependencyEntry, +} from './tests/dependency-entry.test-helper.js'; import { buildTestGeneratorEntry, buildTestGeneratorTaskEntry, @@ -26,12 +32,7 @@ import { 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 readOnlyProvider = createReadOnlyProviderType('read-only-provider'); const testLogger = createEventedLogger({ noConsole: true }); // Create test scopes @@ -108,11 +109,10 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'child#main': { - dep: { + dep: createReadOnlyDependencyEntry({ id: 'root#main', providerName: readOnlyProvider.name, - options: { isReadOnly: true }, - }, + }), }, }); }); @@ -191,18 +191,16 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'child1#main': { - dep: { + dep: createDependencyEntry({ id: 'root#main', providerName: providerOne.name, - options: {}, - }, + }), }, 'child2#main': { - dep: { + dep: createDependencyEntry({ id: 'root#main', providerName: providerTwo.name, - options: {}, - }, + }), }, }); }); @@ -248,18 +246,16 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'child1#main': { - dep: { + dep: createDependencyEntry({ id: 'root#main', providerName: providerOne.name, - options: {}, - }, + }), }, 'child2#main': { - dep: { + dep: createDependencyEntry({ id: 'root#main', providerName: providerOne.name, - options: {}, - }, + }), }, }); }); @@ -314,11 +310,10 @@ describe('resolveTaskDependenciesForPhase', () => { 'root#main': {}, 'middle#main': {}, 'leaf#main': { - dep1: { + dep1: createDependencyEntry({ id: 'middle#main', providerName: providerOne.name, - options: {}, - }, + }), }, }); }); @@ -368,11 +363,10 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'child#main': { - dep: { + dep: createDependencyEntry({ id: 'peer#main', providerName: providerOne.name, - options: {}, - }, + }), }, 'peer#main': {}, }); @@ -464,10 +458,16 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'middle#main': { - dep: { id: 'root#main', providerName: providerOne.name, options: {} }, + dep: createDependencyEntry({ + id: 'root#main', + providerName: providerOne.name, + }), }, 'leaf#main': { - dep: { id: 'middle#main', providerName: providerOne.name, options: {} }, + dep: createDependencyEntry({ + id: 'middle#main', + providerName: providerOne.name, + }), }, }); }); @@ -518,11 +518,10 @@ describe('resolveTaskDependenciesForPhase', () => { 'root#main': {}, 'middle#main': {}, 'leaf#main': { - dep: { + dep: createDependencyEntry({ id: 'middle#main', providerName: providerOne.name, - options: {}, - }, + }), }, }); }); @@ -536,7 +535,7 @@ describe('resolveTaskDependenciesForPhase', () => { }, { outputs: { - outputProvider: outputOnlyProvider.export(defaultScope), + outputProvider: readOnlyProvider.export(defaultScope), }, }, ); @@ -547,7 +546,7 @@ describe('resolveTaskDependenciesForPhase', () => { scopes: [defaultScope], }, { - dependencies: { dep: outputOnlyProvider.dependency() }, + dependencies: { dep: readOnlyProvider.dependency() }, }, ); @@ -560,11 +559,10 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#main': {}, 'child#main': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#main', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); @@ -578,14 +576,14 @@ describe('resolveTaskDependenciesForPhase', () => { id: 'root#producer', task: { outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), buildTestGeneratorTaskEntry({ id: 'root#consumer', task: { - dependencies: { dep: outputOnlyProvider.dependency() }, + dependencies: { dep: readOnlyProvider.dependency() }, exports: {}, outputs: {}, }, @@ -599,16 +597,15 @@ describe('resolveTaskDependenciesForPhase', () => { expect(dependencyMap).toEqual({ 'root#producer': {}, 'root#consumer': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#producer', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); - it('should throw error when non-output provider is used in task outputs', () => { + it('should throw error when a mutable provider is used in task outputs', () => { // Arrange const entry = buildTestGeneratorEntry( { @@ -625,27 +622,7 @@ describe('resolveTaskDependenciesForPhase', () => { // 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/, + /All providers in task outputs must be read-only providers/, ); }); @@ -659,7 +636,7 @@ describe('resolveTaskDependenciesForPhase', () => { task: { phase: phase1, outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -667,7 +644,7 @@ describe('resolveTaskDependenciesForPhase', () => { id: 'root#phase2', task: { phase: phase2, - dependencies: { dep: outputOnlyProvider.dependency() }, + dependencies: { dep: readOnlyProvider.dependency() }, }, }), ], @@ -692,11 +669,10 @@ describe('resolveTaskDependenciesForPhase', () => { expect(phase2DependencyMap).toEqual({ 'root#phase2': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#phase1', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); @@ -749,16 +725,14 @@ describe('resolveTaskDependenciesForPhase', () => { expect(phase2DependencyMap).toEqual({ 'root#phase2': { - dep1: { + dep1: createDependencyEntry({ id: 'root#phase1', providerName: providerOne.name, - options: {}, - }, - dep2: { + }), + dep2: createDependencyEntry({ id: 'root#phase1', providerName: providerTwo.name, - options: {}, - }, + }), }, }); }); @@ -773,7 +747,7 @@ describe('resolveTaskDependenciesForPhase', () => { task: { phase: phase1, outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -788,10 +762,10 @@ describe('resolveTaskDependenciesForPhase', () => { task: { phase: phase1, dependencies: { - dep: outputOnlyProvider.dependency().parentScopeOnly(), + dep: readOnlyProvider.dependency().parentScopeOnly(), }, outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -805,7 +779,7 @@ describe('resolveTaskDependenciesForPhase', () => { id: 'leaf#phase2', task: { phase: phase2, - dependencies: { dep: outputOnlyProvider.dependency() }, + dependencies: { dep: readOnlyProvider.dependency() }, }, }), ], @@ -830,21 +804,19 @@ describe('resolveTaskDependenciesForPhase', () => { expect(phase1DependencyMap).toEqual({ 'root#phase1': {}, 'middle#phase1': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#phase1', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); expect(phase2DependencyMap).toEqual({ 'leaf#phase2': { - dep: { + dep: createOutputDependencyEntry({ id: 'middle#phase1', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); @@ -859,7 +831,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'main', task: { outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -873,7 +845,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'dynamic-task', task: { phase: phase1, - dependencies: { dep: outputOnlyProvider }, + dependencies: { dep: readOnlyProvider }, }, }), ]); @@ -889,11 +861,10 @@ describe('resolveTaskDependenciesForPhase', () => { // Assert expect(phase1DependencyMap).toEqual({ 'root#dynamic-task': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#main', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); @@ -908,7 +879,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'main', task: { outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -922,7 +893,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'dynamic-task1', task: { phase: phase1, - dependencies: { dep: outputOnlyProvider }, + dependencies: { dep: readOnlyProvider }, }, }), buildTestGeneratorTaskEntry({ @@ -930,7 +901,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'dynamic-task2', task: { phase: phase2, - dependencies: { dep: outputOnlyProvider }, + dependencies: { dep: readOnlyProvider }, }, }), ]); @@ -952,21 +923,19 @@ describe('resolveTaskDependenciesForPhase', () => { // Assert expect(phase1DependencyMap).toEqual({ 'root#dynamic-task1': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#main', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); expect(phase2DependencyMap).toEqual({ 'root#dynamic-task2': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#main', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); @@ -981,7 +950,7 @@ describe('resolveTaskDependenciesForPhase', () => { name: 'main', task: { outputs: { - outputProvider: outputOnlyProvider.export(), + outputProvider: readOnlyProvider.export(), }, }, }), @@ -1003,7 +972,7 @@ describe('resolveTaskDependenciesForPhase', () => { task: { phase: phase1, dependencies: { - dep: outputOnlyProvider.dependency().parentScopeOnly(), + dep: readOnlyProvider.dependency().parentScopeOnly(), }, }, }), @@ -1020,11 +989,10 @@ describe('resolveTaskDependenciesForPhase', () => { // Assert expect(phase1DependencyMap).toEqual({ 'child#dynamic-task': { - dep: { + dep: createOutputDependencyEntry({ id: 'root#main', - providerName: outputOnlyProvider.name, - options: { isOutput: true }, - }, + providerName: readOnlyProvider.name, + }), }, }); }); diff --git a/packages/sync/src/runner/dependency-sort.ts b/packages/sync/src/runner/dependency-sort.ts index 2ee5adc02..c08ba549c 100644 --- a/packages/sync/src/runner/dependency-sort.ts +++ b/packages/sync/src/runner/dependency-sort.ts @@ -1,4 +1,4 @@ -import toposort from 'toposort'; +import { toposort } from '@halfdomelabs/utils'; import type { GeneratorOutputMetadata } from '@src/output/generator-task-output.js'; @@ -50,8 +50,8 @@ export function getSortedRunSteps( providerTaskId: dependent.id, consumerTaskId: entry.id, providerName: dependent.providerName, - isOutput: dependent.options?.isOutput ?? false, - isReadOnly: dependent.options?.isReadOnly ?? false, + isOutput: dependent.isOutput, + isReadOnly: dependent.isReadOnly, }); // if the dependent task is not in the entries, we don't need to add a dependency @@ -63,14 +63,14 @@ export function getSortedRunSteps( // 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) { + if (dependent.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 - ...(dependent.options?.isReadOnly + ...(dependent.isReadOnly ? [] : [[entryBuild, dependentBuild] as [string, string]]), ]; @@ -81,7 +81,7 @@ export function getSortedRunSteps( const fullSteps = entries.flatMap(({ id }) => [`init|${id}`, `build|${id}`]); const fullEdges = dependencyGraph; - const result = toposort.array(fullSteps, fullEdges); + const result = toposort(fullSteps, fullEdges); return { steps: result, diff --git a/packages/sync/src/runner/dependency-sort.unit.test.ts b/packages/sync/src/runner/dependency-sort.unit.test.ts index 70e5c9e68..d012709f2 100644 --- a/packages/sync/src/runner/dependency-sort.unit.test.ts +++ b/packages/sync/src/runner/dependency-sort.unit.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '../providers/index.js'; import { getSortedRunSteps } from './dependency-sort.js'; +import { + createDependencyEntry, + createOutputDependencyEntry, + createReadOnlyDependencyEntry, +} from './tests/dependency-entry.test-helper.js'; import { buildTestGeneratorTaskEntry } from './tests/factories.test-helper.js'; describe('getSortedRunSteps', () => { @@ -21,13 +26,20 @@ describe('getSortedRunSteps', () => { ]; const dependencyGraphOne = { entryOne: {}, - entryTwo: { dep: { id: 'entryOne', providerName: 'dep', options: {} } }, - entryThree: { dep: { id: 'entryTwo', providerName: 'dep', options: {} } }, + entryTwo: { + dep: createDependencyEntry({ id: 'entryOne', providerName: 'dep' }), + }, + entryThree: { + dep: createDependencyEntry({ id: 'entryTwo', providerName: 'dep' }), + }, }; - const dependencyGraphTwo = { - entryOne: { dep: { id: 'entryTwo', providerName: 'dep', options: {} } }, - entryTwo: { dep: { id: 'entryThree', providerName: 'dep', options: {} } }, + entryOne: { + dep: createDependencyEntry({ id: 'entryTwo', providerName: 'dep' }), + }, + entryTwo: { + dep: createDependencyEntry({ id: 'entryThree', providerName: 'dep' }), + }, entryThree: {}, }; @@ -56,7 +68,7 @@ describe('getSortedRunSteps', () => { describe('with provider dependencies', () => { const providerOne = createProviderType('provider-one'); const providerTwo = createProviderType('provider-two'); - const outputProvider = createOutputProviderType('output-provider'); + const outputProvider = createReadOnlyProviderType('output-provider'); const readOnlyProvider = createProviderType('readonly-provider', { isReadOnly: true, }); @@ -80,11 +92,10 @@ describe('getSortedRunSteps', () => { const dependencyMap = { producer: {}, consumer: { - dep: { + dep: createOutputDependencyEntry({ id: 'producer', providerName: 'dep', - options: { isOutput: true }, - }, + }), }, }; @@ -126,16 +137,14 @@ describe('getSortedRunSteps', () => { outputProducer: {}, normalProducer: {}, consumer: { - outDep: { + outDep: createOutputDependencyEntry({ id: 'outputProducer', providerName: 'outDep', - options: { isOutput: true }, - }, - normalDep: { + }), + normalDep: createDependencyEntry({ id: 'normalProducer', providerName: 'normalDep', - options: {}, - }, + }), }, }; @@ -169,11 +178,10 @@ describe('getSortedRunSteps', () => { const dependencyMap = { producer: {}, consumer: { - dep: { + dep: createReadOnlyDependencyEntry({ id: 'producer', providerName: 'dep', - options: { isReadOnly: true }, - }, + }), }, }; @@ -221,23 +229,20 @@ describe('getSortedRunSteps', () => { const dependencyMap = { outputProducer: {}, middleConsumer: { - outDep: { + outDep: createOutputDependencyEntry({ id: 'outputProducer', providerName: 'outDep', - options: { isOutput: true }, - }, + }), }, finalConsumer: { - normalDep: { + normalDep: createDependencyEntry({ id: 'middleConsumer', providerName: 'normalDep', - options: {}, - }, - readonlyDep: { + }), + readonlyDep: createReadOnlyDependencyEntry({ id: 'readonlyProducer', providerName: 'readonlyDep', - options: { isReadOnly: true }, - }, + }), }, readonlyProducer: {}, }; diff --git a/packages/sync/src/runner/generator-runner.ts b/packages/sync/src/runner/generator-runner.ts index 94658e68e..1a59f0084 100644 --- a/packages/sync/src/runner/generator-runner.ts +++ b/packages/sync/src/runner/generator-runner.ts @@ -92,18 +92,19 @@ export async function executeGeneratorEntry( dependencyId === undefined ? undefined : providerMapById[dependencyId][dependency.name]; - const { optional, isOutput } = + const { isReadOnly } = dependency; + const { optional } = dependency.type === 'dependency' ? dependency.options - : { optional: false, isOutput: dependency.isOutput }; + : { optional: false }; // check dependency comes from a previous phase if (phase !== undefined && dependencyId) { const dependencyTask = taskEntriesById[dependencyId]; if (dependencyTask.task.phase !== phase) { - if (!isOutput) { + if (!isReadOnly) { throw new Error( - `Dependency ${key} in ${taskId} cannot come from a previous phase since it is not an output`, + `Dependency ${key} in ${taskId} cannot come from a previous phase since it is not read-only`, ); } if ( diff --git a/packages/sync/src/runner/generator-runner.unit.test.ts b/packages/sync/src/runner/generator-runner.unit.test.ts index 4bd691494..0c173749c 100644 --- a/packages/sync/src/runner/generator-runner.unit.test.ts +++ b/packages/sync/src/runner/generator-runner.unit.test.ts @@ -14,8 +14,8 @@ import type { import type { Provider } from '../providers/index.js'; import { - createOutputProviderType, createProviderType, + createReadOnlyProviderType, } from '../providers/index.js'; import { executeGeneratorEntry } from './generator-runner.js'; import { @@ -218,7 +218,7 @@ describe('executeGeneratorEntry', () => { }); it('handles output providers correctly', async () => { - const outputProviderType = createOutputProviderType<{ + const outputProviderType = createReadOnlyProviderType<{ generate: () => void; }>('output-provider'); const outputProvider = { generate: vi.fn() }; @@ -337,11 +337,11 @@ describe('executeGeneratorEntry', () => { }); it('handles phased task execution correctly', async () => { - const mainOutputProviderType = createOutputProviderType<{ + const mainOutputProviderType = createReadOnlyProviderType<{ generate: () => void; }>('main-output-provider'); const mainOutputProvider = { generate: vi.fn() }; - const phase1OutputProviderType = createOutputProviderType<{ + const phase1OutputProviderType = createReadOnlyProviderType<{ generate: () => void; }>('phase1-output-provider'); const phase1OutputProvider = { generate: vi.fn() }; @@ -458,7 +458,7 @@ describe('executeGeneratorEntry', () => { }); await expect(executeGeneratorEntry(entry, logger)).rejects.toThrow( - /Dependency dep in root#phase2 cannot come from a previous phase since it is not an output/, + /Dependency dep in root#phase2 cannot come from a previous phase since it is not read-only/, ); }); @@ -617,7 +617,7 @@ describe('executeGeneratorEntry', () => { }); it('handles dynamic tasks with dependencies correctly', async () => { - const outputProviderType = createOutputProviderType<{ + const outputProviderType = createReadOnlyProviderType<{ generate: () => void; }>('output-provider'); const outputProvider = { generate: vi.fn() }; diff --git a/packages/sync/src/runner/tests/dependency-entry.test-helper.ts b/packages/sync/src/runner/tests/dependency-entry.test-helper.ts new file mode 100644 index 000000000..4dc6c1e90 --- /dev/null +++ b/packages/sync/src/runner/tests/dependency-entry.test-helper.ts @@ -0,0 +1,45 @@ +import type { EntryDependencyRecord } from '../dependency-map.js'; + +export function createDependencyEntry({ + id, + providerName, + isOutput, + isReadOnly, +}: { + id: string; + providerName: string; + isOutput?: boolean; + isReadOnly?: boolean; +}): EntryDependencyRecord { + return { + id, + providerName, + isOutput: isOutput ?? false, + isReadOnly: isReadOnly ?? false, + }; +} + +export function createReadOnlyDependencyEntry({ + id, + providerName, +}: { + id: string; + providerName: string; +}): EntryDependencyRecord { + return createDependencyEntry({ id, providerName, isReadOnly: true }); +} + +export function createOutputDependencyEntry({ + id, + providerName, +}: { + id: string; + providerName: string; +}): EntryDependencyRecord { + return createDependencyEntry({ + id, + providerName, + isOutput: true, + isReadOnly: true, + }); +} diff --git a/packages/sync/src/utils/create-config-provider-task-with-info.ts b/packages/sync/src/utils/create-config-provider-task-with-info.ts index 6d06f42f3..bf330025b 100644 --- a/packages/sync/src/utils/create-config-provider-task-with-info.ts +++ b/packages/sync/src/utils/create-config-provider-task-with-info.ts @@ -10,8 +10,8 @@ import type { ProviderExportScope } from '@src/providers/export-scopes.js'; import { createGeneratorTask } from '@src/generators/generators.js'; import { - createOutputProviderType, createProviderType, + createReadOnlyProviderType, type ProviderType, } from '@src/providers/providers.js'; @@ -108,7 +108,7 @@ export function createConfigProviderTaskWithInfo< const configProvider = createProviderType( `${prefix}-${taskName}-config`, ); - const configValuesProvider = createOutputProviderType< + const configValuesProvider = createReadOnlyProviderType< FieldMapValues & InfoFromDescriptor >(`${prefix}-${taskName}-config-values`); diff --git a/packages/sync/src/utils/create-config-provider-task.ts b/packages/sync/src/utils/create-config-provider-task.ts index b062919af..a61e62dd4 100644 --- a/packages/sync/src/utils/create-config-provider-task.ts +++ b/packages/sync/src/utils/create-config-provider-task.ts @@ -14,8 +14,8 @@ import { type GeneratorTask, } from '@src/generators/generators.js'; import { - createOutputProviderType, createProviderType, + createReadOnlyProviderType, type ProviderType, } from '@src/providers/providers.js'; @@ -97,7 +97,7 @@ export function createConfigProviderTask( const configProvider = createProviderType( `${prefix}-${taskName}-config`, ); - const configValuesProvider = createOutputProviderType< + const configValuesProvider = createReadOnlyProviderType< FieldMapValues >(`${prefix}-${taskName}-config-values`); diff --git a/packages/tools/tsconfig.node.base.json b/packages/tools/tsconfig.node.base.json index f8f99a365..f5786b960 100644 --- a/packages/tools/tsconfig.node.base.json +++ b/packages/tools/tsconfig.node.base.json @@ -1,5 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/node22/tsconfig.json", - "display": "Node.JS Base Config" + "display": "Node.JS Base Config", + "watchOptions": { + "watchFile": "usefseventsonparentdirectory" + } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 211da7ce1..ff9a9eb03 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,3 +3,4 @@ export * from './events/index.js'; export * from './field-map/index.js'; export * from './maps/index.js'; export * from './objects/index.js'; +export * from './toposort/index.js'; diff --git a/packages/utils/src/toposort/index.ts b/packages/utils/src/toposort/index.ts new file mode 100644 index 000000000..7537629c5 --- /dev/null +++ b/packages/utils/src/toposort/index.ts @@ -0,0 +1 @@ +export * from './toposort.js'; diff --git a/packages/utils/src/toposort/toposort.ts b/packages/utils/src/toposort/toposort.ts new file mode 100644 index 000000000..c28df5929 --- /dev/null +++ b/packages/utils/src/toposort/toposort.ts @@ -0,0 +1,104 @@ +export class ToposortCyclicalDependencyError extends Error { + public cyclePath: unknown[]; + constructor(nodes: unknown[]) { + super( + `Cyclical dependency detected: ${nodes.map((n) => JSON.stringify(n)).join(' -> ')}`, + ); + this.name = 'ToposortCyclicalDependencyError'; + this.cyclePath = nodes; // Store the path for potential inspection + } +} + +export class ToposortUnknownNodeError extends Error { + public unknownNode: unknown; + constructor(node: unknown) { + super(`Unknown node referenced in edges: ${JSON.stringify(node)}`); + this.name = 'ToposortUnknownNodeError'; + this.unknownNode = node; // Store the node for potential inspection + } +} + +function makeOutgoingEdges( + nodes: Map, + edgeArr: [T, T][], +): Map> { + const edges = new Map>(); + for (const edge of edgeArr) { + const [source, target] = edge; + const sourceIndex = nodes.get(source); + const targetIndex = nodes.get(target); + // Check both source and target exist in the provided nodes set + if (sourceIndex === undefined) throw new ToposortUnknownNodeError(source); + if (targetIndex === undefined) throw new ToposortUnknownNodeError(target); + + const sourceEdges = edges.get(sourceIndex); + if (sourceEdges) { + sourceEdges.add(targetIndex); + } else { + edges.set(sourceIndex, new Set([targetIndex])); + } + } + return edges; +} + +/** + * Topological sort of nodes using the depth-first search algorithm + * + * @param nodes - The nodes to sort + * @param edges - The edges of the graph + * @returns The sorted nodes + */ +export function toposort(nodes: T[], edges: [T, T][]): T[] { + const nodeIndexMap = new Map( + nodes.map((node, index) => [node, index]), + ); + + let cursor = nodes.length; + const sorted = Array.from({ length: cursor }); + const outgoingEdgesMap = makeOutgoingEdges(nodeIndexMap, edges); + + const visited = new Set(); // Nodes whose subgraph is fully explored (Black set) + const visiting = new Set(); // Nodes currently on the recursion stack (Gray set) + + function visit(idx: number, path: number[]): void { + if (visited.has(idx)) { + return; // Already fully processed, do nothing + } + if (visiting.has(idx)) { + // Cycle detected! Reconstruct the cycle path from the current path + const cycleStartIndex = path.indexOf(idx); + const cyclePath = [...path.slice(cycleStartIndex), idx].map( + (i) => nodes[i], + ); + throw new ToposortCyclicalDependencyError(cyclePath); + } + + visiting.add(idx); + path.push(idx); // Add node to current path (for error reporting) + + const outgoingEdges = outgoingEdgesMap.get(idx); + if (outgoingEdges) { + // TODO: Reversing the array is necessary to keep the behavior consistent + // with toposort.array from the toposort package. Once we make the + // generation order independent, we can remove this logic and the reverse + // iteration below. + const outgoingEdgesArray = [...outgoingEdges]; + for (const neighbor of outgoingEdgesArray.reverse()) { + visit(neighbor, path); + } + } + + path.pop(); // Remove node from current path as we backtrack + visiting.delete(idx); // Move from visiting (Gray) set... + visited.add(idx); // ...to visited (Black) set + sorted[--cursor] = nodes[idx]; // Add to the head of the sorted list + } + + // Iterate through the original nodes array to maintain initial order preference + // for disconnected components or nodes with same topological level. + for (let i = nodes.length - 1; i >= 0; i--) { + visit(i, []); + } + + return sorted; +} diff --git a/packages/utils/src/toposort/toposort.unit.test.ts b/packages/utils/src/toposort/toposort.unit.test.ts new file mode 100644 index 000000000..65e58ab22 --- /dev/null +++ b/packages/utils/src/toposort/toposort.unit.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; + +import { + toposort, + ToposortCyclicalDependencyError, + ToposortUnknownNodeError, +} from './toposort.js'; + +describe('toposort', () => { + // Helper to check if dependencies are met in the sorted output + const expectOrder = (sorted: T[], edges: [T, T][]): void => { + const positions = new Map(); + for (const [index, node] of sorted.entries()) positions.set(node, index); + + for (const [source, target] of edges) { + const sourcePos = positions.get(source); + const targetPos = positions.get(target); + // Check if both nodes are in the sorted output before comparing positions + // (Handles cases where edges might involve nodes not in the primary 'nodes' list, + // although our makeOutgoingEdges prevents this) + if (sourcePos !== undefined && targetPos !== undefined) { + expect( + sourcePos, + `Dependency violated: ${JSON.stringify(source)} should come before ${JSON.stringify(target)}`, + ).toBeLessThan(targetPos); + } else { + // This case should ideally not be reached if input validation is correct + if (!positions.has(source)) + throw new Error( + `Source node ${JSON.stringify(source)} not found in sorted output`, + ); + if (!positions.has(target)) + throw new Error( + `Target node ${JSON.stringify(target)} not found in sorted output`, + ); + } + } + }; + + it('should return an empty array for an empty graph', () => { + expect(toposort([], [])).toEqual([]); + }); + + it('should return the single node for a graph with one node', () => { + expect(toposort(['a'], [])).toEqual(['a']); + expect(toposort([1], [])).toEqual([1]); + }); + + it('should sort nodes in a simple linear chain', () => { + const nodes = ['a', 'b', 'c']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ]; + const sorted = toposort(nodes, edges); + expect(sorted).toEqual(['a', 'b', 'c']); + expectOrder(sorted, edges); + }); + + it('should sort nodes in a simple linear chain (numbers)', () => { + const nodes = [1, 2, 3, 0]; + const edges: [number, number][] = [ + [1, 2], + [2, 3], + [0, 1], + ]; + const sorted = toposort(nodes, edges); + expect(sorted).toEqual([0, 1, 2, 3]); + expectOrder(sorted, edges); + }); + + it('should handle multiple paths correctly', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['a', 'c'], + ['b', 'd'], + ['c', 'd'], + ]; + const sorted = toposort(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); // Ensure all nodes are present + expectOrder(sorted, edges); + // Note: Multiple valid sorts exist, e.g., ['a', 'c', 'b', 'd'] or ['a', 'b', 'c', 'd'] + // expectOrder verifies the constraints. + }); + + it('should handle disconnected components', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['c', 'd'], + ]; + const sorted = toposort(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); + expectOrder(sorted, edges); + // Example valid sorts: ['c', 'd', 'a', 'b'], ['a', 'b', 'c', 'd'] + }); + + it('should handle nodes with no edges', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [['a', 'b']]; + const sorted = toposort(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); + expectOrder(sorted, edges); + // Example valid sorts: ['c', 'd', 'a', 'b'], ['d', 'a', 'b', 'c'] etc. + // Check that 'a' comes before 'b'. + expect(sorted.indexOf('a')).toBeLessThan(sorted.indexOf('b')); + }); + + it('should throw ToposortCyclicalDependencyError for a simple cycle', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'a'], + ]; + try { + toposort(nodes.reverse(), edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + // Depending on traversal order, the reported cycle might start at 'b' + expect((e as ToposortCyclicalDependencyError).cyclePath).satisfies( + (path: unknown[]) => + (path.length === 3 && + path[0] === 'a' && + path[1] === 'b' && + path[2] === 'a') || + (path.length === 3 && + path[0] === 'b' && + path[1] === 'a' && + path[2] === 'b'), + ); + } + }); + + it('should throw ToposortCyclicalDependencyError for a longer cycle', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ['c', 'd'], + ['d', 'b'], + ]; // Cycle: b -> c -> d -> b + try { + toposort(nodes.reverse(), edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + expect((e as ToposortCyclicalDependencyError).cyclePath).toEqual([ + 'b', + 'c', + 'd', + 'b', + ]); + } + }); + + it('should throw ToposortCyclicalDependencyError for self-loop', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [ + ['a', 'a'], + ['a', 'b'], + ]; + expect(() => toposort(nodes, edges)).toThrowError( + ToposortCyclicalDependencyError, + ); + expect(() => toposort(nodes, edges)).toThrowError( + /Cyclical dependency detected: "a" -> "a"/, + ); + try { + toposort(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + expect((e as ToposortCyclicalDependencyError).cyclePath).toEqual([ + 'a', + 'a', + ]); + } + }); + + it('should throw ToposortUnknownNodeError if edge source is not in nodes', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [['c', 'a']]; // 'c' is unknown + expect(() => toposort(nodes, edges)).toThrowError(ToposortUnknownNodeError); + expect(() => toposort(nodes, edges)).toThrowError( + /Unknown node referenced in edges: "c"/, + ); + try { + toposort(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortUnknownNodeError); + expect((e as ToposortUnknownNodeError).unknownNode).toBe('c'); + } + }); + + it('should throw ToposortUnknownNodeError if edge target is not in nodes', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [['a', 'c']]; // 'c' is unknown + expect(() => toposort(nodes, edges)).toThrowError(ToposortUnknownNodeError); + expect(() => toposort(nodes, edges)).toThrowError( + /Unknown node referenced in edges: "c"/, + ); + try { + toposort(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortUnknownNodeError); + expect((e as ToposortUnknownNodeError).unknownNode).toBe('c'); + } + }); + + it('should handle nodes as objects (reference equality)', () => { + const nodeA = { id: 'a' }; + const nodeB = { id: 'b' }; + const nodeC = { id: 'c' }; + const nodes = [nodeA, nodeB, nodeC]; + const edges: [object, object][] = [ + [nodeA, nodeB], + [nodeB, nodeC], + ]; + const sorted = toposort(nodes, edges); + // Use toStrictEqual for deep equality check with objects + expect(sorted).toStrictEqual([nodeA, nodeB, nodeC]); + expectOrder(sorted, edges); + }); + + it('should handle duplicate edges gracefully', () => { + const nodes = ['a', 'b', 'c']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ['a', 'b'], + ]; // Duplicate a -> b + const sorted = toposort(nodes, edges); + expect(sorted).toEqual(['a', 'b', 'c']); + expectOrder(sorted, edges.slice(0, 2)); // Check order against unique edges + }); +});