diff --git a/bun.lock b/bun.lock index cf0936b0b..35ecb04e6 100644 --- a/bun.lock +++ b/bun.lock @@ -568,4 +568,4 @@ "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } -} +} \ No newline at end of file diff --git a/src/api/builder.ts b/src/api/builder.ts index 1ad067303..6e18a595c 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -10,7 +10,7 @@ import * as afs from "node:fs/promises"; import * as Path from "node:path"; import { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import type { GeneratedFile } from "@root/api/generators/base/types"; -import { camelCase } from "@root/api/writer-generator/utils.ts"; +import { camelCase, deepEqual } from "@root/api/writer-generator/utils.ts"; import { registerFromManager } from "@root/typeschema/register"; import { mkTypeSchemaIndex } from "@root/typeschema/utils"; import { generateTypeSchemas, TypeSchemaCache, TypeSchemaGenerator, TypeSchemaParser } from "@typeschema/index"; @@ -268,42 +268,68 @@ export class APIBuilder { return this; } + private static async isIdenticalTo(path: string, tsJSON: string): Promise { + if (!(await afs.exists(path))) return false; + const json = await afs.readFile(path); + const ts1 = JSON.parse(json.toString()) as TypeSchema; + const ts2 = JSON.parse(tsJSON) as TypeSchema; + return deepEqual(ts1, ts2); + } + + private async writeTypeSchemasToSeparateFiles(typeSchemas: TypeSchema[], outputDir: string): Promise { + if (this.options.cleanOutput) fs.rmSync(outputDir, { recursive: true, force: true }); + await afs.mkdir(outputDir, { recursive: true }); + + const usedNames: Record = {}; + + this.logger.info(`Writing TypeSchema files to ${outputDir}...`); + + for (const ts of typeSchemas) { + const package_name = camelCase(ts.identifier.package.replaceAll("/", "-")); + const name = normalizeFileName(ts.identifier.name.toString()); + const json = JSON.stringify(ts, null, 2); + + const baseName = Path.join(outputDir, package_name, name); + let fullName: string; + if (usedNames[baseName] !== undefined) { + usedNames[baseName]++; + fullName = `${baseName}-${usedNames[baseName]}.typeschema.json`; + } else { + usedNames[baseName] = 0; + fullName = `${baseName}.typeschema.json`; + } + + if (await APIBuilder.isIdenticalTo(fullName, json)) continue; + + await afs.mkdir(Path.dirname(fullName), { recursive: true }); + await afs.writeFile(fullName, json); + } + } + + private async writeTypeSchemasToSingleFile(typeSchemas: TypeSchema[], outputFile: string): Promise { + if (this.options.cleanOutput && fs.existsSync(outputFile)) fs.rmSync(outputFile); + await afs.mkdir(Path.dirname(outputFile), { recursive: true }); + + this.logger.info(`Writing TypeSchemas to one file ${outputFile}...`); + + for (const ts of typeSchemas) { + const json = JSON.stringify(ts, null, 2); + await afs.appendFile(outputFile, json + "\n"); + } + } + private async tryWriteTypeSchema(typeSchemas: TypeSchema[]) { if (!this.options.typeSchemaOutputDir) return; try { - if (this.options.cleanOutput) fs.rmSync(this.options.typeSchemaOutputDir, { recursive: true, force: true }); - await afs.mkdir(this.options.typeSchemaOutputDir, { recursive: true }); - - let writtenCount = 0; - let overrideCount = 0; - const usedNames: Record = {}; - - this.logger.info(`Writing TypeSchema files to ${this.options.typeSchemaOutputDir}...`); - - for (const ts of typeSchemas) { - const package_name = camelCase(ts.identifier.package.replaceAll("/", "-")); - const name = normalizeFileName(ts.identifier.name.toString()); - - const baseName = Path.join(this.options.typeSchemaOutputDir, package_name, name); - let fullName: string; - if (usedNames[baseName] !== undefined) { - usedNames[baseName]++; - fullName = `${baseName}-${usedNames[baseName]}.typeschema.json`; - } else { - usedNames[baseName] = 0; - fullName = `${baseName}.typeschema.json`; - } - - await afs.mkdir(Path.dirname(fullName), { recursive: true }); - afs.writeFile(fullName, JSON.stringify(ts, null, 2)); - - if (await afs.exists(fullName)) overrideCount++; - else writtenCount++; - } - this.logger.info(`Created ${writtenCount} new TypeSchema files, overrode ${overrideCount} files`); + this.logger.info(`Starting writing TypeSchema files.`); + + if (Path.extname(this.options.typeSchemaOutputDir) === ".ndjson") + await this.writeTypeSchemasToSingleFile(typeSchemas, this.options.typeSchemaOutputDir); + else await this.writeTypeSchemasToSeparateFiles(typeSchemas, this.options.typeSchemaOutputDir); + + this.logger.info(`Finished writing TypeSchema files.`); } catch (error) { if (this.options.throwException) throw error; - this.logger.error( "Failed to write TypeSchema output", error instanceof Error ? error : new Error(String(error)), diff --git a/src/api/writer-generator/utils.ts b/src/api/writer-generator/utils.ts index cdceeb69c..a2596da6d 100644 --- a/src/api/writer-generator/utils.ts +++ b/src/api/writer-generator/utils.ts @@ -37,3 +37,27 @@ export const uppercaseFirstLetter = (str: string): string => { export const uppercaseFirstLetterOfEach = (strings: string[]): string[] => { return strings.map((str) => uppercaseFirstLetter(str)); }; + +export function deepEqual(obj1: T, obj2: T): boolean { + if (obj1 === obj2) return true; + + if (obj1 === null || obj2 === null || typeof obj1 !== "object" || typeof obj2 !== "object") { + return false; + } + + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) return false; + return obj1.every((item, index) => deepEqual(item, obj2[index])); + } + + if (Array.isArray(obj1) || Array.isArray(obj2)) { + return false; + } + + const keys1 = Object.keys(obj1) as (keyof T)[]; + const keys2 = Object.keys(obj2) as (keyof T)[]; + + if (keys1.length !== keys2.length) return false; + + return keys1.every((key) => keys2.includes(key) && deepEqual(obj1[key], obj2[key])); +} diff --git a/src/utils/codegen-logger.ts b/src/utils/codegen-logger.ts index dea984a5b..e4757bcd5 100644 --- a/src/utils/codegen-logger.ts +++ b/src/utils/codegen-logger.ts @@ -74,7 +74,7 @@ export class CodegenLogger { */ error(message: string, error?: Error): void { if (this.isSuppressed(LogLevel.ERROR)) return; - console.error(this.formatMessage("", message, pc.red)); + console.error(this.formatMessage("X", message, pc.red)); if (error && this.options.verbose) { console.error(pc.red(` ${error.message}`)); if (error.stack) { diff --git a/test/helpers/mock-generators.ts b/test/helpers/mock-generators.ts index 519be60a7..a9e728a52 100644 --- a/test/helpers/mock-generators.ts +++ b/test/helpers/mock-generators.ts @@ -2,9 +2,9 @@ * Mock implementations for testing */ -import type { TypeSchema } from "@typeschema/types"; import { BaseGenerator } from "@root/api/generators/base/BaseGenerator"; import type { BaseGeneratorOptions, GeneratedFile, TemplateContext } from "@root/api/generators/base/types"; +import type { TypeSchema } from "@typeschema/types"; /** * Mock logger that captures all log messages diff --git a/test/unit/api/generators/base/error-handling.test.ts b/test/unit/api/generators/base/error-handling.test.ts index a9b1f48f1..23e7f9ff0 100644 --- a/test/unit/api/generators/base/error-handling.test.ts +++ b/test/unit/api/generators/base/error-handling.test.ts @@ -9,8 +9,8 @@ import { EnhancedTemplateError, } from "@root/api/generators/base/enhanced-errors"; import { ErrorHandler, GeneratorErrorBoundary } from "@root/api/generators/base/error-handler"; -import { createMockSchema } from "../../../../helpers/schema-helpers"; import { MockLogger } from "../../../../helpers/mock-generators"; +import { createMockSchema } from "../../../../helpers/schema-helpers"; describe("Enhanced Error Handling", () => { describe("EnhancedSchemaValidationError", () => {