@wordrhyme/auto-crud-server 0.4.0 → 0.6.1

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
@@ -446,11 +446,19 @@ 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
- const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, hooks = {} } = config;
458
+ const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
451
459
  const resolved = resolveConfig(config);
452
460
  const softDelete = resolveSoftDelete(softDeleteOption);
453
- const h = hooks;
461
+ const m = middleware;
454
462
  const getIdColumn = () => table[idField];
455
463
  const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
456
464
  const buildWhere = (ctx, operation, additionalCondition) => {
@@ -468,6 +476,12 @@ function createCrudRouter(config) {
468
476
  };
469
477
  const withGuard = async (ctx, operation) => {
470
478
  if (resolved.guard) {
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 Error("Forbidden: upsert requires both create and update permissions");
483
+ return;
484
+ }
471
485
  if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
472
486
  }
473
487
  };
@@ -478,72 +492,75 @@ function createCrudRouter(config) {
478
492
  const procedures = {
479
493
  list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
480
494
  await withGuard(ctx, "list");
481
- let processedInput = input;
482
- if (h.beforeList) {
483
- const result = await h.beforeList(ctx, processedInput);
484
- if (result) processedInput = result;
485
- }
486
- const offset = (processedInput.page - 1) * processedInput.perPage;
487
- const validatedFilters = processedInput.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
488
- const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
489
- table,
490
- filters: validatedFilters,
491
- joinOperator: processedInput.joinOperator
492
- }) : void 0);
493
- let query = ctx.db.select().from(table).$dynamic();
494
- if (where) query = query.where(where);
495
- if (processedInput.sort?.length) {
496
- const sortField = processedInput.sort[0];
497
- if (sortField && validateColumn(sortField.id, sortableColumns)) {
498
- const column = table[sortField.id];
499
- if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
495
+ const doList = async (listInput$1) => {
496
+ const offset = (listInput$1.page - 1) * listInput$1.perPage;
497
+ const validatedFilters = listInput$1.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
498
+ const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
499
+ table,
500
+ filters: validatedFilters,
501
+ joinOperator: listInput$1.joinOperator
502
+ }) : void 0);
503
+ let query = ctx.db.select().from(table).$dynamic();
504
+ if (where) query = query.where(where);
505
+ if (listInput$1.sort?.length) {
506
+ const sortField = listInput$1.sort[0];
507
+ if (sortField && validateColumn(sortField.id, sortableColumns)) {
508
+ const column = table[sortField.id];
509
+ if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
510
+ }
500
511
  }
501
- }
502
- const data = await query.limit(processedInput.perPage).offset(offset);
503
- let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
504
- if (where) countQuery = countQuery.where(where);
505
- const count = (await countQuery)[0]?.count ?? 0;
506
- let listResult = {
507
- data,
508
- total: count,
509
- page: processedInput.page,
510
- perPage: processedInput.perPage,
511
- pageCount: Math.ceil(count / processedInput.perPage)
512
+ const data = await query.limit(listInput$1.perPage).offset(offset);
513
+ let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
514
+ if (where) countQuery = countQuery.where(where);
515
+ const count = (await countQuery)[0]?.count ?? 0;
516
+ return {
517
+ data,
518
+ total: count,
519
+ page: listInput$1.page,
520
+ perPage: listInput$1.perPage,
521
+ pageCount: Math.ceil(count / listInput$1.perPage)
522
+ };
512
523
  };
513
- if (h.afterList) {
514
- const result = await h.afterList(ctx, listResult);
515
- if (result) listResult = result;
516
- }
517
- return listResult;
524
+ const listInput = input;
525
+ if (m.list) return m.list({
526
+ ctx,
527
+ input: listInput,
528
+ next: async (modifiedInput) => doList(modifiedInput ?? listInput)
529
+ });
530
+ return doList(listInput);
518
531
  }),
519
- getById: resolved.procedureFactory("get").input(z.string()).query(async ({ ctx, input }) => {
532
+ get: resolved.procedureFactory("get").input(z.string()).query(async ({ ctx, input }) => {
520
533
  await withGuard(ctx, "get");
521
- if (h.beforeGet) await h.beforeGet(ctx, input);
522
- const where = buildWhere(ctx, "get", eq(getIdColumn(), input));
523
- const [item] = await ctx.db.select().from(table).where(where);
524
- await withAuthorize(ctx, item, "get");
525
- let result = item ?? null;
526
- if (h.afterGet) {
527
- const hookResult = await h.afterGet(ctx, result);
528
- if (hookResult !== void 0) result = hookResult;
529
- }
530
- return result;
534
+ const doGet = async () => {
535
+ const where = buildWhere(ctx, "get", eq(getIdColumn(), input));
536
+ const [item] = await ctx.db.select().from(table).where(where);
537
+ await withAuthorize(ctx, item, "get");
538
+ return item ?? null;
539
+ };
540
+ if (m.get) return m.get({
541
+ ctx,
542
+ id: input,
543
+ next: doGet
544
+ });
545
+ return doGet();
531
546
  }),
532
547
  create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
533
548
  await withGuard(ctx, "create");
534
- let processedData = input;
535
- if (h.beforeCreate) {
536
- const result = await h.beforeCreate(ctx, processedData);
537
- if (result) processedData = result;
538
- }
539
- const injectData = resolved.getInject(ctx, "create");
540
- const data = {
541
- ...processedData,
542
- ...injectData
549
+ const doCreate = async (inputData) => {
550
+ const injectData = resolved.getInject(ctx, "create");
551
+ const data = {
552
+ ...inputData,
553
+ ...injectData
554
+ };
555
+ const [created] = await ctx.db.insert(table).values(data).returning();
556
+ return created;
543
557
  };
544
- const [created] = await ctx.db.insert(table).values(data).returning();
545
- if (h.afterCreate) await h.afterCreate(ctx, created);
546
- return created;
558
+ if (m.create) return m.create({
559
+ ctx,
560
+ input,
561
+ next: async (modifiedInput) => doCreate(modifiedInput ?? input)
562
+ });
563
+ return doCreate(input);
547
564
  }),
548
565
  update: resolved.procedureFactory("update").input(z.object({
549
566
  id: z.string(),
@@ -554,19 +571,23 @@ function createCrudRouter(config) {
554
571
  const [existing] = await ctx.db.select().from(table).where(where);
555
572
  await withAuthorize(ctx, existing, "update");
556
573
  if (!existing) throw new Error("Resource not found or access denied");
557
- let processedData = input.data;
558
- if (h.beforeUpdate) {
559
- const result = await h.beforeUpdate(ctx, input.id, processedData, existing);
560
- if (result) processedData = result;
561
- }
562
- const injectData = resolved.getInject(ctx, "update");
563
- const data = {
564
- ...processedData,
565
- ...injectData
574
+ const doUpdate = async (updateData) => {
575
+ const injectData = resolved.getInject(ctx, "update");
576
+ const data = {
577
+ ...updateData,
578
+ ...injectData
579
+ };
580
+ const [updated] = await ctx.db.update(table).set(data).where(where).returning();
581
+ return updated;
566
582
  };
567
- const [updated] = await ctx.db.update(table).set(data).where(where).returning();
568
- if (h.afterUpdate) await h.afterUpdate(ctx, updated);
569
- return updated;
583
+ if (m.update) return m.update({
584
+ ctx,
585
+ id: input.id,
586
+ data: input.data,
587
+ existing,
588
+ next: async (modifiedData) => doUpdate(modifiedData ?? input.data)
589
+ });
590
+ return doUpdate(input.data);
570
591
  }),
571
592
  delete: resolved.procedureFactory("delete").input(z.string()).mutation(async ({ ctx, input }) => {
572
593
  await withGuard(ctx, "delete");
@@ -574,51 +595,163 @@ function createCrudRouter(config) {
574
595
  const [existing] = await ctx.db.select().from(table).where(where);
575
596
  await withAuthorize(ctx, existing, "delete");
576
597
  if (!existing) throw new Error("Resource not found or access denied");
577
- if (h.beforeDelete) await h.beforeDelete(ctx, input, existing);
578
- let deleted;
579
- if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
580
- else [deleted] = await ctx.db.delete(table).where(where).returning();
581
- if (h.afterDelete) await h.afterDelete(ctx, deleted);
582
- return deleted;
598
+ const doDelete = async () => {
599
+ let deleted;
600
+ if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
601
+ else [deleted] = await ctx.db.delete(table).where(where).returning();
602
+ return deleted;
603
+ };
604
+ if (m.delete) return m.delete({
605
+ ctx,
606
+ id: input,
607
+ existing,
608
+ next: doDelete
609
+ });
610
+ return doDelete();
583
611
  }),
584
612
  deleteMany: resolved.procedureFactory("deleteMany").input(z.array(z.string()).max(maxBatchSize)).mutation(async ({ ctx, input }) => {
585
613
  await withGuard(ctx, "deleteMany");
586
- if (h.beforeDeleteMany) await h.beforeDeleteMany(ctx, input);
587
614
  const where = buildWhere(ctx, "deleteMany", inArray(getIdColumn(), input));
588
- let result;
589
- if (softDelete) result = { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
590
- else result = { deleted: (await ctx.db.delete(table).where(where).returning()).length };
591
- if (h.afterDeleteMany) await h.afterDeleteMany(ctx, result);
592
- return result;
615
+ const doDeleteMany = async () => {
616
+ if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
617
+ else return { deleted: (await ctx.db.delete(table).where(where).returning()).length };
618
+ };
619
+ if (m.deleteMany) return m.deleteMany({
620
+ ctx,
621
+ ids: input,
622
+ next: doDeleteMany
623
+ });
624
+ return doDeleteMany();
593
625
  }),
594
626
  updateMany: resolved.procedureFactory("updateMany").input(z.object({
595
627
  ids: z.array(z.string()).max(maxBatchSize),
596
628
  data: updateSchema
597
629
  })).mutation(async ({ ctx, input }) => {
598
630
  await withGuard(ctx, "updateMany");
599
- let processedData = input.data;
600
- if (h.beforeUpdateMany) {
601
- const result$1 = await h.beforeUpdateMany(ctx, input.ids, processedData);
602
- if (result$1) processedData = result$1;
603
- }
604
631
  const where = buildWhere(ctx, "updateMany", inArray(getIdColumn(), input.ids));
605
- const injectData = resolved.getInject(ctx, "update");
606
- const data = {
607
- ...processedData,
608
- ...injectData
632
+ const doUpdateMany = async (updateData) => {
633
+ const injectData = resolved.getInject(ctx, "update");
634
+ const data = {
635
+ ...updateData,
636
+ ...injectData
637
+ };
638
+ return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
639
+ };
640
+ if (m.updateMany) return m.updateMany({
641
+ ctx,
642
+ ids: input.ids,
643
+ data: input.data,
644
+ next: async (modifiedData) => doUpdateMany(modifiedData ?? input.data)
645
+ });
646
+ return doUpdateMany(input.data);
647
+ }),
648
+ upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
649
+ await withGuard(ctx, "upsert");
650
+ const doUpsert = async (inputData) => {
651
+ const injectData = resolved.getInject(ctx, "create");
652
+ const data = {
653
+ ...inputData,
654
+ ...injectData
655
+ };
656
+ const inputId = inputData[idField];
657
+ let isNew = true;
658
+ if (inputId) {
659
+ const where = buildWhere(ctx, "get", eq(getIdColumn(), inputId));
660
+ const [existing] = await ctx.db.select().from(table).where(where);
661
+ isNew = !existing;
662
+ }
663
+ const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
664
+ target: getIdColumn(),
665
+ set: data
666
+ }).returning();
667
+ return {
668
+ data: result,
669
+ isNew
670
+ };
609
671
  };
610
- const result = { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
611
- if (h.afterUpdateMany) await h.afterUpdateMany(ctx, result);
612
- return result;
672
+ if (m.upsert) return m.upsert({
673
+ ctx,
674
+ input,
675
+ next: async (modifiedInput) => doUpsert(modifiedInput ?? input)
676
+ });
677
+ return doUpsert(input);
613
678
  })
614
679
  };
615
680
  const crudRouter = router(procedures);
616
681
  return Object.assign(crudRouter, { procedures });
617
682
  }
618
683
 
684
+ //#endregion
685
+ //#region src/lib/middleware-helpers.ts
686
+ /**
687
+ * Middleware 便捷工具函数
688
+ * 简化常见场景的 middleware 创建
689
+ */
690
+ /**
691
+ * 创建 after-only 的 middleware(最常见场景)
692
+ * 在操作完成后执行副作用,不修改返回值
693
+ *
694
+ * @example
695
+ * middleware: {
696
+ * create: afterMiddleware(async (ctx, result) => {
697
+ * await sendEmail(result);
698
+ * await logAudit(ctx.user, 'create', result);
699
+ * }),
700
+ * }
701
+ */
702
+ function afterMiddleware(fn) {
703
+ return async (params) => {
704
+ const result = await params.next();
705
+ await fn(params.ctx, result);
706
+ return result;
707
+ };
708
+ }
709
+ /**
710
+ * 创建 before-only 的 middleware
711
+ * 在操作执行前修改输入数据
712
+ *
713
+ * @example
714
+ * middleware: {
715
+ * create: beforeMiddleware(async (ctx, input) => {
716
+ * return { ...input, slug: slugify(input.title), createdBy: ctx.user.id };
717
+ * }),
718
+ * }
719
+ */
720
+ function beforeMiddleware(fn) {
721
+ return async (params) => {
722
+ const modifiedInput = await fn(params.ctx, params.input);
723
+ return params.next(modifiedInput);
724
+ };
725
+ }
726
+ /**
727
+ * 组合多个 middleware 为一个
728
+ *
729
+ * @example
730
+ * middleware: {
731
+ * create: composeMiddleware(
732
+ * beforeMiddleware((ctx, input) => ({ ...input, slug: slugify(input.title) })),
733
+ * afterMiddleware((ctx, result) => sendEmail(result)),
734
+ * ),
735
+ * }
736
+ */
737
+ function composeMiddleware(...middlewares) {
738
+ return async (params) => {
739
+ let index = 0;
740
+ const executeNext = async (...args) => {
741
+ if (index >= middlewares.length) return params.next(...args);
742
+ const currentMiddleware = middlewares[index++];
743
+ return currentMiddleware({
744
+ ...params,
745
+ next: executeNext
746
+ });
747
+ };
748
+ return executeNext();
749
+ };
750
+ }
751
+
619
752
  //#endregion
620
753
  //#region src/routers/index.ts
621
754
  const appRouter = router({});
622
755
 
623
756
  //#endregion
624
- export { appRouter, createCrudRouter, publicProcedure, router };
757
+ 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.4.0",
4
+ "version": "0.6.1",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -47,10 +47,10 @@
47
47
  "typescript": "^5.9.3",
48
48
  "zod": "^4.1.13",
49
49
  "@internal/eslint-config": "0.3.0",
50
- "@internal/tsconfig": "0.1.0",
51
50
  "@internal/prettier-config": "0.0.1",
52
- "@internal/tsdown-config": "0.1.0",
53
- "@internal/vitest-config": "0.1.0"
51
+ "@internal/tsconfig": "0.1.0",
52
+ "@internal/vitest-config": "0.1.0",
53
+ "@internal/tsdown-config": "0.1.0"
54
54
  },
55
55
  "prettier": "@internal/prettier-config",
56
56
  "scripts": {