@wordrhyme/auto-crud-server 0.6.0 → 0.6.2

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/dist/index.js CHANGED
@@ -1,6 +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 { initTRPC } from "@trpc/server";
3
+ import { TRPCError, initTRPC } from "@trpc/server";
4
4
  import superjson from "superjson";
5
5
  import { addDays, endOfDay, startOfDay } from "date-fns";
6
6
 
@@ -446,6 +446,14 @@ function validateColumn(columnId, allowedColumns) {
446
446
  if (!allowedColumns || allowedColumns.length === 0) return true;
447
447
  return allowedColumns.includes(columnId);
448
448
  }
449
+ /**
450
+ * 创建 CRUD Router
451
+ *
452
+ * @typeParam TContext - 上下文类型(包含 db)
453
+ * @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
454
+ * @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
455
+ * @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
456
+ */
449
457
  function createCrudRouter(config) {
450
458
  const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
451
459
  const resolved = resolveConfig(config);
@@ -457,7 +465,7 @@ function createCrudRouter(config) {
457
465
  const conditions = [];
458
466
  const scopeCondition = resolved.getScope(ctx, table, operation);
459
467
  if (scopeCondition) conditions.push(scopeCondition);
460
- if (softDelete && (operation === "list" || operation === "get")) {
468
+ if (softDelete) {
461
469
  const col = getSoftDeleteColumn();
462
470
  if (col) conditions.push(isNull(col));
463
471
  }
@@ -468,12 +476,27 @@ function createCrudRouter(config) {
468
476
  };
469
477
  const withGuard = async (ctx, operation) => {
470
478
  if (resolved.guard) {
471
- if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
479
+ if (operation === "upsert") {
480
+ const canCreate = await resolved.guard(ctx, "create");
481
+ const canUpdate = await resolved.guard(ctx, "update");
482
+ if (!canCreate || !canUpdate) throw new TRPCError({
483
+ code: "FORBIDDEN",
484
+ message: "Forbidden: upsert requires both create and update permissions"
485
+ });
486
+ return;
487
+ }
488
+ if (!await resolved.guard(ctx, operation)) throw new TRPCError({
489
+ code: "FORBIDDEN",
490
+ message: `Forbidden: ${operation} not allowed`
491
+ });
472
492
  }
473
493
  };
474
494
  const withAuthorize = async (ctx, resource, operation) => {
475
495
  if (!resource) return;
476
- if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new Error(`Forbidden: Cannot ${operation} this resource`);
496
+ if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new TRPCError({
497
+ code: "FORBIDDEN",
498
+ message: `Forbidden: Cannot ${operation} this resource`
499
+ });
477
500
  };
478
501
  const procedures = {
479
502
  list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
@@ -515,7 +538,7 @@ function createCrudRouter(config) {
515
538
  });
516
539
  return doList(listInput);
517
540
  }),
518
- getById: resolved.procedureFactory("get").input(z.string()).query(async ({ ctx, input }) => {
541
+ get: resolved.procedureFactory("get").input(z.string()).query(async ({ ctx, input }) => {
519
542
  await withGuard(ctx, "get");
520
543
  const doGet = async () => {
521
544
  const where = buildWhere(ctx, "get", eq(getIdColumn(), input));
@@ -556,7 +579,10 @@ function createCrudRouter(config) {
556
579
  const where = buildWhere(ctx, "update", eq(getIdColumn(), input.id));
557
580
  const [existing] = await ctx.db.select().from(table).where(where);
558
581
  await withAuthorize(ctx, existing, "update");
559
- if (!existing) throw new Error("Resource not found or access denied");
582
+ if (!existing) throw new TRPCError({
583
+ code: "NOT_FOUND",
584
+ message: "Resource not found or access denied"
585
+ });
560
586
  const doUpdate = async (updateData) => {
561
587
  const injectData = resolved.getInject(ctx, "update");
562
588
  const data = {
@@ -580,7 +606,10 @@ function createCrudRouter(config) {
580
606
  const where = buildWhere(ctx, "delete", eq(getIdColumn(), input));
581
607
  const [existing] = await ctx.db.select().from(table).where(where);
582
608
  await withAuthorize(ctx, existing, "delete");
583
- if (!existing) throw new Error("Resource not found or access denied");
609
+ if (!existing) throw new TRPCError({
610
+ code: "NOT_FOUND",
611
+ message: "Resource not found or access denied"
612
+ });
584
613
  const doDelete = async () => {
585
614
  let deleted;
586
615
  if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
@@ -599,8 +628,8 @@ function createCrudRouter(config) {
599
628
  await withGuard(ctx, "deleteMany");
600
629
  const where = buildWhere(ctx, "deleteMany", inArray(getIdColumn(), input));
601
630
  const doDeleteMany = async () => {
602
- if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
603
- else return { deleted: (await ctx.db.delete(table).where(where).returning()).length };
631
+ if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning({ id: getIdColumn() })).length };
632
+ else return { deleted: (await ctx.db.delete(table).where(where).returning({ id: getIdColumn() })).length };
604
633
  };
605
634
  if (m.deleteMany) return m.deleteMany({
606
635
  ctx,
@@ -621,7 +650,7 @@ function createCrudRouter(config) {
621
650
  ...updateData,
622
651
  ...injectData
623
652
  };
624
- return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
653
+ return { updated: (await ctx.db.update(table).set(data).where(where).returning({ id: getIdColumn() })).length };
625
654
  };
626
655
  if (m.updateMany) return m.updateMany({
627
656
  ctx,
@@ -630,6 +659,43 @@ function createCrudRouter(config) {
630
659
  next: async (modifiedData) => doUpdateMany(modifiedData ?? input.data)
631
660
  });
632
661
  return doUpdateMany(input.data);
662
+ }),
663
+ upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
664
+ await withGuard(ctx, "upsert");
665
+ const doUpsert = async (inputData) => {
666
+ const inputId = inputData[idField];
667
+ let isNew = true;
668
+ let existing = null;
669
+ if (inputId) {
670
+ const where = buildWhere(ctx, "get", eq(getIdColumn(), inputId));
671
+ const [found] = await ctx.db.select().from(table).where(where);
672
+ existing = found;
673
+ isNew = !existing;
674
+ if (!isNew && existing) await withAuthorize(ctx, existing, "update");
675
+ }
676
+ const injectData = resolved.getInject(ctx, isNew ? "create" : "update");
677
+ const data = {
678
+ ...inputData,
679
+ ...injectData
680
+ };
681
+ const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
682
+ target: getIdColumn(),
683
+ set: {
684
+ ...inputData,
685
+ ...resolved.getInject(ctx, "update")
686
+ }
687
+ }).returning();
688
+ return {
689
+ data: result,
690
+ isNew
691
+ };
692
+ };
693
+ if (m.upsert) return m.upsert({
694
+ ctx,
695
+ input,
696
+ next: async (modifiedInput) => doUpsert(modifiedInput ?? input)
697
+ });
698
+ return doUpsert(input);
633
699
  })
634
700
  };
635
701
  const crudRouter = router(procedures);
@@ -639,6 +705,10 @@ function createCrudRouter(config) {
639
705
  //#endregion
640
706
  //#region src/lib/middleware-helpers.ts
641
707
  /**
708
+ * Middleware 便捷工具函数
709
+ * 简化常见场景的 middleware 创建
710
+ */
711
+ /**
642
712
  * 创建 after-only 的 middleware(最常见场景)
643
713
  * 在操作完成后执行副作用,不修改返回值
644
714
  *
@@ -651,28 +721,13 @@ function createCrudRouter(config) {
651
721
  * }
652
722
  */
653
723
  function afterMiddleware(fn) {
654
- return async ({ ctx, next }) => {
655
- const result = await next();
656
- await fn(ctx, result);
724
+ return async (params) => {
725
+ const result = await params.next();
726
+ await fn(params.ctx, result);
657
727
  return result;
658
728
  };
659
729
  }
660
730
  /**
661
- * 创建 after-only 的 middleware,可修改返回值
662
- *
663
- * @example
664
- * middleware: {
665
- * get: afterMiddlewareTransform(async (ctx, result) => {
666
- * return { ...result, computed: calculateSomething(result) };
667
- * }),
668
- * }
669
- */
670
- function afterMiddlewareTransform(fn) {
671
- return async ({ ctx, next }) => {
672
- return fn(ctx, await next());
673
- };
674
- }
675
- /**
676
731
  * 创建 before-only 的 middleware
677
732
  * 在操作执行前修改输入数据
678
733
  *
@@ -684,8 +739,9 @@ function afterMiddlewareTransform(fn) {
684
739
  * }
685
740
  */
686
741
  function beforeMiddleware(fn) {
687
- return async ({ ctx, input, next }) => {
688
- return next(await fn(ctx, input));
742
+ return async (params) => {
743
+ const modifiedInput = await fn(params.ctx, params.input);
744
+ return params.next(modifiedInput);
689
745
  };
690
746
  }
691
747
  /**
@@ -702,58 +758,21 @@ function beforeMiddleware(fn) {
702
758
  function composeMiddleware(...middlewares) {
703
759
  return async (params) => {
704
760
  let index = 0;
705
- const executeNext = async (input) => {
706
- if (index >= middlewares.length) return params.next(input);
761
+ const executeNext = async (...args) => {
762
+ if (index >= middlewares.length) return params.next(...args);
707
763
  const currentMiddleware = middlewares[index++];
708
764
  return currentMiddleware({
709
- ctx: params.ctx,
710
- input,
765
+ ...params,
711
766
  next: executeNext
712
767
  });
713
768
  };
714
- return executeNext(params.input);
769
+ return executeNext();
715
770
  };
716
771
  }
717
- /**
718
- * 创建 list 操作的 after middleware
719
- */
720
- function afterList(fn) {
721
- return afterMiddleware(fn);
722
- }
723
- /**
724
- * 创建 list 操作的 before middleware
725
- */
726
- function beforeList(fn) {
727
- return beforeMiddleware(fn);
728
- }
729
- /**
730
- * 创建 create 操作的 after middleware
731
- */
732
- function afterCreate(fn) {
733
- return afterMiddleware(fn);
734
- }
735
- /**
736
- * 创建 create 操作的 before middleware
737
- */
738
- function beforeCreate(fn) {
739
- return beforeMiddleware(fn);
740
- }
741
- /**
742
- * 创建 update 操作的 after middleware
743
- */
744
- function afterUpdate(fn) {
745
- return afterMiddleware(fn);
746
- }
747
- /**
748
- * 创建 delete 操作的 after middleware
749
- */
750
- function afterDelete(fn) {
751
- return afterMiddleware(fn);
752
- }
753
772
 
754
773
  //#endregion
755
774
  //#region src/routers/index.ts
756
775
  const appRouter = router({});
757
776
 
758
777
  //#endregion
759
- export { afterCreate, afterDelete, afterList, afterMiddleware, afterMiddlewareTransform, afterUpdate, appRouter, beforeCreate, beforeList, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
778
+ export { afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wordrhyme/auto-crud-server",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -45,12 +45,13 @@
45
45
  "superjson": "^2.2.6",
46
46
  "tsdown": "^0.15.12",
47
47
  "typescript": "^5.9.3",
48
+ "vitest": "^4.0.18",
48
49
  "zod": "^4.1.13",
49
- "@internal/eslint-config": "0.3.0",
50
50
  "@internal/prettier-config": "0.0.1",
51
51
  "@internal/tsconfig": "0.1.0",
52
52
  "@internal/tsdown-config": "0.1.0",
53
- "@internal/vitest-config": "0.1.0"
53
+ "@internal/vitest-config": "0.1.0",
54
+ "@internal/eslint-config": "0.3.0"
54
55
  },
55
56
  "prettier": "@internal/prettier-config",
56
57
  "scripts": {
@@ -60,6 +61,7 @@
60
61
  "build:watch": "tsdown --watch",
61
62
  "test": "vitest --run --passWithNoTests",
62
63
  "test:watch": "vitest --watch",
64
+ "test:coverage": "vitest --run --coverage",
63
65
  "typecheck": "tsc --noEmit",
64
66
  "lint": "eslint",
65
67
  "format": "prettier --check . --ignore-path ../../.gitignore"