Skip to content

Commit 6ad87cb

Browse files
authored
blog: zenstack next chapter part III (#443)
1 parent c6d971f commit 6ad87cb

File tree

3 files changed

+199
-5
lines changed

3 files changed

+199
-5
lines changed

blog/next-chapter-1/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: ZenStack - The Next Chapter (Part I. Overview)
33
description: Looking into the future of ZenStack.
4-
tags: [zenstack]
4+
tags: [zenstack, v3]
55
authors: yiming
66
image: ./cover.png
77
date: 2025-04-08

blog/next-chapter-2/index.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: ZenStack - The Next Chapter (Part II. An Extensible ORM)
33
description: This post explores how ZenStack V3 will become a more extensible ORM.
4-
tags: [zenstack]
4+
tags: [zenstack, v3]
55
authors: yiming
66
image: ../next-chapter-1/cover.png
77
date: 2025-04-10
@@ -21,10 +21,10 @@ While continuing to provide the fully typed CRUD API like `PrismaClient` (`findM
2121

2222
```ts
2323
// CRUD API (the same as PrismaClient)
24-
await db.user.findMany({ include: { posts: true } });
24+
await client.user.findMany({ include: { posts: true } });
2525

2626
// Query builder API (backed by Kysely)
27-
await db.$qb.selectFrom('User')
27+
await client.$qb.selectFrom('User')
2828
.leftJoin('Post', 'Post.authorId', 'User.id')
2929
.select(['User.id', 'User.email', 'Post.title'])
3030
.execute()
@@ -35,7 +35,7 @@ Both the CRUD API and the query builder API are automatically inferred from the
3535
What's even more powerful is that you can blend query builder into CRUD calls. For complex queries, you can still enjoy the terse syntax of the CRUD API, and mix in the query builder for extra expressiveness. Here's an example:
3636

3737
```ts
38-
await db.user.findMany({
38+
await client.user.findMany({
3939
where: {
4040
age: { gt: 18 },
4141
// "eb" is a Kysely expression builder

blog/next-chapter-3/index.md

+194
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
---
2+
title: ZenStack - The Next Chapter (Part III. New Plugin System)
3+
description: This post explores the new plugin system of ZenStack v3.
4+
tags: [zenstack, v3]
5+
authors: yiming
6+
image: ../next-chapter-1/cover.png
7+
date: 2025-04-11
8+
---
9+
10+
# ZenStack - The Next Chapter (Part III. New Plugin System)
11+
12+
![Cover Image](../next-chapter-1/cover.png)
13+
14+
In the [previous post](../next-chapter-2/index.md), we discussed the new extensibility opportunities of the core ORM by adopting Kysely. In this post, we'll continue exploring the plan for v3's new plugin system that allows you to deeply customize ZenStack's behavior in a clean and maintainable way.
15+
16+
<!-- truncate -->
17+
18+
## Plugin Composition
19+
20+
ZenStack v2 provided a rudimentary [plugin system](../../docs/the-complete-guide/part2) that allows you to participate in the process of `zenstack generate`. It's sufficient for use cases like generating OpenAPI specs or TanStack Query hooks. However, there's no well-defined way to extend the ORM's runtime behavior in a pluggable way.
21+
22+
V3 aims to provide a more complete plugin system that allows you to contribute at the schema, generation, and runtime levels. A plugin can include the following parts:
23+
24+
1. A `plugin.zmodel` file that can define attributes, functions, procedures, etc.
25+
V2 already offers this. When running `zenstack generate`, all ZModel contributions from plugins will be merged with the user ZModel files.
26+
27+
1. A generator function that's called during generation
28+
V2 already offers this. The function will be called at `zenstack generate` time and given the ZModel AST (and potentially the Prisma DMMF too, TBD) as input. The generator can interpret attributes, functions, etc. defined in `plugin.zmodel`.
29+
30+
1. A runtime plugin class that implements various callbacks
31+
This provides great flexibility for a plugin to hook into the ORM's lifecycle at various levels - more about this in the next section.
32+
33+
## Runtime Plugin
34+
35+
A runtime plugin is an object satisfying the following interface. It may look a bit complex because it contains callbacks for different purposes. Don't worry, we'll dissect them shortly.
36+
37+
```ts
38+
interface RuntimePlugin {
39+
/**
40+
* Plugin ID.
41+
*/
42+
id: string;
43+
44+
/**
45+
* Intercepts an ORM query.
46+
*/
47+
onQuery?: (
48+
args: PluginContext,
49+
proceed: ProceedQueryFunction
50+
) => Promise<unknown>;
51+
52+
/**
53+
* Kysely query transformation.
54+
*/
55+
transformKyselyQuery?: (
56+
args: PluginTransformKyselyQueryArgs
57+
) => RootOperationNode;
58+
59+
/**
60+
* Kysely query result transformation.
61+
*/
62+
transformKyselyResult?: (
63+
args: PluginTransformKyselyResultArgs
64+
) => Promise<QueryResult<UnknownRow>>;
65+
66+
/**
67+
* This callback determines whether a mutation should be intercepted, and if so,
68+
* what data should be loaded before and after the mutation.
69+
*/
70+
mutationInterceptionFilter?: (
71+
args: MutationHooksArgs
72+
) => MaybePromise<MutationInterceptionFilterResult>;
73+
74+
/**
75+
* Called before an entity is mutated.
76+
*/
77+
beforeEntityMutation?: (
78+
args: PluginBeforeEntityMutationArgs
79+
) => MaybePromise<void>;
80+
81+
/**
82+
* Called after an entity is mutated.
83+
*/
84+
afterEntityMutation?: (
85+
args: PluginAfterEntityMutationArgs
86+
) => MaybePromise<void>;
87+
}
88+
```
89+
90+
To install a plugin, simply call the client's `$use` method to pass in its definition.
91+
92+
```ts
93+
const client = new ZenStackClient(schema)
94+
.$use({
95+
id: 'my-plugin',
96+
onQuery: (args, proceed) => ...
97+
});
98+
```
99+
100+
### ORM Query Interception
101+
102+
A high-level way of hooking into the ORM's lifecycle is to intercept the CRUD calls: `create`, `update`, `findMany`, etc. The `args` parameter contains the model (e.g., "User"), the operation (e.g., "findMany"), and the arguments (e.g.: `{ where: { id: 1 } }`). The `proceed` parameter is an async function that triggers the CRUD's execution. Things you can do include:
103+
104+
1. Executing arbitrary code before and after calling `proceed`.
105+
2. Altering the query arguments.
106+
3. Transforming the query results.
107+
4. Overriding the call completely without calling `proceed`.
108+
109+
Here's an example of logging slow queries:
110+
111+
```ts
112+
const client = new ZenStackClient(schema)
113+
.$use({
114+
id: 'slow-query-logger',
115+
onQuery: async (args, proceed) => {
116+
const start = Date.now();
117+
const result = await proceed(args.queryArgs);
118+
const duration = Date.now() - start;
119+
if (duration > 1000) {
120+
logger.log(`Slow query: ${args.model}, ${args.operation}, ${JSON.stringify(args.queryArgs)}`);
121+
}
122+
return result;
123+
}
124+
});
125+
```
126+
127+
The plugin can also have side effects by making extra ORM calls (the `args` parameter includes the current `ZenStackClient` instance), or even start a transaction to group multiple operations.
128+
129+
ORM query interception is useful for many scenarios, but it doesn't intercept CRUD made with the query builder API:
130+
131+
```ts
132+
// the following call will not trigger the plugin's `onQuery` callback
133+
await client.$qb.selectFrom('User')
134+
.leftJoin('Post', 'Post.authorId', 'User.id')
135+
.select(['User.id', 'User.email', 'Post.title'])
136+
.execute();
137+
```
138+
139+
To ubiquitously handle all database operations, whether originating from ORM call or query builder, use the Kysely transformers explained in the next section.
140+
141+
### Kysely Transformation
142+
143+
Kysely has a built-in [plugin mechanism](https://kysely.dev/docs/plugins) that allows you to transform the SQL-like query tree before execution and transform the query result before returning to the caller. ZenStack v3 will leverage it directly in its plugin system. Here's an example for automatically attaching prefixes to id fields during insert:
144+
145+
```ts
146+
import { OperationNodeTransformer } from 'kysely';
147+
148+
const client = new ZenStackClient(schema)
149+
.$use({
150+
id: 'id-prefixer',
151+
transformKyselyQuery: ({node}) => {
152+
if (!InsertQueryNode.is(node)) {
153+
return node;
154+
} else {
155+
return new IdPrefixTransformer().transform(node);
156+
}
157+
}
158+
});
159+
160+
// a transformer that recursively visit the node and prefix primary key
161+
// assignment values
162+
class IdPrefixTransformer extends OperationNodeTransformer {
163+
...
164+
}
165+
```
166+
167+
No matter how you access the database with ZenStack, using the ORM API or query builder, eventually, an operation is transformed into a Kysely query tree and then executed. Intercepting at the Kysely level allows you to preprocess and post-process all database queries uniformly.
168+
169+
### Entity Mutation Hooks
170+
171+
Sometimes, you don't care about what SQL is executed, but instead, you want to tap into entity mutation events: entities created, updated, or deleted. You can intercept entity mutations via [ORM Query Interception](#orm-query-interception) or [Kysely Transformation](#kysely-transformation), but it can be rather complex to implement.
172+
173+
The entity mutation hooks are designed to make this scenario easy. It allows you to directly provide callbacks invoked before and after a mutation happens. There are a few complexities to consider, though:
174+
175+
1. Accessing the entities pre/post mutation
176+
177+
It's often desirable to be able to inspect entities impacted by the mutation (before and after). However, loading the entities can be expensive, especially for mutations affecting many rows.
178+
179+
2. Side effects and transaction
180+
181+
There can be cases where you want to have database side effects in your callbacks, and you may wish your side effects and the original mutation to happen atomically. However, unconditionally employing a transaction for every mutation can bring unnecessary overhead.
182+
183+
To mitigate these problems, a runtime plugin allows you to provide an additional `mutationInterceptionFilter` callback to "preflight" an interception. The callback gives you access to the mutation that's about to happen and lets you return several pieces of information that control the interception:
184+
185+
- If the mutation should be intercepted at all
186+
- If the pre-mutation entities should be loaded and passed to the `beforeEntityMutation` call
187+
- If the post-mutation entities should be loaded and passed to the `afterEntityMutation` call
188+
- If a transaction should be used to wrap around the before/after hooks call and the mutation itself
189+
190+
By carefully implementing the "preflight" callback, you can minimize the performance impact caused by the mutation hooks.
191+
192+
## Conclusion
193+
194+
ZenStack v3's core part will focus on providing the database access primitives, and most of the upper-level features will be implemented as plugins. This can include access control, soft delete, encryption, etc. An emphasis on extensibility will allow developers to adapt the ORM to the exact needs of their applications.

0 commit comments

Comments
 (0)