Code as Doc: Automate by Vercel AI SDK and ZenStack for Free
December 23, 2024

Code as Doc: Automate by Vercel AI SDK and ZenStack for Free


Few developers enjoy writing documentation

If you’ve ever worked as a developer at a large company, you know that coding is only a small part of your daily responsibilities. Ray Farias, a full-end software engineer at Google, once estimated that developers would write 100-150 lines of code per day at Google. While this estimate may vary between different teams, the order of magnitude is consistent with my observations as a Microsoft developer.

So where did the time go? A large portion is used for activities such as meetings, code reviews, planning sessions, and documentation tasks. Of all these missions, Files is my least favorite – and I suspect many other teammates feel the same way.

The main reason is that we don’t see how valuable it is. We were required to write design documents at the beginning of each sprint before coding, and after reviewing each other, most would remain unchanged forever. I can’t tell you how many times I’ve found something weird in a document, only to be told by the author that it was out of date. 😂 Why don’t we update the file? Because our boss doesn’t think it’s as important as fixing bugs or adding new features.

Documentation should serve as a high-level abstraction of the code to aid understanding. When a document is out of sync with the code, it loses its purpose. However, keeping files and code in sync requires effort—something few people actually enjoy doing.

Uncle Bob (Robert C. Martin) has a famous saying about clean code:

Good code has its own comments

I think it would be great if this principle could be extended to files as well:

Good code itself is the best documentation


Use AI to generate documents

The current trend in AI adoption has a simple rule: If humans don’t like doing something, let AI handle it. Documentation seems to fit well into this category, especially now that artificial intelligence has generated more and more code.

The timing couldn’t be better, GitHub just announced Co-pilot function is free. You can try it to generate files for your project for free. However, the results may not be as good as you expected. Is it because your prompts are not good enough? Maybe, but there is a more fundamental reason behind this:

The LL.M. does not deal with imperative code, nor does it deal with declaration literals.

Imperative code often involves complex control flows, state management, and intricate dependencies. This procedural nature requires a deeper understanding of the intent behind the code, which is difficult for an LL.M. to deduce accurately. Additionally, the larger the code size, the more likely the results will be inaccurate and less informative.

What’s the first thing you want to see in a web application file? Most likely, the data model serves as the basis for the entire application. Can data models be defined declaratively? Absolutely! Prism ORM has done a great job by allowing developers to define their application models in an intuitive data modeling language**.

The ZenStack toolkit is built on Prisma and enhances the architecture with additional functionality. By defining access policies and validation rules directly in the data model, it becomes the single source of truth for the application backend.

When I say “single source of truth,” it encompasses not just all the information on the backend, but actually the entire backend. ZenStack will automatically generate APIs and corresponding front-end hooks for you. Once access policies are defined, they can be securely called directly from the front-end without enabling row-level security (RLS) at the database layer. Or, in other words, you barely need to write any code for the backend.

Here is a very simplified example of a blog post application:

datasource db  x.type.reference?.ref?.name,
                    x.name,
                    isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '',
                    x.type.optional ? '"?"' : '',
                ].join(' ');
            

generator js 

plugin hooks  x.type.reference?.ref?.name,
                    x.name,
                    isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '',
                    x.type.optional ? '"?"' : '',
                ].join(' ');
            

enum Role 
                    //one to one
                    relation = currentType.optional ? '

model Post 
                return [
                    x.type.type 

model User o--
Enter full screen mode

Exit full screen mode

We can easily build a tool that uses artificial intelligence to generate files from this pattern. You no longer need to manually write and maintain files – just integrate the build process into your CI/CD pipeline and no more out-of-sync issues. The following is an example of a file generated from the schema:

I’ll walk you through the steps on how to set up this tool.


ZenStack plug-in system

Like many great tools in the web development world, ZenStack uses a plugin-based architecture. The core of the system is the ZModel mode, around which functions are implemented in the form of plug-ins. Let’s create a plug-in that generates markdown for ZModel so that others can easily adopt it.

For the sake of brevity, we will focus on the core parts. See ZenStack files Learn complete plug-in development details.

The plug-in is just a Node.js module, containing two parts:

  1. Named export name Specifies the name of the plugin used for logging and error reporting.
  2. Preset function exports containing plugin logic.

It looks like this:

import type 
                return [
                    x.type.type  from '@zenstackhq/sdk';
import type ' : ' from '@zenstackhq/sdk/prisma';
import type { Model } from '@zenstackhq/sdk/ast';

export const name = 'ZenStack MarkDown';

export default async function run(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
    ...
}
Enter full screen mode

Exit full screen mode

model is the ZModel AST. It is the object model that is the result of parsing and connecting the ZModel pattern. It is a tree structure that contains all the information in the pattern.

we can use ZModelCodeGenerator Provided by ZenStack sdk, obtain ZModel content from AST.

import { ZModelCodeGenerator } from '@zenstackhq/sdk';
const zModelGenerator = new ZModelCodeGenerator();
const zmodel = zModelGenerator.generate(model);
Enter full screen mode

Exit full screen mode

Now that we have the ingredients, let’s let AI do the cooking.


Use Vercel AI SDK to generate documents

Initially, I planned to use OpenAI to do this. But soon, I realized that this would exclude developers who couldn’t access paid OpenAI services. Thanks to Elon Musk, you can get a free API key from Grok (https://x.ai/).

However, I have to write separate code for each model provider. This is where the Vercel AI SDK shines. It provides a standardized interface for interacting with various LLM providers, allowing us to write code that works with multiple AI models. Whether you use OpenAI, Anthropic’s Claude, or another vendor, the implementation remains consistent.

It provides a unified LanguageModel type that allows you to specify any LLM model you wish to use. Just check your environment to determine which models are available.

    let model: LanguageModel;

    if (process.env.OPENAI_API_KEY) {
        model = openai('gpt-4-turbo');
    } else if (process.env.XAI_API_KEY) {
        model = xai('grok-beta');
    }
    ...
Enter full screen mode

Exit full screen mode

No matter which provider you choose, the rest of the implementation uses the same unified API.

This is the prompt we use to get the AI ​​to generate the file contents:

  const prompt = `
    You are the expert of ZenStack open-source toolkit. 
    You will generate a technical design document from a provided ZModel schema file that help developer understand the structure and behavior of the application. 
    The document should include the following sections:
    1. Overview 
        a. A short paragraph for the high-level description of this app
        b. Functionality
    2. an array of model. Each model has below two information:
        a. model name
        b. array of access policies explained by plain text
    here is the ZModel schema file:
    \`\`\`zmodel
    ${zmodel}
    \`\`\`
    `;
Enter full screen mode

Exit full screen mode


generate structured data

When working with APIs, we prefer to use JSON data rather than plain text. Although many LLMs are capable of producing JSON, each LLM has its own method. For example, OpenAI provides a JSON schema, while Claude requires specifying the JSON format in the prompt. The good news is that the Vercel SDK also unifies this functionality across model providers using the Zod schema.

For the above prompt, here is the corresponding response data structure we expect to receive.

    const schema = z.object({
        overview: z.object({
            description: z.string(),
            functionality: z.string(),
        }),
        models: z.array(
            z.object({
                name: z.string(),
                access_control_policies: z.array(z.string()),
            })
        ),
    });
Enter full screen mode

Exit full screen mode

then call generateObject API to let AI do the work:

const { object } = await generateObject({
        model
        schema
        prompt
    });
Enter full screen mode

Exit full screen mode

This is the type returned, allowing you to work in a type-safe manner:

const object: {
    overview: {
        description: string;
        functionality: string;
    };
    models: {
        name: string;
        access_control_policies: string[];
    }[];
}
Enter full screen mode

Exit full screen mode


Generate Mermaid ERD diagram

We also generate ERD plots for each model. This part is very simple and easy to implement, so I think it’s more reliable and efficient to code here. Of course, you can still use artificial intelligence as a co-pilot here. 😄

export default class MermaidGenerator {
    generate(dataModel: DataModel) {
        const fields = dataModel.fields
            .filter((x) => !isRelationshipField(x))
            .map((x) => {
                return [
                    x.type.type || x.type.reference?.ref?.name,
                    x.name,
                    isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '',
                    x.type.optional ? '"?"' : '',
                ].join(' ');
            })
            .map((x) => `  ${x}`)
            .join('\n');

        const relations = dataModel.fields
            .filter((x) => isRelationshipField(x))
            .map((x) => {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                const oppositeModel = x.type.reference!.ref as DataModel;

                const oppositeField = oppositeModel.fields.find(
                    (x) => x.type.reference?.ref == dataModel
                ) as DataModelField;

                const currentType = x.type;
                const oppositeType = oppositeField.type;

                let relation = '';

                if (currentType.array && oppositeType.array) {
                    //many to many
                    relation = '}o--o{';
                } else if (currentType.array && !oppositeType.array) {
                    //one to many
                    relation = '||--o{';
                } else if (!currentType.array && oppositeType.array) {
                    //many to one
                    relation = '}o--||';
                } else {
                    //one to one
                    relation = currentType.optional ? '||--o|' : '|o--||';
                }

                return [`"${dataModel.name}"`, relation, `"${oppositeField.$container.name}": ${x.name}`].join(' ');
            })
            .join('\n');

        return ['```

mermaid', 'erDiagram', {% raw %}`"${dataModel.name}" {\n${fields}\n}`{% endraw %}, relations, '

```'].join('\n');
    }
}
Enter full screen mode

Exit full screen mode


Sew everything up

Finally, we will combine all the generated components together to get the final file:

 const modelChapter = dataModels
        .map((x) => {
            return [
                `### ${x.name}`,
                mermaidGenerator.generate(x),
                object.models
                    .find((model) => model.name === x.name)
                    ?.access_control_policies.map((x) => `- ${x}`)
                    .join('\n'),
            ].join('\n');
        })
        .join('\n');

 const content = [
        `# Technical Design Document`,
        '> Generated by [`ZenStack-markdown`](https://github.com/jiashengguo/zenstack-markdown)',
        `${object.overview.description}`,
        `## Functionality`,
        `${object.overview.functionality}`,
        '## Models:',
        dataModels.map((x) => `- [${x.name}](#${x.name})`).join('\n'),
        modelChapter,
    ].join('\n\n');
Enter full screen mode

Exit full screen mode


off the shelf

Of course, you don’t have to implement this yourself. It has been released as an NPM package for you to install:

npm i -D zenstack-markdown
Enter full screen mode

Exit full screen mode

Add the plugin to your ZModel schema file

plugin zenstackmd {
    provider = 'zenstack-markdown'
}
Enter full screen mode

Exit full screen mode

Just don’t forget to put any available AI API keys into .env. Otherwise, you might get some surprising results. 😉

OPENAI_API_KEY=xxxx
XAI_API_KEY=xxxxx
ANTHROPIC_API_KEY=xxxx
Enter full screen mode

Exit full screen mode

2024-12-23 06:04:53

Leave a Reply

Your email address will not be published. Required fields are marked *