-
-
Notifications
You must be signed in to change notification settings - Fork 132
Description
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
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 toolHigh-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).
-
zgenvalidates, 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).
- string module references in JSON:
Modes:
- DSL-first (default when
schema.zmodelexists): 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 diffto show differences andzgen syncto update chosen primary source (requires conservative conflict rules).
Complex example (improved for ZenStack features)
Below are three representations of the same, relatively complex domain:
- ZModel — what you'd place into
schema.zmodel(ZenStack-style). - JSON — canonical machine-friendly representation that a generator can consume.
- 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-libis a minimal illustrative API; the real implementation should:- avoid executing resolver code at generation time (store module references),
- emit
generated/schema.tsthat 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-execfor advanced use where generation time execution is desired (dangerous — should be opt-in).
Migration & compatibility
- Default: prefer
schema.zmodelif present (no breaking change). - Teams opt into code-first by setting
zgen.source = "ts"orzgen.source = "json"in config. - Provide
zgen diffto surface conflicts;zgen synccould be opt-in and guarded by review. - Generated artifacts may be committed or added to
.gitignoredepending 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 diffin PRs to prevent unintended schema drift.
Open questions & decisions for discussion
- Which source should be canonical by default in multi-source repos (prefer ZModel unless configured)?
- Should the generator support bidirectional sync or only one-way generation? (bidirectional increases complexity)
- How should function references be expressed in JSON to cover nested exports and default exports? (proposed
"path/to/module#exportName") - Should generated artifacts be committed by default, or left as build artifacts? (configurable)
- How to represent ACLs /
@@allowrules in the JSON/TS descriptor so they map clearly to ZenStack runtime checks? - 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?
- Import tooling: Should
zgenprovideimportcommand 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
- https://kysely.dev/docs/examples/select/function-calls
- https://orm.drizzle.team/docs/sql-schema-declaration
- https://docs.convex.dev/database/schemas
- https://sequelize.org/docs/v6/core-concepts/model-basics/
- https://www.instantdb.com/docs/modeling-data
- https://github.com/ridafkih/schemix
- https://github.com/amplication/prisma-schema-dsl
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=tssupport and ensure generator never executes user code by default - Add
zgen importcommand for Prisma/TypeORM/Mongoose migration - Add integration tests (Prisma migration + runtime computed fields)
- Document recommended workflow (commit generated artifacts vs ignore)