Repository: xataio/client-ts
Pull request #145 (2022-05-05 09:31:01)

Generate typedefs from dts files
Emit declaration files and import type defs from them

Closes: #143

<!-- Add a nice description here -->

<!-- Don't forget the `Closed #{issue_number}` -->



Patch:
diff --git a/.changeset/little-hounds-deny.md b/.changeset/little-hounds-deny.md
new file mode 100644
index 00000000..858e2434
--- /dev/null
+++ b/.changeset/little-hounds-deny.md
@@ -0,0 +1,5 @@
+---
+'@xata.io/codegen': minor
+---
+
+Generate typedefs from dts files
diff --git a/codegen/example/types.d.ts b/codegen/example/types.d.ts
new file mode 100644
index 00000000..fe55bc8e
--- /dev/null
+++ b/codegen/example/types.d.ts
@@ -0,0 +1,23 @@
+import { BaseClient, Repository, XataClientOptions, XataRecord } from '../../client/src';
+export interface Team {
+    name?: string | null;
+    labels?: string[] | null;
+    owner?: UserRecord | null;
+}
+export declare type TeamRecord = Team & XataRecord;
+export interface User {
+    email?: string | null;
+    full_name?: string | null;
+    address?: {
+        street?: string | null;
+        zipcode?: number | null;
+    } | null;
+    team?: TeamRecord | null;
+}
+export declare type UserRecord = User & XataRecord;
+export declare class XataClient extends BaseClient<{
+    "teams": Repository<Team>;
+    "users": Repository<User>;
+}> {
+    constructor(options: XataClientOptions);
+}
diff --git a/codegen/example/xata.js b/codegen/example/xata.js
index 2953112d..0bdf69c4 100644
--- a/codegen/example/xata.js
+++ b/codegen/example/xata.js
@@ -1,30 +1,10 @@
-/** @typedef { import('../../client/src').Repository } Repository */
 import { BaseClient, RestRespositoryFactory } from "../../client/src";
-/**
- * @typedef {Object} Team
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<Team>} read
- * @property {() => Promise<Team>} update
- * @property {() => Promise<void>} delete
- * @property {string=} name
- * @property {string[]=} labels
- * @property {UserRecord=} owner
- 
- */
-/**
- * @typedef {Object} User
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<User>} read
- * @property {() => Promise<User>} update
- * @property {() => Promise<void>} delete
- * @property {string=} email
- * @property {string=} full_name
- * @property {{ street?: string | null; zipcode?: number | null }=} address
- * @property {TeamRecord=} team
- 
- */
+/** @typedef { import('./types').Team } Team */
+/** @typedef { import('./types').TeamRecord } TeamRecord */
+/** @typedef { import('../../client/src').Repository<Team, TeamRecord> } TeamRepository */
+/** @typedef { import('./types').User } User */
+/** @typedef { import('./types').UserRecord } UserRecord */
+/** @typedef { import('../../client/src').Repository<User, UserRecord> } UserRepository */
 const links = { teams: [["owner", "users"]], users: [["team", "teams"]] };
 export class XataClient extends BaseClient {
   constructor(options) {
@@ -33,7 +13,7 @@ export class XataClient extends BaseClient {
       links
     );
     const factory = options.repositoryFactory || new RestRespositoryFactory();
-    /** @type {{ "teams": Repository; "users": Repository }} */
+    /** @type {{ "teams": TeamRepository; "users": UserRepository }} */
     this.db = {
       teams: factory.createRepository(this, "teams"),
       users: factory.createRepository(this, "users"),
diff --git a/codegen/package.json b/codegen/package.json
index aaddcdb9..16b44551 100644
--- a/codegen/package.json
+++ b/codegen/package.json
@@ -11,7 +11,7 @@
     "postbuild": "node scripts/inject-hashbang.js",
     "build": "tsc",
     "example-ts": "npm run build && node dist/cli.js example/xata -o example/xata.ts && sed -i '' 's|@xata.io/client|../../client/src|' example/xata.ts",
-    "example-js": "npm run build && node dist/cli.js example/xata -o example/xata.js && sed -i '' 's|@xata.io/client|../../client/src|' example/xata.js"
+    "example-js": "npm run build && node dist/cli.js example/xata -o example/xata.js && sed -i '' 's|@xata.io/client|../../client/src|' example/xata.js example/types.d.ts"
   },
   "repository": {
     "type": "git",
diff --git a/codegen/src/codegen.ts b/codegen/src/codegen.ts
index 8e2028cc..dc874043 100644
--- a/codegen/src/codegen.ts
+++ b/codegen/src/codegen.ts
@@ -13,6 +13,12 @@ export type GenerateOptions = {
   javascriptTarget?: JavascriptTarget;
 };
 
+export type GenerateOutput = {
+  original: string;
+  transpiled: string;
+  declarations?: string;
+};
+
 function getTypeName(tableName: string) {
   const snglr = pluralize.singular(tableName);
 
@@ -38,25 +44,24 @@ function generateColumnType(column: Column) {
   return `${column.name}?: ${getTypeScriptType(column)} | null`;
 }
 
-function generateJSdocType(table: Table) {
-  const { columns } = table;
-  const revLinks: { table: string }[] = []; // table.rev_links || [];
-  const typeName = getTypeName(table.name);
-  return `
-/**
- * @typedef {Object} ${typeName}
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<${typeName}>} read
- * @property {() => Promise<${typeName}>} update
- * @property {() => Promise<void>} delete
- ${columns.map((column) => generateJSDocColumnType(column)).join('\n ')}
- ${revLinks.map((link) => `${link.table}: Query<${getTypeName(link.table)}>`).join('\n ')}
- */`;
+function generateJSDocImportTypes(tables: Table[]) {
+  return tables
+    .map(
+      (table) => `
+  /** @typedef { import('./types').${getTypeName(table.name)} } ${getTypeName(table.name)} */
+  /** @typedef { import('./types').${getTypeName(table.name)}Record } ${getTypeName(table.name)}Record */
+  /** @typedef { import('@xata.io/client').Repository<${getTypeName(table.name)}, ${getTypeName(
+        table.name
+      )}Record> } ${getTypeName(table.name)}Repository */
+`
+    )
+    .join('\n');
 }
 
-function generateJSDocColumnType(column: Column) {
-  return `* @property {${getTypeScriptType(column)}=} ${column.name}`;
+function generateJSDocInternalType(tables: Table[]) {
+  return `/** @type {{ ${tables
+    .map((table) => `"${table.name}": ${getTypeName(table.name)}Repository`)
+    .join('; ')} }} */`;
 }
 
 function getTypeScriptType(column: Column): string {
@@ -81,7 +86,12 @@ function getTypeScriptType(column: Column): string {
 export type Language = 'typescript' | 'javascript';
 export type JavascriptTarget = keyof typeof ts.ScriptTarget | undefined;
 
-export async function generate({ schema, config, language, javascriptTarget }: GenerateOptions) {
+export async function generate({
+  schema,
+  config,
+  language,
+  javascriptTarget
+}: GenerateOptions): Promise<GenerateOutput> {
   const { tables } = schema;
   const links: Record<string, string[][]> = {};
   for (const table of tables) {
@@ -96,7 +106,6 @@ export async function generate({ schema, config, language, javascriptTarget }: G
   const parser = prettierParsers[language];
 
   const code = `
-  ${language === 'javascript' ? `    /** @typedef { import('@xata.io/client').Repository } Repository */` : ''}
     import {
       BaseClient,
       Repository,
@@ -107,7 +116,7 @@ export async function generate({ schema, config, language, javascriptTarget }: G
 
     ${tables.map((table) => generateTableType(table)).join('\n')}
 
-    ${language === 'javascript' ? tables.map((table) => generateJSdocType(table)).join('\n') : ''}
+    ${language === 'javascript' ? generateJSDocImportTypes(tables) : ''}
 
     const links = ${JSON.stringify(links)};
 
@@ -118,12 +127,7 @@ export async function generate({ schema, config, language, javascriptTarget }: G
         super({ databaseURL: "https://${config.workspaceID}.xata.sh/db/${config.dbName}", ...options}, links);
         
         const factory = options.repositoryFactory || new RestRespositoryFactory();
-        ${
-          language === 'javascript'
-            ? `/** @type {{ ${tables.map((table) => `"${table.name}": Repository`).join('; ')} }} */`
-            : ''
-        }
-        
+        ${language === 'javascript' ? generateJSDocInternalType(tables) : ''}
         this.db = {
           ${tables.map((table) => `"${table.name}": factory.createRepository(this, "${table.name}"),`).join('\n')}
         };
@@ -132,8 +136,11 @@ export async function generate({ schema, config, language, javascriptTarget }: G
   `;
 
   const transpiled = transpile(code, language, javascriptTarget);
+  const declarations = emitDeclarations(code);
+
+  const pretty = prettier.format(transpiled, { parser, plugins: [parserTypeScript, parserJavascript] });
 
-  return prettier.format(transpiled, { parser, plugins: [parserTypeScript, parserJavascript] });
+  return { original: code, transpiled: pretty, declarations };
 }
 
 const prettierParsers: Record<Language, BuiltInParserName> = {
@@ -149,3 +156,29 @@ function transpile(code: string, language: Language, javascriptTarget: Javascrip
       return ts.transpile(code, { target: ts.ScriptTarget[javascriptTarget] });
   }
 }
+
+function emitDeclarations(code: string) {
+  const files = new Map<string, string>();
+  const inputFileName = 'index.ts';
+  const sourceFile = ts.createSourceFile(inputFileName, code, ts.ScriptTarget.ESNext);
+
+  const compilerHost = {
+    getSourceFile: (fileName: string) => (fileName === inputFileName ? sourceFile : undefined),
+    // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
+    writeFile: (_name: string, _text: string) => {},
+    getDefaultLibFileName: () => 'lib.d.ts',
+    useCaseSensitiveFileNames: () => false,
+    getCanonicalFileName: (fileName: string) => fileName,
+    getCurrentDirectory: () => '',
+    getNewLine: () => '\n',
+    fileExists: (fileName: string) => fileName === inputFileName,
+    readFile: () => '',
+    directoryExists: () => true,
+    getDirectories: () => []
+  };
+
+  const program = ts.createProgram(['index.ts'], { declaration: true, emitDeclarationOnly: true }, compilerHost);
+  program.emit(undefined, (fileName, data) => files.set(fileName, data), undefined, true);
+
+  return files.get('index.d.ts');
+}
diff --git a/codegen/src/generateWithOutput.ts b/codegen/src/generateWithOutput.ts
index 1b0ec879..e9cc82f5 100644
--- a/codegen/src/generateWithOutput.ts
+++ b/codegen/src/generateWithOutput.ts
@@ -1,7 +1,7 @@
 import * as fs from 'fs/promises';
 import { Ora } from 'ora';
 import * as path from 'path';
-import { join, relative } from 'path';
+import { dirname, join, relative } from 'path';
 import { ZodError } from 'zod';
 import { generate, Language } from './codegen.js';
 import { parseConfigFile } from './config.js';
@@ -47,8 +47,11 @@ export const generateWithOutput = async ({
     return;
   }
 
-  const code = await generate({ schema, config, language });
+  const { transpiled: code, declarations } = await generate({ schema, config, language });
   await writeFile(fullOutputPath, code);
+  if (language === 'javascript' && declarations) {
+    await writeFile(`${dirname(outputFilePath)}/types.d.ts`, declarations);
+  }
 
   spinner?.succeed(`Your XataClient is generated at ./${relative(process.cwd(), outputFilePath)}.`);
 };


Test patch:
diff --git a/test/__snapshots__/codegen.test.ts.snap b/test/__snapshots__/codegen.test.ts.snap
index 144709ee..33f75edf 100644
--- a/test/__snapshots__/codegen.test.ts.snap
+++ b/test/__snapshots__/codegen.test.ts.snap
@@ -1,53 +1,16 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`codegen should generate correct JavaScript 1`] = `
-"/** @typedef { import('@xata.io/client').Repository } Repository */
-import { BaseClient, RestRespositoryFactory } from \\"@xata.io/client\\";
-/**
- * @typedef {Object} Author
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<Author>} read
- * @property {() => Promise<Author>} update
- * @property {() => Promise<void>} delete
- * @property {string=} name
- * @property {string=} email
- * @property {string=} photoUrl
- * @property {string=} bio
- * @property {string=} title
- 
- */
-/**
- * @typedef {Object} Post
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<Post>} read
- * @property {() => Promise<Post>} update
- * @property {() => Promise<void>} delete
- * @property {string=} title
- * @property {string=} summary
- * @property {string=} content
- * @property {string[]=} tags
- * @property {number=} likes
- * @property {AuthorRecord=} author
- * @property {string=} date
- * @property {string=} slug
- * @property {boolean=} published
- 
- */
-/**
- * @typedef {Object} Person
- * @property {string} id
- * @property {Object} xata
- * @property {() => Promise<Person>} read
- * @property {() => Promise<Person>} update
- * @property {() => Promise<void>} delete
- * @property {string=} slug
- * @property {string=} name
- * @property {string=} title
- * @property {string=} photoUrl
- 
- */
+"import { BaseClient, RestRespositoryFactory } from \\"@xata.io/client\\";
+/** @typedef { import('./types').Author } Author */
+/** @typedef { import('./types').AuthorRecord } AuthorRecord */
+/** @typedef { import('@xata.io/client').Repository<Author, AuthorRecord> } AuthorRepository */
+/** @typedef { import('./types').Post } Post */
+/** @typedef { import('./types').PostRecord } PostRecord */
+/** @typedef { import('@xata.io/client').Repository<Post, PostRecord> } PostRepository */
+/** @typedef { import('./types').Person } Person */
+/** @typedef { import('./types').PersonRecord } PersonRecord */
+/** @typedef { import('@xata.io/client').Repository<Person, PersonRecord> } PersonRepository */
 const links = { authors: [], posts: [[\\"author\\", \\"authors\\"]], people: [] };
 export class XataClient extends BaseClient {
   constructor(options) {
@@ -59,7 +22,7 @@ export class XataClient extends BaseClient {
       links
     );
     const factory = options.repositoryFactory || new RestRespositoryFactory();
-    /** @type {{ \\"authors\\": Repository; \\"posts\\": Repository; \\"people\\": Repository }} */
+    /** @type {{ \\"authors\\": AuthorRepository; \\"posts\\": PostRepository; \\"people\\": PersonRepository }} */
     this.db = {
       authors: factory.createRepository(this, \\"authors\\"),
       posts: factory.createRepository(this, \\"posts\\"),