|
| 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 | + |
| 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