@wordrhyme/auto-crud-server 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,27 +75,32 @@ export const tasks = pgTable("tasks", {
75
75
  // src/server/routers/tasks.ts
76
76
  import { createCrudRouter } from "@wordrhyme/auto-crud-server";
77
77
  import { tasks } from "@/db/schema";
78
+
79
+ // 🚀 零配置!一行代码生成完整 CRUD 路由
80
+ export const tasksRouter = createCrudRouter({
81
+ table: tasks,
82
+ // Schema 自动从 table 派生,排除 id, createdAt, updatedAt
83
+ });
84
+ ```
85
+
86
+ **或者,显式传入 Schema:**
87
+
88
+ ```typescript
89
+ import { createCrudRouter } from "@wordrhyme/auto-crud-server";
90
+ import { tasks } from "@/db/schema";
78
91
  import { createSelectSchema } from "drizzle-zod";
79
92
 
80
93
  // 从 Drizzle Schema 自动生成 Zod Schema
81
- const selectTaskSchema = createSelectSchema(tasks);
82
-
83
- // 创建输入 Schema
84
- const insertTaskSchema = selectTaskSchema.omit({
94
+ const taskSchema = createSelectSchema(tasks).omit({
85
95
  id: true,
86
96
  createdAt: true,
87
97
  updatedAt: true,
88
98
  });
89
99
 
90
- const updateTaskSchema = insertTaskSchema.partial();
91
-
92
- // 🚀 一行代码生成完整 CRUD 路由
93
100
  export const tasksRouter = createCrudRouter({
94
101
  table: tasks,
95
- selectSchema: selectTaskSchema,
96
- insertSchema: insertTaskSchema,
97
- updateSchema: updateTaskSchema,
98
- idField: "id", // 可选,默认为 "id"
102
+ schema: taskSchema, // 主 Schema(用于 create/upsert)
103
+ // updateSchema 自动派生为 schema.partial()
99
104
  });
100
105
  ```
101
106
 
@@ -440,14 +445,57 @@ await trpc.tasks.list({
440
445
 
441
446
  ```typescript
442
447
  interface CrudRouterConfig<TTable, TSelect, TInsert, TUpdate> {
448
+ // ========== 必填 ==========
443
449
  table: TTable; // Drizzle 表定义
444
- selectSchema: z.ZodType<TSelect>; // 查询返回 Schema
445
- insertSchema: z.ZodType<TInsert>; // 创建输入 Schema
446
- updateSchema: z.ZodType<TUpdate>; // 更新输入 Schema
450
+
451
+ // ========== Schema 配置(可选) ==========
452
+ schema?: z.ZodType<TInsert>; // Schema(用于 create/upsert)
453
+ updateSchema?: z.ZodType<TUpdate>;// 更新 Schema(覆盖自动派生)
454
+ selectSchema?: z.ZodType<TSelect>;// 查询返回 Schema
455
+
456
+ // ========== 其他配置 ==========
447
457
  idField?: string; // ID 字段名,默认 "id"
458
+ omitFields?: string[]; // 自动派生时排除的字段
459
+ // 默认 ["id", "createdAt", "updatedAt"]
448
460
  }
449
461
  ```
450
462
 
463
+ #### Schema 派生规则
464
+
465
+ | 配置 | 派生行为 |
466
+ |-----|---------|
467
+ | 无 `schema` | 从 `table` 自动派生,排除 `omitFields` |
468
+ | 无 `updateSchema` | 从 `schema.partial().refine(nonEmpty)` 派生 |
469
+ | 无 `selectSchema` | 若有 `schema` 则使用它,否则从 `table` 派生 |
470
+
471
+ #### 使用示例
472
+
473
+ ```typescript
474
+ // 1. 零配置 - 全部自动派生
475
+ const tasksRouter = createCrudRouter({
476
+ table: tasks,
477
+ });
478
+
479
+ // 2. 传入 schema,updateSchema 自动派生
480
+ const postsRouter = createCrudRouter({
481
+ table: posts,
482
+ schema: postSchema,
483
+ });
484
+
485
+ // 3. 传入 schema + 自定义 updateSchema
486
+ const usersRouter = createCrudRouter({
487
+ table: users,
488
+ schema: userSchema,
489
+ updateSchema: customUpdateSchema,
490
+ });
491
+
492
+ // 4. 自定义排除字段
493
+ const ordersRouter = createCrudRouter({
494
+ table: orders,
495
+ omitFields: ["id", "createdAt", "updatedAt", "internalCode"],
496
+ });
497
+ ```
498
+
451
499
  #### 返回值
452
500
 
453
501
  ```typescript
@@ -459,95 +507,94 @@ interface CrudRouterConfig<TTable, TSelect, TInsert, TUpdate> {
459
507
  // 可 spread 的 procedures 对象(用于扩展自定义路由)
460
508
  procedures: {
461
509
  list: Procedure<ListInput, ListOutput>,
462
- getById: Procedure<string, TSelect>,
510
+ get: Procedure<string, TSelect>,
463
511
  create: Procedure<TInsert, TSelect>,
464
512
  update: Procedure<{ id: string, data: TUpdate }, TSelect>,
465
513
  delete: Procedure<string, TSelect>,
466
514
  deleteMany: Procedure<string[], { deleted: number }>,
467
515
  updateMany: Procedure<{ ids: string[], data: TUpdate }, { updated: number }>,
516
+ upsert: Procedure<TInsert, { data: TSelect, isNew: boolean }>,
468
517
  }
469
518
  }
470
519
  ```
471
520
 
472
- #### 完整示例
521
+ ---
522
+
523
+ ## 🔐 权限控制
524
+
525
+ ### 使用 procedure 配置
473
526
 
474
527
  ```typescript
475
528
  import { createCrudRouter } from "@wordrhyme/auto-crud-server";
476
- import { users } from "@/db/schema";
477
- import { createSelectSchema } from "drizzle-zod";
478
- import { z } from "zod";
529
+ import { protectedProcedure, adminProcedure, publicProcedure } from "../trpc";
479
530
 
480
- const selectUserSchema = createSelectSchema(users);
481
-
482
- const insertUserSchema = selectUserSchema.omit({
483
- id: true,
484
- createdAt: true,
485
- updatedAt: true,
531
+ export const tasksRouter = createCrudRouter({
532
+ table: tasks,
533
+ // 按操作指定不同的 procedure
534
+ procedure: {
535
+ list: publicProcedure, // 公开读
536
+ get: publicProcedure,
537
+ create: protectedProcedure, // 需要登录
538
+ update: protectedProcedure,
539
+ delete: adminProcedure, // 需要管理员
540
+ default: protectedProcedure,
541
+ },
486
542
  });
543
+ ```
487
544
 
488
- const updateUserSchema = insertUserSchema.partial();
545
+ ### 使用 guard(操作级守卫)
489
546
 
490
- export const usersRouter = createCrudRouter({
491
- table: users,
492
- selectSchema: selectUserSchema,
493
- insertSchema: insertUserSchema,
494
- updateSchema: updateUserSchema,
495
- idField: "id",
547
+ ```typescript
548
+ export const tasksRouter = createCrudRouter({
549
+ table: tasks,
550
+ guard: (ctx, operation) => {
551
+ // 删除操作需要管理员权限
552
+ if (operation === "delete") {
553
+ return ctx.user.role === "admin";
554
+ }
555
+ // 其他操作需要登录
556
+ return !!ctx.user;
557
+ },
496
558
  });
497
559
  ```
498
560
 
499
- ---
500
-
501
- ## 🔐 权限控制
502
-
503
- ### 使用 tRPC Middleware
561
+ ### 使用 scope(行级过滤 RLS)
504
562
 
505
563
  ```typescript
506
- import { initTRPC, TRPCError } from "@trpc/server";
507
-
508
- const t = initTRPC.context<Context>().create();
564
+ import { eq } from "drizzle-orm";
509
565
 
510
- // 创建受保护的 procedure
511
- const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
512
- if (!ctx.user) {
513
- throw new TRPCError({ code: "UNAUTHORIZED" });
514
- }
515
- return next({ ctx: { ...ctx, user: ctx.user } });
566
+ export const tasksRouter = createCrudRouter({
567
+ table: tasks,
568
+ // 自动注入到所有查询的 WHERE 子句
569
+ scope: (ctx, table, operation) => {
570
+ return eq(table.tenantId, ctx.user.tenantId);
571
+ },
516
572
  });
573
+ ```
574
+
575
+ ### 使用 authorize(资源级检查 ABAC)
517
576
 
518
- // 在 createCrudRouter 中使用
577
+ ```typescript
519
578
  export const tasksRouter = createCrudRouter({
520
579
  table: tasks,
521
- selectSchema: selectTaskSchema,
522
- insertSchema: insertTaskSchema,
523
- updateSchema: updateTaskSchema,
524
- // 注意:当前版本不支持直接传入 procedure
525
- // 需要在路由层面添加权限控制
580
+ // 在 get/update/delete 时,预取资源后调用
581
+ authorize: (ctx, resource, operation) => {
582
+ return resource.ownerId === ctx.user.id;
583
+ },
526
584
  });
527
-
528
- // 包装路由添加权限
529
- export const protectedTasksRouter = {
530
- list: protectedProcedure.query(async ({ input, ctx }) => {
531
- return tasksRouter.list(input, ctx);
532
- }),
533
- // ... 其他路由
534
- };
535
585
  ```
536
586
 
537
- ### 行级权限控制
587
+ ### 使用 inject(强制注入字段)
538
588
 
539
589
  ```typescript
540
- // createContext 中添加用户信息
541
- export const createContext = async ({ req }: { req: Request }) => {
542
- const user = await getUserFromRequest(req);
543
- return { db, user };
544
- };
545
-
546
- // 在查询中添加过滤条件
547
- const result = await db
548
- .select()
549
- .from(tasks)
550
- .where(eq(tasks.userId, ctx.user.id)); // 只查询当前用户的任务
590
+ export const tasksRouter = createCrudRouter({
591
+ table: tasks,
592
+ // 写入时强制覆盖字段(防止伪造)
593
+ inject: (ctx, operation) => ({
594
+ tenantId: ctx.user.tenantId,
595
+ ...(operation === "create" ? { createdBy: ctx.user.id } : { updatedBy: ctx.user.id }),
596
+ }),
597
+ });
551
598
  ```
552
599
 
553
600
  ---
@@ -566,9 +613,6 @@ import { z } from "zod";
566
613
  // 创建基础 CRUD
567
614
  const tasksCrud = createCrudRouter({
568
615
  table: tasks,
569
- selectSchema: selectTaskSchema,
570
- insertSchema: insertTaskSchema,
571
- updateSchema: updateTaskSchema,
572
616
  });
573
617
 
574
618
  // 使用 .procedures spread 并添加自定义路由
@@ -604,9 +648,6 @@ export const tasksRouter = router({
604
648
  ```typescript
605
649
  const tasksCrud = createCrudRouter({
606
650
  table: tasks,
607
- selectSchema: selectTaskSchema,
608
- insertSchema: insertTaskSchema,
609
- updateSchema: updateTaskSchema,
610
651
  });
611
652
 
612
653
  // 直接嵌套到 appRouter
@@ -619,7 +660,7 @@ export const appRouter = router({
619
660
 
620
661
  ```typescript
621
662
  // 覆盖 list 路由,添加自定义过滤逻辑
622
- const tasksCrud = createCrudRouter({ ... });
663
+ const tasksCrud = createCrudRouter({ table: tasks });
623
664
 
624
665
  export const tasksRouter = router({
625
666
  ...tasksCrud.procedures,
@@ -655,8 +696,6 @@ import { createCrudRouter } from "@wordrhyme/auto-crud-server";
655
696
 
656
697
  const tasksRouter = createCrudRouter({
657
698
  table: tasks,
658
- insertSchema: insertTaskSchema,
659
- updateSchema: updateTaskSchema,
660
699
 
661
700
  // 中间件:完全控制操作流程
662
701
  middleware: {
@@ -711,8 +750,6 @@ import {
711
750
 
712
751
  const tasksRouter = createCrudRouter({
713
752
  table: tasks,
714
- insertSchema: insertTaskSchema,
715
- updateSchema: updateTaskSchema,
716
753
 
717
754
  middleware: {
718
755
  // 简单副作用:只在操作后执行
@@ -811,24 +848,53 @@ app.listen(3000);
811
848
  ```typescript
812
849
  export const usersRouter = createCrudRouter({
813
850
  table: users,
814
- selectSchema: selectUserSchema,
815
- insertSchema: insertUserSchema,
816
- updateSchema: updateUserSchema,
817
851
  idField: "userId", // 使用自定义 ID 字段
818
852
  });
819
853
  ```
820
854
 
821
- ### 自定义过滤器配置
855
+ ### 自定义排除字段
822
856
 
823
857
  ```typescript
824
- // src/config/data-table.ts
825
- export const filterVariants = {
826
- text: ["eq", "ne", "like", "notLike"],
827
- number: ["eq", "ne", "gt", "gte", "lt", "lte", "between"],
828
- select: ["eq", "ne", "in", "notIn"],
829
- date: ["eq", "ne", "gt", "gte", "lt", "lte", "between"],
830
- boolean: ["eq"],
831
- };
858
+ export const ordersRouter = createCrudRouter({
859
+ table: orders,
860
+ // 自动派生 schema 时排除这些字段
861
+ omitFields: ["id", "createdAt", "updatedAt", "internalCode"],
862
+ });
863
+ ```
864
+
865
+ ### 软删除
866
+
867
+ ```typescript
868
+ export const tasksRouter = createCrudRouter({
869
+ table: tasks,
870
+ // 方式 1:使用默认列名 'deletedAt'
871
+ softDelete: true,
872
+
873
+ // 方式 2:指定列名
874
+ softDelete: "deletedAt",
875
+
876
+ // 方式 3:完整配置(用于布尔字段)
877
+ softDelete: { column: "isDeleted", value: () => true },
878
+ });
879
+ ```
880
+
881
+ ### 批量操作限制
882
+
883
+ ```typescript
884
+ export const tasksRouter = createCrudRouter({
885
+ table: tasks,
886
+ maxBatchSize: 50, // 默认 100
887
+ });
888
+ ```
889
+
890
+ ### 列白名单
891
+
892
+ ```typescript
893
+ export const tasksRouter = createCrudRouter({
894
+ table: tasks,
895
+ filterableColumns: ["title", "status", "priority"], // 只允许这些列过滤
896
+ sortableColumns: ["title", "createdAt", "priority"], // 只允许这些列排序
897
+ });
832
898
  ```
833
899
 
834
900
  ---
package/dist/index.cjs CHANGED
@@ -25,6 +25,8 @@ let zod = require("zod");
25
25
  zod = __toESM(zod);
26
26
  let drizzle_orm = require("drizzle-orm");
27
27
  drizzle_orm = __toESM(drizzle_orm);
28
+ let drizzle_zod = require("drizzle-zod");
29
+ drizzle_zod = __toESM(drizzle_zod);
28
30
  let __trpc_server = require("@trpc/server");
29
31
  __trpc_server = __toESM(__trpc_server);
30
32
  let superjson = require("superjson");
@@ -399,6 +401,26 @@ const dataTableConfig = {
399
401
  joinOperators: ["and", "or"]
400
402
  };
401
403
 
404
+ //#endregion
405
+ //#region src/lib/schema-utils.ts
406
+ /**
407
+ * 检查是否为 ZodObject 类型
408
+ * 兼容 Zod v3 和 v4
409
+ *
410
+ * 实现策略:检查 schema 是否具有 ZodObject 特有的 .shape 属性
411
+ * 这种方式比检查内部 _def 属性更稳定,因为不依赖 Zod 的内部实现细节
412
+ */
413
+ function isZodObject(schema) {
414
+ return schema !== null && typeof schema === "object" && "shape" in schema && typeof schema.shape === "object" && schema.shape !== null;
415
+ }
416
+ /**
417
+ * 非空对象验证器
418
+ * 用于 updateSchema 的 refine,防止空更新
419
+ */
420
+ function nonEmpty(value) {
421
+ return Object.keys(value).length > 0;
422
+ }
423
+
402
424
  //#endregion
403
425
  //#region src/routers/_factory.ts
404
426
  function resolveSoftDelete(option) {
@@ -485,20 +507,46 @@ const listInputSchema = zod.z.object({
485
507
  filters: zod.z.array(filterItemSchema).optional(),
486
508
  joinOperator: zod.z.enum(["and", "or"]).default("and")
487
509
  });
510
+ const DEFAULT_OMIT_FIELDS = [
511
+ "id",
512
+ "createdAt",
513
+ "updatedAt"
514
+ ];
515
+ /**
516
+ * 解析并派生 Schema
517
+ * - 优先使用显式传入的 Schema
518
+ * - 否则从 Drizzle table 自动派生
519
+ */
520
+ function resolveSchemas(config) {
521
+ const { table, omitFields = DEFAULT_OMIT_FIELDS } = config;
522
+ let schema;
523
+ if (config.schema) {
524
+ if (!isZodObject(config.schema)) throw new Error("[createCrudRouter] schema must be a ZodObject (not ZodEffects or other types). If you're using .refine() or .transform(), please remove them from schema.");
525
+ schema = config.schema;
526
+ } else {
527
+ const rawSchema = (0, drizzle_zod.createInsertSchema)(table);
528
+ if (!isZodObject(rawSchema)) throw new Error("[createCrudRouter] drizzle-zod returned non-ZodObject schema. This may be due to table refinements. Please provide schema explicitly.");
529
+ const omitConfig = Object.fromEntries(omitFields.map((f) => [f, true]));
530
+ schema = rawSchema.omit(omitConfig);
531
+ }
532
+ let selectSchema;
533
+ if (config.selectSchema) selectSchema = config.selectSchema;
534
+ else if (config.schema) selectSchema = schema;
535
+ else selectSchema = (0, drizzle_zod.createSelectSchema)(table);
536
+ const updateSchema = config.updateSchema ?? schema.partial().refine(nonEmpty, { message: "Update payload cannot be empty. At least one field is required." });
537
+ return {
538
+ selectSchema,
539
+ schema,
540
+ updateSchema
541
+ };
542
+ }
488
543
  function validateColumn(columnId, allowedColumns) {
489
544
  if (!allowedColumns || allowedColumns.length === 0) return true;
490
545
  return allowedColumns.includes(columnId);
491
546
  }
492
- /**
493
- * 创建 CRUD Router
494
- *
495
- * @typeParam TContext - 上下文类型(包含 db)
496
- * @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
497
- * @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
498
- * @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
499
- */
500
547
  function createCrudRouter(config) {
501
- const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
548
+ const { table, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
549
+ const { schema, updateSchema } = resolveSchemas(config);
502
550
  const resolved = resolveConfig(config);
503
551
  const softDelete = resolveSoftDelete(softDeleteOption);
504
552
  const m = middleware;
@@ -596,7 +644,7 @@ function createCrudRouter(config) {
596
644
  });
597
645
  return doGet();
598
646
  }),
599
- create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
647
+ create: resolved.procedureFactory("create").input(schema).mutation(async ({ ctx, input }) => {
600
648
  await withGuard(ctx, "create");
601
649
  const doCreate = async (inputData) => {
602
650
  const injectData = resolved.getInject(ctx, "create");
@@ -703,7 +751,7 @@ function createCrudRouter(config) {
703
751
  });
704
752
  return doUpdateMany(input.data);
705
753
  }),
706
- upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
754
+ upsert: resolved.procedureFactory("upsert").input(schema).mutation(async ({ ctx, input }) => {
707
755
  await withGuard(ctx, "upsert");
708
756
  const doUpsert = async (inputData) => {
709
757
  const inputId = inputData[idField];
package/dist/index.d.cts CHANGED
@@ -1,8 +1,8 @@
1
+ import { z } from "zod";
2
+ import { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm";
3
+ import { PgTable } from "drizzle-orm/pg-core";
1
4
  import * as _trpc_server2 from "@trpc/server";
2
5
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
- import { SQL } from "drizzle-orm";
4
- import { PgTable } from "drizzle-orm/pg-core";
5
- import { z } from "zod";
6
6
 
7
7
  //#region src/trpc.d.ts
8
8
  /**
@@ -23,6 +23,11 @@ declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, objec
23
23
  //#region src/types/config.d.ts
24
24
  type CrudOperation = "list" | "get" | "create" | "update" | "delete" | "deleteMany" | "updateMany" | "upsert";
25
25
  type WriteOperation = "create" | "update";
26
+ /**
27
+ * 表字段键类型提取
28
+ * 用于 omitFields 的强类型支持
29
+ */
30
+ type TableColumnKeys<T extends PgTable> = keyof T["_"]["columns"];
26
31
  type AnyProcedure = any;
27
32
  interface SoftDeleteConfig {
28
33
  /** 软删除标记列名 */
@@ -270,13 +275,37 @@ type ProcedureConfig = AnyProcedure | ProcedureMap | ProcedureFactory;
270
275
  * authorize: (ctx, resource) => resource.ownerId === ctx.user.id,
271
276
  * })
272
277
  */
273
- interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
278
+ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
274
279
  /** Drizzle 表定义 */
275
- table: PgTable;
276
- /** 创建输入 Schema */
277
- insertSchema: z.ZodType<TInsert>;
278
- /** 更新输入 Schema */
279
- updateSchema: z.ZodType<TUpdate>;
280
+ table: TTable;
281
+ /**
282
+ * 主 Schema(用于 create/upsert 输入验证)
283
+ * - 如果不传,自动从 table 派生并排除 omitFields
284
+ * - updateSchema 默认为 schema.partial().refine(nonEmpty)
285
+ *
286
+ * @example
287
+ * // 自动派生
288
+ * createCrudRouter({ table: tasks })
289
+ *
290
+ * // 显式传入
291
+ * createCrudRouter({ table: tasks, schema: taskSchema })
292
+ */
293
+ schema?: z.ZodType<TInsert>;
294
+ /**
295
+ * 更新输入 Schema(可选)
296
+ * - 如果不传,自动从 schema.partial() 派生
297
+ * - 传入时覆盖自动派生
298
+ *
299
+ * @example
300
+ * createCrudRouter({
301
+ * table: tasks,
302
+ * schema: taskSchema,
303
+ * updateSchema: customUpdateSchema, // 覆盖自动派生
304
+ * })
305
+ */
306
+ updateSchema?: z.ZodType<TUpdate>;
307
+ /** 查询返回 Schema */
308
+ selectSchema?: z.ZodType<TSelect>;
280
309
  /**
281
310
  * Procedure 配置(统一字段)
282
311
  *
@@ -320,8 +349,11 @@ interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unkn
320
349
  * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
321
350
  */
322
351
  authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
323
- /** 查询返回 Schema */
324
- selectSchema?: z.ZodType<TSelect>;
352
+ /**
353
+ * 要从 schema 排除的字段(自动派生时使用)
354
+ * @default ["id", "createdAt", "updatedAt"]
355
+ */
356
+ omitFields?: TableColumnKeys<TTable>[];
325
357
  /** ID 字段名,默认 "id" */
326
358
  idField?: string;
327
359
  /** 可过滤的列白名单 */
@@ -390,17 +422,33 @@ interface CrudProcedures<TSelect, TInsert, TUpdate> {
390
422
  }
391
423
  //#endregion
392
424
  //#region src/routers/_factory.d.ts
425
+ /**
426
+ * createCrudRouter 返回类型
427
+ */
428
+ type CrudRouterReturn<TSelect, TInsert, TUpdate> = ReturnType<typeof router> & {
429
+ procedures: CrudProcedures<TSelect, TInsert, TUpdate>;
430
+ };
393
431
  /**
394
432
  * 创建 CRUD Router
395
433
  *
396
434
  * @typeParam TContext - 上下文类型(包含 db)
397
- * @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
398
- * @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
399
- * @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
435
+ * @typeParam TSelect - 查询返回类型
436
+ * @typeParam TInsert - 创建输入类型(从 schema 推断)
437
+ * @typeParam TUpdate - 更新输入类型(从 updateSchema 或 schema.partial() 推断)
400
438
  */
401
- declare function createCrudRouter<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown>(config: CrudRouterConfig<TContext, TSelect, TInsert, TUpdate>): ReturnType<typeof router> & {
402
- procedures: CrudProcedures<TSelect, TInsert, TUpdate>;
403
- };
439
+ declare function createCrudRouter<TTable extends PgTable, TContext, TSelect, TInsert, TUpdate>(config: CrudRouterConfig<TTable, TContext, TSelect, TInsert, TUpdate> & {
440
+ schema: z.ZodType<TInsert>;
441
+ updateSchema: z.ZodType<TUpdate>;
442
+ }): CrudRouterReturn<TSelect, TInsert, TUpdate>;
443
+ declare function createCrudRouter<TTable extends PgTable, TContext = unknown>(config: Omit<CrudRouterConfig<TTable, TContext>, "schema" | "updateSchema"> & {
444
+ schema?: undefined;
445
+ updateSchema?: undefined;
446
+ }): CrudRouterReturn<InferSelectModel<TTable>, InferInsertModel<TTable>, Partial<InferInsertModel<TTable>>>;
447
+ declare function createCrudRouter<TTable extends PgTable, TContext, TInsert>(config: Omit<CrudRouterConfig<TTable, TContext, unknown, TInsert, unknown>, "updateSchema"> & {
448
+ schema: z.ZodType<TInsert>;
449
+ updateSchema?: undefined;
450
+ }): CrudRouterReturn<unknown, TInsert, Partial<TInsert>>;
451
+ declare function createCrudRouter<TTable extends PgTable, TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown>(config: CrudRouterConfig<TTable, TContext, TSelect, TInsert, TUpdate>): CrudRouterReturn<TSelect, TInsert, TUpdate>;
404
452
  //#endregion
405
453
  //#region src/lib/middleware-helpers.d.ts
406
454
  /**
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { SQL } from "drizzle-orm";
2
+ import { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm";
3
3
  import * as _trpc_server2 from "@trpc/server";
4
4
  import superjson from "superjson";
5
- import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
6
5
  import { PgTable } from "drizzle-orm/pg-core";
6
+ import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
7
7
 
8
8
  //#region src/trpc.d.ts
9
9
  /**
@@ -24,6 +24,11 @@ declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, objec
24
24
  //#region src/types/config.d.ts
25
25
  type CrudOperation = "list" | "get" | "create" | "update" | "delete" | "deleteMany" | "updateMany" | "upsert";
26
26
  type WriteOperation = "create" | "update";
27
+ /**
28
+ * 表字段键类型提取
29
+ * 用于 omitFields 的强类型支持
30
+ */
31
+ type TableColumnKeys<T extends PgTable> = keyof T["_"]["columns"];
27
32
  type AnyProcedure = any;
28
33
  interface SoftDeleteConfig {
29
34
  /** 软删除标记列名 */
@@ -271,13 +276,37 @@ type ProcedureConfig = AnyProcedure | ProcedureMap | ProcedureFactory;
271
276
  * authorize: (ctx, resource) => resource.ownerId === ctx.user.id,
272
277
  * })
273
278
  */
274
- interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
279
+ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
275
280
  /** Drizzle 表定义 */
276
- table: PgTable;
277
- /** 创建输入 Schema */
278
- insertSchema: z.ZodType<TInsert>;
279
- /** 更新输入 Schema */
280
- updateSchema: z.ZodType<TUpdate>;
281
+ table: TTable;
282
+ /**
283
+ * 主 Schema(用于 create/upsert 输入验证)
284
+ * - 如果不传,自动从 table 派生并排除 omitFields
285
+ * - updateSchema 默认为 schema.partial().refine(nonEmpty)
286
+ *
287
+ * @example
288
+ * // 自动派生
289
+ * createCrudRouter({ table: tasks })
290
+ *
291
+ * // 显式传入
292
+ * createCrudRouter({ table: tasks, schema: taskSchema })
293
+ */
294
+ schema?: z.ZodType<TInsert>;
295
+ /**
296
+ * 更新输入 Schema(可选)
297
+ * - 如果不传,自动从 schema.partial() 派生
298
+ * - 传入时覆盖自动派生
299
+ *
300
+ * @example
301
+ * createCrudRouter({
302
+ * table: tasks,
303
+ * schema: taskSchema,
304
+ * updateSchema: customUpdateSchema, // 覆盖自动派生
305
+ * })
306
+ */
307
+ updateSchema?: z.ZodType<TUpdate>;
308
+ /** 查询返回 Schema */
309
+ selectSchema?: z.ZodType<TSelect>;
281
310
  /**
282
311
  * Procedure 配置(统一字段)
283
312
  *
@@ -321,8 +350,11 @@ interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unkn
321
350
  * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
322
351
  */
323
352
  authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
324
- /** 查询返回 Schema */
325
- selectSchema?: z.ZodType<TSelect>;
353
+ /**
354
+ * 要从 schema 排除的字段(自动派生时使用)
355
+ * @default ["id", "createdAt", "updatedAt"]
356
+ */
357
+ omitFields?: TableColumnKeys<TTable>[];
326
358
  /** ID 字段名,默认 "id" */
327
359
  idField?: string;
328
360
  /** 可过滤的列白名单 */
@@ -391,17 +423,33 @@ interface CrudProcedures<TSelect, TInsert, TUpdate> {
391
423
  }
392
424
  //#endregion
393
425
  //#region src/routers/_factory.d.ts
426
+ /**
427
+ * createCrudRouter 返回类型
428
+ */
429
+ type CrudRouterReturn<TSelect, TInsert, TUpdate> = ReturnType<typeof router> & {
430
+ procedures: CrudProcedures<TSelect, TInsert, TUpdate>;
431
+ };
394
432
  /**
395
433
  * 创建 CRUD Router
396
434
  *
397
435
  * @typeParam TContext - 上下文类型(包含 db)
398
- * @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
399
- * @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
400
- * @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
436
+ * @typeParam TSelect - 查询返回类型
437
+ * @typeParam TInsert - 创建输入类型(从 schema 推断)
438
+ * @typeParam TUpdate - 更新输入类型(从 updateSchema 或 schema.partial() 推断)
401
439
  */
402
- declare function createCrudRouter<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown>(config: CrudRouterConfig<TContext, TSelect, TInsert, TUpdate>): ReturnType<typeof router> & {
403
- procedures: CrudProcedures<TSelect, TInsert, TUpdate>;
404
- };
440
+ declare function createCrudRouter<TTable extends PgTable, TContext, TSelect, TInsert, TUpdate>(config: CrudRouterConfig<TTable, TContext, TSelect, TInsert, TUpdate> & {
441
+ schema: z.ZodType<TInsert>;
442
+ updateSchema: z.ZodType<TUpdate>;
443
+ }): CrudRouterReturn<TSelect, TInsert, TUpdate>;
444
+ declare function createCrudRouter<TTable extends PgTable, TContext = unknown>(config: Omit<CrudRouterConfig<TTable, TContext>, "schema" | "updateSchema"> & {
445
+ schema?: undefined;
446
+ updateSchema?: undefined;
447
+ }): CrudRouterReturn<InferSelectModel<TTable>, InferInsertModel<TTable>, Partial<InferInsertModel<TTable>>>;
448
+ declare function createCrudRouter<TTable extends PgTable, TContext, TInsert>(config: Omit<CrudRouterConfig<TTable, TContext, unknown, TInsert, unknown>, "updateSchema"> & {
449
+ schema: z.ZodType<TInsert>;
450
+ updateSchema?: undefined;
451
+ }): CrudRouterReturn<unknown, TInsert, Partial<TInsert>>;
452
+ declare function createCrudRouter<TTable extends PgTable, TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown>(config: CrudRouterConfig<TTable, TContext, TSelect, TInsert, TUpdate>): CrudRouterReturn<TSelect, TInsert, TUpdate>;
405
453
  //#endregion
406
454
  //#region src/lib/middleware-helpers.d.ts
407
455
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { and, asc, desc, eq, gt, gte, ilike, inArray, isNull, lt, lte, ne, not, notIlike, notInArray, or, sql } from "drizzle-orm";
3
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
3
4
  import { TRPCError, initTRPC } from "@trpc/server";
4
5
  import superjson from "superjson";
5
6
  import { addDays, endOfDay, startOfDay } from "date-fns";
@@ -371,6 +372,26 @@ const dataTableConfig = {
371
372
  joinOperators: ["and", "or"]
372
373
  };
373
374
 
375
+ //#endregion
376
+ //#region src/lib/schema-utils.ts
377
+ /**
378
+ * 检查是否为 ZodObject 类型
379
+ * 兼容 Zod v3 和 v4
380
+ *
381
+ * 实现策略:检查 schema 是否具有 ZodObject 特有的 .shape 属性
382
+ * 这种方式比检查内部 _def 属性更稳定,因为不依赖 Zod 的内部实现细节
383
+ */
384
+ function isZodObject(schema) {
385
+ return schema !== null && typeof schema === "object" && "shape" in schema && typeof schema.shape === "object" && schema.shape !== null;
386
+ }
387
+ /**
388
+ * 非空对象验证器
389
+ * 用于 updateSchema 的 refine,防止空更新
390
+ */
391
+ function nonEmpty(value) {
392
+ return Object.keys(value).length > 0;
393
+ }
394
+
374
395
  //#endregion
375
396
  //#region src/routers/_factory.ts
376
397
  function resolveSoftDelete(option) {
@@ -457,20 +478,46 @@ const listInputSchema = z.object({
457
478
  filters: z.array(filterItemSchema).optional(),
458
479
  joinOperator: z.enum(["and", "or"]).default("and")
459
480
  });
481
+ const DEFAULT_OMIT_FIELDS = [
482
+ "id",
483
+ "createdAt",
484
+ "updatedAt"
485
+ ];
486
+ /**
487
+ * 解析并派生 Schema
488
+ * - 优先使用显式传入的 Schema
489
+ * - 否则从 Drizzle table 自动派生
490
+ */
491
+ function resolveSchemas(config) {
492
+ const { table, omitFields = DEFAULT_OMIT_FIELDS } = config;
493
+ let schema;
494
+ if (config.schema) {
495
+ if (!isZodObject(config.schema)) throw new Error("[createCrudRouter] schema must be a ZodObject (not ZodEffects or other types). If you're using .refine() or .transform(), please remove them from schema.");
496
+ schema = config.schema;
497
+ } else {
498
+ const rawSchema = createInsertSchema(table);
499
+ if (!isZodObject(rawSchema)) throw new Error("[createCrudRouter] drizzle-zod returned non-ZodObject schema. This may be due to table refinements. Please provide schema explicitly.");
500
+ const omitConfig = Object.fromEntries(omitFields.map((f) => [f, true]));
501
+ schema = rawSchema.omit(omitConfig);
502
+ }
503
+ let selectSchema;
504
+ if (config.selectSchema) selectSchema = config.selectSchema;
505
+ else if (config.schema) selectSchema = schema;
506
+ else selectSchema = createSelectSchema(table);
507
+ const updateSchema = config.updateSchema ?? schema.partial().refine(nonEmpty, { message: "Update payload cannot be empty. At least one field is required." });
508
+ return {
509
+ selectSchema,
510
+ schema,
511
+ updateSchema
512
+ };
513
+ }
460
514
  function validateColumn(columnId, allowedColumns) {
461
515
  if (!allowedColumns || allowedColumns.length === 0) return true;
462
516
  return allowedColumns.includes(columnId);
463
517
  }
464
- /**
465
- * 创建 CRUD Router
466
- *
467
- * @typeParam TContext - 上下文类型(包含 db)
468
- * @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
469
- * @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
470
- * @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
471
- */
472
518
  function createCrudRouter(config) {
473
- const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
519
+ const { table, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
520
+ const { schema, updateSchema } = resolveSchemas(config);
474
521
  const resolved = resolveConfig(config);
475
522
  const softDelete = resolveSoftDelete(softDeleteOption);
476
523
  const m = middleware;
@@ -568,7 +615,7 @@ function createCrudRouter(config) {
568
615
  });
569
616
  return doGet();
570
617
  }),
571
- create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
618
+ create: resolved.procedureFactory("create").input(schema).mutation(async ({ ctx, input }) => {
572
619
  await withGuard(ctx, "create");
573
620
  const doCreate = async (inputData) => {
574
621
  const injectData = resolved.getInject(ctx, "create");
@@ -675,7 +722,7 @@ function createCrudRouter(config) {
675
722
  });
676
723
  return doUpdateMany(input.data);
677
724
  }),
678
- upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
725
+ upsert: resolved.procedureFactory("upsert").input(schema).mutation(async ({ ctx, input }) => {
679
726
  await withGuard(ctx, "upsert");
680
727
  const doUpsert = async (inputData) => {
681
728
  const inputId = inputData[idField];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wordrhyme/auto-crud-server",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.9.0",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -50,8 +50,8 @@
50
50
  "@internal/eslint-config": "0.3.0",
51
51
  "@internal/tsconfig": "0.1.0",
52
52
  "@internal/prettier-config": "0.0.1",
53
- "@internal/vitest-config": "0.1.0",
54
- "@internal/tsdown-config": "0.1.0"
53
+ "@internal/tsdown-config": "0.1.0",
54
+ "@internal/vitest-config": "0.1.0"
55
55
  },
56
56
  "prettier": "@internal/prettier-config",
57
57
  "scripts": {