@wordrhyme/auto-crud-server 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,7 +170,8 @@ export { handler as GET, handler as POST };
170
170
  ```
171
171
 
172
172
  未传 `sort` 或传空数组时,如果表存在 `createdAt` 且未被 `sortableColumns`
173
- 白名单排除,列表默认按 `createdAt` 降序返回。
173
+ 白名单排除,列表默认按 `createdAt` 降序返回。存在有效排序时,服务端会自动追加
174
+ 主键作为同方向的稳定排序条件,避免 OFFSET 分页在相同排序值之间漂移。
174
175
 
175
176
  **输出**:
176
177
 
@@ -1120,6 +1121,50 @@ createCrudRouter({
1120
1121
 
1121
1122
  ---
1122
1123
 
1124
+ ## ⚡ 性能与索引
1125
+
1126
+ - `list` 和 `export` 会并发执行数据查询与精确计数,降低一次数据库往返延迟。
1127
+ - 多字段 `sort` 会全部应用,并自动追加 `idField` 作为稳定 tie-breaker。
1128
+ - `perPage`、批量写入和导出都有上限,但深分页仍建议由业务 middleware 改为
1129
+ cursor/keyset pagination。
1130
+ - 不要开放所有字段过滤和排序;请通过 `filters`、`sort`、`search` 白名单只暴露
1131
+ 已有索引支持的字段。
1132
+
1133
+ 常见列表至少应覆盖默认排序;有租户和软删除时可使用部分复合索引:
1134
+
1135
+ ```sql
1136
+ create index tasks_created_at_id_idx
1137
+ on tasks (created_at desc, id desc);
1138
+
1139
+ create index tasks_tenant_created_at_id_idx
1140
+ on tasks (tenant_id, created_at desc, id desc)
1141
+ where deleted_at is null;
1142
+ ```
1143
+
1144
+ `search` 的 contains 查询使用 `ILIKE '%term%'`。PostgreSQL 普通 B-tree 无法高效处理,
1145
+ 应为真实文本列配置 `pg_trgm` GIN 索引;JSON/custom expression 需要与生成 SQL 一致的
1146
+ 表达式索引。
1147
+
1148
+ 扩展字段 provider 可以实现批量写接口,避免 `createMany`、`updateMany` 和 `import`
1149
+ 逐行访问数据库。未实现时仍兼容旧的 `saveExtraValues`:
1150
+
1151
+ ```typescript
1152
+ const extensions: CrudExtensionsProvider = {
1153
+ saveExtraValuesMany: async ({ id, rows, tx }) => {
1154
+ await saveExtensionRowsInOneStatement(tx, id, rows);
1155
+ },
1156
+ };
1157
+ ```
1158
+
1159
+ `createMany` 和 `import` 中携带扩展字段的每一行都必须提供唯一、稳定的 `idField`。
1160
+ 服务端只按显式 ID 关联 `RETURNING` 结果,不依赖数据库未承诺的返回顺序;无法可靠映射时
1161
+ 会在写入前返回 `PRECONDITION_FAILED`。
1162
+
1163
+ 任意组合筛选的列表缓存失效成本较高。优先优化索引和查询计划;metadata 或低频变更的
1164
+ 无筛选计数才适合按 TTL 缓存。
1165
+
1166
+ ---
1167
+
1123
1168
  ## 📦 导出的类型
1124
1169
 
1125
1170
  ```typescript
@@ -1134,6 +1179,8 @@ export type {
1134
1179
  CrudColumnConfig,
1135
1180
  CrudColumnExpression,
1136
1181
  CrudColumnRef,
1182
+ CrudExtensionValueWrite,
1183
+ CrudExtensionsProvider,
1137
1184
  CrudRouterConfig,
1138
1185
  CrudMiddleware,
1139
1186
  GetInput,
package/dist/index.cjs CHANGED
@@ -21,18 +21,22 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
 
23
23
  //#endregion
24
- let zod = require("zod");
25
- zod = __toESM(zod);
24
+ let __trpc_server = require("@trpc/server");
25
+ __trpc_server = __toESM(__trpc_server);
26
26
  let drizzle_orm = require("drizzle-orm");
27
27
  drizzle_orm = __toESM(drizzle_orm);
28
28
  let drizzle_zod = require("drizzle-zod");
29
29
  drizzle_zod = __toESM(drizzle_zod);
30
- let __trpc_server = require("@trpc/server");
31
- __trpc_server = __toESM(__trpc_server);
30
+ let zod = require("zod");
31
+ zod = __toESM(zod);
32
32
  let superjson = require("superjson");
33
33
  superjson = __toESM(superjson);
34
- let date_fns = require("date-fns");
35
- date_fns = __toESM(date_fns);
34
+ let date_fns_addDays = require("date-fns/addDays");
35
+ date_fns_addDays = __toESM(date_fns_addDays);
36
+ let date_fns_endOfDay = require("date-fns/endOfDay");
37
+ date_fns_endOfDay = __toESM(date_fns_endOfDay);
38
+ let date_fns_startOfDay = require("date-fns/startOfDay");
39
+ date_fns_startOfDay = __toESM(date_fns_startOfDay);
36
40
 
37
41
  //#region src/trpc.ts
38
42
  const t = __trpc_server.initTRPC.context().create({ transformer: superjson.default });
@@ -256,16 +260,16 @@ function filterColumns({ table, filters, joinOperator, resolveColumn, resolveDat
256
260
  if (!amount || !unit) return void 0;
257
261
  switch (unit) {
258
262
  case "days":
259
- startDate = (0, date_fns.startOfDay)((0, date_fns.addDays)(today, Number.parseInt(amount, 10)));
260
- endDate = (0, date_fns.endOfDay)(startDate);
263
+ startDate = (0, date_fns_startOfDay.startOfDay)((0, date_fns_addDays.addDays)(today, Number.parseInt(amount, 10)));
264
+ endDate = (0, date_fns_endOfDay.endOfDay)(startDate);
261
265
  break;
262
266
  case "weeks":
263
- startDate = (0, date_fns.startOfDay)((0, date_fns.addDays)(today, Number.parseInt(amount, 10) * 7));
264
- endDate = (0, date_fns.endOfDay)((0, date_fns.addDays)(startDate, 6));
267
+ startDate = (0, date_fns_startOfDay.startOfDay)((0, date_fns_addDays.addDays)(today, Number.parseInt(amount, 10) * 7));
268
+ endDate = (0, date_fns_endOfDay.endOfDay)((0, date_fns_addDays.addDays)(startDate, 6));
265
269
  break;
266
270
  case "months":
267
- startDate = (0, date_fns.startOfDay)((0, date_fns.addDays)(today, Number.parseInt(amount, 10) * 30));
268
- endDate = (0, date_fns.endOfDay)((0, date_fns.addDays)(startDate, 29));
271
+ startDate = (0, date_fns_startOfDay.startOfDay)((0, date_fns_addDays.addDays)(today, Number.parseInt(amount, 10) * 30));
272
+ endDate = (0, date_fns_endOfDay.endOfDay)((0, date_fns_addDays.addDays)(startDate, 29));
269
273
  break;
270
274
  default: return;
271
275
  }
@@ -872,24 +876,44 @@ function splitCrudExtensionWriteInput(inputData, tableColumnNames) {
872
876
  extraValues: Object.keys(extraValues).length > 0 ? extraValues : null
873
877
  };
874
878
  }
875
- async function saveCrudExtraValues(ctx, config, entityId, input) {
879
+ function createCrudExtensionValueWrite(entityId, input) {
880
+ const { extraValues } = input;
881
+ if (!extraValues) return null;
882
+ if (entityId === null || entityId === void 0 || String(entityId).length === 0) return null;
883
+ return {
884
+ entityId: String(entityId),
885
+ rawValues: input.rawValues,
886
+ baseValues: input.baseValues,
887
+ extraValues
888
+ };
889
+ }
890
+ async function persistCrudExtensionValueWrites(ctx, config, rows, preferBulk) {
876
891
  const target = readCrudTarget(config);
877
- const extraValues = input.extraValues;
878
- if (!target || !extraValues) return;
879
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) return;
892
+ if (!target || rows.length === 0) return;
880
893
  const provider = resolveCrudExtensions(ctx, config);
881
- if (!provider?.saveExtraValues) throw new __trpc_server.TRPCError({
894
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new __trpc_server.TRPCError({
882
895
  code: "PRECONDITION_FAILED",
883
896
  message: "CRUD extension persistence is not available"
884
897
  });
885
- await provider.saveExtraValues({
898
+ const tx = ctx?.tx;
899
+ if ((preferBulk || !provider.saveExtraValues) && provider.saveExtraValuesMany) {
900
+ await provider.saveExtraValuesMany({
901
+ id: target.id,
902
+ rows,
903
+ tx
904
+ });
905
+ return;
906
+ }
907
+ await Promise.all(rows.map(async (row) => provider.saveExtraValues({
886
908
  id: target.id,
887
- entityId: String(entityId),
888
- rawValues: input.rawValues,
889
- baseValues: input.baseValues,
890
- extraValues,
891
- tx: ctx?.tx
892
- });
909
+ ...row,
910
+ tx
911
+ })));
912
+ }
913
+ async function saveCrudExtraValues(ctx, config, entityId, input) {
914
+ const row = createCrudExtensionValueWrite(entityId, input);
915
+ if (!row) return;
916
+ await persistCrudExtensionValueWrites(ctx, config, [row], false);
893
917
  }
894
918
  async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
895
919
  if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
@@ -901,7 +925,8 @@ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
901
925
  }
902
926
  function assertCrudExtraValuesWritable(ctx, config, input) {
903
927
  if (!readCrudTarget(config) || !input.extraValues) return;
904
- if (!resolveCrudExtensions(ctx, config)?.saveExtraValues) throw new __trpc_server.TRPCError({
928
+ const provider = resolveCrudExtensions(ctx, config);
929
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new __trpc_server.TRPCError({
905
930
  code: "PRECONDITION_FAILED",
906
931
  message: "CRUD extension persistence is not available"
907
932
  });
@@ -909,38 +934,44 @@ function assertCrudExtraValuesWritable(ctx, config, input) {
909
934
  function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
910
935
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
911
936
  }
937
+ function assertCrudExtraValuesMappableForRows(inputs, idField) {
938
+ const idCounts = /* @__PURE__ */ new Map();
939
+ for (const input of inputs) {
940
+ const inputId = isObjectRecord(input.data) ? input.data[idField] : void 0;
941
+ if (inputId !== null && inputId !== void 0 && String(inputId).length > 0) {
942
+ const normalizedId = String(inputId);
943
+ idCounts.set(normalizedId, (idCounts.get(normalizedId) ?? 0) + 1);
944
+ }
945
+ }
946
+ for (const input of inputs) if (input.extraValues) {
947
+ const inputId = isObjectRecord(input.data) ? input.data[idField] : void 0;
948
+ if (inputId === null || inputId === void 0 || String(inputId).length === 0) throw new __trpc_server.TRPCError({
949
+ code: "PRECONDITION_FAILED",
950
+ message: "Batch CRUD extension writes require every row with extra values to provide a stable id."
951
+ });
952
+ const normalizedId = String(inputId);
953
+ if (idCounts.get(normalizedId) !== 1) throw new __trpc_server.TRPCError({
954
+ code: "PRECONDITION_FAILED",
955
+ message: `Batch CRUD extension writes require unique ids; duplicate id "${normalizedId}" was provided.`
956
+ });
957
+ }
958
+ }
912
959
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
913
- const inputsWithExtra = inputs.filter((input) => input.extraValues);
914
- if (inputsWithExtra.length === 0) return;
915
- const extraByInputId = /* @__PURE__ */ new Map();
960
+ if (inputs.filter((input) => input.extraValues).length === 0) return;
961
+ assertCrudExtraValuesMappableForRows(inputs, idField);
962
+ const inputById = /* @__PURE__ */ new Map();
916
963
  for (const input of inputs) {
917
- if (!input.extraValues || !isObjectRecord(input.data)) continue;
964
+ if (!isObjectRecord(input.data)) continue;
918
965
  const inputId = input.data[idField];
919
- if (inputId !== null && inputId !== void 0) extraByInputId.set(String(inputId), input);
966
+ if (inputId !== null && inputId !== void 0 && String(inputId).length > 0) inputById.set(String(inputId), input);
920
967
  }
921
- const canUseIndexFallback = results.length === inputs.length;
922
- const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
923
- if (!isObjectRecord(input.data)) return true;
924
- const inputId = input.data[idField];
925
- return inputId === null || inputId === void 0 || String(inputId).length === 0;
926
- });
927
- if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new __trpc_server.TRPCError({
928
- code: "PRECONDITION_FAILED",
929
- message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
930
- });
931
- await Promise.all(results.map((result, index) => {
968
+ await persistCrudExtensionValueWrites(ctx, config, results.flatMap((result) => {
932
969
  const resultId = result[idField];
933
- const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
934
- const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
935
- const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
936
- const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
937
- if (!input?.extraValues) return Promise.resolve();
938
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new __trpc_server.TRPCError({
939
- code: "PRECONDITION_FAILED",
940
- message: "Cannot persist CRUD extension values because the created entity id is unavailable."
941
- });
942
- return saveCrudExtraValues(ctx, config, entityId, input);
943
- }));
970
+ const input = resultId !== null && resultId !== void 0 ? inputById.get(String(resultId)) : void 0;
971
+ if (!input?.extraValues) return [];
972
+ const row = createCrudExtensionValueWrite(resultId, input);
973
+ return row ? [row] : [];
974
+ }), true);
944
975
  }
945
976
  function createCrudBatchTransactionInput(inputs) {
946
977
  return {
@@ -1009,28 +1040,25 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1009
1040
  filters: baseFilters
1010
1041
  };
1011
1042
  const provider = resolveCrudExtensions(ctx, config);
1012
- const matchedSets = [];
1043
+ const matchRequests = [];
1013
1044
  if (extensionFilters.length > 0) {
1014
1045
  if (!provider?.matchEntityIds) throw new __trpc_server.TRPCError({
1015
1046
  code: "PRECONDITION_FAILED",
1016
1047
  message: "CRUD extension filtering is not available"
1017
1048
  });
1018
- const matchedIds$1 = await provider.matchEntityIds({
1049
+ matchRequests.push(provider.matchEntityIds({
1019
1050
  id: target.id,
1020
1051
  filters: extensionFilters,
1021
1052
  joinOperator: input.joinOperator,
1022
1053
  limit: 5e3
1023
- });
1024
- matchedSets.push(new Set(matchedIds$1));
1054
+ }));
1025
1055
  }
1026
- if (search && !searchDisabled && provider?.searchEntityIds) {
1027
- const matchedIds$1 = await provider.searchEntityIds({
1028
- id: target.id,
1029
- search,
1030
- limit: 5e3
1031
- });
1032
- matchedSets.push(new Set(matchedIds$1));
1033
- } else if (search && !searchDisabled && !buildCrudSearchCondition({
1056
+ if (search && !searchDisabled && provider?.searchEntityIds) matchRequests.push(provider.searchEntityIds({
1057
+ id: target.id,
1058
+ search,
1059
+ limit: 5e3
1060
+ }));
1061
+ else if (search && !searchDisabled && !buildCrudSearchCondition({
1034
1062
  table,
1035
1063
  search,
1036
1064
  searchColumns
@@ -1038,6 +1066,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1038
1066
  code: "PRECONDITION_FAILED",
1039
1067
  message: "CRUD search is not available"
1040
1068
  });
1069
+ const matchedSets = (await Promise.all(matchRequests)).map((matchedIds$1) => new Set(matchedIds$1));
1041
1070
  if (matchedSets.length === 0) return {
1042
1071
  ...input,
1043
1072
  filters: baseFilters
@@ -1058,11 +1087,17 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1058
1087
  function buildCrudSearchCondition({ table, search, searchColumns }) {
1059
1088
  const term = typeof search === "string" ? search.trim() : "";
1060
1089
  if (!term || !Array.isArray(searchColumns) || searchColumns.length === 0) return;
1061
- const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
1062
- table,
1063
- columnId: getColumnRefId(columnRef),
1064
- allowedColumns: searchColumns
1065
- })).filter((column) => column !== void 0).map((column) => drizzle_orm.sql`${column}::text ilike ${`%${term}%`}`);
1090
+ const conditions = searchColumns.flatMap((columnRef) => {
1091
+ const column = resolveColumnTarget({
1092
+ table,
1093
+ columnId: getColumnRefId(columnRef),
1094
+ allowedColumns: searchColumns
1095
+ });
1096
+ if (!column) return [];
1097
+ const pattern = `%${term}%`;
1098
+ const isJsonTextExpression = typeof columnRef !== "string" && columnRef.expression === void 0 && columnRef.jsonField !== void 0;
1099
+ return ["dataType" in column && column.dataType === "string" || isJsonTextExpression ? (0, drizzle_orm.ilike)(column, pattern) : (0, drizzle_orm.ilike)(drizzle_orm.sql`${column}::text`, pattern)];
1100
+ });
1066
1101
  if (conditions.length === 0) return void 0;
1067
1102
  if (conditions.length === 1) return conditions[0];
1068
1103
  return (0, drizzle_orm.or)(...conditions);
@@ -1158,6 +1193,34 @@ function createCrudRouter(config) {
1158
1193
  const getIdColumn = () => table[idField];
1159
1194
  const tableColumnNames = getTableColumnNames(table);
1160
1195
  const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
1196
+ const buildOrderByExpressions = (requestedSort, useCreatedAtDefault) => {
1197
+ let sortFields = [];
1198
+ if (requestedSort && requestedSort.length > 0) sortFields = requestedSort;
1199
+ else if (useCreatedAtDefault && getTableColumn(table, "createdAt")) sortFields = [{
1200
+ id: "createdAt",
1201
+ desc: true
1202
+ }];
1203
+ const expressions = [];
1204
+ const appliedIds = /* @__PURE__ */ new Set();
1205
+ let tieBreakerDesc = false;
1206
+ for (const sortField of sortFields) if (!appliedIds.has(sortField.id) && validateColumn(sortField.id, sortableColumns)) {
1207
+ const column = resolveColumnTarget({
1208
+ table,
1209
+ columnId: sortField.id,
1210
+ allowedColumns: sortableColumns
1211
+ });
1212
+ if (column) {
1213
+ if (expressions.length === 0) tieBreakerDesc = sortField.desc;
1214
+ expressions.push(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
1215
+ appliedIds.add(sortField.id);
1216
+ }
1217
+ }
1218
+ if (expressions.length > 0 && !appliedIds.has(idField)) {
1219
+ const idColumn = getTableColumn(table, idField);
1220
+ if (idColumn) expressions.push(tieBreakerDesc ? (0, drizzle_orm.desc)(idColumn) : (0, drizzle_orm.asc)(idColumn));
1221
+ }
1222
+ return expressions;
1223
+ };
1161
1224
  const buildWhere = (ctx, operation, additionalCondition) => {
1162
1225
  const conditions = [];
1163
1226
  const scopeCondition = resolved.getScope(ctx, table, operation);
@@ -1231,24 +1294,15 @@ function createCrudRouter(config) {
1231
1294
  })));
1232
1295
  let query = ctx.db.select().from(table).$dynamic();
1233
1296
  if (where) query = query.where(where);
1234
- const sortField = effectiveInput.sort?.[0] ?? (getTableColumn(table, "createdAt") ? {
1235
- id: "createdAt",
1236
- desc: true
1237
- } : void 0);
1238
- if (sortField) {
1239
- if (validateColumn(sortField.id, sortableColumns)) {
1240
- const column = resolveColumnTarget({
1241
- table,
1242
- columnId: sortField.id,
1243
- allowedColumns: sortableColumns
1244
- });
1245
- if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
1246
- }
1247
- }
1248
- const enrichedData = await enrichCrudRows(ctx, config, idField, await query.limit(effectiveInput.perPage).offset(offset));
1297
+ const orderByExpressions = buildOrderByExpressions(effectiveInput.sort, true);
1298
+ if (orderByExpressions.length > 0) query = query.orderBy(...orderByExpressions);
1249
1299
  let countQuery = ctx.db.select({ count: drizzle_orm.sql`count(*)::int` }).from(table).$dynamic();
1250
1300
  if (where) countQuery = countQuery.where(where);
1251
- const count = (await countQuery)[0]?.count ?? 0;
1301
+ const dataPromise = Promise.resolve(query.limit(effectiveInput.perPage).offset(offset));
1302
+ const countPromise = Promise.resolve(countQuery);
1303
+ const enrichedDataPromise = dataPromise.then(async (data) => enrichCrudRows(ctx, config, idField, data));
1304
+ const [enrichedData, countResult] = await Promise.all([enrichedDataPromise, countPromise]);
1305
+ const count = countResult[0]?.count ?? 0;
1252
1306
  return {
1253
1307
  data: enrichedData,
1254
1308
  total: count,
@@ -1430,7 +1484,10 @@ function createCrudRouter(config) {
1430
1484
  let updated = input.ids.map((id) => ({ [idField]: id }));
1431
1485
  if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1432
1486
  else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1433
- await Promise.all(updated.map((row) => saveCrudExtraValues(writeCtx, config, row[idField], splitUpdate)));
1487
+ await persistCrudExtensionValueWrites(writeCtx, config, updated.flatMap((row) => {
1488
+ const extensionRow = createCrudExtensionValueWrite(row[idField], splitUpdate);
1489
+ return extensionRow ? [extensionRow] : [];
1490
+ }), true);
1434
1491
  return { updated: updated.length };
1435
1492
  });
1436
1493
  };
@@ -1526,21 +1583,15 @@ function createCrudRouter(config) {
1526
1583
  })));
1527
1584
  let query = ctx.db.select().from(table).$dynamic();
1528
1585
  if (where) query = query.where(where);
1529
- if (effectiveInput.sort?.length) {
1530
- const sortField = effectiveInput.sort[0];
1531
- if (sortField && validateColumn(sortField.id, sortableColumns)) {
1532
- const column = resolveColumnTarget({
1533
- table,
1534
- columnId: sortField.id,
1535
- allowedColumns: sortableColumns
1536
- });
1537
- if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
1538
- }
1539
- }
1540
- const enrichedData = await enrichCrudRows(ctx, config, idField, await query.limit(effectiveLimit));
1586
+ const orderByExpressions = buildOrderByExpressions(effectiveInput.sort, false);
1587
+ if (orderByExpressions.length > 0) query = query.orderBy(...orderByExpressions);
1541
1588
  let countQuery = ctx.db.select({ count: drizzle_orm.sql`count(*)::int` }).from(table).$dynamic();
1542
1589
  if (where) countQuery = countQuery.where(where);
1543
- const total = (await countQuery)[0]?.count ?? 0;
1590
+ const dataPromise = Promise.resolve(query.limit(effectiveLimit));
1591
+ const countPromise = Promise.resolve(countQuery);
1592
+ const enrichedDataPromise = dataPromise.then(async (data) => enrichCrudRows(ctx, config, idField, data));
1593
+ const [enrichedData, countResult] = await Promise.all([enrichedDataPromise, countPromise]);
1594
+ const total = countResult[0]?.count ?? 0;
1544
1595
  return {
1545
1596
  data: enrichedData,
1546
1597
  total,
@@ -1560,6 +1611,7 @@ function createCrudRouter(config) {
1560
1611
  const doCreateMany = async (items) => {
1561
1612
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1562
1613
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1614
+ assertCrudExtraValuesMappableForRows(splitItems, idField);
1563
1615
  return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1564
1616
  const injectData = resolved.getInject(writeCtx, "create");
1565
1617
  const values = splitItems.map(({ data }) => ({
@@ -1613,6 +1665,7 @@ function createCrudRouter(config) {
1613
1665
  failed
1614
1666
  };
1615
1667
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1668
+ assertCrudExtraValuesMappableForRows(validRows, idField);
1616
1669
  const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1617
1670
  const injectData = resolved.getInject(writeCtx, "create");
1618
1671
  const values = validRows.map((row) => ({
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AnyColumn, SQL, Table } from "drizzle-orm";
2
- import { z } from "zod";
3
2
  import { PgTable } from "drizzle-orm/pg-core";
3
+ import { z } from "zod";
4
4
  import * as _trpc_server2 from "@trpc/server";
5
5
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
6
6
 
@@ -186,16 +186,31 @@ interface CrudExtensionFilter {
186
186
  operator: string;
187
187
  filterId?: string | undefined;
188
188
  }
189
+ interface CrudExtensionValueWrite {
190
+ entityId: string;
191
+ rawValues: Record<string, unknown>;
192
+ baseValues: Record<string, unknown>;
193
+ extraValues: Record<string, unknown>;
194
+ }
189
195
  interface CrudExtensionsProvider {
190
196
  getMetadata?: (input: {
191
197
  id: string;
192
198
  }) => Promise<CrudExtensionMetadata>;
193
199
  saveExtraValues?: (input: {
194
200
  id: string;
195
- entityId: string;
196
- rawValues: Record<string, unknown>;
197
- baseValues: Record<string, unknown>;
198
- extraValues: Record<string, unknown>;
201
+ tx?: unknown;
202
+ } & CrudExtensionValueWrite) => Promise<void>;
203
+ /**
204
+ * Optional bulk persistence path used by createMany, updateMany, and import.
205
+ *
206
+ * Providers should implement this when extension values are stored in a
207
+ * database. The router falls back to saveExtraValues for backward
208
+ * compatibility when this method is absent. Batch create/import rows with
209
+ * extension values must provide stable, unique entity ids.
210
+ */
211
+ saveExtraValuesMany?: (input: {
212
+ id: string;
213
+ rows: CrudExtensionValueWrite[];
199
214
  tx?: unknown;
200
215
  }) => Promise<void>;
201
216
  readProjection?: (input: {
@@ -972,4 +987,4 @@ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
972
987
  }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
973
988
  type AppRouter = typeof appRouter;
974
989
  //#endregion
975
- export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudCapabilityMetadata, type CrudColumnCapability, 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 DateFilterRange, type DateRangeResolver, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type ExtendedColumnFilter, type FilterOperator, type FilterVariant, 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 };
990
+ export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudCapabilityMetadata, type CrudColumnCapability, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudExtensionFilter, type CrudExtensionMetadata, type CrudExtensionValueWrite, type CrudExtensionsConfig, type CrudExtensionsProvider, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DateFilterRange, type DateRangeResolver, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type ExtendedColumnFilter, type FilterOperator, type FilterVariant, 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
- import { z } from "zod";
2
- import { AnyColumn, SQL, Table } from "drizzle-orm";
3
1
  import * as _trpc_server2 from "@trpc/server";
2
+ import { AnyColumn, SQL, Table } from "drizzle-orm";
3
+ import { z } from "zod";
4
4
  import superjson from "superjson";
5
5
  import { PgTable } from "drizzle-orm/pg-core";
6
6
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
@@ -187,16 +187,31 @@ interface CrudExtensionFilter {
187
187
  operator: string;
188
188
  filterId?: string | undefined;
189
189
  }
190
+ interface CrudExtensionValueWrite {
191
+ entityId: string;
192
+ rawValues: Record<string, unknown>;
193
+ baseValues: Record<string, unknown>;
194
+ extraValues: Record<string, unknown>;
195
+ }
190
196
  interface CrudExtensionsProvider {
191
197
  getMetadata?: (input: {
192
198
  id: string;
193
199
  }) => Promise<CrudExtensionMetadata>;
194
200
  saveExtraValues?: (input: {
195
201
  id: string;
196
- entityId: string;
197
- rawValues: Record<string, unknown>;
198
- baseValues: Record<string, unknown>;
199
- extraValues: Record<string, unknown>;
202
+ tx?: unknown;
203
+ } & CrudExtensionValueWrite) => Promise<void>;
204
+ /**
205
+ * Optional bulk persistence path used by createMany, updateMany, and import.
206
+ *
207
+ * Providers should implement this when extension values are stored in a
208
+ * database. The router falls back to saveExtraValues for backward
209
+ * compatibility when this method is absent. Batch create/import rows with
210
+ * extension values must provide stable, unique entity ids.
211
+ */
212
+ saveExtraValuesMany?: (input: {
213
+ id: string;
214
+ rows: CrudExtensionValueWrite[];
200
215
  tx?: unknown;
201
216
  }) => Promise<void>;
202
217
  readProjection?: (input: {
@@ -973,4 +988,4 @@ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
973
988
  }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
974
989
  type AppRouter = typeof appRouter;
975
990
  //#endregion
976
- export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudCapabilityMetadata, type CrudColumnCapability, 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 DateFilterRange, type DateRangeResolver, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type ExtendedColumnFilter, type FilterOperator, type FilterVariant, 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 };
991
+ export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudCapabilityMetadata, type CrudColumnCapability, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudExtensionFilter, type CrudExtensionMetadata, type CrudExtensionValueWrite, type CrudExtensionsConfig, type CrudExtensionsProvider, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DateFilterRange, type DateRangeResolver, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type ExtendedColumnFilter, type FilterOperator, type FilterVariant, 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
@@ -1,9 +1,11 @@
1
- import { z } from "zod";
1
+ import { TRPCError, initTRPC } from "@trpc/server";
2
2
  import { and, asc, desc, eq, getTableColumns, gt, gte, ilike, inArray, isNull, lt, lte, ne, not, notIlike, notInArray, or, sql } from "drizzle-orm";
3
3
  import { createInsertSchema, createSelectSchema } from "drizzle-zod";
4
- import { TRPCError, initTRPC } from "@trpc/server";
4
+ import { z } from "zod";
5
5
  import superjson from "superjson";
6
- import { addDays, endOfDay, startOfDay } from "date-fns";
6
+ import { addDays } from "date-fns/addDays";
7
+ import { endOfDay } from "date-fns/endOfDay";
8
+ import { startOfDay } from "date-fns/startOfDay";
7
9
 
8
10
  //#region src/trpc.ts
9
11
  const t = initTRPC.context().create({ transformer: superjson });
@@ -843,24 +845,44 @@ function splitCrudExtensionWriteInput(inputData, tableColumnNames) {
843
845
  extraValues: Object.keys(extraValues).length > 0 ? extraValues : null
844
846
  };
845
847
  }
846
- async function saveCrudExtraValues(ctx, config, entityId, input) {
848
+ function createCrudExtensionValueWrite(entityId, input) {
849
+ const { extraValues } = input;
850
+ if (!extraValues) return null;
851
+ if (entityId === null || entityId === void 0 || String(entityId).length === 0) return null;
852
+ return {
853
+ entityId: String(entityId),
854
+ rawValues: input.rawValues,
855
+ baseValues: input.baseValues,
856
+ extraValues
857
+ };
858
+ }
859
+ async function persistCrudExtensionValueWrites(ctx, config, rows, preferBulk) {
847
860
  const target = readCrudTarget(config);
848
- const extraValues = input.extraValues;
849
- if (!target || !extraValues) return;
850
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) return;
861
+ if (!target || rows.length === 0) return;
851
862
  const provider = resolveCrudExtensions(ctx, config);
852
- if (!provider?.saveExtraValues) throw new TRPCError({
863
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new TRPCError({
853
864
  code: "PRECONDITION_FAILED",
854
865
  message: "CRUD extension persistence is not available"
855
866
  });
856
- await provider.saveExtraValues({
867
+ const tx = ctx?.tx;
868
+ if ((preferBulk || !provider.saveExtraValues) && provider.saveExtraValuesMany) {
869
+ await provider.saveExtraValuesMany({
870
+ id: target.id,
871
+ rows,
872
+ tx
873
+ });
874
+ return;
875
+ }
876
+ await Promise.all(rows.map(async (row) => provider.saveExtraValues({
857
877
  id: target.id,
858
- entityId: String(entityId),
859
- rawValues: input.rawValues,
860
- baseValues: input.baseValues,
861
- extraValues,
862
- tx: ctx?.tx
863
- });
878
+ ...row,
879
+ tx
880
+ })));
881
+ }
882
+ async function saveCrudExtraValues(ctx, config, entityId, input) {
883
+ const row = createCrudExtensionValueWrite(entityId, input);
884
+ if (!row) return;
885
+ await persistCrudExtensionValueWrites(ctx, config, [row], false);
864
886
  }
865
887
  async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
866
888
  if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
@@ -872,7 +894,8 @@ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
872
894
  }
873
895
  function assertCrudExtraValuesWritable(ctx, config, input) {
874
896
  if (!readCrudTarget(config) || !input.extraValues) return;
875
- if (!resolveCrudExtensions(ctx, config)?.saveExtraValues) throw new TRPCError({
897
+ const provider = resolveCrudExtensions(ctx, config);
898
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new TRPCError({
876
899
  code: "PRECONDITION_FAILED",
877
900
  message: "CRUD extension persistence is not available"
878
901
  });
@@ -880,38 +903,44 @@ function assertCrudExtraValuesWritable(ctx, config, input) {
880
903
  function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
881
904
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
882
905
  }
906
+ function assertCrudExtraValuesMappableForRows(inputs, idField) {
907
+ const idCounts = /* @__PURE__ */ new Map();
908
+ for (const input of inputs) {
909
+ const inputId = isObjectRecord(input.data) ? input.data[idField] : void 0;
910
+ if (inputId !== null && inputId !== void 0 && String(inputId).length > 0) {
911
+ const normalizedId = String(inputId);
912
+ idCounts.set(normalizedId, (idCounts.get(normalizedId) ?? 0) + 1);
913
+ }
914
+ }
915
+ for (const input of inputs) if (input.extraValues) {
916
+ const inputId = isObjectRecord(input.data) ? input.data[idField] : void 0;
917
+ if (inputId === null || inputId === void 0 || String(inputId).length === 0) throw new TRPCError({
918
+ code: "PRECONDITION_FAILED",
919
+ message: "Batch CRUD extension writes require every row with extra values to provide a stable id."
920
+ });
921
+ const normalizedId = String(inputId);
922
+ if (idCounts.get(normalizedId) !== 1) throw new TRPCError({
923
+ code: "PRECONDITION_FAILED",
924
+ message: `Batch CRUD extension writes require unique ids; duplicate id "${normalizedId}" was provided.`
925
+ });
926
+ }
927
+ }
883
928
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
884
- const inputsWithExtra = inputs.filter((input) => input.extraValues);
885
- if (inputsWithExtra.length === 0) return;
886
- const extraByInputId = /* @__PURE__ */ new Map();
929
+ if (inputs.filter((input) => input.extraValues).length === 0) return;
930
+ assertCrudExtraValuesMappableForRows(inputs, idField);
931
+ const inputById = /* @__PURE__ */ new Map();
887
932
  for (const input of inputs) {
888
- if (!input.extraValues || !isObjectRecord(input.data)) continue;
933
+ if (!isObjectRecord(input.data)) continue;
889
934
  const inputId = input.data[idField];
890
- if (inputId !== null && inputId !== void 0) extraByInputId.set(String(inputId), input);
935
+ if (inputId !== null && inputId !== void 0 && String(inputId).length > 0) inputById.set(String(inputId), input);
891
936
  }
892
- const canUseIndexFallback = results.length === inputs.length;
893
- const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
894
- if (!isObjectRecord(input.data)) return true;
895
- const inputId = input.data[idField];
896
- return inputId === null || inputId === void 0 || String(inputId).length === 0;
897
- });
898
- if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new TRPCError({
899
- code: "PRECONDITION_FAILED",
900
- message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
901
- });
902
- await Promise.all(results.map((result, index) => {
937
+ await persistCrudExtensionValueWrites(ctx, config, results.flatMap((result) => {
903
938
  const resultId = result[idField];
904
- const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
905
- const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
906
- const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
907
- const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
908
- if (!input?.extraValues) return Promise.resolve();
909
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new TRPCError({
910
- code: "PRECONDITION_FAILED",
911
- message: "Cannot persist CRUD extension values because the created entity id is unavailable."
912
- });
913
- return saveCrudExtraValues(ctx, config, entityId, input);
914
- }));
939
+ const input = resultId !== null && resultId !== void 0 ? inputById.get(String(resultId)) : void 0;
940
+ if (!input?.extraValues) return [];
941
+ const row = createCrudExtensionValueWrite(resultId, input);
942
+ return row ? [row] : [];
943
+ }), true);
915
944
  }
916
945
  function createCrudBatchTransactionInput(inputs) {
917
946
  return {
@@ -980,28 +1009,25 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
980
1009
  filters: baseFilters
981
1010
  };
982
1011
  const provider = resolveCrudExtensions(ctx, config);
983
- const matchedSets = [];
1012
+ const matchRequests = [];
984
1013
  if (extensionFilters.length > 0) {
985
1014
  if (!provider?.matchEntityIds) throw new TRPCError({
986
1015
  code: "PRECONDITION_FAILED",
987
1016
  message: "CRUD extension filtering is not available"
988
1017
  });
989
- const matchedIds$1 = await provider.matchEntityIds({
1018
+ matchRequests.push(provider.matchEntityIds({
990
1019
  id: target.id,
991
1020
  filters: extensionFilters,
992
1021
  joinOperator: input.joinOperator,
993
1022
  limit: 5e3
994
- });
995
- matchedSets.push(new Set(matchedIds$1));
1023
+ }));
996
1024
  }
997
- if (search && !searchDisabled && provider?.searchEntityIds) {
998
- const matchedIds$1 = await provider.searchEntityIds({
999
- id: target.id,
1000
- search,
1001
- limit: 5e3
1002
- });
1003
- matchedSets.push(new Set(matchedIds$1));
1004
- } else if (search && !searchDisabled && !buildCrudSearchCondition({
1025
+ if (search && !searchDisabled && provider?.searchEntityIds) matchRequests.push(provider.searchEntityIds({
1026
+ id: target.id,
1027
+ search,
1028
+ limit: 5e3
1029
+ }));
1030
+ else if (search && !searchDisabled && !buildCrudSearchCondition({
1005
1031
  table,
1006
1032
  search,
1007
1033
  searchColumns
@@ -1009,6 +1035,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1009
1035
  code: "PRECONDITION_FAILED",
1010
1036
  message: "CRUD search is not available"
1011
1037
  });
1038
+ const matchedSets = (await Promise.all(matchRequests)).map((matchedIds$1) => new Set(matchedIds$1));
1012
1039
  if (matchedSets.length === 0) return {
1013
1040
  ...input,
1014
1041
  filters: baseFilters
@@ -1029,11 +1056,17 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1029
1056
  function buildCrudSearchCondition({ table, search, searchColumns }) {
1030
1057
  const term = typeof search === "string" ? search.trim() : "";
1031
1058
  if (!term || !Array.isArray(searchColumns) || searchColumns.length === 0) return;
1032
- const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
1033
- table,
1034
- columnId: getColumnRefId(columnRef),
1035
- allowedColumns: searchColumns
1036
- })).filter((column) => column !== void 0).map((column) => sql`${column}::text ilike ${`%${term}%`}`);
1059
+ const conditions = searchColumns.flatMap((columnRef) => {
1060
+ const column = resolveColumnTarget({
1061
+ table,
1062
+ columnId: getColumnRefId(columnRef),
1063
+ allowedColumns: searchColumns
1064
+ });
1065
+ if (!column) return [];
1066
+ const pattern = `%${term}%`;
1067
+ const isJsonTextExpression = typeof columnRef !== "string" && columnRef.expression === void 0 && columnRef.jsonField !== void 0;
1068
+ return ["dataType" in column && column.dataType === "string" || isJsonTextExpression ? ilike(column, pattern) : ilike(sql`${column}::text`, pattern)];
1069
+ });
1037
1070
  if (conditions.length === 0) return void 0;
1038
1071
  if (conditions.length === 1) return conditions[0];
1039
1072
  return or(...conditions);
@@ -1129,6 +1162,34 @@ function createCrudRouter(config) {
1129
1162
  const getIdColumn = () => table[idField];
1130
1163
  const tableColumnNames = getTableColumnNames(table);
1131
1164
  const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
1165
+ const buildOrderByExpressions = (requestedSort, useCreatedAtDefault) => {
1166
+ let sortFields = [];
1167
+ if (requestedSort && requestedSort.length > 0) sortFields = requestedSort;
1168
+ else if (useCreatedAtDefault && getTableColumn(table, "createdAt")) sortFields = [{
1169
+ id: "createdAt",
1170
+ desc: true
1171
+ }];
1172
+ const expressions = [];
1173
+ const appliedIds = /* @__PURE__ */ new Set();
1174
+ let tieBreakerDesc = false;
1175
+ for (const sortField of sortFields) if (!appliedIds.has(sortField.id) && validateColumn(sortField.id, sortableColumns)) {
1176
+ const column = resolveColumnTarget({
1177
+ table,
1178
+ columnId: sortField.id,
1179
+ allowedColumns: sortableColumns
1180
+ });
1181
+ if (column) {
1182
+ if (expressions.length === 0) tieBreakerDesc = sortField.desc;
1183
+ expressions.push(sortField.desc ? desc(column) : asc(column));
1184
+ appliedIds.add(sortField.id);
1185
+ }
1186
+ }
1187
+ if (expressions.length > 0 && !appliedIds.has(idField)) {
1188
+ const idColumn = getTableColumn(table, idField);
1189
+ if (idColumn) expressions.push(tieBreakerDesc ? desc(idColumn) : asc(idColumn));
1190
+ }
1191
+ return expressions;
1192
+ };
1132
1193
  const buildWhere = (ctx, operation, additionalCondition) => {
1133
1194
  const conditions = [];
1134
1195
  const scopeCondition = resolved.getScope(ctx, table, operation);
@@ -1202,24 +1263,15 @@ function createCrudRouter(config) {
1202
1263
  })));
1203
1264
  let query = ctx.db.select().from(table).$dynamic();
1204
1265
  if (where) query = query.where(where);
1205
- const sortField = effectiveInput.sort?.[0] ?? (getTableColumn(table, "createdAt") ? {
1206
- id: "createdAt",
1207
- desc: true
1208
- } : void 0);
1209
- if (sortField) {
1210
- if (validateColumn(sortField.id, sortableColumns)) {
1211
- const column = resolveColumnTarget({
1212
- table,
1213
- columnId: sortField.id,
1214
- allowedColumns: sortableColumns
1215
- });
1216
- if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
1217
- }
1218
- }
1219
- const enrichedData = await enrichCrudRows(ctx, config, idField, await query.limit(effectiveInput.perPage).offset(offset));
1266
+ const orderByExpressions = buildOrderByExpressions(effectiveInput.sort, true);
1267
+ if (orderByExpressions.length > 0) query = query.orderBy(...orderByExpressions);
1220
1268
  let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
1221
1269
  if (where) countQuery = countQuery.where(where);
1222
- const count = (await countQuery)[0]?.count ?? 0;
1270
+ const dataPromise = Promise.resolve(query.limit(effectiveInput.perPage).offset(offset));
1271
+ const countPromise = Promise.resolve(countQuery);
1272
+ const enrichedDataPromise = dataPromise.then(async (data) => enrichCrudRows(ctx, config, idField, data));
1273
+ const [enrichedData, countResult] = await Promise.all([enrichedDataPromise, countPromise]);
1274
+ const count = countResult[0]?.count ?? 0;
1223
1275
  return {
1224
1276
  data: enrichedData,
1225
1277
  total: count,
@@ -1401,7 +1453,10 @@ function createCrudRouter(config) {
1401
1453
  let updated = input.ids.map((id) => ({ [idField]: id }));
1402
1454
  if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1403
1455
  else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1404
- await Promise.all(updated.map((row) => saveCrudExtraValues(writeCtx, config, row[idField], splitUpdate)));
1456
+ await persistCrudExtensionValueWrites(writeCtx, config, updated.flatMap((row) => {
1457
+ const extensionRow = createCrudExtensionValueWrite(row[idField], splitUpdate);
1458
+ return extensionRow ? [extensionRow] : [];
1459
+ }), true);
1405
1460
  return { updated: updated.length };
1406
1461
  });
1407
1462
  };
@@ -1497,21 +1552,15 @@ function createCrudRouter(config) {
1497
1552
  })));
1498
1553
  let query = ctx.db.select().from(table).$dynamic();
1499
1554
  if (where) query = query.where(where);
1500
- if (effectiveInput.sort?.length) {
1501
- const sortField = effectiveInput.sort[0];
1502
- if (sortField && validateColumn(sortField.id, sortableColumns)) {
1503
- const column = resolveColumnTarget({
1504
- table,
1505
- columnId: sortField.id,
1506
- allowedColumns: sortableColumns
1507
- });
1508
- if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
1509
- }
1510
- }
1511
- const enrichedData = await enrichCrudRows(ctx, config, idField, await query.limit(effectiveLimit));
1555
+ const orderByExpressions = buildOrderByExpressions(effectiveInput.sort, false);
1556
+ if (orderByExpressions.length > 0) query = query.orderBy(...orderByExpressions);
1512
1557
  let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
1513
1558
  if (where) countQuery = countQuery.where(where);
1514
- const total = (await countQuery)[0]?.count ?? 0;
1559
+ const dataPromise = Promise.resolve(query.limit(effectiveLimit));
1560
+ const countPromise = Promise.resolve(countQuery);
1561
+ const enrichedDataPromise = dataPromise.then(async (data) => enrichCrudRows(ctx, config, idField, data));
1562
+ const [enrichedData, countResult] = await Promise.all([enrichedDataPromise, countPromise]);
1563
+ const total = countResult[0]?.count ?? 0;
1515
1564
  return {
1516
1565
  data: enrichedData,
1517
1566
  total,
@@ -1531,6 +1580,7 @@ function createCrudRouter(config) {
1531
1580
  const doCreateMany = async (items) => {
1532
1581
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1533
1582
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1583
+ assertCrudExtraValuesMappableForRows(splitItems, idField);
1534
1584
  return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1535
1585
  const injectData = resolved.getInject(writeCtx, "create");
1536
1586
  const values = splitItems.map(({ data }) => ({
@@ -1584,6 +1634,7 @@ function createCrudRouter(config) {
1584
1634
  failed
1585
1635
  };
1586
1636
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1637
+ assertCrudExtraValuesMappableForRows(validRows, idField);
1587
1638
  const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1588
1639
  const injectData = resolved.getInject(writeCtx, "create");
1589
1640
  const values = validRows.map((row) => ({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wordrhyme/auto-crud-server",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -48,11 +48,11 @@
48
48
  "typescript": "^5.9.3",
49
49
  "vitest": "^4.0.18",
50
50
  "zod": "^4.3.6",
51
- "@internal/eslint-config": "0.3.0",
52
- "@internal/tsdown-config": "0.1.0",
53
51
  "@internal/tsconfig": "0.1.0",
52
+ "@internal/eslint-config": "0.3.0",
53
+ "@internal/vitest-config": "0.1.0",
54
54
  "@internal/prettier-config": "0.0.1",
55
- "@internal/vitest-config": "0.1.0"
55
+ "@internal/tsdown-config": "0.1.0"
56
56
  },
57
57
  "prettier": "@internal/prettier-config",
58
58
  "scripts": {