Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/cli/src/plugins/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CliPlugin } from '@zenstackhq/sdk';
import { TsSchemaGenerator } from '@zenstackhq/sdk';
import { detectImportFileExtension, TsSchemaGenerator } from '@zenstackhq/sdk';
import fs from 'node:fs';
import path from 'node:path';

Expand All @@ -22,9 +22,12 @@ const plugin: CliPlugin = {
// liteOnly mode
const liteOnly = pluginOptions['liteOnly'] === true;

// add .js extension when importing
const importWithFileExtension = pluginOptions['importWithFileExtension'];
if (importWithFileExtension && typeof importWithFileExtension !== 'string') {
// file extension to append to relative imports; when not explicitly set,
// auto-detect from the project's tsconfig (".js" for node16/nodenext ESM)
let importWithFileExtension = pluginOptions['importWithFileExtension'];
if (importWithFileExtension === undefined) {
importWithFileExtension = detectImportFileExtension(outDir);
} else if (typeof importWithFileExtension !== 'string') {
throw new Error('The "importWithFileExtension" option must be a string if specified.');
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/db/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,7 +1406,7 @@ describe('DB pull - SQL specific features', () => {
return;
}

const { workDir, schema } = await createProject(
const { workDir } = await createProject(
`model User {
id Int @id @default(autoincrement())
email String @unique
Expand Down
134 changes: 134 additions & 0 deletions packages/cli/test/import-extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from './utils';

const model = `
model User {
id String @id @default(cuid())
name String
posts Post[]
}

model Post {
id String @id @default(cuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
`;

/**
* Overwrites the project's tsconfig.json with the given compiler options
* (merged over a sensible default that includes the generated files).
*/
function writeTsConfig(workDir: string, compilerOptions: Record<string, unknown>) {
fs.writeFileSync(
path.join(workDir, 'tsconfig.json'),
JSON.stringify(
{
compilerOptions: {
target: 'ESNext',
esModuleInterop: true,
skipLibCheck: true,
strict: true,
noEmit: true,
types: ['node'],
...compilerOptions,
},
include: ['**/*.ts'],
},
null,
4,
),
);
}

function readGenerated(workDir: string, file: string) {
return fs.readFileSync(path.join(workDir, 'zenstack', file), 'utf8');
}

function typeCheck(workDir: string) {
// throws (non-zero exit) if type checking fails
execSync('npx tsc --noEmit', { cwd: workDir, stdio: 'pipe' });
}

describe('Import file extension generation', () => {
it('omits the extension and compiles under bundler resolution', async () => {
const { workDir } = await createProject(model);
// createTestProject already writes a bundler tsconfig, but set it explicitly here
writeTsConfig(workDir, { module: 'ESNext', moduleResolution: 'Bundler' });

runCli('generate', workDir);

// relative imports between generated files carry no extension
for (const file of ['models.ts', 'input.ts']) {
const content = readGenerated(workDir, file);
expect(content).toContain(`from "./schema"`);
expect(content).not.toContain(`./schema.js`);
}

// and the project type-checks
expect(() => typeCheck(workDir)).not.toThrow();
});

it('auto-adds ".js" and compiles under nodenext resolution', async () => {
const { workDir } = await createProject(model);
writeTsConfig(workDir, { module: 'NodeNext', moduleResolution: 'NodeNext' });

runCli('generate', workDir);

// node16/nodenext requires explicit extensions; they must be present
for (const file of ['models.ts', 'input.ts']) {
const content = readGenerated(workDir, file);
expect(content).toContain(`from "./schema.js"`);
}

// the real proof: nodenext would fail to resolve "./schema" without the extension
expect(() => typeCheck(workDir)).not.toThrow();
});

it('fails to compile under nodenext if the extension is suppressed', async () => {
// negative control proving the auto-detected extension is load-bearing
const modelWithoutExtension = `
plugin typescript {
provider = "@core/typescript"
importWithFileExtension = ""
}

${model}`;
const { workDir } = await createProject(modelWithoutExtension);
writeTsConfig(workDir, { module: 'NodeNext', moduleResolution: 'NodeNext' });

runCli('generate', workDir);

const content = readGenerated(workDir, 'input.ts');
expect(content).toContain(`from "./schema"`);
expect(content).not.toContain(`./schema.js`);

// missing extension is a hard error under nodenext
expect(() => typeCheck(workDir)).toThrow();
});

it('honors an explicit importWithFileExtension over auto-detection', async () => {
const modelWithExtension = `
plugin typescript {
provider = "@core/typescript"
importWithFileExtension = "js"
}

${model}`;
const { workDir } = await createProject(modelWithExtension);
// bundler resolution would auto-detect "no extension"; the explicit option wins
writeTsConfig(workDir, { module: 'ESNext', moduleResolution: 'Bundler' });

runCli('generate', workDir);

const content = readGenerated(workDir, 'input.ts');
expect(content).toContain(`from "./schema.js"`);

// bundler resolution also accepts the explicit extension
expect(() => typeCheck(workDir)).not.toThrow();
});
});
3 changes: 2 additions & 1 deletion packages/cli/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ describe('CLI proxy tests', () => {
// create the client with skipValidationForComputedFields.
const client = await createTestClient(zmodel, {
skipValidationForComputedFields: true,
omit: { User: { postCount: true } },
// a string schema can't be statically typed, so the omit config is untyped here
omit: { User: { postCount: true } } as any,
});

const app = createProxyApp(client, client.$schema);
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/test/ts-schema-gen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,9 @@ model User {
true,
);

expect(schemaLite!.models.User.attributes).toBeUndefined();
expect(schemaLite!.models.User.fields.id.attributes).toBeUndefined();
expect(schemaLite!.models.User.fields.email.attributes).toBeUndefined();
expect(schemaLite!.models['User']!.attributes).toBeUndefined();
expect(schemaLite!.models['User']!.fields['id']!.attributes).toBeUndefined();
expect(schemaLite!.models['User']!.fields['email']!.attributes).toBeUndefined();
});

it('supports ignorable fields for @updatedAt', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "test/**/*.ts"]
}
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import * as ModelUtils from './model-utils';
export * from './cli-plugin';
export { PrismaSchemaGenerator } from './prisma/prisma-schema-generator';
export * from './ts-schema-generator';
export * from './tsconfig-utils';
export { ModelUtils };
54 changes: 54 additions & 0 deletions packages/sdk/src/tsconfig-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import path from 'node:path';
import * as ts from 'typescript';

/**
* Detects the file extension that should be appended to relative imports in
* generated TypeScript, by inspecting the nearest `tsconfig.json` to the given
* directory.
*
* Returns `'.js'` when the project uses native ESM module resolution
* (`node16`/`nodenext`), which requires explicit extensions on relative imports.
* Returns `undefined` for `bundler`/`node` resolution (where extensionless
* imports are the idiomatic, maximally-compatible form) or when no tsconfig is
* found.
*/
export function detectImportFileExtension(fromDir: string): string | undefined {
const configPath = ts.findConfigFile(fromDir, ts.sys.fileExists, 'tsconfig.json');
if (!configPath) {
return undefined;
}

const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) {
return undefined;
}

// resolve `extends` chains and module/moduleResolution defaults
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));

// Prefer an explicit `moduleResolution`; when it's omitted, TypeScript derives
// it from `module`, so fall back to inspecting the module kind ourselves.
let moduleResolution = parsed.options.moduleResolution;
if (moduleResolution === undefined) {
switch (parsed.options.module) {
case ts.ModuleKind.Node16:
case ts.ModuleKind.Node18:
case ts.ModuleKind.Node20:
case ts.ModuleKind.NodeNext:
moduleResolution = ts.ModuleResolutionKind.NodeNext;
break;
default:
break;
}
}

// node16/nodenext resolution requires explicit extensions on relative imports
if (
moduleResolution === ts.ModuleResolutionKind.Node16 ||
moduleResolution === ts.ModuleResolutionKind.NodeNext
) {
return '.js';
}

return undefined;
}
Loading