From 78710643b422b7b68fae5a4bbf180bcf22dfcc45 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 20 May 2026 18:38:18 +0530 Subject: [PATCH 1/4] Fix schema lookups and repository resolution to be resilient to mangling (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix schema lookups and repository resolution to be resilient to name mangling * Fix schema lookups to use the active naming strategy for table name resolution * Add e2e tests for mangling resilience Reproduce the bugs PR #18 fixes against a real DataSource and Postgres container by mutating Function.name on entity classes before TypeORM metadata is built (the same effect bundlers have in production). Failing-on-broken-code (regression coverage): - Top-level isStringField: lowerCase/unaccent normalization is silently disabled because the schemaDefs lookup misses when targetName is mangled. SELECTs return zero rows instead of normalized matches. - Relation reference resolved by String field: `title: { select: { name "Jose" } }` throws "Referenced X not found" when the inverse entity is mangled, because the inner findOne hits the same isStringField bug and skips unaccent/lower wrapping. Passing-as-documentation: - ManyToOne reference, OneToMany collection-create, and relation-loader query under single-class mangling — these paths work incidentally because both sides read the mangled Function.name; the getRepository(rMeta.target) change is defensive. - Name collision (two entity classes forced to the same mangled name) — still resolves to the correct repository, confirming TypeORM's lookup is more resilient than feared. --------- Co-authored-by: ame-axelor <236853329+ame-axelor@users.noreply.github.com> --- src/client/parser/context.ts | 4 +- src/client/repository/query/query.ts | 2 +- src/client/repository/repository.test.ts | 11 ++ src/client/repository/repository.ts | 16 +- src/test/mangling-resilience.test.ts | 219 +++++++++++++++++++++++ 5 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 src/test/mangling-resilience.test.ts diff --git a/src/client/parser/context.ts b/src/client/parser/context.ts index be8a530..2e14750 100644 --- a/src/client/parser/context.ts +++ b/src/client/parser/context.ts @@ -93,8 +93,10 @@ export class ParserContext { isStringField(repo: Repository, name: string): boolean { if (!this.schema) return false; + const ns = repo.metadata.connection.namingStrategy; const schemaDef = this.schema.find( - (x) => x.name === repo.metadata.targetName, + (x) => + ns.tableName(x.name, x.table) === repo.metadata.tableNameWithoutPrefix, ); const fieldDef = schemaDef?.fields?.find((x) => x.name === name); return fieldDef?.type === "String"; diff --git a/src/client/repository/query/query.ts b/src/client/repository/query/query.ts index f35fc5a..9dae56d 100644 --- a/src/client/repository/query/query.ts +++ b/src/client/repository/query/query.ts @@ -94,7 +94,7 @@ const doQuery = async ( ); } - const rr = repo.manager.getRepository(relation.inverseEntityMetadata.name); + const rr = repo.manager.getRepository(relation.inverseEntityMetadata.target); const nq = relationQuery(repo.manager, relation) .clone() .setParameter("__parents", ids); diff --git a/src/client/repository/repository.test.ts b/src/client/repository/repository.test.ts index 153e200..1c30a9e 100644 --- a/src/client/repository/repository.test.ts +++ b/src/client/repository/repository.test.ts @@ -7,7 +7,17 @@ describe("EntityRepository protected field guards", () => { ({ metadata: { name: "Thing", + tableName: "thing", + tableNameWithoutPrefix: "thing", findRelationWithPropertyPath: () => null, + connection: { + namingStrategy: { + tableName: ( + targetName: string, + userSpecifiedName: string | undefined, + ) => userSpecifiedName ?? targetName.toLowerCase(), + }, + }, }, create: vi.fn(), save: vi.fn(), @@ -19,6 +29,7 @@ describe("EntityRepository protected field guards", () => { __schema: [ { name: "Thing", + table: "thing", fields: [ { name: "secret", type: "String", internal: true }, { name: "auditStamp", type: "DateTime", readonly: true }, diff --git a/src/client/repository/repository.ts b/src/client/repository/repository.ts index 4787ecc..52a9a26 100644 --- a/src/client/repository/repository.ts +++ b/src/client/repository/repository.ts @@ -35,12 +35,16 @@ import { isValueSame, valueOrID } from "./utils"; import { getSimpleSelect } from "../parser/processors/select-processor"; function getProtectedFields( + repo: OrmRepository, client: QueryClient, - entityName: string, mode: "create" | "update", ): Set { + const ns = repo.metadata.connection.namingStrategy; const schema: EntityOptions[] = (client as any).__schema ?? []; - const entity = schema.find((e) => e.name === entityName); + const entity = schema.find( + (e) => + ns.tableName(e.name, e.table) === repo.metadata.tableNameWithoutPrefix, + ); if (!entity?.fields) return new Set(); return new Set( entity.fields @@ -81,7 +85,7 @@ class RelationHandlers { const update = Array.isArray(data.update) ? data.update[0] : data.update; const rMeta = relation.inverseEntityMetadata; - const rRepo = repo.manager.getRepository(rMeta.name); + const rRepo = repo.manager.getRepository(rMeta.target); // Import EntityRepository here to avoid circular dependency const { EntityRepository } = await import("./repository"); @@ -124,7 +128,7 @@ class RelationHandlers { const field = relation.propertyName; const inverseField = relation.inverseRelation?.propertyName; const rMeta = relation.inverseEntityMetadata; - const rRepo = repo.manager.getRepository(rMeta.name); + const rRepo = repo.manager.getRepository(rMeta.target); const link = inverseField ? { [inverseField]: { select: { id: obj.id } } } @@ -287,7 +291,7 @@ export class EntityRepository implements Repository { meta: EntityMetadata, data: CreateArgs, ) { - const protectedFields = getProtectedFields(this.#client, meta.name, "create"); + const protectedFields = getProtectedFields(repo, this.#client, "create"); rejectProtectedFields(Object.keys(data), protectedFields, meta.name); const attrs: Record = {}; @@ -429,7 +433,7 @@ export class EntityRepository implements Repository { const { id, version, ...rest } = data; const meta = repo.metadata; - const protectedFields = getProtectedFields(this.#client, meta.name, "update"); + const protectedFields = getProtectedFields(repo, this.#client, "update"); rejectProtectedFields(Object.keys(rest), protectedFields, meta.name); const attrs: Record = {}; diff --git a/src/test/mangling-resilience.test.ts b/src/test/mangling-resilience.test.ts new file mode 100644 index 0000000..abfe0d6 --- /dev/null +++ b/src/test/mangling-resilience.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createClient, type GooveeClient } from "./db/client"; +import { Address, Bio, Contact, Title } from "./db/models"; + +// Bundlers (webpack, turbopack) mangle top-level class identifiers in +// production builds, which changes Function.name at runtime. TypeORM captures +// EntityMetadata.targetName / .name from `(EntityClass as Function).name` once +// during DataSource.initialize(); any code that later compares those names to +// values baked at compile time silently mismatches. +// +// These tests reproduce that scenario by mutating Function.name on specific +// entity classes immediately before initializing a fresh DataSource, then +// restoring the originals so unrelated tests run later see entities under their +// real identity. + +type Cls = { name: string }; + +const define = (klass: Cls, value: string) => { + Object.defineProperty(klass, "name", { value, configurable: true }); +}; + +describe("mangling resilience for entity schema lookups", () => { + let client: GooveeClient; + const original = { + Contact: Contact.name, + Title: Title.name, + Address: Address.name, + Bio: Bio.name, + }; + + const restoreAll = () => { + define(Contact, original.Contact); + define(Title, original.Title); + define(Address, original.Address); + define(Bio, original.Bio); + }; + + afterEach(async () => { + restoreAll(); + if (client?.$connected) await client.$disconnect(); + }); + + const connectWith = async ( + mangle: () => void, + features: Parameters[0]["features"] = {}, + ) => { + mangle(); + client = createClient({ features }); + await client.$connect(); + // metadata is now cached; restore the real Function.name so any code that + // reads it later (e.g. other test files) sees the original identity. + restoreAll(); + await client.$sync(true); + }; + + describe("isStringField (parser/context.ts)", () => { + beforeEach(async () => { + await connectWith(() => define(Contact, "a"), { + normalization: { lowerCase: true, unaccent: true }, + }); + await client.$raw("CREATE EXTENSION IF NOT EXISTS unaccent"); + }); + + it("applies unaccent normalization when entity class name is mangled", async () => { + await client.contact.create({ + data: { firstName: "José", lastName: "González" }, + }); + + const results = await client.contact.find({ + select: { firstName: true }, + where: { firstName: "Jose" }, + }); + + expect(results).toHaveLength(1); + expect(results[0].firstName).toBe("José"); + }); + + it("applies lowerCase normalization when entity class name is mangled", async () => { + await client.contact.create({ + data: { firstName: "John", lastName: "Smith" }, + }); + + const results = await client.contact.find({ + select: { firstName: true }, + where: { firstName: "john" }, + }); + + expect(results).toHaveLength(1); + expect(results[0].firstName).toBe("John"); + }); + }); + + describe("relation handling under mangling", () => { + it("ManyToOne reference resolves the inverse entity repo when inverse class is mangled", async () => { + // Title is the inverse side of Contact.title — mangle it. + await connectWith(() => define(Title, "b")); + + const { id: titleId } = await client.title.create({ + data: { code: "MR", name: "Mr." }, + }); + + // This exercises RelationHandlers.handleReference, which calls + // repo.manager.getRepository(rMeta.name) on the unfixed code. + const created = await client.contact.create({ + data: { + firstName: "John", + lastName: "Doe", + title: { select: { id: titleId } }, + }, + select: { id: true, title: { id: true, name: true } }, + }); + + expect(created.title?.name).toBe("Mr."); + }); + + it("OneToMany collection-create resolves the inverse entity repo when inverse class is mangled", async () => { + // Address is the inverse side of Contact.addresses — mangle it. + await connectWith(() => define(Address, "c")); + + // RelationHandlers.handleCollection → getRepository(rMeta.name) on unfixed. + const created = await client.contact.create({ + data: { + firstName: "Jane", + lastName: "Doe", + addresses: { + create: [{ street: "1 Main St", city: "Springfield" }], + }, + }, + select: { + id: true, + addresses: { select: { id: true, street: true, city: true } }, + }, + }); + + expect(created.addresses).toHaveLength(1); + expect(created.addresses?.[0].street).toBe("1 Main St"); + }); + + it("relation query loads collection when inverse class is mangled", async () => { + // doQuery in query.ts also reads relation.inverseEntityMetadata.name. + await connectWith(() => define(Address, "d")); + + const { id } = await client.contact.create({ + data: { + firstName: "Q", + lastName: "User", + addresses: { create: [{ street: "Elm", city: "Town" }] }, + }, + }); + + const found = await client.contact.findOne({ + where: { id }, + select: { + id: true, + addresses: { select: { id: true, street: true } }, + }, + }); + + expect(found?.addresses).toHaveLength(1); + expect(found?.addresses?.[0].street).toBe("Elm"); + }); + }); + + describe("relation reference resolved by String field under mangling", () => { + it("normalization applies on inverse entity's String field when inverse class is mangled", async () => { + // Mangle Title (the inverse side) so its metadata.targetName is "n". + // Then look up a Title by its String `name` with normalization features + // enabled — this exercises the inverse-side parser's isStringField path. + await connectWith( + () => define(Title, "n"), + { normalization: { lowerCase: true, unaccent: true } }, + ); + await client.$raw("CREATE EXTENSION IF NOT EXISTS unaccent"); + + await client.title.create({ data: { code: "MS", name: "José" } }); + + const created = await client.contact.create({ + data: { + firstName: "Z", + lastName: "User", + title: { select: { name: "Jose" } }, // unaccented search on Title.name + }, + select: { id: true, title: { id: true, name: true } }, + }); + + expect(created.title?.name).toBe("José"); + }); + }); + + // The PR 18 description argues `getRepository(rMeta.name)` "works + // incidentally" because both sides use the mangled value. This test checks + // that argument by forcing a *collision* — two different entity classes + // mangled to the same Function.name. On unfixed code, getRepository(string) + // can then return the wrong entity's repository; on PR 18, getRepository + // receives the constructor reference (rMeta.target) and stays correct. + describe("relation handling under name collision", () => { + it("ManyToOne reference still picks the right inverse repo when two entity classes mangle to the same name", async () => { + await connectWith(() => { + define(Title, "x"); + define(Bio, "x"); + }); + + const { id: titleId } = await client.title.create({ + data: { code: "DR", name: "Dr." }, + }); + + const created = await client.contact.create({ + data: { + firstName: "C", + lastName: "User", + title: { select: { id: titleId } }, + }, + select: { id: true, title: { id: true, name: true } }, + }); + + expect(created.title?.name).toBe("Dr."); + }); + }); +}); From 3e0c1c2af5fa14f70b302c2b5659f7f7da4e10d1 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 1 May 2026 15:34:14 +0530 Subject: [PATCH 2/4] Generate package.json and .js imports when transpile is enabled Allows the transpiled output to be consumed as a real package (e.g. marked as a Next.js serverExternalPackage), avoiding bundler mangling. - Write package.json into outDir after transpile, with `type` derived from transpile.module (commonjs vs module). - Add transpile.packageName to override the default @goovee/generated. - Append .js to relative import specifiers when transpiling, so Node's ESM/CJS resolver can resolve the output without bundler help. Without transpile, behavior is unchanged: raw .ts is emitted with extensionless imports and no package.json. --- src/cli/commands/generate.ts | 26 +++++++++++++++++++++++- src/client/generator/generator.ts | 25 ++++++++++++++++------- src/client/types/config.ts | 6 ++++++ src/schema/schema-generator.ts | 33 ++++++++++++++++++++++--------- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 21d5bf3..c794965 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -1,10 +1,28 @@ import fs from "node:fs"; +import path from "node:path"; import { Command } from "commander"; import { generateClient, transpileClient } from "../../client/generator"; +import type { TranspileConfig } from "../../client/types"; import { expandConfig, loadConfig } from "../config"; +export function buildPackageJson( + transpile: boolean | TranspileConfig, +): Record { + const opts = + typeof transpile === "object" && transpile !== null ? transpile : {}; + return { + name: opts.packageName ?? "@goovee/generated", + version: "1.0.0", + type: opts.module === "commonjs" ? "commonjs" : "module", + exports: { + "./models": "./models/index.js", + "./client": "./client/index.js", + }, + }; +} + export const generate = new Command() .name("generate") .description("Generate goovee client from the schema") @@ -29,7 +47,9 @@ export const generate = new Command() } // generate client - const files = generateClient(dirs, outDir); + const files = generateClient(dirs, outDir, { + transpile: !!schema.transpile, + }); // transpile client? if (schema?.transpile) { @@ -41,5 +61,9 @@ export const generate = new Command() for (const file of files) { fs.rmSync(file, { force: true }); } + fs.writeFileSync( + path.join(outDir, "package.json"), + JSON.stringify(buildPackageJson(schema.transpile), null, 2) + "\n", + ); } }); diff --git a/src/client/generator/generator.ts b/src/client/generator/generator.ts index b025207..d65dec4 100644 --- a/src/client/generator/generator.ts +++ b/src/client/generator/generator.ts @@ -21,7 +21,11 @@ export const createFile = (outDir: string, fileName: string, content: any) => { return fileName; }; -const createClient = (schema: EntityOptions[], names: string[]) => { +const createClient = ( + schema: EntityOptions[], + names: string[], + extension: string, +) => { const file = new CodeFile("index.ts"); const pkgName = "@goovee/orm"; file.write(`\ @@ -43,7 +47,7 @@ import { file.write(`${name},`); file.write("\n"); } - file.write('} from "../models";'); + file.write(`} from "../models/index${extension}";`); file.write("\n"); file.write("\n"); @@ -86,9 +90,14 @@ export function createSchema(): GraphQLSchema { return file.toJSON(); }; -export const generateClient = (schemaDirs: string[], outDir: string) => { +export const generateClient = ( + schemaDirs: string[], + outDir: string, + options?: { transpile?: boolean }, +) => { const modelsDir = path.join(outDir, "models"); const clientDir = path.join(outDir, "client"); + const extension = options?.transpile ? ".js" : ""; const schema = schemaDirs.flatMap(readSchema); const files: string[] = []; @@ -103,9 +112,11 @@ export const generateClient = (schemaDirs: string[], outDir: string) => { visited.add(name); } - files.push(...generateSchema(modelsDir, { schema, naming: "goovee" })); + files.push( + ...generateSchema(modelsDir, { schema, naming: "goovee", extension }), + ); - createFile(clientDir, "index.ts", createClient(schema, names)); + createFile(clientDir, "index.ts", createClient(schema, names, extension)); files.push(path.join(clientDir, "index.ts")); @@ -146,7 +157,7 @@ const TS_MODULE_TYPES: Record = { nodenext: ts.ModuleKind.NodeNext, }; -function prepateCompilerOptions(options?: TranspileConfig) { +function prepareCompilerOptions(options?: TranspileConfig) { const cfgFile = ts.findConfigFile(".", ts.sys.fileExists, "tsconfig.json"); const cfg = cfgFile ? ts.readConfigFile(cfgFile, ts.sys.readFile).config || {} @@ -166,7 +177,7 @@ function prepateCompilerOptions(options?: TranspileConfig) { } export const transpileClient = (files: string[], options?: TranspileConfig) => { - const opts = prepateCompilerOptions(options); + const opts = prepareCompilerOptions(options); const outputFiles: string[] = []; const filePaths = files.map((x) => path.resolve(x)); diff --git a/src/client/types/config.ts b/src/client/types/config.ts index 565a1f1..8e9fc8b 100644 --- a/src/client/types/config.ts +++ b/src/client/types/config.ts @@ -17,6 +17,12 @@ export type TranspileConfig = { * Defaults to `esnext` */ module?: "commonjs" | "esnext"; + + /** + * Package name written into the generated `package.json`. + * Defaults to `@goovee/generated`. + */ + packageName?: string; }; /** diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index d747bee..216a7df 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -177,7 +177,8 @@ class FieldGenerator

class EnumFieldGenerator extends FieldGenerator { protected get actualType() { const { enumType } = this.options; - return new ImportName(enumType, `./${enumType}`); + const ext = this.entity.config.extension ?? ""; + return new ImportName(enumType, `./${enumType}${ext}`); } protected decorators(options: EnumProperty) { @@ -232,7 +233,8 @@ class RelationalField< protected get actualType() { const { type, target } = this.options; const collection = type.endsWith("ToMany"); - return new RelationImportName(collection, target, `./${target}`); + const ext = this.entity.config.extension ?? ""; + return new RelationImportName(collection, target, `./${target}${ext}`); } protected get inverseRelation() { const { name, type, target } = this.options; @@ -409,6 +411,11 @@ class EnumGenerator implements CodeGenerator { export type GeneratorConfig = { schema: EntityOptions[]; naming?: "goovee" | "default"; + /** + * Extension suffix to append to relative import specifiers (e.g. `.js`). + * Required for ESM/NodeNext consumers; leave empty for raw `.ts` consumption. + */ + extension?: string; }; class EntityGenerator implements CodeGenerator { @@ -459,7 +466,8 @@ class EntityGenerator implements CodeGenerator { private get extends() { const name = this.options.extends; - if (name && name !== this.name) return new ImportName(name, `./${name}`); + const ext = this.config.extension ?? ""; + if (name && name !== this.name) return new ImportName(name, `./${name}${ext}`); } private get implements() { @@ -468,7 +476,8 @@ class EntityGenerator implements CodeGenerator { if (typeof types === "string") types = [types]; const filtered = types.filter((name: string) => name !== this.name); if (filtered.length === 0) return; - return filtered.map((name: string) => new ImportName(name, `./${name}`)); + const ext = this.config.extension ?? ""; + return filtered.map((name: string) => new ImportName(name, `./${name}${ext}`)); } } @@ -528,9 +537,11 @@ class EntityGenerator implements CodeGenerator { class IndexGenerator implements CodeGenerator { private names; + private extension; - constructor(names: string[]) { + constructor(names: string[], extension: string) { this.names = names; + this.extension = extension; } get name() { @@ -541,7 +552,7 @@ class IndexGenerator implements CodeGenerator { return { emit: (file) => { for (const name of this.names) { - file.write(`export { ${name} } from "./${name}";`); + file.write(`export { ${name} } from "./${name}${this.extension}";`); file.write("\n"); } }, @@ -583,8 +594,12 @@ const generateEntity = ( return save(outDir, type); }; -const generateIndex = (outDir: string, names: string[]) => { - const type = new IndexGenerator(names); +const generateIndex = ( + outDir: string, + names: string[], + extension: string, +) => { + const type = new IndexGenerator(names, extension); return save(outDir, type); }; @@ -693,7 +708,7 @@ export const generateSchema = (outDir: string, config: GeneratorConfig) => { const names = files .map((x) => path.basename(x)) .map((x) => x.replace(".ts", "")); - files.push(generateIndex(outDir, names)); + files.push(generateIndex(outDir, names, config.extension ?? "")); return files; }; From 0dda3573865d62f7e4170d1ca78064a9ae727759 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 1 May 2026 16:23:18 +0530 Subject: [PATCH 3/4] Add tests for transpile-driven .js imports and package.json shape --- src/cli/commands/generate.test.ts | 31 +++++++++++++++ src/client/generator/generator.test.ts | 52 ++++++++++++++++++++++++++ src/schema/schema-generator.test.ts | 32 ++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/cli/commands/generate.test.ts diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts new file mode 100644 index 0000000..d5ff2b8 --- /dev/null +++ b/src/cli/commands/generate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { buildPackageJson } from "./generate"; + +describe("buildPackageJson", () => { + it("uses module type and default name when transpile is true", () => { + expect(buildPackageJson(true)).toEqual({ + name: "@goovee/generated", + version: "1.0.0", + type: "module", + exports: { + "./models": "./models/index.js", + "./client": "./client/index.js", + }, + }); + }); + + it("uses commonjs type when transpile.module is commonjs", () => { + expect(buildPackageJson({ module: "commonjs" }).type).toBe("commonjs"); + }); + + it("uses module type for non-commonjs transpile config", () => { + expect(buildPackageJson({ module: "esnext" }).type).toBe("module"); + }); + + it("uses transpile.packageName as the package name", () => { + expect(buildPackageJson({ packageName: "@myorg/db" }).name).toBe( + "@myorg/db", + ); + }); +}); diff --git a/src/client/generator/generator.test.ts b/src/client/generator/generator.test.ts index 5172fef..a700bd1 100644 --- a/src/client/generator/generator.test.ts +++ b/src/client/generator/generator.test.ts @@ -27,6 +27,58 @@ describe("client generator tests", () => { cleanUp(outDir, files); }); + it("should emit extensionless imports without transpile", () => { + const schemaDir = path.join(__dirname, "..", "..", "test", "schema"); + const outDir = fs.mkdtempSync(path.join(__dirname, "noext.")); + const files = generateClient([schemaDir], outDir); + + const tsFiles = files.filter((f) => f.endsWith(".ts")); + expect(tsFiles.length).toBeGreaterThan(0); + for (const file of tsFiles) { + const code = fs.readFileSync(file, { encoding: "utf-8" }); + // reject any relative import with an extension, e.g. `from "./Foo.js"` + expect(code).not.toMatch(/from "\.\/[A-Z][A-Za-z]*\.[a-z]+"/); + } + + cleanUp(outDir, files); + }); + + it("should emit .js imports when transpile is requested", () => { + const schemaDir = path.join(__dirname, "..", "..", "test", "schema"); + const outDir = fs.mkdtempSync(path.join(__dirname, "ext.")); + const files = generateClient([schemaDir], outDir, { transpile: true }); + + const code = fs.readFileSync(path.join(outDir, "models", "Contact.ts"), { + encoding: "utf-8", + }); + // require at least one relative import with a `.js` extension + expect(code).toMatch(/from "\.\/[A-Z][A-Za-z]*\.js"/); + + const clientCode = fs.readFileSync( + path.join(outDir, "client", "index.ts"), + { encoding: "utf-8" }, + ); + expect(clientCode).toContain('from "../models/index.js"'); + + cleanUp(outDir, files); + }); + + it("should produce transpiled output with resolvable .js imports", () => { + const schemaDir = path.join(__dirname, "..", "..", "test", "schema"); + const outDir = fs.mkdtempSync(path.join(__dirname, "e2e.")); + const files = generateClient([schemaDir], outDir, { transpile: true }); + const outputFiles = transpileClient(files, { target: "esnext" }); + + const code = fs.readFileSync(path.join(outDir, "models", "Contact.js"), { + encoding: "utf-8", + }); + // require at least one relative `.js` import; tsc may emit either quote style + expect(code).toMatch(/from ['"]\.\/[A-Z][A-Za-z]*\.js['"]/); + + cleanUp(outDir, files); + cleanUp(outDir, outputFiles); + }); + it("should transpile client", () => { const schemaDir = path.join(__dirname, "..", "..", "test", "schema"); const outDir = fs.mkdtempSync(path.join(__dirname, "transpile.")); diff --git a/src/schema/schema-generator.test.ts b/src/schema/schema-generator.test.ts index d2286d5..a7653e4 100644 --- a/src/schema/schema-generator.test.ts +++ b/src/schema/schema-generator.test.ts @@ -624,6 +624,38 @@ describe("schema generator tests", () => { expect(code).toBe(expectedDecimalsCode); }); + it("should append extension to relative import specifiers when configured", () => { + generateSchema(outDir, { + schema, + naming: "goovee", + extension: ".js", + }); + const code = fs.readFileSync(path.join(outDir, "Contact.ts"), { + encoding: "utf-8", + }); + expect(code).toContain('from "./AuditableModel.js"'); + expect(code).toContain('from "./Title.js"'); + expect(code).toContain('from "./ContactType.js"'); + expect(code).not.toMatch(/from "\.\/[A-Z][A-Za-z]*"/); + + const indexCode = fs.readFileSync(path.join(outDir, "index.ts"), { + encoding: "utf-8", + }); + expect(indexCode).toContain('from "./Contact.js"'); + }); + + it("should emit extensionless imports when no extension is configured", () => { + generateSchema(outDir, { + schema, + naming: "goovee", + }); + const code = fs.readFileSync(path.join(outDir, "Contact.ts"), { + encoding: "utf-8", + }); + expect(code).toContain('from "./AuditableModel"'); + expect(code).not.toMatch(/from "\.\/[A-Z][A-Za-z]*\.js"/); + }); + it("should generate non-auditable entity extending Model instead of AuditableModel", () => { generateSchema(outDir, { schema, From 264d9c882e1125bdc76d1b3585d5c590d85ff32d Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 1 May 2026 17:21:44 +0530 Subject: [PATCH 4/4] Update readme with transpile configuration --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f98678..079ccf5 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,53 @@ Common field options: - `index`: Create database index - `nullable`: Allow null values -Then you can generate ORM config using: `npx goovee generate`. Once executed, this CLI will generate a class for each model defined in the project with the fields defined. It will also generate the global client of the application which should be used to access data. All generated code will be in the folder goovee/.generated. +Then you can generate ORM config using: `npx goovee generate`. Once executed, this CLI will generate a class for each model defined in the project with the fields defined. It will also generate the global client of the application which should be used to access data. All generated code will be in the folder `goovee/.generated` by default, or as configured by `outDir`. + +### Code Generation Configuration + +Configure `transpile` to generate code as an external package (prevents bundler mangling in Next.js): + +Configure `transpile` in `goovee.config.json`: + +```json +{ + "schema": { + "outDir": "node_modules/@goovee/generated", + "transpile": true + } +} +``` + +Or with a custom package name (for monorepo): + +```json +{ + "schema": { + "outDir": "packages/db", + "transpile": { "packageName": "@myorg/db" } + } +} +``` + +Then configure Next.js to skip mangling: + +```js +// next.config.mjs +const nextConfig = { + serverExternalPackages: ["@goovee/generated"], // or your custom package name +}; +export default nextConfig; +``` + +For monorepo workspaces, add to your app's `package.json`: + +```json +{ + "dependencies": { + "@myorg/db": "workspace:*" + } +} +``` Then, you need to create a client getter function. This should be done once in the project in an index file at the root of goovee folder to be used later for data access or modification in the application.