@wordrhyme/auto-crud-server 1.1.1 → 1.1.5

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
@@ -648,6 +648,28 @@ function readCrudTarget(config) {
648
648
  if (!id) return null;
649
649
  return { id };
650
650
  }
651
+ function buildCrudLifecycleHookId(config, operation) {
652
+ const target = readCrudTarget(config);
653
+ if (!target) return null;
654
+ return `${target.id}.${operation}`;
655
+ }
656
+ function getCrudLifecycleHooks(ctx) {
657
+ const hooks = ctx?.hooks;
658
+ return typeof hooks?.emit === "function" ? hooks : null;
659
+ }
660
+ function shouldWrapCrudLifecycleTransaction(ctx, config) {
661
+ return Boolean(buildCrudLifecycleHookId(config, "create") && getCrudLifecycleHooks(ctx));
662
+ }
663
+ async function emitCrudWriteLifecycle(ctx, config, operation, payload) {
664
+ const hookId = buildCrudLifecycleHookId(config, operation);
665
+ const hooks = getCrudLifecycleHooks(ctx);
666
+ if (!hookId || !hooks) return;
667
+ await hooks.emit(hookId, payload, {
668
+ dispatch: "hook",
669
+ mode: "effect",
670
+ tx: ctx?.tx
671
+ });
672
+ }
651
673
  function resolveCrudExtensions(ctx, config) {
652
674
  const extensions = config.extensions;
653
675
  if (extensions === false) return null;
@@ -707,6 +729,14 @@ async function saveCrudExtraValues(ctx, config, entityId, input) {
707
729
  tx: ctx?.tx
708
730
  });
709
731
  }
732
+ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
733
+ if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
734
+ ...ctx,
735
+ db: tx,
736
+ tx
737
+ }));
738
+ return operation(ctx);
739
+ }
710
740
  function assertCrudExtraValuesWritable(ctx, config, input) {
711
741
  if (!readCrudTarget(config) || !input.extraValues) return;
712
742
  if (!resolveCrudExtensions(ctx, config)?.saveExtraValues) throw new __trpc_server.TRPCError({
@@ -718,6 +748,8 @@ function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
718
748
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
719
749
  }
720
750
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
751
+ const inputsWithExtra = inputs.filter((input) => input.extraValues);
752
+ if (inputsWithExtra.length === 0) return;
721
753
  const extraByInputId = /* @__PURE__ */ new Map();
722
754
  for (const input of inputs) {
723
755
  if (!input.extraValues || !isObjectRecord(input.data)) continue;
@@ -725,15 +757,37 @@ async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs)
725
757
  if (inputId !== null && inputId !== void 0) extraByInputId.set(String(inputId), input);
726
758
  }
727
759
  const canUseIndexFallback = results.length === inputs.length;
760
+ const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
761
+ if (!isObjectRecord(input.data)) return true;
762
+ const inputId = input.data[idField];
763
+ return inputId === null || inputId === void 0 || String(inputId).length === 0;
764
+ });
765
+ if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new __trpc_server.TRPCError({
766
+ code: "PRECONDITION_FAILED",
767
+ message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
768
+ });
728
769
  await Promise.all(results.map((result, index) => {
729
770
  const resultId = result[idField];
730
771
  const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
731
772
  const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
732
773
  const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
733
774
  const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
734
- return input ? saveCrudExtraValues(ctx, config, entityId, input) : Promise.resolve();
775
+ if (!input?.extraValues) return Promise.resolve();
776
+ if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new __trpc_server.TRPCError({
777
+ code: "PRECONDITION_FAILED",
778
+ message: "Cannot persist CRUD extension values because the created entity id is unavailable."
779
+ });
780
+ return saveCrudExtraValues(ctx, config, entityId, input);
735
781
  }));
736
782
  }
783
+ function createCrudBatchTransactionInput(inputs) {
784
+ return {
785
+ data: {},
786
+ rawValues: {},
787
+ baseValues: {},
788
+ extraValues: inputs.some((input) => input.extraValues) ? {} : null
789
+ };
790
+ }
737
791
  function readProjectionDisplay(value) {
738
792
  if (!isObjectRecord(value)) return value;
739
793
  if ("display" in value && value.display !== null && value.display !== void 0) return value.display;
@@ -772,7 +826,7 @@ function isAllowedBaseCrudColumn(table, columnId, allowedColumns) {
772
826
  function isKnownBaseCrudColumn(table, columnId, allowedColumns) {
773
827
  return getTableColumn(table, columnId) !== void 0 || findColumnRef(columnId, allowedColumns) !== void 0;
774
828
  }
775
- async function applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, input) {
829
+ async function applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, input) {
776
830
  const target = readCrudTarget(config);
777
831
  const filters = input.filters ?? [];
778
832
  const search = typeof input.search === "string" ? input.search.trim() : "";
@@ -808,7 +862,14 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
808
862
  limit: 5e3
809
863
  });
810
864
  matchedSets.push(new Set(matchedIds$1));
811
- }
865
+ } else if (search && !buildCrudSearchCondition({
866
+ table,
867
+ search,
868
+ searchColumns
869
+ })) throw new __trpc_server.TRPCError({
870
+ code: "PRECONDITION_FAILED",
871
+ message: "CRUD search is not available"
872
+ });
812
873
  if (matchedSets.length === 0) return {
813
874
  ...input,
814
875
  filters: baseFilters
@@ -826,6 +887,24 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
826
887
  }]
827
888
  };
828
889
  }
890
+ function buildCrudSearchCondition({ table, search, searchColumns }) {
891
+ const term = typeof search === "string" ? search.trim() : "";
892
+ if (!term || !searchColumns || searchColumns.length === 0) return void 0;
893
+ const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
894
+ table,
895
+ columnId: getColumnRefId(columnRef),
896
+ allowedColumns: searchColumns
897
+ })).filter((column) => column !== void 0).map((column) => drizzle_orm.sql`${column}::text ilike ${`%${term}%`}`);
898
+ if (conditions.length === 0) return void 0;
899
+ if (conditions.length === 1) return conditions[0];
900
+ return (0, drizzle_orm.or)(...conditions);
901
+ }
902
+ function combineConditions(...conditions) {
903
+ const filtered = conditions.filter((condition) => condition !== void 0);
904
+ if (filtered.length === 0) return void 0;
905
+ if (filtered.length === 1) return filtered[0];
906
+ return (0, drizzle_orm.and)(...filtered);
907
+ }
829
908
  /**
830
909
  * 创建 CRUD Router
831
910
  *
@@ -839,7 +918,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
839
918
  * @typeParam TUpdate - 更新输入类型
840
919
  */
841
920
  function createCrudRouter(config) {
842
- const { table, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, maxExportSize = 5e3, softDelete: softDeleteOption, middleware = {} } = config;
921
+ const { table, idField = "id", filterableColumns, searchColumns, sortableColumns, maxBatchSize = 100, maxExportSize = 5e3, softDelete: softDeleteOption, middleware = {} } = config;
843
922
  const { selectSchema, schema, updateSchema } = resolveSchemas(config);
844
923
  const entityOutputSchema = withCrudExtensionInput(selectSchema, config);
845
924
  const listOutputSchema = zod.z.object({
@@ -937,10 +1016,10 @@ function createCrudRouter(config) {
937
1016
  list: resolved.procedureFactory("list").input(resolvedListInputSchema).output(listOutputSchema).query(async ({ ctx, input }) => {
938
1017
  await withGuard(ctx, "list");
939
1018
  const doList = async (listInput$1) => {
940
- const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, listInput$1);
1019
+ const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, listInput$1);
941
1020
  const offset = (effectiveInput.page - 1) * effectiveInput.perPage;
942
1021
  const validatedFilters = effectiveInput.filters?.filter((filter) => isCrudExtensionIdFilter(filter, idField) || validateColumn(filter.id, filterableColumns));
943
- const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
1022
+ const where = buildWhere(ctx, "list", combineConditions(validatedFilters?.length ? filterColumns({
944
1023
  table,
945
1024
  filters: validatedFilters,
946
1025
  joinOperator: effectiveInput.joinOperator,
@@ -953,7 +1032,11 @@ function createCrudRouter(config) {
953
1032
  allowedColumns: filterableColumns
954
1033
  });
955
1034
  }
956
- }) : void 0);
1035
+ }) : void 0, buildCrudSearchCondition({
1036
+ table,
1037
+ search: effectiveInput.search,
1038
+ searchColumns
1039
+ })));
957
1040
  let query = ctx.db.select().from(table).$dynamic();
958
1041
  if (where) query = query.where(where);
959
1042
  if (effectiveInput.sort?.length) {
@@ -1012,14 +1095,24 @@ function createCrudRouter(config) {
1012
1095
  const doCreate = async (inputData) => {
1013
1096
  const splitInput = splitCrudExtensionWriteInput(inputData, tableColumnNames);
1014
1097
  assertCrudExtraValuesWritable(ctx, config, splitInput);
1015
- const injectData = resolved.getInject(ctx, "create");
1016
- const data = {
1017
- ...splitInput.data,
1018
- ...injectData
1019
- };
1020
- const [created] = await ctx.db.insert(table).values(data).returning();
1021
- await saveCrudExtraValues(ctx, config, created?.[idField], splitInput);
1022
- return created;
1098
+ return runCrudExtensionWriteTransaction(ctx, config, splitInput, async (writeCtx) => {
1099
+ const injectData = resolved.getInject(writeCtx, "create");
1100
+ const data = {
1101
+ ...splitInput.data,
1102
+ ...injectData
1103
+ };
1104
+ const [created] = await writeCtx.db.insert(table).values(data).returning();
1105
+ await saveCrudExtraValues(writeCtx, config, created?.[idField], splitInput);
1106
+ await emitCrudWriteLifecycle(writeCtx, config, "create", {
1107
+ input: inputData,
1108
+ row: created,
1109
+ entityId: created?.[idField],
1110
+ rawValues: splitInput.rawValues,
1111
+ baseValues: splitInput.baseValues,
1112
+ extraValues: splitInput.extraValues ?? {}
1113
+ });
1114
+ return created;
1115
+ });
1023
1116
  };
1024
1117
  if (m.create) return m.create({
1025
1118
  ctx,
@@ -1043,15 +1136,27 @@ function createCrudRouter(config) {
1043
1136
  const doUpdate = async (updateData) => {
1044
1137
  const splitUpdate = splitCrudExtensionWriteInput(updateData, tableColumnNames);
1045
1138
  assertCrudExtraValuesWritable(ctx, config, splitUpdate);
1046
- const injectData = resolved.getInject(ctx, "update");
1047
- const data = {
1048
- ...splitUpdate.data,
1049
- ...injectData
1050
- };
1051
- let updated = existing;
1052
- if (Object.keys(data).length > 0) [updated] = await ctx.db.update(table).set(data).where(where).returning();
1053
- await saveCrudExtraValues(ctx, config, input.id, splitUpdate);
1054
- return updated;
1139
+ return runCrudExtensionWriteTransaction(ctx, config, splitUpdate, async (writeCtx) => {
1140
+ const injectData = resolved.getInject(writeCtx, "update");
1141
+ const data = {
1142
+ ...splitUpdate.data,
1143
+ ...injectData
1144
+ };
1145
+ let updated = existing;
1146
+ if (Object.keys(data).length > 0) [updated] = await writeCtx.db.update(table).set(data).where(where).returning();
1147
+ await saveCrudExtraValues(writeCtx, config, input.id, splitUpdate);
1148
+ await emitCrudWriteLifecycle(writeCtx, config, "update", {
1149
+ id: input.id,
1150
+ input: updateData,
1151
+ row: updated,
1152
+ existing,
1153
+ entityId: input.id,
1154
+ rawValues: splitUpdate.rawValues,
1155
+ baseValues: splitUpdate.baseValues,
1156
+ extraValues: splitUpdate.extraValues ?? {}
1157
+ });
1158
+ return updated;
1159
+ });
1055
1160
  };
1056
1161
  if (m.update) return m.update({
1057
1162
  ctx,
@@ -1072,10 +1177,23 @@ function createCrudRouter(config) {
1072
1177
  message: "Resource not found or access denied"
1073
1178
  });
1074
1179
  const doDelete = async () => {
1075
- let deleted;
1076
- if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
1077
- else [deleted] = await ctx.db.delete(table).where(where).returning();
1078
- return deleted;
1180
+ return runCrudExtensionWriteTransaction(ctx, config, {
1181
+ data: {},
1182
+ rawValues: { id: input },
1183
+ baseValues: {},
1184
+ extraValues: null
1185
+ }, async (writeCtx) => {
1186
+ let deleted;
1187
+ if (softDelete) [deleted] = await writeCtx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
1188
+ else [deleted] = await writeCtx.db.delete(table).where(where).returning();
1189
+ await emitCrudWriteLifecycle(writeCtx, config, "delete", {
1190
+ id: input,
1191
+ row: deleted,
1192
+ existing,
1193
+ entityId: input
1194
+ });
1195
+ return deleted;
1196
+ });
1079
1197
  };
1080
1198
  if (m.delete) return m.delete({
1081
1199
  ctx,
@@ -1108,16 +1226,18 @@ function createCrudRouter(config) {
1108
1226
  const doUpdateMany = async (updateData) => {
1109
1227
  const splitUpdate = splitCrudExtensionWriteInput(updateData, tableColumnNames);
1110
1228
  assertCrudExtraValuesWritable(ctx, config, splitUpdate);
1111
- const injectData = resolved.getInject(ctx, "update");
1112
- const data = {
1113
- ...splitUpdate.data,
1114
- ...injectData
1115
- };
1116
- let updated = input.ids.map((id) => ({ [idField]: id }));
1117
- if (Object.keys(data).length > 0) updated = await ctx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1118
- else updated = await ctx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1119
- await Promise.all(updated.map((row) => saveCrudExtraValues(ctx, config, row[idField], splitUpdate)));
1120
- return { updated: updated.length };
1229
+ return runCrudExtensionWriteTransaction(ctx, config, splitUpdate, async (writeCtx) => {
1230
+ const injectData = resolved.getInject(writeCtx, "update");
1231
+ const data = {
1232
+ ...splitUpdate.data,
1233
+ ...injectData
1234
+ };
1235
+ let updated = input.ids.map((id) => ({ [idField]: id }));
1236
+ if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1237
+ else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1238
+ await Promise.all(updated.map((row) => saveCrudExtraValues(writeCtx, config, row[idField], splitUpdate)));
1239
+ return { updated: updated.length };
1240
+ });
1121
1241
  };
1122
1242
  if (m.updateMany) return m.updateMany({
1123
1243
  ctx,
@@ -1142,23 +1262,35 @@ function createCrudRouter(config) {
1142
1262
  isNew = !existing;
1143
1263
  if (!isNew && existing) await withAuthorize(ctx, existing, "update");
1144
1264
  }
1145
- const injectData = resolved.getInject(ctx, isNew ? "create" : "update");
1146
- const data = {
1147
- ...splitInput.data,
1148
- ...injectData
1149
- };
1150
- const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
1151
- target: getIdColumn(),
1152
- set: {
1265
+ return runCrudExtensionWriteTransaction(ctx, config, splitInput, async (writeCtx) => {
1266
+ const injectData = resolved.getInject(writeCtx, isNew ? "create" : "update");
1267
+ const data = {
1153
1268
  ...splitInput.data,
1154
- ...resolved.getInject(ctx, "update")
1155
- }
1156
- }).returning();
1157
- await saveCrudExtraValues(ctx, config, result?.[idField] ?? inputId, splitInput);
1158
- return {
1159
- data: result,
1160
- isNew
1161
- };
1269
+ ...injectData
1270
+ };
1271
+ const [result] = await writeCtx.db.insert(table).values(data).onConflictDoUpdate({
1272
+ target: getIdColumn(),
1273
+ set: {
1274
+ ...splitInput.data,
1275
+ ...resolved.getInject(writeCtx, "update")
1276
+ }
1277
+ }).returning();
1278
+ await saveCrudExtraValues(writeCtx, config, result?.[idField] ?? inputId, splitInput);
1279
+ await emitCrudWriteLifecycle(writeCtx, config, "upsert", {
1280
+ input: inputData,
1281
+ row: result,
1282
+ existing,
1283
+ isNew,
1284
+ entityId: result?.[idField] ?? inputId,
1285
+ rawValues: splitInput.rawValues,
1286
+ baseValues: splitInput.baseValues,
1287
+ extraValues: splitInput.extraValues ?? {}
1288
+ });
1289
+ return {
1290
+ data: result,
1291
+ isNew
1292
+ };
1293
+ });
1162
1294
  };
1163
1295
  if (m.upsert) return m.upsert({
1164
1296
  ctx,
@@ -1170,7 +1302,7 @@ function createCrudRouter(config) {
1170
1302
  export: resolved.procedureFactory("export").input(resolvedExportInputSchema).output(exportOutputSchema).query(async ({ ctx, input }) => {
1171
1303
  await withGuard(ctx, "export");
1172
1304
  const doExport = async (exportInput$1) => {
1173
- const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, {
1305
+ const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, {
1174
1306
  page: 1,
1175
1307
  perPage: Math.min(Math.max(exportInput$1.limit ?? maxExportSize, 1), maxExportSize),
1176
1308
  joinOperator: exportInput$1.joinOperator ?? "and",
@@ -1178,7 +1310,7 @@ function createCrudRouter(config) {
1178
1310
  });
1179
1311
  const effectiveLimit = Math.min(Math.max(effectiveInput.limit ?? maxExportSize, 1), maxExportSize);
1180
1312
  const validatedFilters = effectiveInput.filters?.filter((filter) => isCrudExtensionIdFilter(filter, idField) || validateColumn(filter.id, filterableColumns));
1181
- const where = buildWhere(ctx, "export", validatedFilters?.length ? filterColumns({
1313
+ const where = buildWhere(ctx, "export", combineConditions(validatedFilters?.length ? filterColumns({
1182
1314
  table,
1183
1315
  filters: validatedFilters,
1184
1316
  joinOperator: effectiveInput.joinOperator ?? "and",
@@ -1191,7 +1323,11 @@ function createCrudRouter(config) {
1191
1323
  allowedColumns: filterableColumns
1192
1324
  });
1193
1325
  }
1194
- }) : void 0);
1326
+ }) : void 0, buildCrudSearchCondition({
1327
+ table,
1328
+ search: effectiveInput.search,
1329
+ searchColumns
1330
+ })));
1195
1331
  let query = ctx.db.select().from(table).$dynamic();
1196
1332
  if (where) query = query.where(where);
1197
1333
  if (effectiveInput.sort?.length) {
@@ -1228,17 +1364,19 @@ function createCrudRouter(config) {
1228
1364
  const doCreateMany = async (items) => {
1229
1365
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1230
1366
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1231
- const injectData = resolved.getInject(ctx, "create");
1232
- const values = splitItems.map(({ data }) => ({
1233
- ...data,
1234
- ...injectData
1235
- }));
1236
- const created = await ctx.db.insert(table).values(values).onConflictDoNothing().returning();
1237
- await saveCrudExtraValuesForRows(ctx, config, idField, created, splitItems);
1238
- return {
1239
- created,
1240
- count: created.length
1241
- };
1367
+ return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1368
+ const injectData = resolved.getInject(writeCtx, "create");
1369
+ const values = splitItems.map(({ data }) => ({
1370
+ ...data,
1371
+ ...injectData
1372
+ }));
1373
+ const created = await writeCtx.db.insert(table).values(values).onConflictDoNothing().returning();
1374
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, created, splitItems);
1375
+ return {
1376
+ created,
1377
+ count: created.length
1378
+ };
1379
+ });
1242
1380
  };
1243
1381
  if (m.createMany) return m.createMany({
1244
1382
  ctx,
@@ -1279,36 +1417,41 @@ function createCrudRouter(config) {
1279
1417
  failed
1280
1418
  };
1281
1419
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1282
- const injectData = resolved.getInject(ctx, "create");
1283
- const values = validRows.map((row) => ({
1284
- ...row.data,
1285
- ...injectData
1286
- }));
1287
- let insertedCount = 0;
1288
- let updatedCount = 0;
1289
- if (importData$1.onConflict === "upsert") {
1290
- const existingCount = (await ctx.db.select({ id: getIdColumn() }).from(table).where((0, drizzle_orm.inArray)(getIdColumn(), values.map((v) => v[idField]).filter(Boolean)))).length;
1291
- const updateFields = Object.keys(validRows[0].data);
1292
- const setClause = { ...resolved.getInject(ctx, "update") };
1293
- for (const key of updateFields) setClause[key] = drizzle_orm.sql.raw(`excluded."${key}"`);
1294
- const results = await ctx.db.insert(table).values(values).onConflictDoUpdate({
1295
- target: getIdColumn(),
1296
- set: setClause
1297
- }).returning({ id: getIdColumn() });
1298
- await saveCrudExtraValuesForRows(ctx, config, idField, results.map((result) => ({ [idField]: result.id })), validRows);
1299
- updatedCount = Math.min(existingCount, results.length);
1300
- insertedCount = results.length - updatedCount;
1301
- } else {
1302
- const inserted = await ctx.db.insert(table).values(values).onConflictDoNothing().returning({ id: getIdColumn() });
1303
- await saveCrudExtraValuesForRows(ctx, config, idField, inserted.map((result) => ({ [idField]: result.id })), validRows);
1304
- insertedCount = inserted.length;
1305
- updatedCount = 0;
1306
- }
1307
- const skipped = validRows.length - insertedCount - updatedCount;
1420
+ const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1421
+ const injectData = resolved.getInject(writeCtx, "create");
1422
+ const values = validRows.map((row) => ({
1423
+ ...row.data,
1424
+ ...injectData
1425
+ }));
1426
+ let insertedCount$1 = 0;
1427
+ let updatedCount$1 = 0;
1428
+ if (importData$1.onConflict === "upsert") {
1429
+ const existingCount = (await writeCtx.db.select({ id: getIdColumn() }).from(table).where((0, drizzle_orm.inArray)(getIdColumn(), values.map((v) => v[idField]).filter(Boolean)))).length;
1430
+ const updateFields = Object.keys(validRows[0].data);
1431
+ const setClause = { ...resolved.getInject(writeCtx, "update") };
1432
+ for (const key of updateFields) setClause[key] = drizzle_orm.sql.raw(`excluded."${key}"`);
1433
+ const results = await writeCtx.db.insert(table).values(values).onConflictDoUpdate({
1434
+ target: getIdColumn(),
1435
+ set: setClause
1436
+ }).returning({ id: getIdColumn() });
1437
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, results.map((result) => ({ [idField]: result.id })), validRows);
1438
+ updatedCount$1 = Math.min(existingCount, results.length);
1439
+ insertedCount$1 = results.length - updatedCount$1;
1440
+ } else {
1441
+ const inserted = await writeCtx.db.insert(table).values(values).onConflictDoNothing().returning({ id: getIdColumn() });
1442
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, inserted.map((result) => ({ [idField]: result.id })), validRows);
1443
+ insertedCount$1 = inserted.length;
1444
+ updatedCount$1 = 0;
1445
+ }
1446
+ return {
1447
+ insertedCount: insertedCount$1,
1448
+ updatedCount: updatedCount$1
1449
+ };
1450
+ });
1308
1451
  return {
1309
1452
  success: insertedCount,
1310
1453
  updated: updatedCount,
1311
- skipped,
1454
+ skipped: validRows.length - insertedCount - updatedCount,
1312
1455
  failed
1313
1456
  };
1314
1457
  };
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
- import * as _trpc_server2 from "@trpc/server";
3
+ import * as _trpc_server0 from "@trpc/server";
4
4
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
5
5
  import { AnyColumn, SQL } from "drizzle-orm";
6
6
 
@@ -12,13 +12,13 @@ import { AnyColumn, SQL } from "drizzle-orm";
12
12
  interface Context {
13
13
  db: PostgresJsDatabase<Record<string, never>>;
14
14
  }
15
- declare const router: _trpc_server2.TRPCRouterBuilder<{
15
+ declare const router: _trpc_server0.TRPCRouterBuilder<{
16
16
  ctx: Context;
17
17
  meta: object;
18
- errorShape: _trpc_server2.TRPCDefaultErrorShape;
18
+ errorShape: _trpc_server0.TRPCDefaultErrorShape;
19
19
  transformer: true;
20
20
  }>;
21
- declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, object, object, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, false>;
21
+ declare const publicProcedure: _trpc_server0.TRPCProcedureBuilder<Context, object, object, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, false>;
22
22
  //#endregion
23
23
  //#region src/types/config.d.ts
24
24
  type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete' | 'deleteMany' | 'updateMany' | 'upsert' | 'export' | 'import' | 'createMany';
@@ -599,6 +599,17 @@ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown,
599
599
  * ]
600
600
  */
601
601
  filterableColumns?: CrudColumnRef<TTable>[];
602
+ /**
603
+ * 全局搜索列白名单。
604
+ *
605
+ * 当列表/导出输入包含 `search` 时,默认查询会对这些列做文本化 ILIKE
606
+ * 搜索,并与其他筛选条件按 AND 组合。扩展字段搜索仍由
607
+ * `CrudExtensionsProvider.searchEntityIds` 处理。
608
+ *
609
+ * @example
610
+ * searchColumns: ['title', { id: 'name', jsonField: ['zh-CN', 'en-US'] }]
611
+ */
612
+ searchColumns?: CrudColumnRef<TTable>[];
602
613
  /**
603
614
  * 可排序的列白名单。
604
615
  *
@@ -879,12 +890,12 @@ type CrudHookEventMap<PluginId extends string, ResourceName extends string, TCre
879
890
  } } & { [K in `${PluginId}.${ResourceName}.createMany`]: TCreate[] };
880
891
  //#endregion
881
892
  //#region src/routers/index.d.ts
882
- declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
893
+ declare const appRouter: _trpc_server0.TRPCBuiltRouter<{
883
894
  ctx: Context;
884
895
  meta: object;
885
- errorShape: _trpc_server2.TRPCDefaultErrorShape;
896
+ errorShape: _trpc_server0.TRPCDefaultErrorShape;
886
897
  transformer: true;
887
- }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
898
+ }, _trpc_server0.TRPCDecorateCreateRouterOptions<{}>>;
888
899
  type AppRouter = typeof appRouter;
889
900
  //#endregion
890
901
  export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudExtensionFilter, type CrudExtensionMetadata, type CrudExtensionsConfig, type CrudExtensionsProvider, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, 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, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { AnyColumn, SQL } from "drizzle-orm";
3
- import * as _trpc_server0 from "@trpc/server";
3
+ import * as _trpc_server2 from "@trpc/server";
4
4
  import superjson from "superjson";
5
5
  import { PgTable } from "drizzle-orm/pg-core";
6
6
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
@@ -13,13 +13,13 @@ import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
13
13
  interface Context {
14
14
  db: PostgresJsDatabase<Record<string, never>>;
15
15
  }
16
- declare const router: _trpc_server0.TRPCRouterBuilder<{
16
+ declare const router: _trpc_server2.TRPCRouterBuilder<{
17
17
  ctx: Context;
18
18
  meta: object;
19
- errorShape: _trpc_server0.TRPCDefaultErrorShape;
19
+ errorShape: _trpc_server2.TRPCDefaultErrorShape;
20
20
  transformer: true;
21
21
  }>;
22
- declare const publicProcedure: _trpc_server0.TRPCProcedureBuilder<Context, object, object, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, false>;
22
+ declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, object, object, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, false>;
23
23
  //#endregion
24
24
  //#region src/types/config.d.ts
25
25
  type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete' | 'deleteMany' | 'updateMany' | 'upsert' | 'export' | 'import' | 'createMany';
@@ -600,6 +600,17 @@ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown,
600
600
  * ]
601
601
  */
602
602
  filterableColumns?: CrudColumnRef<TTable>[];
603
+ /**
604
+ * 全局搜索列白名单。
605
+ *
606
+ * 当列表/导出输入包含 `search` 时,默认查询会对这些列做文本化 ILIKE
607
+ * 搜索,并与其他筛选条件按 AND 组合。扩展字段搜索仍由
608
+ * `CrudExtensionsProvider.searchEntityIds` 处理。
609
+ *
610
+ * @example
611
+ * searchColumns: ['title', { id: 'name', jsonField: ['zh-CN', 'en-US'] }]
612
+ */
613
+ searchColumns?: CrudColumnRef<TTable>[];
603
614
  /**
604
615
  * 可排序的列白名单。
605
616
  *
@@ -880,12 +891,12 @@ type CrudHookEventMap<PluginId extends string, ResourceName extends string, TCre
880
891
  } } & { [K in `${PluginId}.${ResourceName}.createMany`]: TCreate[] };
881
892
  //#endregion
882
893
  //#region src/routers/index.d.ts
883
- declare const appRouter: _trpc_server0.TRPCBuiltRouter<{
894
+ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
884
895
  ctx: Context;
885
896
  meta: object;
886
- errorShape: _trpc_server0.TRPCDefaultErrorShape;
897
+ errorShape: _trpc_server2.TRPCDefaultErrorShape;
887
898
  transformer: true;
888
- }, _trpc_server0.TRPCDecorateCreateRouterOptions<{}>>;
899
+ }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
889
900
  type AppRouter = typeof appRouter;
890
901
  //#endregion
891
902
  export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudExtensionFilter, type CrudExtensionMetadata, type CrudExtensionsConfig, type CrudExtensionsProvider, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, 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, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/dist/index.js CHANGED
@@ -619,6 +619,28 @@ function readCrudTarget(config) {
619
619
  if (!id) return null;
620
620
  return { id };
621
621
  }
622
+ function buildCrudLifecycleHookId(config, operation) {
623
+ const target = readCrudTarget(config);
624
+ if (!target) return null;
625
+ return `${target.id}.${operation}`;
626
+ }
627
+ function getCrudLifecycleHooks(ctx) {
628
+ const hooks = ctx?.hooks;
629
+ return typeof hooks?.emit === "function" ? hooks : null;
630
+ }
631
+ function shouldWrapCrudLifecycleTransaction(ctx, config) {
632
+ return Boolean(buildCrudLifecycleHookId(config, "create") && getCrudLifecycleHooks(ctx));
633
+ }
634
+ async function emitCrudWriteLifecycle(ctx, config, operation, payload) {
635
+ const hookId = buildCrudLifecycleHookId(config, operation);
636
+ const hooks = getCrudLifecycleHooks(ctx);
637
+ if (!hookId || !hooks) return;
638
+ await hooks.emit(hookId, payload, {
639
+ dispatch: "hook",
640
+ mode: "effect",
641
+ tx: ctx?.tx
642
+ });
643
+ }
622
644
  function resolveCrudExtensions(ctx, config) {
623
645
  const extensions = config.extensions;
624
646
  if (extensions === false) return null;
@@ -678,6 +700,14 @@ async function saveCrudExtraValues(ctx, config, entityId, input) {
678
700
  tx: ctx?.tx
679
701
  });
680
702
  }
703
+ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
704
+ if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
705
+ ...ctx,
706
+ db: tx,
707
+ tx
708
+ }));
709
+ return operation(ctx);
710
+ }
681
711
  function assertCrudExtraValuesWritable(ctx, config, input) {
682
712
  if (!readCrudTarget(config) || !input.extraValues) return;
683
713
  if (!resolveCrudExtensions(ctx, config)?.saveExtraValues) throw new TRPCError({
@@ -689,6 +719,8 @@ function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
689
719
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
690
720
  }
691
721
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
722
+ const inputsWithExtra = inputs.filter((input) => input.extraValues);
723
+ if (inputsWithExtra.length === 0) return;
692
724
  const extraByInputId = /* @__PURE__ */ new Map();
693
725
  for (const input of inputs) {
694
726
  if (!input.extraValues || !isObjectRecord(input.data)) continue;
@@ -696,15 +728,37 @@ async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs)
696
728
  if (inputId !== null && inputId !== void 0) extraByInputId.set(String(inputId), input);
697
729
  }
698
730
  const canUseIndexFallback = results.length === inputs.length;
731
+ const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
732
+ if (!isObjectRecord(input.data)) return true;
733
+ const inputId = input.data[idField];
734
+ return inputId === null || inputId === void 0 || String(inputId).length === 0;
735
+ });
736
+ if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new TRPCError({
737
+ code: "PRECONDITION_FAILED",
738
+ message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
739
+ });
699
740
  await Promise.all(results.map((result, index) => {
700
741
  const resultId = result[idField];
701
742
  const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
702
743
  const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
703
744
  const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
704
745
  const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
705
- return input ? saveCrudExtraValues(ctx, config, entityId, input) : Promise.resolve();
746
+ if (!input?.extraValues) return Promise.resolve();
747
+ if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new TRPCError({
748
+ code: "PRECONDITION_FAILED",
749
+ message: "Cannot persist CRUD extension values because the created entity id is unavailable."
750
+ });
751
+ return saveCrudExtraValues(ctx, config, entityId, input);
706
752
  }));
707
753
  }
754
+ function createCrudBatchTransactionInput(inputs) {
755
+ return {
756
+ data: {},
757
+ rawValues: {},
758
+ baseValues: {},
759
+ extraValues: inputs.some((input) => input.extraValues) ? {} : null
760
+ };
761
+ }
708
762
  function readProjectionDisplay(value) {
709
763
  if (!isObjectRecord(value)) return value;
710
764
  if ("display" in value && value.display !== null && value.display !== void 0) return value.display;
@@ -743,7 +797,7 @@ function isAllowedBaseCrudColumn(table, columnId, allowedColumns) {
743
797
  function isKnownBaseCrudColumn(table, columnId, allowedColumns) {
744
798
  return getTableColumn(table, columnId) !== void 0 || findColumnRef(columnId, allowedColumns) !== void 0;
745
799
  }
746
- async function applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, input) {
800
+ async function applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, input) {
747
801
  const target = readCrudTarget(config);
748
802
  const filters = input.filters ?? [];
749
803
  const search = typeof input.search === "string" ? input.search.trim() : "";
@@ -779,7 +833,14 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
779
833
  limit: 5e3
780
834
  });
781
835
  matchedSets.push(new Set(matchedIds$1));
782
- }
836
+ } else if (search && !buildCrudSearchCondition({
837
+ table,
838
+ search,
839
+ searchColumns
840
+ })) throw new TRPCError({
841
+ code: "PRECONDITION_FAILED",
842
+ message: "CRUD search is not available"
843
+ });
783
844
  if (matchedSets.length === 0) return {
784
845
  ...input,
785
846
  filters: baseFilters
@@ -797,6 +858,24 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
797
858
  }]
798
859
  };
799
860
  }
861
+ function buildCrudSearchCondition({ table, search, searchColumns }) {
862
+ const term = typeof search === "string" ? search.trim() : "";
863
+ if (!term || !searchColumns || searchColumns.length === 0) return void 0;
864
+ const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
865
+ table,
866
+ columnId: getColumnRefId(columnRef),
867
+ allowedColumns: searchColumns
868
+ })).filter((column) => column !== void 0).map((column) => sql`${column}::text ilike ${`%${term}%`}`);
869
+ if (conditions.length === 0) return void 0;
870
+ if (conditions.length === 1) return conditions[0];
871
+ return or(...conditions);
872
+ }
873
+ function combineConditions(...conditions) {
874
+ const filtered = conditions.filter((condition) => condition !== void 0);
875
+ if (filtered.length === 0) return void 0;
876
+ if (filtered.length === 1) return filtered[0];
877
+ return and(...filtered);
878
+ }
800
879
  /**
801
880
  * 创建 CRUD Router
802
881
  *
@@ -810,7 +889,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
810
889
  * @typeParam TUpdate - 更新输入类型
811
890
  */
812
891
  function createCrudRouter(config) {
813
- const { table, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, maxExportSize = 5e3, softDelete: softDeleteOption, middleware = {} } = config;
892
+ const { table, idField = "id", filterableColumns, searchColumns, sortableColumns, maxBatchSize = 100, maxExportSize = 5e3, softDelete: softDeleteOption, middleware = {} } = config;
814
893
  const { selectSchema, schema, updateSchema } = resolveSchemas(config);
815
894
  const entityOutputSchema = withCrudExtensionInput(selectSchema, config);
816
895
  const listOutputSchema = z.object({
@@ -908,10 +987,10 @@ function createCrudRouter(config) {
908
987
  list: resolved.procedureFactory("list").input(resolvedListInputSchema).output(listOutputSchema).query(async ({ ctx, input }) => {
909
988
  await withGuard(ctx, "list");
910
989
  const doList = async (listInput$1) => {
911
- const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, listInput$1);
990
+ const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, listInput$1);
912
991
  const offset = (effectiveInput.page - 1) * effectiveInput.perPage;
913
992
  const validatedFilters = effectiveInput.filters?.filter((filter) => isCrudExtensionIdFilter(filter, idField) || validateColumn(filter.id, filterableColumns));
914
- const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
993
+ const where = buildWhere(ctx, "list", combineConditions(validatedFilters?.length ? filterColumns({
915
994
  table,
916
995
  filters: validatedFilters,
917
996
  joinOperator: effectiveInput.joinOperator,
@@ -924,7 +1003,11 @@ function createCrudRouter(config) {
924
1003
  allowedColumns: filterableColumns
925
1004
  });
926
1005
  }
927
- }) : void 0);
1006
+ }) : void 0, buildCrudSearchCondition({
1007
+ table,
1008
+ search: effectiveInput.search,
1009
+ searchColumns
1010
+ })));
928
1011
  let query = ctx.db.select().from(table).$dynamic();
929
1012
  if (where) query = query.where(where);
930
1013
  if (effectiveInput.sort?.length) {
@@ -983,14 +1066,24 @@ function createCrudRouter(config) {
983
1066
  const doCreate = async (inputData) => {
984
1067
  const splitInput = splitCrudExtensionWriteInput(inputData, tableColumnNames);
985
1068
  assertCrudExtraValuesWritable(ctx, config, splitInput);
986
- const injectData = resolved.getInject(ctx, "create");
987
- const data = {
988
- ...splitInput.data,
989
- ...injectData
990
- };
991
- const [created] = await ctx.db.insert(table).values(data).returning();
992
- await saveCrudExtraValues(ctx, config, created?.[idField], splitInput);
993
- return created;
1069
+ return runCrudExtensionWriteTransaction(ctx, config, splitInput, async (writeCtx) => {
1070
+ const injectData = resolved.getInject(writeCtx, "create");
1071
+ const data = {
1072
+ ...splitInput.data,
1073
+ ...injectData
1074
+ };
1075
+ const [created] = await writeCtx.db.insert(table).values(data).returning();
1076
+ await saveCrudExtraValues(writeCtx, config, created?.[idField], splitInput);
1077
+ await emitCrudWriteLifecycle(writeCtx, config, "create", {
1078
+ input: inputData,
1079
+ row: created,
1080
+ entityId: created?.[idField],
1081
+ rawValues: splitInput.rawValues,
1082
+ baseValues: splitInput.baseValues,
1083
+ extraValues: splitInput.extraValues ?? {}
1084
+ });
1085
+ return created;
1086
+ });
994
1087
  };
995
1088
  if (m.create) return m.create({
996
1089
  ctx,
@@ -1014,15 +1107,27 @@ function createCrudRouter(config) {
1014
1107
  const doUpdate = async (updateData) => {
1015
1108
  const splitUpdate = splitCrudExtensionWriteInput(updateData, tableColumnNames);
1016
1109
  assertCrudExtraValuesWritable(ctx, config, splitUpdate);
1017
- const injectData = resolved.getInject(ctx, "update");
1018
- const data = {
1019
- ...splitUpdate.data,
1020
- ...injectData
1021
- };
1022
- let updated = existing;
1023
- if (Object.keys(data).length > 0) [updated] = await ctx.db.update(table).set(data).where(where).returning();
1024
- await saveCrudExtraValues(ctx, config, input.id, splitUpdate);
1025
- return updated;
1110
+ return runCrudExtensionWriteTransaction(ctx, config, splitUpdate, async (writeCtx) => {
1111
+ const injectData = resolved.getInject(writeCtx, "update");
1112
+ const data = {
1113
+ ...splitUpdate.data,
1114
+ ...injectData
1115
+ };
1116
+ let updated = existing;
1117
+ if (Object.keys(data).length > 0) [updated] = await writeCtx.db.update(table).set(data).where(where).returning();
1118
+ await saveCrudExtraValues(writeCtx, config, input.id, splitUpdate);
1119
+ await emitCrudWriteLifecycle(writeCtx, config, "update", {
1120
+ id: input.id,
1121
+ input: updateData,
1122
+ row: updated,
1123
+ existing,
1124
+ entityId: input.id,
1125
+ rawValues: splitUpdate.rawValues,
1126
+ baseValues: splitUpdate.baseValues,
1127
+ extraValues: splitUpdate.extraValues ?? {}
1128
+ });
1129
+ return updated;
1130
+ });
1026
1131
  };
1027
1132
  if (m.update) return m.update({
1028
1133
  ctx,
@@ -1043,10 +1148,23 @@ function createCrudRouter(config) {
1043
1148
  message: "Resource not found or access denied"
1044
1149
  });
1045
1150
  const doDelete = async () => {
1046
- let deleted;
1047
- if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
1048
- else [deleted] = await ctx.db.delete(table).where(where).returning();
1049
- return deleted;
1151
+ return runCrudExtensionWriteTransaction(ctx, config, {
1152
+ data: {},
1153
+ rawValues: { id: input },
1154
+ baseValues: {},
1155
+ extraValues: null
1156
+ }, async (writeCtx) => {
1157
+ let deleted;
1158
+ if (softDelete) [deleted] = await writeCtx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
1159
+ else [deleted] = await writeCtx.db.delete(table).where(where).returning();
1160
+ await emitCrudWriteLifecycle(writeCtx, config, "delete", {
1161
+ id: input,
1162
+ row: deleted,
1163
+ existing,
1164
+ entityId: input
1165
+ });
1166
+ return deleted;
1167
+ });
1050
1168
  };
1051
1169
  if (m.delete) return m.delete({
1052
1170
  ctx,
@@ -1079,16 +1197,18 @@ function createCrudRouter(config) {
1079
1197
  const doUpdateMany = async (updateData) => {
1080
1198
  const splitUpdate = splitCrudExtensionWriteInput(updateData, tableColumnNames);
1081
1199
  assertCrudExtraValuesWritable(ctx, config, splitUpdate);
1082
- const injectData = resolved.getInject(ctx, "update");
1083
- const data = {
1084
- ...splitUpdate.data,
1085
- ...injectData
1086
- };
1087
- let updated = input.ids.map((id) => ({ [idField]: id }));
1088
- if (Object.keys(data).length > 0) updated = await ctx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1089
- else updated = await ctx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1090
- await Promise.all(updated.map((row) => saveCrudExtraValues(ctx, config, row[idField], splitUpdate)));
1091
- return { updated: updated.length };
1200
+ return runCrudExtensionWriteTransaction(ctx, config, splitUpdate, async (writeCtx) => {
1201
+ const injectData = resolved.getInject(writeCtx, "update");
1202
+ const data = {
1203
+ ...splitUpdate.data,
1204
+ ...injectData
1205
+ };
1206
+ let updated = input.ids.map((id) => ({ [idField]: id }));
1207
+ if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1208
+ else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1209
+ await Promise.all(updated.map((row) => saveCrudExtraValues(writeCtx, config, row[idField], splitUpdate)));
1210
+ return { updated: updated.length };
1211
+ });
1092
1212
  };
1093
1213
  if (m.updateMany) return m.updateMany({
1094
1214
  ctx,
@@ -1113,23 +1233,35 @@ function createCrudRouter(config) {
1113
1233
  isNew = !existing;
1114
1234
  if (!isNew && existing) await withAuthorize(ctx, existing, "update");
1115
1235
  }
1116
- const injectData = resolved.getInject(ctx, isNew ? "create" : "update");
1117
- const data = {
1118
- ...splitInput.data,
1119
- ...injectData
1120
- };
1121
- const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
1122
- target: getIdColumn(),
1123
- set: {
1236
+ return runCrudExtensionWriteTransaction(ctx, config, splitInput, async (writeCtx) => {
1237
+ const injectData = resolved.getInject(writeCtx, isNew ? "create" : "update");
1238
+ const data = {
1124
1239
  ...splitInput.data,
1125
- ...resolved.getInject(ctx, "update")
1126
- }
1127
- }).returning();
1128
- await saveCrudExtraValues(ctx, config, result?.[idField] ?? inputId, splitInput);
1129
- return {
1130
- data: result,
1131
- isNew
1132
- };
1240
+ ...injectData
1241
+ };
1242
+ const [result] = await writeCtx.db.insert(table).values(data).onConflictDoUpdate({
1243
+ target: getIdColumn(),
1244
+ set: {
1245
+ ...splitInput.data,
1246
+ ...resolved.getInject(writeCtx, "update")
1247
+ }
1248
+ }).returning();
1249
+ await saveCrudExtraValues(writeCtx, config, result?.[idField] ?? inputId, splitInput);
1250
+ await emitCrudWriteLifecycle(writeCtx, config, "upsert", {
1251
+ input: inputData,
1252
+ row: result,
1253
+ existing,
1254
+ isNew,
1255
+ entityId: result?.[idField] ?? inputId,
1256
+ rawValues: splitInput.rawValues,
1257
+ baseValues: splitInput.baseValues,
1258
+ extraValues: splitInput.extraValues ?? {}
1259
+ });
1260
+ return {
1261
+ data: result,
1262
+ isNew
1263
+ };
1264
+ });
1133
1265
  };
1134
1266
  if (m.upsert) return m.upsert({
1135
1267
  ctx,
@@ -1141,7 +1273,7 @@ function createCrudRouter(config) {
1141
1273
  export: resolved.procedureFactory("export").input(resolvedExportInputSchema).output(exportOutputSchema).query(async ({ ctx, input }) => {
1142
1274
  await withGuard(ctx, "export");
1143
1275
  const doExport = async (exportInput$1) => {
1144
- const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, {
1276
+ const effectiveInput = await applyCrudExtensionFilters(ctx, config, table, idField, filterableColumns, searchColumns, {
1145
1277
  page: 1,
1146
1278
  perPage: Math.min(Math.max(exportInput$1.limit ?? maxExportSize, 1), maxExportSize),
1147
1279
  joinOperator: exportInput$1.joinOperator ?? "and",
@@ -1149,7 +1281,7 @@ function createCrudRouter(config) {
1149
1281
  });
1150
1282
  const effectiveLimit = Math.min(Math.max(effectiveInput.limit ?? maxExportSize, 1), maxExportSize);
1151
1283
  const validatedFilters = effectiveInput.filters?.filter((filter) => isCrudExtensionIdFilter(filter, idField) || validateColumn(filter.id, filterableColumns));
1152
- const where = buildWhere(ctx, "export", validatedFilters?.length ? filterColumns({
1284
+ const where = buildWhere(ctx, "export", combineConditions(validatedFilters?.length ? filterColumns({
1153
1285
  table,
1154
1286
  filters: validatedFilters,
1155
1287
  joinOperator: effectiveInput.joinOperator ?? "and",
@@ -1162,7 +1294,11 @@ function createCrudRouter(config) {
1162
1294
  allowedColumns: filterableColumns
1163
1295
  });
1164
1296
  }
1165
- }) : void 0);
1297
+ }) : void 0, buildCrudSearchCondition({
1298
+ table,
1299
+ search: effectiveInput.search,
1300
+ searchColumns
1301
+ })));
1166
1302
  let query = ctx.db.select().from(table).$dynamic();
1167
1303
  if (where) query = query.where(where);
1168
1304
  if (effectiveInput.sort?.length) {
@@ -1199,17 +1335,19 @@ function createCrudRouter(config) {
1199
1335
  const doCreateMany = async (items) => {
1200
1336
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1201
1337
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1202
- const injectData = resolved.getInject(ctx, "create");
1203
- const values = splitItems.map(({ data }) => ({
1204
- ...data,
1205
- ...injectData
1206
- }));
1207
- const created = await ctx.db.insert(table).values(values).onConflictDoNothing().returning();
1208
- await saveCrudExtraValuesForRows(ctx, config, idField, created, splitItems);
1209
- return {
1210
- created,
1211
- count: created.length
1212
- };
1338
+ return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1339
+ const injectData = resolved.getInject(writeCtx, "create");
1340
+ const values = splitItems.map(({ data }) => ({
1341
+ ...data,
1342
+ ...injectData
1343
+ }));
1344
+ const created = await writeCtx.db.insert(table).values(values).onConflictDoNothing().returning();
1345
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, created, splitItems);
1346
+ return {
1347
+ created,
1348
+ count: created.length
1349
+ };
1350
+ });
1213
1351
  };
1214
1352
  if (m.createMany) return m.createMany({
1215
1353
  ctx,
@@ -1250,36 +1388,41 @@ function createCrudRouter(config) {
1250
1388
  failed
1251
1389
  };
1252
1390
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1253
- const injectData = resolved.getInject(ctx, "create");
1254
- const values = validRows.map((row) => ({
1255
- ...row.data,
1256
- ...injectData
1257
- }));
1258
- let insertedCount = 0;
1259
- let updatedCount = 0;
1260
- if (importData$1.onConflict === "upsert") {
1261
- const existingCount = (await ctx.db.select({ id: getIdColumn() }).from(table).where(inArray(getIdColumn(), values.map((v) => v[idField]).filter(Boolean)))).length;
1262
- const updateFields = Object.keys(validRows[0].data);
1263
- const setClause = { ...resolved.getInject(ctx, "update") };
1264
- for (const key of updateFields) setClause[key] = sql.raw(`excluded."${key}"`);
1265
- const results = await ctx.db.insert(table).values(values).onConflictDoUpdate({
1266
- target: getIdColumn(),
1267
- set: setClause
1268
- }).returning({ id: getIdColumn() });
1269
- await saveCrudExtraValuesForRows(ctx, config, idField, results.map((result) => ({ [idField]: result.id })), validRows);
1270
- updatedCount = Math.min(existingCount, results.length);
1271
- insertedCount = results.length - updatedCount;
1272
- } else {
1273
- const inserted = await ctx.db.insert(table).values(values).onConflictDoNothing().returning({ id: getIdColumn() });
1274
- await saveCrudExtraValuesForRows(ctx, config, idField, inserted.map((result) => ({ [idField]: result.id })), validRows);
1275
- insertedCount = inserted.length;
1276
- updatedCount = 0;
1277
- }
1278
- const skipped = validRows.length - insertedCount - updatedCount;
1391
+ const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1392
+ const injectData = resolved.getInject(writeCtx, "create");
1393
+ const values = validRows.map((row) => ({
1394
+ ...row.data,
1395
+ ...injectData
1396
+ }));
1397
+ let insertedCount$1 = 0;
1398
+ let updatedCount$1 = 0;
1399
+ if (importData$1.onConflict === "upsert") {
1400
+ const existingCount = (await writeCtx.db.select({ id: getIdColumn() }).from(table).where(inArray(getIdColumn(), values.map((v) => v[idField]).filter(Boolean)))).length;
1401
+ const updateFields = Object.keys(validRows[0].data);
1402
+ const setClause = { ...resolved.getInject(writeCtx, "update") };
1403
+ for (const key of updateFields) setClause[key] = sql.raw(`excluded."${key}"`);
1404
+ const results = await writeCtx.db.insert(table).values(values).onConflictDoUpdate({
1405
+ target: getIdColumn(),
1406
+ set: setClause
1407
+ }).returning({ id: getIdColumn() });
1408
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, results.map((result) => ({ [idField]: result.id })), validRows);
1409
+ updatedCount$1 = Math.min(existingCount, results.length);
1410
+ insertedCount$1 = results.length - updatedCount$1;
1411
+ } else {
1412
+ const inserted = await writeCtx.db.insert(table).values(values).onConflictDoNothing().returning({ id: getIdColumn() });
1413
+ await saveCrudExtraValuesForRows(writeCtx, config, idField, inserted.map((result) => ({ [idField]: result.id })), validRows);
1414
+ insertedCount$1 = inserted.length;
1415
+ updatedCount$1 = 0;
1416
+ }
1417
+ return {
1418
+ insertedCount: insertedCount$1,
1419
+ updatedCount: updatedCount$1
1420
+ };
1421
+ });
1279
1422
  return {
1280
1423
  success: insertedCount,
1281
1424
  updated: updatedCount,
1282
- skipped,
1425
+ skipped: validRows.length - insertedCount - updatedCount,
1283
1426
  failed
1284
1427
  };
1285
1428
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wordrhyme/auto-crud-server",
3
3
  "type": "module",
4
- "version": "1.1.1",
4
+ "version": "1.1.5",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "zod": "^3.0.0 || ^4.0.0"
38
38
  },
39
39
  "devDependencies": {
40
+ "@testing-library/jest-dom": "^6.9.1",
40
41
  "@trpc/server": "^11.0.0",
41
42
  "@types/node": "^22.19.17",
42
43
  "drizzle-orm": "1.0.0-beta.15-859cf75",
@@ -49,9 +50,9 @@
49
50
  "zod": "^4.3.6",
50
51
  "@internal/eslint-config": "0.3.0",
51
52
  "@internal/tsdown-config": "0.1.0",
52
- "@internal/prettier-config": "0.0.1",
53
53
  "@internal/vitest-config": "0.1.0",
54
- "@internal/tsconfig": "0.1.0"
54
+ "@internal/tsconfig": "0.1.0",
55
+ "@internal/prettier-config": "0.0.1"
55
56
  },
56
57
  "prettier": "@internal/prettier-config",
57
58
  "scripts": {