@wordrhyme/auto-crud-server 1.3.1 → 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,14 +21,14 @@ 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
34
  let date_fns_addDays = require("date-fns/addDays");
@@ -876,24 +876,44 @@ function splitCrudExtensionWriteInput(inputData, tableColumnNames) {
876
876
  extraValues: Object.keys(extraValues).length > 0 ? extraValues : null
877
877
  };
878
878
  }
879
- 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) {
880
891
  const target = readCrudTarget(config);
881
- const extraValues = input.extraValues;
882
- if (!target || !extraValues) return;
883
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) return;
892
+ if (!target || rows.length === 0) return;
884
893
  const provider = resolveCrudExtensions(ctx, config);
885
- if (!provider?.saveExtraValues) throw new __trpc_server.TRPCError({
894
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new __trpc_server.TRPCError({
886
895
  code: "PRECONDITION_FAILED",
887
896
  message: "CRUD extension persistence is not available"
888
897
  });
889
- 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({
890
908
  id: target.id,
891
- entityId: String(entityId),
892
- rawValues: input.rawValues,
893
- baseValues: input.baseValues,
894
- extraValues,
895
- tx: ctx?.tx
896
- });
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);
897
917
  }
898
918
  async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
899
919
  if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
@@ -905,7 +925,8 @@ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
905
925
  }
906
926
  function assertCrudExtraValuesWritable(ctx, config, input) {
907
927
  if (!readCrudTarget(config) || !input.extraValues) return;
908
- 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({
909
930
  code: "PRECONDITION_FAILED",
910
931
  message: "CRUD extension persistence is not available"
911
932
  });
@@ -913,38 +934,44 @@ function assertCrudExtraValuesWritable(ctx, config, input) {
913
934
  function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
914
935
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
915
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
+ }
916
959
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
917
- const inputsWithExtra = inputs.filter((input) => input.extraValues);
918
- if (inputsWithExtra.length === 0) return;
919
- 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();
920
963
  for (const input of inputs) {
921
- if (!input.extraValues || !isObjectRecord(input.data)) continue;
964
+ if (!isObjectRecord(input.data)) continue;
922
965
  const inputId = input.data[idField];
923
- 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);
924
967
  }
925
- const canUseIndexFallback = results.length === inputs.length;
926
- const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
927
- if (!isObjectRecord(input.data)) return true;
928
- const inputId = input.data[idField];
929
- return inputId === null || inputId === void 0 || String(inputId).length === 0;
930
- });
931
- if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new __trpc_server.TRPCError({
932
- code: "PRECONDITION_FAILED",
933
- message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
934
- });
935
- await Promise.all(results.map((result, index) => {
968
+ await persistCrudExtensionValueWrites(ctx, config, results.flatMap((result) => {
936
969
  const resultId = result[idField];
937
- const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
938
- const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
939
- const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
940
- const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
941
- if (!input?.extraValues) return Promise.resolve();
942
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new __trpc_server.TRPCError({
943
- code: "PRECONDITION_FAILED",
944
- message: "Cannot persist CRUD extension values because the created entity id is unavailable."
945
- });
946
- return saveCrudExtraValues(ctx, config, entityId, input);
947
- }));
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);
948
975
  }
949
976
  function createCrudBatchTransactionInput(inputs) {
950
977
  return {
@@ -1013,28 +1040,25 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1013
1040
  filters: baseFilters
1014
1041
  };
1015
1042
  const provider = resolveCrudExtensions(ctx, config);
1016
- const matchedSets = [];
1043
+ const matchRequests = [];
1017
1044
  if (extensionFilters.length > 0) {
1018
1045
  if (!provider?.matchEntityIds) throw new __trpc_server.TRPCError({
1019
1046
  code: "PRECONDITION_FAILED",
1020
1047
  message: "CRUD extension filtering is not available"
1021
1048
  });
1022
- const matchedIds$1 = await provider.matchEntityIds({
1049
+ matchRequests.push(provider.matchEntityIds({
1023
1050
  id: target.id,
1024
1051
  filters: extensionFilters,
1025
1052
  joinOperator: input.joinOperator,
1026
1053
  limit: 5e3
1027
- });
1028
- matchedSets.push(new Set(matchedIds$1));
1054
+ }));
1029
1055
  }
1030
- if (search && !searchDisabled && provider?.searchEntityIds) {
1031
- const matchedIds$1 = await provider.searchEntityIds({
1032
- id: target.id,
1033
- search,
1034
- limit: 5e3
1035
- });
1036
- matchedSets.push(new Set(matchedIds$1));
1037
- } 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({
1038
1062
  table,
1039
1063
  search,
1040
1064
  searchColumns
@@ -1042,6 +1066,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1042
1066
  code: "PRECONDITION_FAILED",
1043
1067
  message: "CRUD search is not available"
1044
1068
  });
1069
+ const matchedSets = (await Promise.all(matchRequests)).map((matchedIds$1) => new Set(matchedIds$1));
1045
1070
  if (matchedSets.length === 0) return {
1046
1071
  ...input,
1047
1072
  filters: baseFilters
@@ -1062,11 +1087,17 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1062
1087
  function buildCrudSearchCondition({ table, search, searchColumns }) {
1063
1088
  const term = typeof search === "string" ? search.trim() : "";
1064
1089
  if (!term || !Array.isArray(searchColumns) || searchColumns.length === 0) return;
1065
- const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
1066
- table,
1067
- columnId: getColumnRefId(columnRef),
1068
- allowedColumns: searchColumns
1069
- })).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
+ });
1070
1101
  if (conditions.length === 0) return void 0;
1071
1102
  if (conditions.length === 1) return conditions[0];
1072
1103
  return (0, drizzle_orm.or)(...conditions);
@@ -1162,6 +1193,34 @@ function createCrudRouter(config) {
1162
1193
  const getIdColumn = () => table[idField];
1163
1194
  const tableColumnNames = getTableColumnNames(table);
1164
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
+ };
1165
1224
  const buildWhere = (ctx, operation, additionalCondition) => {
1166
1225
  const conditions = [];
1167
1226
  const scopeCondition = resolved.getScope(ctx, table, operation);
@@ -1235,24 +1294,15 @@ function createCrudRouter(config) {
1235
1294
  })));
1236
1295
  let query = ctx.db.select().from(table).$dynamic();
1237
1296
  if (where) query = query.where(where);
1238
- const sortField = effectiveInput.sort?.[0] ?? (getTableColumn(table, "createdAt") ? {
1239
- id: "createdAt",
1240
- desc: true
1241
- } : void 0);
1242
- if (sortField) {
1243
- if (validateColumn(sortField.id, sortableColumns)) {
1244
- const column = resolveColumnTarget({
1245
- table,
1246
- columnId: sortField.id,
1247
- allowedColumns: sortableColumns
1248
- });
1249
- if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
1250
- }
1251
- }
1252
- 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);
1253
1299
  let countQuery = ctx.db.select({ count: drizzle_orm.sql`count(*)::int` }).from(table).$dynamic();
1254
1300
  if (where) countQuery = countQuery.where(where);
1255
- 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;
1256
1306
  return {
1257
1307
  data: enrichedData,
1258
1308
  total: count,
@@ -1434,7 +1484,10 @@ function createCrudRouter(config) {
1434
1484
  let updated = input.ids.map((id) => ({ [idField]: id }));
1435
1485
  if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1436
1486
  else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1437
- 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);
1438
1491
  return { updated: updated.length };
1439
1492
  });
1440
1493
  };
@@ -1530,21 +1583,15 @@ function createCrudRouter(config) {
1530
1583
  })));
1531
1584
  let query = ctx.db.select().from(table).$dynamic();
1532
1585
  if (where) query = query.where(where);
1533
- if (effectiveInput.sort?.length) {
1534
- const sortField = effectiveInput.sort[0];
1535
- if (sortField && validateColumn(sortField.id, sortableColumns)) {
1536
- const column = resolveColumnTarget({
1537
- table,
1538
- columnId: sortField.id,
1539
- allowedColumns: sortableColumns
1540
- });
1541
- if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
1542
- }
1543
- }
1544
- 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);
1545
1588
  let countQuery = ctx.db.select({ count: drizzle_orm.sql`count(*)::int` }).from(table).$dynamic();
1546
1589
  if (where) countQuery = countQuery.where(where);
1547
- 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;
1548
1595
  return {
1549
1596
  data: enrichedData,
1550
1597
  total,
@@ -1564,6 +1611,7 @@ function createCrudRouter(config) {
1564
1611
  const doCreateMany = async (items) => {
1565
1612
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1566
1613
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1614
+ assertCrudExtraValuesMappableForRows(splitItems, idField);
1567
1615
  return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1568
1616
  const injectData = resolved.getInject(writeCtx, "create");
1569
1617
  const values = splitItems.map(({ data }) => ({
@@ -1617,6 +1665,7 @@ function createCrudRouter(config) {
1617
1665
  failed
1618
1666
  };
1619
1667
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1668
+ assertCrudExtraValuesMappableForRows(validRows, idField);
1620
1669
  const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1621
1670
  const injectData = resolved.getInject(writeCtx, "create");
1622
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,7 +1,7 @@
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
6
  import { addDays } from "date-fns/addDays";
7
7
  import { endOfDay } from "date-fns/endOfDay";
@@ -845,24 +845,44 @@ function splitCrudExtensionWriteInput(inputData, tableColumnNames) {
845
845
  extraValues: Object.keys(extraValues).length > 0 ? extraValues : null
846
846
  };
847
847
  }
848
- 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) {
849
860
  const target = readCrudTarget(config);
850
- const extraValues = input.extraValues;
851
- if (!target || !extraValues) return;
852
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) return;
861
+ if (!target || rows.length === 0) return;
853
862
  const provider = resolveCrudExtensions(ctx, config);
854
- if (!provider?.saveExtraValues) throw new TRPCError({
863
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new TRPCError({
855
864
  code: "PRECONDITION_FAILED",
856
865
  message: "CRUD extension persistence is not available"
857
866
  });
858
- 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({
859
877
  id: target.id,
860
- entityId: String(entityId),
861
- rawValues: input.rawValues,
862
- baseValues: input.baseValues,
863
- extraValues,
864
- tx: ctx?.tx
865
- });
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);
866
886
  }
867
887
  async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
868
888
  if ((input.extraValues || shouldWrapCrudLifecycleTransaction(ctx, config)) && ctx.tx === void 0 && typeof ctx.db?.transaction === "function") return ctx.db.transaction((tx) => operation({
@@ -874,7 +894,8 @@ async function runCrudExtensionWriteTransaction(ctx, config, input, operation) {
874
894
  }
875
895
  function assertCrudExtraValuesWritable(ctx, config, input) {
876
896
  if (!readCrudTarget(config) || !input.extraValues) return;
877
- if (!resolveCrudExtensions(ctx, config)?.saveExtraValues) throw new TRPCError({
897
+ const provider = resolveCrudExtensions(ctx, config);
898
+ if (!provider?.saveExtraValues && !provider?.saveExtraValuesMany) throw new TRPCError({
878
899
  code: "PRECONDITION_FAILED",
879
900
  message: "CRUD extension persistence is not available"
880
901
  });
@@ -882,38 +903,44 @@ function assertCrudExtraValuesWritable(ctx, config, input) {
882
903
  function assertCrudExtraValuesWritableForRows(ctx, config, inputs) {
883
904
  if (inputs.some((input) => input.extraValues)) assertCrudExtraValuesWritable(ctx, config, inputs.find((input) => input.extraValues));
884
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
+ }
885
928
  async function saveCrudExtraValuesForRows(ctx, config, idField, results, inputs) {
886
- const inputsWithExtra = inputs.filter((input) => input.extraValues);
887
- if (inputsWithExtra.length === 0) return;
888
- 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();
889
932
  for (const input of inputs) {
890
- if (!input.extraValues || !isObjectRecord(input.data)) continue;
933
+ if (!isObjectRecord(input.data)) continue;
891
934
  const inputId = input.data[idField];
892
- 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);
893
936
  }
894
- const canUseIndexFallback = results.length === inputs.length;
895
- const hasUnidentifiedExtraInput = inputsWithExtra.some((input) => {
896
- if (!isObjectRecord(input.data)) return true;
897
- const inputId = input.data[idField];
898
- return inputId === null || inputId === void 0 || String(inputId).length === 0;
899
- });
900
- if (!canUseIndexFallback && hasUnidentifiedExtraInput) throw new TRPCError({
901
- code: "PRECONDITION_FAILED",
902
- message: "Cannot persist CRUD extension values because inserted rows cannot be matched to input rows. Provide stable ids or avoid partial-conflict batch writes."
903
- });
904
- await Promise.all(results.map((result, index) => {
937
+ await persistCrudExtensionValueWrites(ctx, config, results.flatMap((result) => {
905
938
  const resultId = result[idField];
906
- const fallbackInput = canUseIndexFallback ? inputs[index] : void 0;
907
- const fallbackInputId = fallbackInput && isObjectRecord(fallbackInput.data) ? fallbackInput.data[idField] : void 0;
908
- const entityId = resultId !== null && resultId !== void 0 ? resultId : fallbackInputId;
909
- const input = entityId !== null && entityId !== void 0 ? extraByInputId.get(String(entityId)) ?? fallbackInput : fallbackInput;
910
- if (!input?.extraValues) return Promise.resolve();
911
- if (entityId === null || entityId === void 0 || String(entityId).length === 0) throw new TRPCError({
912
- code: "PRECONDITION_FAILED",
913
- message: "Cannot persist CRUD extension values because the created entity id is unavailable."
914
- });
915
- return saveCrudExtraValues(ctx, config, entityId, input);
916
- }));
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);
917
944
  }
918
945
  function createCrudBatchTransactionInput(inputs) {
919
946
  return {
@@ -982,28 +1009,25 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
982
1009
  filters: baseFilters
983
1010
  };
984
1011
  const provider = resolveCrudExtensions(ctx, config);
985
- const matchedSets = [];
1012
+ const matchRequests = [];
986
1013
  if (extensionFilters.length > 0) {
987
1014
  if (!provider?.matchEntityIds) throw new TRPCError({
988
1015
  code: "PRECONDITION_FAILED",
989
1016
  message: "CRUD extension filtering is not available"
990
1017
  });
991
- const matchedIds$1 = await provider.matchEntityIds({
1018
+ matchRequests.push(provider.matchEntityIds({
992
1019
  id: target.id,
993
1020
  filters: extensionFilters,
994
1021
  joinOperator: input.joinOperator,
995
1022
  limit: 5e3
996
- });
997
- matchedSets.push(new Set(matchedIds$1));
1023
+ }));
998
1024
  }
999
- if (search && !searchDisabled && provider?.searchEntityIds) {
1000
- const matchedIds$1 = await provider.searchEntityIds({
1001
- id: target.id,
1002
- search,
1003
- limit: 5e3
1004
- });
1005
- matchedSets.push(new Set(matchedIds$1));
1006
- } 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({
1007
1031
  table,
1008
1032
  search,
1009
1033
  searchColumns
@@ -1011,6 +1035,7 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1011
1035
  code: "PRECONDITION_FAILED",
1012
1036
  message: "CRUD search is not available"
1013
1037
  });
1038
+ const matchedSets = (await Promise.all(matchRequests)).map((matchedIds$1) => new Set(matchedIds$1));
1014
1039
  if (matchedSets.length === 0) return {
1015
1040
  ...input,
1016
1041
  filters: baseFilters
@@ -1031,11 +1056,17 @@ async function applyCrudExtensionFilters(ctx, config, table, idField, filterable
1031
1056
  function buildCrudSearchCondition({ table, search, searchColumns }) {
1032
1057
  const term = typeof search === "string" ? search.trim() : "";
1033
1058
  if (!term || !Array.isArray(searchColumns) || searchColumns.length === 0) return;
1034
- const conditions = searchColumns.map((columnRef) => resolveColumnTarget({
1035
- table,
1036
- columnId: getColumnRefId(columnRef),
1037
- allowedColumns: searchColumns
1038
- })).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
+ });
1039
1070
  if (conditions.length === 0) return void 0;
1040
1071
  if (conditions.length === 1) return conditions[0];
1041
1072
  return or(...conditions);
@@ -1131,6 +1162,34 @@ function createCrudRouter(config) {
1131
1162
  const getIdColumn = () => table[idField];
1132
1163
  const tableColumnNames = getTableColumnNames(table);
1133
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
+ };
1134
1193
  const buildWhere = (ctx, operation, additionalCondition) => {
1135
1194
  const conditions = [];
1136
1195
  const scopeCondition = resolved.getScope(ctx, table, operation);
@@ -1204,24 +1263,15 @@ function createCrudRouter(config) {
1204
1263
  })));
1205
1264
  let query = ctx.db.select().from(table).$dynamic();
1206
1265
  if (where) query = query.where(where);
1207
- const sortField = effectiveInput.sort?.[0] ?? (getTableColumn(table, "createdAt") ? {
1208
- id: "createdAt",
1209
- desc: true
1210
- } : void 0);
1211
- if (sortField) {
1212
- if (validateColumn(sortField.id, sortableColumns)) {
1213
- const column = resolveColumnTarget({
1214
- table,
1215
- columnId: sortField.id,
1216
- allowedColumns: sortableColumns
1217
- });
1218
- if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
1219
- }
1220
- }
1221
- 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);
1222
1268
  let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
1223
1269
  if (where) countQuery = countQuery.where(where);
1224
- 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;
1225
1275
  return {
1226
1276
  data: enrichedData,
1227
1277
  total: count,
@@ -1403,7 +1453,10 @@ function createCrudRouter(config) {
1403
1453
  let updated = input.ids.map((id) => ({ [idField]: id }));
1404
1454
  if (Object.keys(data).length > 0) updated = await writeCtx.db.update(table).set(data).where(where).returning({ [idField]: getIdColumn() });
1405
1455
  else updated = await writeCtx.db.select({ [idField]: getIdColumn() }).from(table).where(where);
1406
- 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);
1407
1460
  return { updated: updated.length };
1408
1461
  });
1409
1462
  };
@@ -1499,21 +1552,15 @@ function createCrudRouter(config) {
1499
1552
  })));
1500
1553
  let query = ctx.db.select().from(table).$dynamic();
1501
1554
  if (where) query = query.where(where);
1502
- if (effectiveInput.sort?.length) {
1503
- const sortField = effectiveInput.sort[0];
1504
- if (sortField && validateColumn(sortField.id, sortableColumns)) {
1505
- const column = resolveColumnTarget({
1506
- table,
1507
- columnId: sortField.id,
1508
- allowedColumns: sortableColumns
1509
- });
1510
- if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
1511
- }
1512
- }
1513
- 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);
1514
1557
  let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
1515
1558
  if (where) countQuery = countQuery.where(where);
1516
- 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;
1517
1564
  return {
1518
1565
  data: enrichedData,
1519
1566
  total,
@@ -1533,6 +1580,7 @@ function createCrudRouter(config) {
1533
1580
  const doCreateMany = async (items) => {
1534
1581
  const splitItems = items.map((item) => splitCrudExtensionWriteInput(item, tableColumnNames));
1535
1582
  assertCrudExtraValuesWritableForRows(ctx, config, splitItems);
1583
+ assertCrudExtraValuesMappableForRows(splitItems, idField);
1536
1584
  return runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(splitItems), async (writeCtx) => {
1537
1585
  const injectData = resolved.getInject(writeCtx, "create");
1538
1586
  const values = splitItems.map(({ data }) => ({
@@ -1586,6 +1634,7 @@ function createCrudRouter(config) {
1586
1634
  failed
1587
1635
  };
1588
1636
  assertCrudExtraValuesWritableForRows(ctx, config, validRows);
1637
+ assertCrudExtraValuesMappableForRows(validRows, idField);
1589
1638
  const { insertedCount, updatedCount } = await runCrudExtensionWriteTransaction(ctx, config, createCrudBatchTransactionInput(validRows), async (writeCtx) => {
1590
1639
  const injectData = resolved.getInject(writeCtx, "create");
1591
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.1",
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
51
  "@internal/tsconfig": "0.1.0",
53
- "@internal/tsdown-config": "0.1.0",
52
+ "@internal/eslint-config": "0.3.0",
54
53
  "@internal/vitest-config": "0.1.0",
55
- "@internal/prettier-config": "0.0.1"
54
+ "@internal/prettier-config": "0.0.1",
55
+ "@internal/tsdown-config": "0.1.0"
56
56
  },
57
57
  "prettier": "@internal/prettier-config",
58
58
  "scripts": {