diff --git a/package.json b/package.json index b40b19e2..234bf427 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "dependencies": { "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", "ts-morph": "^26.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f8d5939..8be3cd35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: commander: specifier: ^14.0.0 version: 14.0.0 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 ts-morph: specifier: ^26.0.0 version: 26.0.0 @@ -2527,6 +2530,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kasi@1.1.1: resolution: {integrity: sha512-pzBwGWFIjf84T/8aD0XzMli1T3Ckr/jVLh6v0Jskwiv5ehmcgDM+vpYFSk8WzGn4ed4HqgaifTgQUHzzZHa+Qw==} @@ -5997,6 +6003,8 @@ snapshots: json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} + kasi@1.1.1: {} lazystream@1.0.1: diff --git a/src/cli/config.ts b/src/cli/config.ts index 58ced1f8..b5c5b738 100755 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -4,6 +4,10 @@ import * as url from "node:url"; import { Project } from "ts-morph"; import { tsImport } from "tsx/esm/api"; import type { DrizzleToZeroSchema } from "../relations"; +import { + parse as JsoncParse, + type ParseError as JsoncParseError, +} from "jsonc-parser"; export const defaultConfigFilePath = "drizzle-zero.config.ts"; @@ -94,3 +98,101 @@ export async function getZeroSchemaDefsFromConfig({ .join(", ")}`, ); } + +export async function discoverAllTsConfigs( + initialTsConfigPath: string, +): Promise> { + const processedPaths = new Set(); + const toProcess = [path.resolve(initialTsConfigPath)]; + + const processTsConfig = async (tsConfigPath: string) => { + if (processedPaths.has(tsConfigPath)) { + return []; + } + processedPaths.add(tsConfigPath); + + try { + const tsConfigContent = await fs.readFile(tsConfigPath, "utf-8"); + const errors: JsoncParseError[] = []; + const tsConfig = JsoncParse(tsConfigContent, errors) as { + references?: { path: string }[]; + }; + + if (errors.length > 0) { + console.warn( + `⚠️ drizzle-zero: Found syntax errors in ${path.relative(process.cwd(), tsConfigPath)}. The resolver will attempt to continue.`, + ); + } + + if (!tsConfig?.references) { + return []; + } + + const tsConfigDir = path.dirname(tsConfigPath); + const newPathsToProcess: string[] = []; + + for (const ref of tsConfig.references) { + const referencedTsConfigPath = await resolveReferencePath( + ref.path, + tsConfigDir, + ); + + if ( + referencedTsConfigPath && + !processedPaths.has(referencedTsConfigPath) + ) { + newPathsToProcess.push(referencedTsConfigPath); + } + } + return newPathsToProcess; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + console.warn( + `⚠️ drizzle-zero: Could not find tsconfig file: ${tsConfigPath}`, + ); + } else { + throw new Error( + `❌ drizzle-zero: Error processing tsconfig file: ${tsConfigPath}: ${error}`, + ); + } + return []; + } + }; + + while (toProcess.length > 0) { + const newPaths = ( + await Promise.all(toProcess.splice(0).map(processTsConfig)) + ).flat(); + if (newPaths.length > 0) { + toProcess.push(...newPaths); + } + } + + return processedPaths; +} + +async function resolveReferencePath( + refPath: string, + tsConfigDir: string, +): Promise { + const resolvedPath = path.resolve(tsConfigDir, refPath); + + // The reference can be a directory or a file. + // We assume that if it's a directory, then it contains a 'tsconfig.json' file. + try { + const stats = await fs.stat(resolvedPath); + if (stats.isDirectory()) { + return path.join(resolvedPath, "tsconfig.json"); + } + return resolvedPath; + } catch { + console.warn( + `⚠️ drizzle-zero: Could not resolve reference path: ${refPath}`, + ); + return; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 66b5e51a..42f41f94 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,7 +3,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { Project } from "ts-morph"; -import { getConfigFromFile, getDefaultConfigFilePath } from "./config"; +import { + getConfigFromFile, + getDefaultConfigFilePath, + discoverAllTsConfigs, +} from "./config"; import { getDefaultConfig } from "./drizzle-kit"; import { getGeneratedSchema } from "./shared"; @@ -74,10 +78,15 @@ async function main(opts: GeneratorOptions = {}) { "😶‍🌫️ drizzle-zero: Using all tables/columns from Drizzle schema", ); } + const allTsConfigPaths = await discoverAllTsConfigs(resolvedTsConfigPath); const tsProject = new Project({ tsConfigFilePath: resolvedTsConfigPath, + skipAddingFilesFromTsConfig: true, }); + for (const tsConfigPath of allTsConfigPaths) { + tsProject.addSourceFilesFromTsConfig(tsConfigPath); + } const result = configFilePath ? await getConfigFromFile({ diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 24628bee..12f3f280 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { Project } from "ts-morph"; import * as path from "node:path"; import * as fs from "node:fs/promises"; -import { getZeroSchemaDefsFromConfig } from "../src/cli/config"; +import { + getZeroSchemaDefsFromConfig, + discoverAllTsConfigs, +} from "../src/cli/config"; import * as oneToOneSchema from "./schemas/one-to-one.zero"; import { getGeneratedSchema } from "../src/cli/shared"; import type { DrizzleToZeroSchema } from "../src/relations"; @@ -443,15 +446,15 @@ describe("getGeneratedSchema", () => { }); // Verify the import statement includes .js extension - expect(generatedSchema).toContain('from "./tests/schemas/one-to-one.zero.js";'); - + expect(generatedSchema).toContain( + 'from "./tests/schemas/one-to-one.zero.js";', + ); + // Verify the rest of the schema is still generated correctly expect(generatedSchema).toContain("export const schema = {"); expect(generatedSchema).toContain('"users": {'); }); - - it("should add .js file extensions to drizzle-kit imports when jsFileExtension is true", async () => { // Mock the DrizzleToZeroSchema type vi.mock("drizzle-zero", () => ({ @@ -498,7 +501,7 @@ describe("getGeneratedSchema", () => { // Verify the import statement includes .js extension for drizzle schema expect(generatedSchema).toContain('from "./mock-drizzle-schema.js";'); - + // Verify the rest of the schema is still generated correctly expect(generatedSchema).toContain("export const schema = {"); expect(generatedSchema).toContain('"users": {'); @@ -629,12 +632,12 @@ describe("drizzle-kit functions", () => { // Create a temporary schema file that exports a valid drizzle schema const tempSchemaContent = ` import { pgTable, serial, text } from 'drizzle-orm/pg-core'; - + export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull() }); - + export default { users }; `; @@ -708,3 +711,134 @@ describe("drizzle-kit functions", () => { }); }); }); + +describe("discoverAllTsConfigs", () => { + const tempDir = path.resolve(__dirname, "temp_tsconfigs"); + + beforeEach(async () => { + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("should find a single tsconfig with no references", async () => { + const rootPath = path.join(tempDir, "tsconfig.json"); + await fs.writeFile(rootPath, JSON.stringify({ compilerOptions: {} })); + + const result = await discoverAllTsConfigs(rootPath); + expect(result).toEqual(new Set([rootPath])); + }); + + it("should find one level of project references", async () => { + const rootPath = path.join(tempDir, "tsconfig.json"); + const libADir = path.join(tempDir, "libs", "lib-a"); + const libBDir = path.join(tempDir, "libs", "lib-b"); + const libAPath = path.join(libADir, "tsconfig.json"); + const libBPath = path.join(libBDir, "tsconfig.json"); + + await fs.mkdir(libADir, { recursive: true }); + await fs.mkdir(libBDir, { recursive: true }); + + await fs.writeFile( + rootPath, + JSON.stringify({ + references: [{ path: "./libs/lib-a" }, { path: "./libs/lib-b" }], + }), + ); + await fs.writeFile(libAPath, JSON.stringify({ compilerOptions: {} })); + await fs.writeFile(libBPath, JSON.stringify({ compilerOptions: {} })); + + const result = await discoverAllTsConfigs(rootPath); + expect(result).toEqual(new Set([rootPath, libAPath, libBPath])); + }); + + it("should handle multi-level nested project references", async () => { + const rootPath = path.join(tempDir, "tsconfig.json"); + const appDir = path.join(tempDir, "apps", "my-app"); + const sharedUiDir = path.join(tempDir, "libs", "shared-ui"); + const utilsDir = path.join(tempDir, "libs", "utils"); + + const appPath = path.join(appDir, "tsconfig.json"); + const sharedUiPath = path.join(sharedUiDir, "tsconfig.json"); + const utilsPath = path.join(utilsDir, "tsconfig.json"); + + await fs.mkdir(appDir, { recursive: true }); + await fs.mkdir(sharedUiDir, { recursive: true }); + await fs.mkdir(utilsDir, { recursive: true }); + + await fs.writeFile( + rootPath, + JSON.stringify({ references: [{ path: "./apps/my-app" }] }), + ); + await fs.writeFile( + appPath, + JSON.stringify({ references: [{ path: "../../libs/shared-ui" }] }), + ); + await fs.writeFile( + sharedUiPath, + JSON.stringify({ references: [{ path: "../utils" }] }), + ); + await fs.writeFile(utilsPath, JSON.stringify({ compilerOptions: {} })); + + const result = await discoverAllTsConfigs(rootPath); + expect(result).toEqual( + new Set([rootPath, appPath, sharedUiPath, utilsPath]), + ); + }); + + it("should correctly handle circular references", async () => { + const rootPath = path.join(tempDir, "tsconfig.json"); + const libADir = path.join(tempDir, "libs", "lib-a"); + const libBDir = path.join(tempDir, "libs", "lib-b"); + const libAPath = path.join(libADir, "tsconfig.json"); + const libBPath = path.join(libBDir, "tsconfig.json"); + + await fs.mkdir(libADir, { recursive: true }); + await fs.mkdir(libBDir, { recursive: true }); + + await fs.writeFile( + rootPath, + JSON.stringify({ references: [{ path: "./libs/lib-a" }] }), + ); + await fs.writeFile( + libAPath, + JSON.stringify({ references: [{ path: "../lib-b" }] }), + ); + await fs.writeFile( + libBPath, + JSON.stringify({ references: [{ path: "../lib-a" }] }), + ); + + const result = await discoverAllTsConfigs(rootPath); + expect(result).toEqual(new Set([rootPath, libAPath, libBPath])); + }); + + it("should continue gracefully if a referenced tsconfig is not found", async () => { + const rootPath = path.join(tempDir, "tsconfig.json"); + const libGoodDir = path.join(tempDir, "libs", "good"); + const libGoodPath = path.join(libGoodDir, "tsconfig.json"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await fs.mkdir(libGoodDir, { recursive: true }); + + await fs.writeFile( + rootPath, + JSON.stringify({ + references: [{ path: "./libs/good" }, { path: "./libs/bad" }], + }), + ); + await fs.writeFile(libGoodPath, JSON.stringify({ compilerOptions: {} })); + + const result = await discoverAllTsConfigs(rootPath); + expect(result).toEqual(new Set([rootPath, libGoodPath])); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not resolve reference path: ./libs/bad"), + ); + + warnSpy.mockRestore(); + }); +});