@wordrhyme/auto-crud-server 0.6.1 → 0.7.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/dist/index.cjs CHANGED
@@ -417,38 +417,53 @@ function resolveSoftDelete(option) {
417
417
  getValue: config.value ?? (() => /* @__PURE__ */ new Date())
418
418
  };
419
419
  }
420
- function resolveConfig(config) {
421
- const mode = config.mode || "declarative";
422
- if (mode === "factory") {
423
- const factoryConfig = config;
424
- return {
425
- procedureFactory: factoryConfig.procedureFactory,
426
- guard: void 0,
427
- getScope: () => void 0,
428
- getInject: (ctx, op) => factoryConfig.inject?.(ctx, op) ?? {},
429
- checkAuthorize: async () => true
430
- };
420
+ /**
421
+ * 判断 procedure 配置的类型
422
+ */
423
+ function isProcedureMap(config) {
424
+ if (!config) return false;
425
+ if (typeof config === "function") return false;
426
+ if (typeof config === "object" && config !== null) {
427
+ const keys = Object.keys(config);
428
+ const validKeys = [
429
+ "list",
430
+ "get",
431
+ "create",
432
+ "update",
433
+ "delete",
434
+ "deleteMany",
435
+ "updateMany",
436
+ "upsert",
437
+ "default"
438
+ ];
439
+ return keys.some((k) => validKeys.includes(k));
431
440
  }
432
- if (mode === "procedures") {
433
- const proceduresConfig = config;
434
- const defaultProc = proceduresConfig.defaultProcedure ?? publicProcedure;
435
- return {
436
- procedureFactory: (op) => proceduresConfig.procedures[op] ?? defaultProc,
437
- guard: void 0,
438
- getScope: () => void 0,
439
- getInject: (ctx, op) => proceduresConfig.inject?.(ctx, op) ?? {},
440
- checkAuthorize: async () => true
441
- };
441
+ return false;
442
+ }
443
+ function isProcedureFactory(config) {
444
+ return typeof config === "function";
445
+ }
446
+ /**
447
+ * 解析 procedure 配置为工厂函数
448
+ */
449
+ function resolveProcedureFactory(config) {
450
+ if (!config) return () => publicProcedure;
451
+ if (isProcedureFactory(config)) return config;
452
+ if (isProcedureMap(config)) {
453
+ const map = config;
454
+ const defaultProc = map.default ?? publicProcedure;
455
+ return (op) => map[op] ?? defaultProc;
442
456
  }
443
- const declarativeConfig = config;
444
- const baseProcedure = declarativeConfig.procedure ?? publicProcedure;
457
+ return () => config;
458
+ }
459
+ function resolveConfig(config) {
445
460
  return {
446
- procedureFactory: () => baseProcedure,
447
- guard: declarativeConfig.guard,
448
- getScope: (ctx, tbl, op) => declarativeConfig.scope?.(ctx, tbl, op),
449
- getInject: (ctx, op) => declarativeConfig.inject?.(ctx, op) ?? {},
461
+ procedureFactory: resolveProcedureFactory(config.procedure),
462
+ guard: config.guard,
463
+ getScope: (ctx, tbl, op) => config.scope?.(ctx, tbl, op),
464
+ getInject: (ctx, op) => config.inject?.(ctx, op) ?? {},
450
465
  checkAuthorize: async (ctx, resource, op) => {
451
- if (declarativeConfig.authorize) return declarativeConfig.authorize(ctx, resource, op);
466
+ if (config.authorize) return config.authorize(ctx, resource, op);
452
467
  return true;
453
468
  }
454
469
  };
@@ -493,7 +508,7 @@ function createCrudRouter(config) {
493
508
  const conditions = [];
494
509
  const scopeCondition = resolved.getScope(ctx, table, operation);
495
510
  if (scopeCondition) conditions.push(scopeCondition);
496
- if (softDelete && (operation === "list" || operation === "get")) {
511
+ if (softDelete) {
497
512
  const col = getSoftDeleteColumn();
498
513
  if (col) conditions.push((0, drizzle_orm.isNull)(col));
499
514
  }
@@ -507,15 +522,24 @@ function createCrudRouter(config) {
507
522
  if (operation === "upsert") {
508
523
  const canCreate = await resolved.guard(ctx, "create");
509
524
  const canUpdate = await resolved.guard(ctx, "update");
510
- if (!canCreate || !canUpdate) throw new Error("Forbidden: upsert requires both create and update permissions");
525
+ if (!canCreate || !canUpdate) throw new __trpc_server.TRPCError({
526
+ code: "FORBIDDEN",
527
+ message: "Forbidden: upsert requires both create and update permissions"
528
+ });
511
529
  return;
512
530
  }
513
- if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
531
+ if (!await resolved.guard(ctx, operation)) throw new __trpc_server.TRPCError({
532
+ code: "FORBIDDEN",
533
+ message: `Forbidden: ${operation} not allowed`
534
+ });
514
535
  }
515
536
  };
516
537
  const withAuthorize = async (ctx, resource, operation) => {
517
538
  if (!resource) return;
518
- if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new Error(`Forbidden: Cannot ${operation} this resource`);
539
+ if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new __trpc_server.TRPCError({
540
+ code: "FORBIDDEN",
541
+ message: `Forbidden: Cannot ${operation} this resource`
542
+ });
519
543
  };
520
544
  const procedures = {
521
545
  list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
@@ -598,7 +622,10 @@ function createCrudRouter(config) {
598
622
  const where = buildWhere(ctx, "update", (0, drizzle_orm.eq)(getIdColumn(), input.id));
599
623
  const [existing] = await ctx.db.select().from(table).where(where);
600
624
  await withAuthorize(ctx, existing, "update");
601
- if (!existing) throw new Error("Resource not found or access denied");
625
+ if (!existing) throw new __trpc_server.TRPCError({
626
+ code: "NOT_FOUND",
627
+ message: "Resource not found or access denied"
628
+ });
602
629
  const doUpdate = async (updateData) => {
603
630
  const injectData = resolved.getInject(ctx, "update");
604
631
  const data = {
@@ -622,7 +649,10 @@ function createCrudRouter(config) {
622
649
  const where = buildWhere(ctx, "delete", (0, drizzle_orm.eq)(getIdColumn(), input));
623
650
  const [existing] = await ctx.db.select().from(table).where(where);
624
651
  await withAuthorize(ctx, existing, "delete");
625
- if (!existing) throw new Error("Resource not found or access denied");
652
+ if (!existing) throw new __trpc_server.TRPCError({
653
+ code: "NOT_FOUND",
654
+ message: "Resource not found or access denied"
655
+ });
626
656
  const doDelete = async () => {
627
657
  let deleted;
628
658
  if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
@@ -641,8 +671,8 @@ function createCrudRouter(config) {
641
671
  await withGuard(ctx, "deleteMany");
642
672
  const where = buildWhere(ctx, "deleteMany", (0, drizzle_orm.inArray)(getIdColumn(), input));
643
673
  const doDeleteMany = async () => {
644
- if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
645
- else return { deleted: (await ctx.db.delete(table).where(where).returning()).length };
674
+ if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning({ id: getIdColumn() })).length };
675
+ else return { deleted: (await ctx.db.delete(table).where(where).returning({ id: getIdColumn() })).length };
646
676
  };
647
677
  if (m.deleteMany) return m.deleteMany({
648
678
  ctx,
@@ -663,7 +693,7 @@ function createCrudRouter(config) {
663
693
  ...updateData,
664
694
  ...injectData
665
695
  };
666
- return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
696
+ return { updated: (await ctx.db.update(table).set(data).where(where).returning({ id: getIdColumn() })).length };
667
697
  };
668
698
  if (m.updateMany) return m.updateMany({
669
699
  ctx,
@@ -676,21 +706,27 @@ function createCrudRouter(config) {
676
706
  upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
677
707
  await withGuard(ctx, "upsert");
678
708
  const doUpsert = async (inputData) => {
679
- const injectData = resolved.getInject(ctx, "create");
680
- const data = {
681
- ...inputData,
682
- ...injectData
683
- };
684
709
  const inputId = inputData[idField];
685
710
  let isNew = true;
711
+ let existing = null;
686
712
  if (inputId) {
687
713
  const where = buildWhere(ctx, "get", (0, drizzle_orm.eq)(getIdColumn(), inputId));
688
- const [existing] = await ctx.db.select().from(table).where(where);
714
+ const [found] = await ctx.db.select().from(table).where(where);
715
+ existing = found;
689
716
  isNew = !existing;
717
+ if (!isNew && existing) await withAuthorize(ctx, existing, "update");
690
718
  }
719
+ const injectData = resolved.getInject(ctx, isNew ? "create" : "update");
720
+ const data = {
721
+ ...inputData,
722
+ ...injectData
723
+ };
691
724
  const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
692
725
  target: getIdColumn(),
693
- set: data
726
+ set: {
727
+ ...inputData,
728
+ ...resolved.getInject(ctx, "update")
729
+ }
694
730
  }).returning();
695
731
  return {
696
732
  data: result,
package/dist/index.d.cts CHANGED
@@ -191,15 +191,137 @@ interface CrudMiddleware<TContext = unknown, TSelect = unknown, TInsert = unknow
191
191
  isNew: boolean;
192
192
  }>;
193
193
  }
194
- interface CrudRouterConfigBase<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
194
+ /**
195
+ * Procedure 映射对象
196
+ * 按操作指定不同的 procedure,支持 default 回退
197
+ */
198
+ interface ProcedureMap {
199
+ list?: AnyProcedure;
200
+ get?: AnyProcedure;
201
+ create?: AnyProcedure;
202
+ update?: AnyProcedure;
203
+ delete?: AnyProcedure;
204
+ deleteMany?: AnyProcedure;
205
+ updateMany?: AnyProcedure;
206
+ upsert?: AnyProcedure;
207
+ /** 未指定操作时的默认 procedure */
208
+ default?: AnyProcedure;
209
+ }
210
+ /**
211
+ * Procedure 工厂函数
212
+ * 根据操作类型动态返回 procedure
213
+ */
214
+ type ProcedureFactory = (operation: CrudOperation) => AnyProcedure;
215
+ /**
216
+ * 统一的 Procedure 配置
217
+ * 支持三种形式:
218
+ * 1. 单一 procedure - 所有操作共用
219
+ * 2. 对象映射 - 按操作指定
220
+ * 3. 工厂函数 - 动态生成
221
+ *
222
+ * @example
223
+ * // 形式 1:单一 procedure
224
+ * procedure: protectedProcedure
225
+ *
226
+ * // 形式 2:对象映射(推荐常用场景)
227
+ * procedure: {
228
+ * list: publicProcedure,
229
+ * get: publicProcedure,
230
+ * create: editorProcedure,
231
+ * delete: adminProcedure,
232
+ * default: protectedProcedure,
233
+ * }
234
+ *
235
+ * // 形式 3:工厂函数(适合复杂逻辑)
236
+ * procedure: (op) => {
237
+ * if (['list', 'get'].includes(op)) return publicProcedure;
238
+ * return protectedProcedure.meta({ permission: { action: op, subject: 'Task' } });
239
+ * }
240
+ */
241
+ type ProcedureConfig = AnyProcedure | ProcedureMap | ProcedureFactory;
242
+ /**
243
+ * CRUD Router 配置
244
+ *
245
+ * v2.0 统一 API 设计:
246
+ * - 所有配置项可自由组合,无互斥模式
247
+ * - procedure 支持多种形式(单一/映射/工厂)
248
+ * - guard/scope/authorize 可与任意 procedure 形式组合
249
+ *
250
+ * @example
251
+ * // 简单场景:公开读,保护写
252
+ * createCrudRouter({
253
+ * table: posts,
254
+ * insertSchema,
255
+ * updateSchema,
256
+ * procedure: {
257
+ * list: publicProcedure,
258
+ * get: publicProcedure,
259
+ * default: protectedProcedure,
260
+ * },
261
+ * })
262
+ *
263
+ * // 复杂场景:自定义 procedure + 行级安全
264
+ * createCrudRouter({
265
+ * table: posts,
266
+ * insertSchema,
267
+ * updateSchema,
268
+ * procedure: adminProcedure,
269
+ * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId),
270
+ * authorize: (ctx, resource) => resource.ownerId === ctx.user.id,
271
+ * })
272
+ */
273
+ interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
195
274
  /** Drizzle 表定义 */
196
275
  table: PgTable;
197
- /** 查询返回 Schema */
198
- selectSchema?: z.ZodType<TSelect>;
199
276
  /** 创建输入 Schema */
200
277
  insertSchema: z.ZodType<TInsert>;
201
278
  /** 更新输入 Schema */
202
279
  updateSchema: z.ZodType<TUpdate>;
280
+ /**
281
+ * Procedure 配置(统一字段)
282
+ *
283
+ * 支持三种形式:
284
+ * 1. 单一 procedure - 所有操作共用
285
+ * 2. 对象映射 - 按操作指定(推荐)
286
+ * 3. 工厂函数 - 动态生成
287
+ *
288
+ * 默认: publicProcedure
289
+ */
290
+ procedure?: ProcedureConfig;
291
+ /**
292
+ * 操作级守卫(RBAC)
293
+ * 在 scope 之前执行,快速拒绝无权限请求
294
+ *
295
+ * @example
296
+ * guard: (ctx, op) => op === 'delete' ? ctx.user.role === 'admin' : true
297
+ */
298
+ guard?: (ctx: TContext, operation: CrudOperation) => boolean | Promise<boolean>;
299
+ /**
300
+ * 行级过滤(RLS)
301
+ * 返回 SQL 条件,自动注入到 WHERE 子句
302
+ *
303
+ * @example
304
+ * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId)
305
+ */
306
+ scope?: (ctx: TContext, table: PgTable, operation: CrudOperation) => SQL | undefined;
307
+ /**
308
+ * 强制注入字段(写入时)
309
+ * 返回的字段会强制覆盖用户输入(防止伪造)
310
+ *
311
+ * @example
312
+ * inject: (ctx) => ({ tenantId: ctx.user.tenantId, createdBy: ctx.user.id })
313
+ */
314
+ inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
315
+ /**
316
+ * 资源级检查(ABAC)
317
+ * 在 get/update/delete 时,预取资源后调用
318
+ *
319
+ * @example
320
+ * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
321
+ */
322
+ authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
323
+ /** 查询返回 Schema */
324
+ selectSchema?: z.ZodType<TSelect>;
203
325
  /** ID 字段名,默认 "id" */
204
326
  idField?: string;
205
327
  /** 可过滤的列白名单 */
@@ -256,90 +378,6 @@ interface CrudRouterConfigBase<TContext = unknown, TSelect = unknown, TInsert =
256
378
  */
257
379
  middleware?: CrudMiddleware<TContext, TSelect, TInsert, TUpdate>;
258
380
  }
259
- interface DeclarativeConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
260
- mode?: "declarative";
261
- /**
262
- * 基础 procedure(认证)
263
- * 所有操作共用,除非被 guard 拦截
264
- */
265
- procedure?: AnyProcedure;
266
- /**
267
- * 操作级守卫(RBAC)
268
- * 在 scope 之前执行,快速拒绝无权限请求
269
- *
270
- * @example
271
- * guard: (ctx, op) => op === 'delete' ? ctx.user.role === 'admin' : true
272
- */
273
- guard?: (ctx: TContext, operation: CrudOperation) => boolean | Promise<boolean>;
274
- /**
275
- * 行级过滤(RLS)
276
- * 返回 SQL 条件,自动注入到 WHERE 子句
277
- *
278
- * @example
279
- * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId)
280
- */
281
- scope?: (ctx: TContext, table: PgTable, operation: CrudOperation) => SQL | undefined;
282
- /**
283
- * 强制注入字段(写入时)
284
- * 返回的字段会强制覆盖用户输入(防止伪造)
285
- *
286
- * @example
287
- * inject: (ctx) => ({ tenantId: ctx.user.tenantId, createdBy: ctx.user.id })
288
- */
289
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
290
- /**
291
- * 资源级检查(ABAC)
292
- * 在 get/update/delete 时,预取资源后调用
293
- *
294
- * @example
295
- * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
296
- */
297
- authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
298
- }
299
- interface FactoryConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
300
- mode: "factory";
301
- /**
302
- * Procedure 工厂函数
303
- * 根据操作类型返回对应的 procedure
304
- *
305
- * @example
306
- * procedureFactory: (op) => {
307
- * if (['list', 'get'].includes(op)) return publicProcedure;
308
- * return protectedProcedure.meta({ permission: { action: op, subject: 'Task' } });
309
- * }
310
- */
311
- procedureFactory: (operation: CrudOperation) => AnyProcedure;
312
- /**
313
- * 强制注入字段(可选)
314
- * 如果 DB 层已处理则不需要
315
- */
316
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
317
- }
318
- interface ProceduresConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
319
- mode: "procedures";
320
- /**
321
- * 按操作指定不同的 procedure
322
- *
323
- * @example
324
- * procedures: {
325
- * list: publicProcedure,
326
- * get: publicProcedure,
327
- * create: editorProcedure,
328
- * update: editorProcedure,
329
- * delete: adminProcedure,
330
- * }
331
- */
332
- procedures: Partial<Record<CrudOperation, AnyProcedure>>;
333
- /**
334
- * 默认 procedure(未指定操作时使用)
335
- */
336
- defaultProcedure?: AnyProcedure;
337
- /**
338
- * 强制注入字段(可选)
339
- */
340
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
341
- }
342
- type CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> = DeclarativeConfig<TContext, TSelect, TInsert, TUpdate> | FactoryConfig<TContext, TSelect, TInsert, TUpdate> | ProceduresConfig<TContext, TSelect, TInsert, TUpdate>;
343
381
  interface CrudProcedures<TSelect, TInsert, TUpdate> {
344
382
  list: AnyProcedure;
345
383
  get: AnyProcedure;
@@ -430,4 +468,4 @@ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
430
468
  }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
431
469
  type AppRouter = typeof appRouter;
432
470
  //#endregion
433
- export { type AnyProcedure, type AppRouter, type CreateMiddlewareParams, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type CrudRouterConfigBase, type DeclarativeConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type FactoryConfig, type GetMiddlewareParams, type ListInput, type ListMiddlewareParams, type ListResult, type ProceduresConfig, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
471
+ export { type AnyProcedure, type AppRouter, type CreateMiddlewareParams, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type GetMiddlewareParams, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/dist/index.d.ts CHANGED
@@ -192,15 +192,137 @@ interface CrudMiddleware<TContext = unknown, TSelect = unknown, TInsert = unknow
192
192
  isNew: boolean;
193
193
  }>;
194
194
  }
195
- interface CrudRouterConfigBase<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
195
+ /**
196
+ * Procedure 映射对象
197
+ * 按操作指定不同的 procedure,支持 default 回退
198
+ */
199
+ interface ProcedureMap {
200
+ list?: AnyProcedure;
201
+ get?: AnyProcedure;
202
+ create?: AnyProcedure;
203
+ update?: AnyProcedure;
204
+ delete?: AnyProcedure;
205
+ deleteMany?: AnyProcedure;
206
+ updateMany?: AnyProcedure;
207
+ upsert?: AnyProcedure;
208
+ /** 未指定操作时的默认 procedure */
209
+ default?: AnyProcedure;
210
+ }
211
+ /**
212
+ * Procedure 工厂函数
213
+ * 根据操作类型动态返回 procedure
214
+ */
215
+ type ProcedureFactory = (operation: CrudOperation) => AnyProcedure;
216
+ /**
217
+ * 统一的 Procedure 配置
218
+ * 支持三种形式:
219
+ * 1. 单一 procedure - 所有操作共用
220
+ * 2. 对象映射 - 按操作指定
221
+ * 3. 工厂函数 - 动态生成
222
+ *
223
+ * @example
224
+ * // 形式 1:单一 procedure
225
+ * procedure: protectedProcedure
226
+ *
227
+ * // 形式 2:对象映射(推荐常用场景)
228
+ * procedure: {
229
+ * list: publicProcedure,
230
+ * get: publicProcedure,
231
+ * create: editorProcedure,
232
+ * delete: adminProcedure,
233
+ * default: protectedProcedure,
234
+ * }
235
+ *
236
+ * // 形式 3:工厂函数(适合复杂逻辑)
237
+ * procedure: (op) => {
238
+ * if (['list', 'get'].includes(op)) return publicProcedure;
239
+ * return protectedProcedure.meta({ permission: { action: op, subject: 'Task' } });
240
+ * }
241
+ */
242
+ type ProcedureConfig = AnyProcedure | ProcedureMap | ProcedureFactory;
243
+ /**
244
+ * CRUD Router 配置
245
+ *
246
+ * v2.0 统一 API 设计:
247
+ * - 所有配置项可自由组合,无互斥模式
248
+ * - procedure 支持多种形式(单一/映射/工厂)
249
+ * - guard/scope/authorize 可与任意 procedure 形式组合
250
+ *
251
+ * @example
252
+ * // 简单场景:公开读,保护写
253
+ * createCrudRouter({
254
+ * table: posts,
255
+ * insertSchema,
256
+ * updateSchema,
257
+ * procedure: {
258
+ * list: publicProcedure,
259
+ * get: publicProcedure,
260
+ * default: protectedProcedure,
261
+ * },
262
+ * })
263
+ *
264
+ * // 复杂场景:自定义 procedure + 行级安全
265
+ * createCrudRouter({
266
+ * table: posts,
267
+ * insertSchema,
268
+ * updateSchema,
269
+ * procedure: adminProcedure,
270
+ * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId),
271
+ * authorize: (ctx, resource) => resource.ownerId === ctx.user.id,
272
+ * })
273
+ */
274
+ interface CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> {
196
275
  /** Drizzle 表定义 */
197
276
  table: PgTable;
198
- /** 查询返回 Schema */
199
- selectSchema?: z.ZodType<TSelect>;
200
277
  /** 创建输入 Schema */
201
278
  insertSchema: z.ZodType<TInsert>;
202
279
  /** 更新输入 Schema */
203
280
  updateSchema: z.ZodType<TUpdate>;
281
+ /**
282
+ * Procedure 配置(统一字段)
283
+ *
284
+ * 支持三种形式:
285
+ * 1. 单一 procedure - 所有操作共用
286
+ * 2. 对象映射 - 按操作指定(推荐)
287
+ * 3. 工厂函数 - 动态生成
288
+ *
289
+ * 默认: publicProcedure
290
+ */
291
+ procedure?: ProcedureConfig;
292
+ /**
293
+ * 操作级守卫(RBAC)
294
+ * 在 scope 之前执行,快速拒绝无权限请求
295
+ *
296
+ * @example
297
+ * guard: (ctx, op) => op === 'delete' ? ctx.user.role === 'admin' : true
298
+ */
299
+ guard?: (ctx: TContext, operation: CrudOperation) => boolean | Promise<boolean>;
300
+ /**
301
+ * 行级过滤(RLS)
302
+ * 返回 SQL 条件,自动注入到 WHERE 子句
303
+ *
304
+ * @example
305
+ * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId)
306
+ */
307
+ scope?: (ctx: TContext, table: PgTable, operation: CrudOperation) => SQL | undefined;
308
+ /**
309
+ * 强制注入字段(写入时)
310
+ * 返回的字段会强制覆盖用户输入(防止伪造)
311
+ *
312
+ * @example
313
+ * inject: (ctx) => ({ tenantId: ctx.user.tenantId, createdBy: ctx.user.id })
314
+ */
315
+ inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
316
+ /**
317
+ * 资源级检查(ABAC)
318
+ * 在 get/update/delete 时,预取资源后调用
319
+ *
320
+ * @example
321
+ * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
322
+ */
323
+ authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
324
+ /** 查询返回 Schema */
325
+ selectSchema?: z.ZodType<TSelect>;
204
326
  /** ID 字段名,默认 "id" */
205
327
  idField?: string;
206
328
  /** 可过滤的列白名单 */
@@ -257,90 +379,6 @@ interface CrudRouterConfigBase<TContext = unknown, TSelect = unknown, TInsert =
257
379
  */
258
380
  middleware?: CrudMiddleware<TContext, TSelect, TInsert, TUpdate>;
259
381
  }
260
- interface DeclarativeConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
261
- mode?: "declarative";
262
- /**
263
- * 基础 procedure(认证)
264
- * 所有操作共用,除非被 guard 拦截
265
- */
266
- procedure?: AnyProcedure;
267
- /**
268
- * 操作级守卫(RBAC)
269
- * 在 scope 之前执行,快速拒绝无权限请求
270
- *
271
- * @example
272
- * guard: (ctx, op) => op === 'delete' ? ctx.user.role === 'admin' : true
273
- */
274
- guard?: (ctx: TContext, operation: CrudOperation) => boolean | Promise<boolean>;
275
- /**
276
- * 行级过滤(RLS)
277
- * 返回 SQL 条件,自动注入到 WHERE 子句
278
- *
279
- * @example
280
- * scope: (ctx, table) => eq(table.tenantId, ctx.user.tenantId)
281
- */
282
- scope?: (ctx: TContext, table: PgTable, operation: CrudOperation) => SQL | undefined;
283
- /**
284
- * 强制注入字段(写入时)
285
- * 返回的字段会强制覆盖用户输入(防止伪造)
286
- *
287
- * @example
288
- * inject: (ctx) => ({ tenantId: ctx.user.tenantId, createdBy: ctx.user.id })
289
- */
290
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
291
- /**
292
- * 资源级检查(ABAC)
293
- * 在 get/update/delete 时,预取资源后调用
294
- *
295
- * @example
296
- * authorize: (ctx, resource, op) => resource.ownerId === ctx.user.id
297
- */
298
- authorize?: (ctx: TContext, resource: TSelect, operation: CrudOperation) => boolean | Promise<boolean>;
299
- }
300
- interface FactoryConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
301
- mode: "factory";
302
- /**
303
- * Procedure 工厂函数
304
- * 根据操作类型返回对应的 procedure
305
- *
306
- * @example
307
- * procedureFactory: (op) => {
308
- * if (['list', 'get'].includes(op)) return publicProcedure;
309
- * return protectedProcedure.meta({ permission: { action: op, subject: 'Task' } });
310
- * }
311
- */
312
- procedureFactory: (operation: CrudOperation) => AnyProcedure;
313
- /**
314
- * 强制注入字段(可选)
315
- * 如果 DB 层已处理则不需要
316
- */
317
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
318
- }
319
- interface ProceduresConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> extends CrudRouterConfigBase<TContext, TSelect, TInsert, TUpdate> {
320
- mode: "procedures";
321
- /**
322
- * 按操作指定不同的 procedure
323
- *
324
- * @example
325
- * procedures: {
326
- * list: publicProcedure,
327
- * get: publicProcedure,
328
- * create: editorProcedure,
329
- * update: editorProcedure,
330
- * delete: adminProcedure,
331
- * }
332
- */
333
- procedures: Partial<Record<CrudOperation, AnyProcedure>>;
334
- /**
335
- * 默认 procedure(未指定操作时使用)
336
- */
337
- defaultProcedure?: AnyProcedure;
338
- /**
339
- * 强制注入字段(可选)
340
- */
341
- inject?: (ctx: TContext, operation: WriteOperation) => Record<string, unknown>;
342
- }
343
- type CrudRouterConfig<TContext = unknown, TSelect = unknown, TInsert = unknown, TUpdate = unknown> = DeclarativeConfig<TContext, TSelect, TInsert, TUpdate> | FactoryConfig<TContext, TSelect, TInsert, TUpdate> | ProceduresConfig<TContext, TSelect, TInsert, TUpdate>;
344
382
  interface CrudProcedures<TSelect, TInsert, TUpdate> {
345
383
  list: AnyProcedure;
346
384
  get: AnyProcedure;
@@ -431,4 +469,4 @@ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
431
469
  }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
432
470
  type AppRouter = typeof appRouter;
433
471
  //#endregion
434
- export { type AnyProcedure, type AppRouter, type CreateMiddlewareParams, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type CrudRouterConfigBase, type DeclarativeConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type FactoryConfig, type GetMiddlewareParams, type ListInput, type ListMiddlewareParams, type ListResult, type ProceduresConfig, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
472
+ export { type AnyProcedure, type AppRouter, type CreateMiddlewareParams, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type GetMiddlewareParams, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
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
 
@@ -389,38 +389,53 @@ function resolveSoftDelete(option) {
389
389
  getValue: config.value ?? (() => /* @__PURE__ */ new Date())
390
390
  };
391
391
  }
392
- function resolveConfig(config) {
393
- const mode = config.mode || "declarative";
394
- if (mode === "factory") {
395
- const factoryConfig = config;
396
- return {
397
- procedureFactory: factoryConfig.procedureFactory,
398
- guard: void 0,
399
- getScope: () => void 0,
400
- getInject: (ctx, op) => factoryConfig.inject?.(ctx, op) ?? {},
401
- checkAuthorize: async () => true
402
- };
392
+ /**
393
+ * 判断 procedure 配置的类型
394
+ */
395
+ function isProcedureMap(config) {
396
+ if (!config) return false;
397
+ if (typeof config === "function") return false;
398
+ if (typeof config === "object" && config !== null) {
399
+ const keys = Object.keys(config);
400
+ const validKeys = [
401
+ "list",
402
+ "get",
403
+ "create",
404
+ "update",
405
+ "delete",
406
+ "deleteMany",
407
+ "updateMany",
408
+ "upsert",
409
+ "default"
410
+ ];
411
+ return keys.some((k) => validKeys.includes(k));
403
412
  }
404
- if (mode === "procedures") {
405
- const proceduresConfig = config;
406
- const defaultProc = proceduresConfig.defaultProcedure ?? publicProcedure;
407
- return {
408
- procedureFactory: (op) => proceduresConfig.procedures[op] ?? defaultProc,
409
- guard: void 0,
410
- getScope: () => void 0,
411
- getInject: (ctx, op) => proceduresConfig.inject?.(ctx, op) ?? {},
412
- checkAuthorize: async () => true
413
- };
413
+ return false;
414
+ }
415
+ function isProcedureFactory(config) {
416
+ return typeof config === "function";
417
+ }
418
+ /**
419
+ * 解析 procedure 配置为工厂函数
420
+ */
421
+ function resolveProcedureFactory(config) {
422
+ if (!config) return () => publicProcedure;
423
+ if (isProcedureFactory(config)) return config;
424
+ if (isProcedureMap(config)) {
425
+ const map = config;
426
+ const defaultProc = map.default ?? publicProcedure;
427
+ return (op) => map[op] ?? defaultProc;
414
428
  }
415
- const declarativeConfig = config;
416
- const baseProcedure = declarativeConfig.procedure ?? publicProcedure;
429
+ return () => config;
430
+ }
431
+ function resolveConfig(config) {
417
432
  return {
418
- procedureFactory: () => baseProcedure,
419
- guard: declarativeConfig.guard,
420
- getScope: (ctx, tbl, op) => declarativeConfig.scope?.(ctx, tbl, op),
421
- getInject: (ctx, op) => declarativeConfig.inject?.(ctx, op) ?? {},
433
+ procedureFactory: resolveProcedureFactory(config.procedure),
434
+ guard: config.guard,
435
+ getScope: (ctx, tbl, op) => config.scope?.(ctx, tbl, op),
436
+ getInject: (ctx, op) => config.inject?.(ctx, op) ?? {},
422
437
  checkAuthorize: async (ctx, resource, op) => {
423
- if (declarativeConfig.authorize) return declarativeConfig.authorize(ctx, resource, op);
438
+ if (config.authorize) return config.authorize(ctx, resource, op);
424
439
  return true;
425
440
  }
426
441
  };
@@ -465,7 +480,7 @@ function createCrudRouter(config) {
465
480
  const conditions = [];
466
481
  const scopeCondition = resolved.getScope(ctx, table, operation);
467
482
  if (scopeCondition) conditions.push(scopeCondition);
468
- if (softDelete && (operation === "list" || operation === "get")) {
483
+ if (softDelete) {
469
484
  const col = getSoftDeleteColumn();
470
485
  if (col) conditions.push(isNull(col));
471
486
  }
@@ -479,15 +494,24 @@ function createCrudRouter(config) {
479
494
  if (operation === "upsert") {
480
495
  const canCreate = await resolved.guard(ctx, "create");
481
496
  const canUpdate = await resolved.guard(ctx, "update");
482
- if (!canCreate || !canUpdate) throw new Error("Forbidden: upsert requires both create and update permissions");
497
+ if (!canCreate || !canUpdate) throw new TRPCError({
498
+ code: "FORBIDDEN",
499
+ message: "Forbidden: upsert requires both create and update permissions"
500
+ });
483
501
  return;
484
502
  }
485
- if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
503
+ if (!await resolved.guard(ctx, operation)) throw new TRPCError({
504
+ code: "FORBIDDEN",
505
+ message: `Forbidden: ${operation} not allowed`
506
+ });
486
507
  }
487
508
  };
488
509
  const withAuthorize = async (ctx, resource, operation) => {
489
510
  if (!resource) return;
490
- if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new Error(`Forbidden: Cannot ${operation} this resource`);
511
+ if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new TRPCError({
512
+ code: "FORBIDDEN",
513
+ message: `Forbidden: Cannot ${operation} this resource`
514
+ });
491
515
  };
492
516
  const procedures = {
493
517
  list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
@@ -570,7 +594,10 @@ function createCrudRouter(config) {
570
594
  const where = buildWhere(ctx, "update", eq(getIdColumn(), input.id));
571
595
  const [existing] = await ctx.db.select().from(table).where(where);
572
596
  await withAuthorize(ctx, existing, "update");
573
- if (!existing) throw new Error("Resource not found or access denied");
597
+ if (!existing) throw new TRPCError({
598
+ code: "NOT_FOUND",
599
+ message: "Resource not found or access denied"
600
+ });
574
601
  const doUpdate = async (updateData) => {
575
602
  const injectData = resolved.getInject(ctx, "update");
576
603
  const data = {
@@ -594,7 +621,10 @@ function createCrudRouter(config) {
594
621
  const where = buildWhere(ctx, "delete", eq(getIdColumn(), input));
595
622
  const [existing] = await ctx.db.select().from(table).where(where);
596
623
  await withAuthorize(ctx, existing, "delete");
597
- if (!existing) throw new Error("Resource not found or access denied");
624
+ if (!existing) throw new TRPCError({
625
+ code: "NOT_FOUND",
626
+ message: "Resource not found or access denied"
627
+ });
598
628
  const doDelete = async () => {
599
629
  let deleted;
600
630
  if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
@@ -613,8 +643,8 @@ function createCrudRouter(config) {
613
643
  await withGuard(ctx, "deleteMany");
614
644
  const where = buildWhere(ctx, "deleteMany", inArray(getIdColumn(), input));
615
645
  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 };
646
+ if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning({ id: getIdColumn() })).length };
647
+ else return { deleted: (await ctx.db.delete(table).where(where).returning({ id: getIdColumn() })).length };
618
648
  };
619
649
  if (m.deleteMany) return m.deleteMany({
620
650
  ctx,
@@ -635,7 +665,7 @@ function createCrudRouter(config) {
635
665
  ...updateData,
636
666
  ...injectData
637
667
  };
638
- return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
668
+ return { updated: (await ctx.db.update(table).set(data).where(where).returning({ id: getIdColumn() })).length };
639
669
  };
640
670
  if (m.updateMany) return m.updateMany({
641
671
  ctx,
@@ -648,21 +678,27 @@ function createCrudRouter(config) {
648
678
  upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
649
679
  await withGuard(ctx, "upsert");
650
680
  const doUpsert = async (inputData) => {
651
- const injectData = resolved.getInject(ctx, "create");
652
- const data = {
653
- ...inputData,
654
- ...injectData
655
- };
656
681
  const inputId = inputData[idField];
657
682
  let isNew = true;
683
+ let existing = null;
658
684
  if (inputId) {
659
685
  const where = buildWhere(ctx, "get", eq(getIdColumn(), inputId));
660
- const [existing] = await ctx.db.select().from(table).where(where);
686
+ const [found] = await ctx.db.select().from(table).where(where);
687
+ existing = found;
661
688
  isNew = !existing;
689
+ if (!isNew && existing) await withAuthorize(ctx, existing, "update");
662
690
  }
691
+ const injectData = resolved.getInject(ctx, isNew ? "create" : "update");
692
+ const data = {
693
+ ...inputData,
694
+ ...injectData
695
+ };
663
696
  const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
664
697
  target: getIdColumn(),
665
- set: data
698
+ set: {
699
+ ...inputData,
700
+ ...resolved.getInject(ctx, "update")
701
+ }
666
702
  }).returning();
667
703
  return {
668
704
  data: result,
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.1",
4
+ "version": "0.7.0",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -45,10 +45,11 @@
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
50
  "@internal/eslint-config": "0.3.0",
50
- "@internal/prettier-config": "0.0.1",
51
51
  "@internal/tsconfig": "0.1.0",
52
+ "@internal/prettier-config": "0.0.1",
52
53
  "@internal/vitest-config": "0.1.0",
53
54
  "@internal/tsdown-config": "0.1.0"
54
55
  },
@@ -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"