Skip to content

[Feature Request] Support code-first schema definitions #2390

@AlexRMU

Description

@AlexRMU

Summary
This issue proposes adding an optional code-first / data-driven pipeline that allows teams to author (or augment) schemas in TypeScript or JSON, generate typed runtime artifacts and a schema.prisma (or .zmodel) artifact, and wire computed fields / checks / hooks to real resolver functions. The approach preserves backward compatibility with the existing ZModel workflow while enabling programmatic generation, better reuse, and easier CI automation.


Architecture

flowchart TB
  subgraph SOURCE["Source Definitions"]
    direction TB
    ZMODEL["schema.zmodel (ZModel)"]
    TS_DEF["schema.def.ts (code-first)"]
    JSON_DEF["schema.def.json (data-driven)"]
    ZMODEL ~~~ TS_DEF ~~~ JSON_DEF
  end

  subgraph GENERATOR["Generator"]
    direction TB
    GEN["zgen (generator)"]
    DIFF["zgen diff / lint"]
    GEN ~~~ DIFF
  end

  subgraph ARTIFACTS["Artifacts"]
    direction TB
    PRISMA["schema.prisma (generated)"]
    GEN_TS["generated/schema.ts<br>(types + resolver manifest)"]
    PRISMA ~~~ GEN_TS
  end

  subgraph NOTE_SUB[" "]
    direction TB
    NOTE["Generated artifacts:<br>- type-safe model interfaces<br>- computed-field resolver map (module path refs)<br>- metadata for checks/hooks"]
  end

  subgraph RUNTIME["Runtime"]
    direction TB
    ZEN["ZenStack runtime<br>(type-safe ORM & ACL)"]
    PRISMA_CLIENT["Prisma Client"]
    KYS["Kysely / custom resolvers"]
    ZEN ~~~ PRISMA_CLIENT ~~~ KYS
  end

  SOURCE --> GENERATOR
  ZMODEL -->|`npx zenstack generate`| GEN
  TS_DEF -->|`zgen --from=ts`| GEN
  JSON_DEF -->|`zgen --from=json`| GEN

  GENERATOR --> ARTIFACTS
  GEN --> PRISMA
  GEN --> GEN_TS
  DIFF --> GEN

  ARTIFACTS --> RUNTIME
  PRISMA -->|used by| PRISMA_CLIENT
  GEN_TS -->|imports| KYS
  GEN_TS -->|used by| ZEN

  PRISMA_CLIENT -.->|enhanced by| ZEN
  KYS -.->|enhanced by| ZEN

  GEN_TS --> NOTE_SUB

  classDef note fill:#fffacd,stroke:#ffd700,stroke-width:2px
  class NOTE note

  style SOURCE fill:#f0f8ff,stroke:#4682b4,stroke-width:2px
  style GENERATOR fill:#f0fff0,stroke:#228b22,stroke-width:2px
  style ARTIFACTS fill:#fffaf0,stroke:#cd853f,stroke-width:2px
  style RUNTIME fill:#faf0ff,stroke:#9932cc,stroke-width:2px
  style NOTE_SUB fill:none,stroke:none
Loading

Problem statement

  • ZModel (and schema.prisma) are concise and expressive, but some teams want to:

    • express computed fields, checks and advanced metadata in actual code (functions, typed helpers);
    • generate schemas programmatically (templates, multi-tenant generation, infra scripts);
    • reuse TypeScript utilities, types and runtime logic rather than duplicating logic into DSL;
    • integrate schema generation into CI pipelines in a machine-friendly format (JSON).
  • Current friction:

    • manual sync between DSL and any auxiliary code;
    • DSL cannot express arbitrary runtime function logic — only references (e.g. @computed) that must be wired separately;
    • poor ergonomics for code-first teams who prefer TypeScript-first workflows.

Goal: provide an opt-in pipeline that keeps ZModel fully supported while allowing TS/JSON-first workflows that generate the same runtime artifacts (typed interfaces, resolver manifests) and, when desired, schema.prisma/.zmodel for existing migration flows.


JSON Schema standard and extension

Why JSON Schema matters

JSON Schema is an IETF standard (draft 2020-12) for describing JSON data structures. Many tools and libraries support it:

Category Examples
Validation Ajv, Zod, Yup, Joi (export support)
Documentation Stoplight, Redoc, Swagger/OpenAPI
Code generation quicktype, json-schema-to-ts
IDE support VS Code, IntelliJ (autocomplete, validation)
Databases MongoDB Schema Validation, PostgreSQL JSON Schema

Benefits of basing on JSON Schema:

  • Interoperability with existing ecosystem
  • IDE autocompletion for schema.def.json
  • Reuse of validation tools
  • Migration from other ORMs/tools that export JSON Schema

Proposed approach: JSON Schema + ZenStack Extension

Instead of inventing a custom JSON format, extend JSON Schema with ZenStack-specific keywords:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://zenstack.dev/schemas/core.json",
  "definitions": {
    "Role": {
      "type": "string",
      "enum": ["USER", "MODERATOR", "ADMIN"]
    }
  },

  "x-zenstack-models": {
    "User": {
      "extends": "AuditModel",
      "properties": {
        "id": {
          "type": "integer",
          "x-zenstack-id": true,
          "x-zenstack-default": { "autoincrement": true }
        },
        "email": {
          "type": "string",
          "format": "email",
          "x-zenstack-unique": true
        },
        "postCount": {
          "type": "integer",
          "x-zenstack-computed": true,
          "x-zenstack-resolver": "resolvers/user#postCount"
        }
      },
      "required": ["id", "email"],
      "x-zenstack-indexes": [{ "fields": ["email"] }],
      "x-zenstack-acl": [
        { "action": "all", "expr": "role == 'ADMIN'" }
      ]
    }
  }
}

Extension keywords (x-zenstack-*)

Keyword Type Description
x-zenstack-id boolean Mark as primary key
x-zenstack-default object Default value (now, autoincrement, uuid)
x-zenstack-unique boolean Unique constraint
x-zenstack-optional boolean Nullable field
x-zenstack-relation object Relation definition
x-zenstack-computed boolean Computed field
x-zenstack-resolver string Resolver reference (module#export)
x-zenstack-indexes array Index definitions
x-zenstack-checks array Validation rules
x-zenstack-acl array Access control rules
x-zenstack-models object Model definitions container
x-zenstack-abstract boolean Abstract model (mixin)
x-zenstack-extends string Model inheritance

Migration path

Phase 1: Support both formats
├── schema.def.json (legacy, as shown in examples)
└── schema.schema.json (JSON Schema based, with x-zenstack-* extensions)

Phase 2: JSON Schema becomes primary
├── $schema property required
└── Legacy format deprecated with warning

Phase 3: Legacy format removed
└── Only JSON Schema format supported

Import from existing tools

Many ORMs and tools can export JSON Schema, enabling migration:

Source Export method
Prisma prisma-json-schema-generator
TypeORM typeorm-json-schema
Mongoose mongoose-to-json-schema
Zod zodToJsonSchema()
TypeScript ts-json-schema-generator
# Example: Import from Prisma
prisma generate --schema=./prisma/schema.prisma
# → Generates JSON Schema
# → Add x-zenstack-* extensions manually or via tool

High-level solution

  • Introduce a generator CLI (zgen) that accepts three possible sources:

    • schema.zmodel (existing ZModel),
    • schema.def.json (data-first),
    • schema.def.ts (code-first TypeScript descriptors).
  • zgen validates, transforms and emits:

    • schema.prisma (or .zmodel) to preserve migrations and Prisma workflows;
    • generated/schema.ts — typed interfaces, manifest that maps computed fields/checks/hooks to module path references or safe imports;
    • optional runtime wiring helpers (e.g., loadResolvers()).
  • Computed fields / checks / hooks may be referenced as:

    • string module references in JSON: "resolvers/user#postCount",
    • direct imports in TS (the generator records the reference but must not execute arbitrary user code during generation unless explicitly allowed).

Modes:

  • DSL-first (default when schema.zmodel exists): generator derives code artifacts from ZModel.
  • Code/JSON-first: type-safe code / JSON is primary — generator produces DSL artifacts for migration compatibility.
  • Bidirectional (optional): zgen diff to show differences and zgen sync to update chosen primary source (requires conservative conflict rules).

Complex example (improved for ZenStack features)

Below are three representations of the same, relatively complex domain:

  1. ZModel — what you'd place into schema.zmodel (ZenStack-style).
  2. JSON — canonical machine-friendly representation that a generator can consume.
  3. TypeScript — code-first descriptors that reference real resolver functions.

The examples include: enums, abstract audit model, composite primary key, computed fields, indexes, checks, and ACL-like annotations (ZModel @@ directives). Computed fields are declared in the schema and mapped to resolver functions (Kysely-style) at runtime.


1) ZModel (schema.zmodel)

enum Role {
  USER
  MODERATOR
  ADMIN
}

abstract model AuditModel {
  createdAt DateTime @default(now())
  createdBy  String?
  updatedAt DateTime?
  updatedBy  String?
}

model User extends AuditModel {
  id           Int    @id @default(autoincrement())
  email        String @unique
  name         String?
  role         Role   @default(USER)
  posts        Post[]
  comments     Comment[]
  settings     Json?
  lastLogin    DateTime?
  isActive     Boolean @default(true)

  // computed — runtime provides resolver
  postCount    Int    @computed
  recentPosts  Post[] @computed

  @@allow("all", role == "ADMIN" || role == "MODERATOR")
  @@index([email])
}

model Post extends AuditModel {
  id           Int    @id @default(autoincrement())
  author        User   @relation(fields: [authorId], references: [id])
  authorId      Int
  title         String
  body          String
  published     Boolean @default(false)
  tags          String[] @default([])

  // computed: number of comments
  commentCount  Int    @computed

  comments      Comment[]
  meta          Json?

  @@index([authorId, published])
}

model Comment {
  id      Int @id @default(autoincrement())
  post    Post @relation(fields: [postId], references: [id])
  postId  Int
  author  User @relation(fields: [authorId], references: [id])
  authorId Int
  text    String

  @@check(text != "")
}

model Document {
  fileId String
  version Int
  ownerId Int
  content String

  @@id([fileId, version])
  @@index([ownerId])
}

2) JSON (schema.def.json)

Two formats are supported:

Option A: JSON Schema extension (recommended)

Uses standard JSON Schema with x-zenstack-* extensions for IDE support and ecosystem compatibility:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "./schema.def.json",
  "definitions": {
    "Role": {
      "type": "string",
      "enum": ["USER", "MODERATOR", "ADMIN"]
    }
  },
  "x-zenstack-abstractModels": {
    "AuditModel": {
      "properties": {
        "createdAt": { "type": "string", "format": "date-time", "x-zenstack-default": "now" },
        "createdBy": { "type": "string", "x-zenstack-optional": true },
        "updatedAt": { "type": "string", "format": "date-time", "x-zenstack-optional": true },
        "updatedBy": { "type": "string", "x-zenstack-optional": true }
      }
    }
  },
  "x-zenstack-models": {
    "User": {
      "x-zenstack-extends": "AuditModel",
      "properties": {
        "id": {
          "type": "integer",
          "x-zenstack-id": true,
          "x-zenstack-default": { "autoincrement": true }
        },
        "email": { "type": "string", "format": "email", "x-zenstack-unique": true },
        "name": { "type": "string", "x-zenstack-optional": true },
        "role": { "$ref": "#/definitions/Role", "x-zenstack-default": "USER" },
        "posts": { "type": "array", "items": { "$ref": "#/x-zenstack-models/Post" }, "x-zenstack-relation": true },
        "comments": { "type": "array", "items": { "$ref": "#/x-zenstack-models/Comment" }, "x-zenstack-relation": true },
        "settings": { "type": "object", "x-zenstack-optional": true },
        "lastLogin": { "type": "string", "format": "date-time", "x-zenstack-optional": true },
        "isActive": { "type": "boolean", "x-zenstack-default": true },
        "postCount": {
          "type": "integer",
          "x-zenstack-computed": true,
          "x-zenstack-resolver": "resolvers/user#postCount"
        },
        "recentPosts": {
          "type": "array",
          "items": { "$ref": "#/x-zenstack-models/Post" },
          "x-zenstack-computed": true,
          "x-zenstack-resolver": "resolvers/user#getRecentPosts"
        }
      },
      "required": ["id", "email"],
      "x-zenstack-indexes": [{ "fields": ["email"] }],
      "x-zenstack-acl": [
        { "action": "all", "expr": "role == 'ADMIN' || role == 'MODERATOR'" }
      ]
    },
    "Post": {
      "x-zenstack-extends": "AuditModel",
      "properties": {
        "id": { "type": "integer", "x-zenstack-id": true, "x-zenstack-default": { "autoincrement": true } },
        "authorId": { "type": "integer" },
        "author": {
          "$ref": "#/x-zenstack-models/User",
          "x-zenstack-relation": { "fields": ["authorId"], "references": ["id"] }
        },
        "title": { "type": "string" },
        "body": { "type": "string" },
        "published": { "type": "boolean", "x-zenstack-default": false },
        "tags": { "type": "array", "items": { "type": "string" }, "x-zenstack-default": [] },
        "commentCount": {
          "type": "integer",
          "x-zenstack-computed": true,
          "x-zenstack-resolver": "resolvers/post#commentCount"
        },
        "meta": { "type": "object", "x-zenstack-optional": true }
      },
      "required": ["id", "authorId", "title", "body"],
      "x-zenstack-indexes": [{ "fields": ["authorId", "published"] }]
    },
    "Comment": {
      "properties": {
        "id": { "type": "integer", "x-zenstack-id": true, "x-zenstack-default": { "autoincrement": true } },
        "postId": { "type": "integer" },
        "post": {
          "$ref": "#/x-zenstack-models/Post",
          "x-zenstack-relation": { "fields": ["postId"], "references": ["id"] }
        },
        "authorId": { "type": "integer" },
        "author": {
          "$ref": "#/x-zenstack-models/User",
          "x-zenstack-relation": { "fields": ["authorId"], "references": ["id"] }
        },
        "text": { "type": "string" }
      },
      "required": ["id", "postId", "authorId", "text"],
      "x-zenstack-checks": [
        { "expr": "text != ''", "message": "Comment text cannot be empty" }
      ]
    },
    "Document": {
      "properties": {
        "fileId": { "type": "string" },
        "version": { "type": "integer" },
        "ownerId": { "type": "integer" },
        "content": { "type": "string" }
      },
      "required": ["fileId", "version", "ownerId", "content"],
      "x-zenstack-primaryKey": ["fileId", "version"],
      "x-zenstack-indexes": [{ "fields": ["ownerId"] }]
    }
  }
}

Option B: Legacy format (simpler, but no IDE support)

{
  "enums": {
    "Role": ["USER", "MODERATOR", "ADMIN"]
  },
  "abstractModels": {
    "AuditModel": {
      "fields": {
        "createdAt": { "type": "DateTime", "default": "now" },
        "createdBy": { "type": "string", "optional": true },
        "updatedAt": { "type": "DateTime", "optional": true },
        "updatedBy": { "type": "string", "optional": true }
      }
    }
  },
  "models": {
    "User": {
      "extends": "AuditModel",
      "fields": {
        "id": {
          "type": "int",
          "id": true,
          "default": { "autoincrement": true }
        },
        "email": { "type": "string", "unique": true },
        "name": { "type": "string", "optional": true },
        "role": { "type": "Role", "default": "USER" },
        "posts": { "type": "Post[]", "relation": true },
        "comments": { "type": "Comment[]", "relation": true },
        "settings": { "type": "json", "optional": true },
        "lastLogin": { "type": "DateTime", "optional": true },
        "isActive": { "type": "boolean", "default": true },
        "postCount": {
          "type": "int",
          "computed": true,
          "resolver": "resolvers/user#postCount"
        },
        "recentPosts": {
          "type": "Post[]",
          "computed": true,
          "resolver": "resolvers/user#getRecentPosts"
        }
      },
      "indexes": [{ "fields": ["email"] }],
      "acl": [
        { "action": "all", "expr": "role == 'ADMIN' || role == 'MODERATOR'" }
      ]
    },

    "Post": {
      "extends": "AuditModel",
      "fields": {
        "id": {
          "type": "int",
          "id": true,
          "default": { "autoincrement": true }
        },
        "authorId": { "type": "int" },
        "author": {
          "type": "User",
          "relation": { "fields": ["authorId"], "references": ["id"] }
        },
        "title": { "type": "string" },
        "body": { "type": "string" },
        "published": { "type": "boolean", "default": false },
        "tags": { "type": "string[]", "default": [] },
        "commentCount": {
          "type": "int",
          "computed": true,
          "resolver": "resolvers/post#commentCount"
        },
        "meta": { "type": "json", "optional": true }
      },
      "indexes": [{ "fields": ["authorId", "published"] }]
    },

    "Comment": {
      "fields": {
        "id": {
          "type": "int",
          "id": true,
          "default": { "autoincrement": true }
        },
        "postId": { "type": "int" },
        "post": {
          "type": "Post",
          "relation": { "fields": ["postId"], "references": ["id"] }
        },
        "authorId": { "type": "int" },
        "author": {
          "type": "User",
          "relation": { "fields": ["authorId"], "references": ["id"] }
        },
        "text": { "type": "string" }
      },
      "checks": [
        { "expr": "text != ''", "message": "Comment text cannot be empty" }
      ]
    },

    "Document": {
      "fields": {
        "fileId": { "type": "string" },
        "version": { "type": "int" },
        "ownerId": { "type": "int" },
        "content": { "type": "string" }
      },
      "primaryKey": ["fileId", "version"],
      "indexes": [{ "fields": ["ownerId"] }]
    }
  }
}

Recommendation: Use JSON Schema extension format (Option A) for new projects. It provides IDE autocompletion, validation, and ecosystem compatibility. Legacy format (Option B) is kept for simplicity and backward compatibility.


3) TypeScript (code-first) — schema.def.ts

// schema.def.ts (code-first descriptor)
import { enumType, model, field, computed, check, id } from "schema-def-lib";
import * as userResolvers from "./resolvers/user";
import * as postResolvers from "./resolvers/post";

export const Role = enumType("Role", ["USER", "MODERATOR", "ADMIN"]);

export const AuditModel = model("AuditModel", {
  createdAt: field.date().defaultNow(),
  createdBy: field.string().optional(),
  updatedAt: field.date().optional(),
  updatedBy: field.string().optional(),
});

export const User = model("User", {
  extends: AuditModel,
  id: id.int().autoIncrement(),
  email: field.string().unique(),
  name: field.string().optional(),
  role: field.enum(Role).default("USER"),
  posts: field.relation("Post[]"),
  comments: field.relation("Comment[]"),
  settings: field.json().optional(),
  lastLogin: field.date().optional(),
  isActive: field.boolean().default(true),

  // computed fields: point to resolver functions (module references)
  postCount: computed.int({ resolver: userResolvers.postCount }),
  recentPosts: computed.relation("Post[]", { resolver: userResolvers.getRecentPosts }),

  // ACL example
  @@allow: [{ action: "all", expr: (r: any) => r.role === "ADMIN" || r.role === "MODERATOR" }]
});

export const Post = model("Post", {
  extends: AuditModel,
  id: id.int().autoIncrement(),
  author: field.relation("User", { fields: ["authorId"], references: ["id"] }),
  authorId: field.int(),
  title: field.string(),
  body: field.string(),
  published: field.boolean().default(false),
  tags: field.stringArray().default([]),
  commentCount: computed.int({ resolver: postResolvers.commentCount }),
  comments: field.relation("Comment[]"),
  meta: field.json().optional(),
  @@index: [["authorId", "published"]]
});

export const Comment = model("Comment", {
  id: id.int().autoIncrement(),
  post: field.relation("Post", { fields: ["postId"], references: ["id"] }),
  postId: field.int(),
  author: field.relation("User", { fields: ["authorId"], references: ["id"] }),
  authorId: field.int(),
  text: field.string(),
  @@check: [ check("text != ''", "Comment text cannot be empty") ],
});

export const Document = model("Document", {
  fileId: field.string(),
  version: field.int(),
  ownerId: field.int(),
  content: field.string(),
  @@id: [["fileId", "version"]],
  @@index: [["ownerId"]]
});

Notes about the TS approach

  • The schema-def-lib is a minimal illustrative API; the real implementation should:
    • avoid executing resolver code at generation time (store module references),
    • emit generated/schema.ts that either imports resolvers lazily at runtime or maps string module refs to runtime imports,
    • produce source maps / metadata so computed-field wiring is debuggable.

Generator behavior & CLI

  • zgen generate --from=ts|json|zmodel --out=./generated — validate and emit artifacts.
  • zgen diff --from=ts --to=zmodel — show diffs between sources, used in PR checks.
  • zgen check --schema=schema.zmodel — run ZModel validation (wraps ZenStack check).
  • zgen watch — optional watch mode for development.

Safety rules

  • By default, the generator must not execute user-defined functions during generation.
  • JSON uses "module#export" strings for resolvers; TS may use direct imports but generator should write import expressions into emitted artifacts rather than executing them.
  • Provide an explicit --allow-exec for advanced use where generation time execution is desired (dangerous — should be opt-in).

Migration & compatibility

  • Default: prefer schema.zmodel if present (no breaking change).
  • Teams opt into code-first by setting zgen.source = "ts" or zgen.source = "json" in config.
  • Provide zgen diff to surface conflicts; zgen sync could be opt-in and guarded by review.
  • Generated artifacts may be committed or added to .gitignore depending on team preference (document both workflows).

Testing, CI, and validation

  • Unit tests: generator transforms (snapshots).
  • Integration: TS/JSON -> generate -> prisma migrate dev -> run smoke queries.
  • Runtime tests: wire resolvers with a test DB and assert computed fields.
  • CI: run zgen diff in PRs to prevent unintended schema drift.

Open questions & decisions for discussion

  1. Which source should be canonical by default in multi-source repos (prefer ZModel unless configured)?
  2. Should the generator support bidirectional sync or only one-way generation? (bidirectional increases complexity)
  3. How should function references be expressed in JSON to cover nested exports and default exports? (proposed "path/to/module#exportName")
  4. Should generated artifacts be committed by default, or left as build artifacts? (configurable)
  5. How to represent ACLs / @@allow rules in the JSON/TS descriptor so they map clearly to ZenStack runtime checks?
  6. JSON Schema vs custom format:
    • JSON Schema extension (Option A) provides IDE support, validation, ecosystem tools
    • Legacy custom format (Option B) is simpler but lacks tooling
    • Should both be supported long-term, or migrate to JSON Schema only?
  7. Import tooling: Should zgen provide import command to convert from Prisma/TypeORM/Mongoose JSON Schema exports?

Example resolver (runtime) pattern

resolvers/user.ts (Kysely-style):

import { Kysely } from "kysely";
import { Database } from "../db-types";

export async function postCount(db: Kysely<Database>, userId: number) {
  const row = await db
    .selectFrom("Post")
    .where("authorId", "=", userId)
    .select(db.fn.count<string>("id").as("c"))
    .executeTakeFirst();
  return Number(row?.c ?? 0);
}

export async function getRecentPosts(
  db: Kysely<Database>,
  userId: number,
  limit = 5,
) {
  return db
    .selectFrom("Post")
    .selectAll()
    .where("authorId", "=", userId)
    .orderBy("createdAt", "desc")
    .limit(limit)
    .execute();
}

At runtime, the ZenStack client (or the generated manifest loader) maps User.postCount to resolvers/user.postCount.


Related / inspirational projects & docs


Checklist

  • Discuss: default canonical source (ZModel vs TS/JSON)
  • Agree on resolver reference format for JSON (module#export)
  • Decide: JSON Schema extension vs legacy format (recommend JSON Schema for new projects)
  • Implement MVP generator: json -> generated/schema.ts + zgen diff
  • Add JSON Schema meta-schema for x-zenstack-* keywords (IDE autocompletion)
  • Add --from=ts support and ensure generator never executes user code by default
  • Add zgen import command for Prisma/TypeORM/Mongoose migration
  • Add integration tests (Prisma migration + runtime computed fields)
  • Document recommended workflow (commit generated artifacts vs ignore)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions