@ai0x0/utils 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.agents/skills/ai0x0-utils/backend/SKILL.md +8 -5
  2. package/SKILL.md +8 -5
  3. package/es/backend/actions/create-get-action.js +11 -7
  4. package/es/backend/actions/create-get-list-action.js +11 -6
  5. package/es/backend/actions/get-list-data.d.ts +1 -1
  6. package/es/backend/actions/get-list-data.js +12 -5
  7. package/es/backend/actions/get-list-query-core.d.ts +26 -5
  8. package/es/backend/actions/get-list-query-core.js +92 -22
  9. package/es/backend/actions/get-list-query.d.ts +2 -0
  10. package/es/backend/actions/get-list-query.js +7 -4
  11. package/es/backend/actions/index.d.ts +1 -0
  12. package/es/backend/actions/index.js +2 -1
  13. package/es/backend/actions/paging.d.ts +11 -0
  14. package/es/backend/actions/paging.js +32 -0
  15. package/es/backend/route-operation/write-operation-core.js +12 -5
  16. package/es/backend/schemas/common.d.ts +2 -2
  17. package/es/backend/schemas/common.js +1 -1
  18. package/es/backend/sqlite/actions/create-get-action.js +9 -6
  19. package/es/backend/sqlite/actions/create-get-list-action.js +7 -5
  20. package/es/backend/sqlite/actions/get-list-data.d.ts +1 -1
  21. package/es/backend/sqlite/actions/get-list-data.js +11 -5
  22. package/es/backend/sqlite/actions/get-list-query.d.ts +2 -0
  23. package/es/backend/sqlite/actions/get-list-query.js +10 -6
  24. package/eslint-rules/api-route-via-nrf.js +83 -0
  25. package/eslint-rules/index.js +14 -1
  26. package/eslint-rules/no-direct-api-url.js +55 -0
  27. package/lib/backend/actions/create-get-action.js +3 -1
  28. package/lib/backend/actions/create-get-list-action.js +3 -1
  29. package/lib/backend/actions/get-list-data.d.ts +1 -1
  30. package/lib/backend/actions/get-list-data.js +8 -3
  31. package/lib/backend/actions/get-list-query-core.d.ts +26 -5
  32. package/lib/backend/actions/get-list-query-core.js +73 -18
  33. package/lib/backend/actions/get-list-query.d.ts +2 -0
  34. package/lib/backend/actions/get-list-query.js +3 -3
  35. package/lib/backend/actions/index.d.ts +1 -0
  36. package/lib/backend/actions/index.js +7 -2
  37. package/lib/backend/actions/paging.d.ts +11 -0
  38. package/lib/backend/actions/paging.js +50 -0
  39. package/lib/backend/schemas/common.d.ts +2 -2
  40. package/lib/backend/schemas/common.js +1 -1
  41. package/lib/backend/sqlite/actions/create-get-action.js +3 -1
  42. package/lib/backend/sqlite/actions/create-get-list-action.js +3 -1
  43. package/lib/backend/sqlite/actions/get-list-data.d.ts +1 -1
  44. package/lib/backend/sqlite/actions/get-list-data.js +8 -3
  45. package/lib/backend/sqlite/actions/get-list-query.d.ts +2 -0
  46. package/lib/backend/sqlite/actions/get-list-query.js +3 -3
  47. package/package.json +1 -1
@@ -18,14 +18,16 @@ export function createGetAction(_ref) {
18
18
  table = _ref.table;
19
19
  return /*#__PURE__*/function () {
20
20
  var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(params, _ref2) {
21
- var scope, fields, _i, _Object$keys, key, field, _getListQuery, query, countQuery, _yield$getListData, _yield$getListData$da, result;
21
+ var scope, exposed, fields, _i, _exposed, key, field, _getListQuery, query, countQuery, _yield$getListData, _yield$getListData$da, result;
22
22
  return _regeneratorRuntime().wrap(function _callee$(_context) {
23
23
  while (1) switch (_context.prev = _context.next) {
24
24
  case 0:
25
25
  scope = _ref2.scope;
26
+ // 筛选面与可见面同源,理由见 backend/actions/create-get-list-action 里的同一段。
27
+ exposed = Object.keys(bodySchema.shape);
26
28
  fields = {};
27
- for (_i = 0, _Object$keys = Object.keys(bodySchema.shape); _i < _Object$keys.length; _i++) {
28
- key = _Object$keys[_i];
29
+ for (_i = 0, _exposed = exposed; _i < _exposed.length; _i++) {
30
+ key = _exposed[_i];
29
31
  field = table[key];
30
32
  if (field) {
31
33
  fields[key] = field;
@@ -34,13 +36,14 @@ export function createGetAction(_ref) {
34
36
  _getListQuery = getListQuery({
35
37
  db: db,
36
38
  fields: fields,
39
+ filterable: exposed,
37
40
  jsonArrayFields: jsonArrayFields,
38
41
  params: params,
39
42
  scope: scope,
40
43
  relations: relations,
41
44
  table: table
42
45
  }), query = _getListQuery.query, countQuery = _getListQuery.countQuery;
43
- _context.next = 6;
46
+ _context.next = 7;
44
47
  return getListData({
45
48
  bodySchema: bodySchema,
46
49
  countQuery: countQuery,
@@ -48,12 +51,12 @@ export function createGetAction(_ref) {
48
51
  pageSize: 1,
49
52
  query: query
50
53
  })();
51
- case 6:
54
+ case 7:
52
55
  _yield$getListData = _context.sent;
53
56
  _yield$getListData$da = _slicedToArray(_yield$getListData.data, 1);
54
57
  result = _yield$getListData$da[0];
55
58
  return _context.abrupt("return", result);
56
- case 10:
59
+ case 11:
57
60
  case "end":
58
61
  return _context.stop();
59
62
  }
@@ -15,15 +15,16 @@ export function createGetListAction(_ref) {
15
15
  table = _ref.table;
16
16
  return /*#__PURE__*/function () {
17
17
  var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(params, _ref2) {
18
- var scope, _ref4, current, pageSize, other, fields, _i, _Object$keys, key, field, _getListQuery, query, countQuery;
18
+ var scope, _ref4, current, pageSize, other, exposed, fields, _i, _exposed, key, field, _getListQuery, query, countQuery;
19
19
  return _regeneratorRuntime().wrap(function _callee$(_context) {
20
20
  while (1) switch (_context.prev = _context.next) {
21
21
  case 0:
22
22
  scope = _ref2.scope;
23
- _ref4 = params, current = _ref4.current, pageSize = _ref4.pageSize, other = _objectWithoutProperties(_ref4, _excluded);
23
+ _ref4 = params, current = _ref4.current, pageSize = _ref4.pageSize, other = _objectWithoutProperties(_ref4, _excluded); // 筛选面与可见面同源,理由见 backend/actions/create-get-list-action 里的同一段。
24
+ exposed = Object.keys(bodySchema.shape);
24
25
  fields = {};
25
- for (_i = 0, _Object$keys = Object.keys(bodySchema.shape); _i < _Object$keys.length; _i++) {
26
- key = _Object$keys[_i];
26
+ for (_i = 0, _exposed = exposed; _i < _exposed.length; _i++) {
27
+ key = _exposed[_i];
27
28
  field = table[key];
28
29
  if (field) {
29
30
  fields[key] = field;
@@ -32,6 +33,7 @@ export function createGetListAction(_ref) {
32
33
  _getListQuery = getListQuery({
33
34
  db: db,
34
35
  fields: fields,
36
+ filterable: exposed,
35
37
  jsonArrayFields: jsonArrayFields,
36
38
  params: other,
37
39
  scope: scope,
@@ -45,7 +47,7 @@ export function createGetListAction(_ref) {
45
47
  pageSize: pageSize,
46
48
  query: query
47
49
  })());
48
- case 6:
50
+ case 7:
49
51
  case "end":
50
52
  return _context.stop();
51
53
  }
@@ -1,5 +1,5 @@
1
1
  import { getListQuery } from "./get-list-query.js";
2
- export declare function getListData({ query, countQuery, pageSize, current, }: {
2
+ export declare function getListData({ query, countQuery, pageSize: rawPageSize, current: rawCurrent, }: {
3
3
  bodySchema?: any;
4
4
  current?: number;
5
5
  pageSize?: number;
@@ -8,6 +8,7 @@ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" !=
8
8
  function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
9
9
  function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
10
10
  function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
11
+ import { normalizePaging } from "../../actions/paging.js";
11
12
  function runSelect(_x) {
12
13
  return _runSelect.apply(this, arguments);
13
14
  }
@@ -42,16 +43,21 @@ function _runSelect() {
42
43
  export function getListData(_ref) {
43
44
  var query = _ref.query,
44
45
  countQuery = _ref.countQuery,
45
- _ref$pageSize = _ref.pageSize,
46
- pageSize = _ref$pageSize === void 0 ? 10 : _ref$pageSize,
47
- _ref$current = _ref.current,
48
- current = _ref$current === void 0 ? 1 : _ref$current;
46
+ rawPageSize = _ref.pageSize,
47
+ rawCurrent = _ref.current;
48
+ // 归一化的理由见 backend/actions/paging。
49
+ var _normalizePaging = normalizePaging({
50
+ current: rawCurrent,
51
+ pageSize: rawPageSize
52
+ }),
53
+ current = _normalizePaging.current,
54
+ pageSize = _normalizePaging.pageSize;
49
55
  return /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee() {
50
56
  var dataQuery, data, _yield$runSelect, _yield$runSelect2, count;
51
57
  return _regeneratorRuntime().wrap(function _callee$(_context) {
52
58
  while (1) switch (_context.prev = _context.next) {
53
59
  case 0:
54
- dataQuery = query.limit(+pageSize).offset((+current - 1) * +pageSize);
60
+ dataQuery = query.limit(pageSize).offset((current - 1) * pageSize);
55
61
  _context.next = 3;
56
62
  return runSelect(dataQuery);
57
63
  case 3:
@@ -5,6 +5,8 @@ import { type ListQueryParams } from "../../actions/get-list-query-core.js";
5
5
  export declare function getListQuery<TTable extends BaseTable, TSelection extends SelectedFields>(args: {
6
6
  db: any;
7
7
  fields: TSelection;
8
+ /** 允许被当成筛选条件的键。不传等于不限制,见核心里的说明。 */
9
+ filterable?: readonly string[];
8
10
  jsonArrayFields?: string[];
9
11
  /** 必填。与 pg 那套同义,见 backend/scope.ts。 */
10
12
  scope: ScopeArg;
@@ -1,4 +1,4 @@
1
- var _templateObject;
1
+ var _templateObject, _templateObject2;
2
2
  function _taggedTemplateLiteral(strings, raw) { if (!raw) { raw = strings.slice(0); } return Object.freeze(Object.defineProperties(strings, { raw: { value: Object.freeze(raw) } })); }
3
3
  // ==============================================================================
4
4
  // 列表查询(SQLite)
@@ -6,16 +6,20 @@ function _taggedTemplateLiteral(strings, raw) { if (!raw) { raw = strings.slice(
6
6
  // 实现在 backend/actions/get-list-query-core,与 pg 那套共用。这里只给它两条 sqlite 专属的
7
7
  // 原语:sqlite 的 like 本来就不区分大小写(没有 ilike),JSON 数组用 json_each 展开。
8
8
 
9
- import { like, sql } from "drizzle-orm";
9
+ import { sql } from "drizzle-orm";
10
10
  import { createGetListQuery } from "../../actions/get-list-query-core.js";
11
+
12
+ // pattern 由核心转义好再传进来(见 escapeLikePattern),但**转义符要自己声明**:sqlite 的 LIKE
13
+ // 不带 ESCAPE 子句时一个转义符都没有,那样转义等于没做,`%` 照旧是通配符。这也是这里不能用
14
+ // drizzle 的 like() 助手的原因 —— 它拼不出 ESCAPE。
11
15
  var run = createGetListQuery({
12
- contains: function contains(column, keyword) {
13
- return like(column, "%".concat(keyword, "%"));
16
+ contains: function contains(column, pattern) {
17
+ return sql(_templateObject || (_templateObject = _taggedTemplateLiteral(["", " LIKE ", " ESCAPE '\\'"], ["", " LIKE ", " ESCAPE '\\\\'"])), column, pattern);
14
18
  },
15
19
  // json_each 展开出来的行里,值在 tag.value 上(pg 那个 _text 变体是直接给字符串的)——
16
20
  // 这是两边唯一一处连字段名都不同的地方。
17
- jsonArrayContains: function jsonArrayContains(column, keyword) {
18
- return sql(_templateObject || (_templateObject = _taggedTemplateLiteral(["\n EXISTS (\n SELECT 1 FROM json_each(", ") tag\n WHERE tag.value LIKE ", "\n )\n "])), column, "%".concat(keyword, "%"));
21
+ jsonArrayContains: function jsonArrayContains(column, pattern) {
22
+ return sql(_templateObject2 || (_templateObject2 = _taggedTemplateLiteral(["\n EXISTS (\n SELECT 1 FROM json_each(", ") tag\n WHERE tag.value LIKE ", " ESCAPE '\\'\n )\n "], ["\n EXISTS (\n SELECT 1 FROM json_each(", ") tag\n WHERE tag.value LIKE ", " ESCAPE '\\\\'\n )\n "])), column, pattern);
19
23
  }
20
24
  });
21
25
  export function getListQuery(args) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * api-route-via-nrf
3
+ * -----------------
4
+ * API 路由一律走 next-rest-framework(route / routeOperation),不许裸导 HTTP 方法。
5
+ *
6
+ * 裸写 `export async function POST(req)` 的代价不是风格不统一,是这条端点从此不存在于
7
+ * openapi.json:生成器不会给它出 client,CLI / agent 读不到它,前端只能手写 fetch 直连
8
+ * (那是 no-direct-api-url 拦的另一头)。框架统一做的鉴权预热、错误翻译、响应校验,
9
+ * 它也都得自己记得补。
10
+ *
11
+ * 判的是「导出形状」,不是「有没有 import route」:
12
+ * 报:`export async function POST(...)`、`export const GET = handler`;
13
+ * 放:`export const { POST } = route({...})`(从 route() 解构,id 是 ObjectPattern)。
14
+ *
15
+ * 作用范围由调用方在 flat config 里用 files 圈定(比如只在 api 目录的 route.ts 上开);
16
+ * 确有理由裸导的文件(第三方库的整体转发,如 better-auth 的 catch-all)用 files override
17
+ * 单独关掉,并在那个文件里写明理由。
18
+ */
19
+
20
+ const HTTP_METHODS = new Set([
21
+ "GET",
22
+ "POST",
23
+ "PUT",
24
+ "DELETE",
25
+ "PATCH",
26
+ "HEAD",
27
+ "OPTIONS",
28
+ ]);
29
+
30
+ /** @type {import('eslint').Rule.RuleModule} */
31
+ const rule = {
32
+ meta: {
33
+ type: "problem",
34
+ docs: {
35
+ description:
36
+ "API 路由必须用 next-rest-framework 的 route()/routeOperation() 构建,禁止裸导 HTTP 方法。",
37
+ },
38
+ schema: [],
39
+ messages: {
40
+ rawMethodExport:
41
+ "API 路由不许裸导 HTTP 方法,改用 next-rest-framework 的 route()/routeOperation" +
42
+ "(export const { POST } = route({...}))。裸导的端点进不了 openapi.json," +
43
+ "前端与 CLI 都拿不到它的 client。",
44
+ },
45
+ },
46
+ create(context) {
47
+ return {
48
+ ExportNamedDeclaration(node) {
49
+ const declaration = node.declaration;
50
+ if (!declaration) {
51
+ return;
52
+ }
53
+ if (
54
+ declaration.type === "FunctionDeclaration" &&
55
+ declaration.id &&
56
+ HTTP_METHODS.has(declaration.id.name)
57
+ ) {
58
+ context.report({
59
+ node: declaration.id,
60
+ messageId: "rawMethodExport",
61
+ });
62
+ return;
63
+ }
64
+ if (declaration.type === "VariableDeclaration") {
65
+ for (const declarator of declaration.declarations) {
66
+ // 从 route() 解构出来的是 ObjectPattern,那才叫走框架。
67
+ if (
68
+ declarator.id.type === "Identifier" &&
69
+ HTTP_METHODS.has(declarator.id.name)
70
+ ) {
71
+ context.report({
72
+ node: declarator.id,
73
+ messageId: "rawMethodExport",
74
+ });
75
+ }
76
+ }
77
+ }
78
+ },
79
+ };
80
+ },
81
+ };
82
+
83
+ export default rule;
@@ -12,6 +12,8 @@ import noThen from "./no-then.js";
12
12
  import noOneLetterVars from "./no-one-letter-vars.js";
13
13
  import noConsecutiveSetState from "./no-consecutive-setstate.js";
14
14
  import maxLines from "./max-lines.js";
15
+ import apiRouteViaNrf from "./api-route-via-nrf.js";
16
+ import noDirectApiUrl from "./no-direct-api-url.js";
15
17
 
16
18
  const rules = {
17
19
  "require-section-divider": requireSectionDivider,
@@ -25,8 +27,17 @@ const rules = {
25
27
  "no-one-letter-vars": noOneLetterVars,
26
28
  "no-consecutive-setstate": noConsecutiveSetState,
27
29
  "max-lines": maxLines,
30
+ "api-route-via-nrf": apiRouteViaNrf,
31
+ "no-direct-api-url": noDirectApiUrl,
28
32
  };
29
33
 
34
+ // 这两条只管「特定位置」的代码,不能跟着 recommended 全局开:
35
+ // · api-route-via-nrf 只该挂在 API 路由文件上(别的文件 export 一个叫 GET 的函数是自由的);
36
+ // · no-direct-api-url 只该挂在前端目录上(后端与中间件里出现 "/api/..." 字面量是本分)。
37
+ // 调用方在 flat config 里用 files 圈定范围自行开启,豁免文件用 files override 关掉,
38
+ // 用法写在两条规则各自的文件头。
39
+ const SCOPED_RULES = new Set(["api-route-via-nrf", "no-direct-api-url"]);
40
+
30
41
  const plugin = {
31
42
  rules,
32
43
  configs: {
@@ -34,7 +45,9 @@ const plugin = {
34
45
  plugins: { ai0x0: { rules } },
35
46
  rules: {
36
47
  ...Object.fromEntries(
37
- Object.keys(rules).map((name) => [`ai0x0/${name}`, "error"]),
48
+ Object.keys(rules)
49
+ .filter((name) => !SCOPED_RULES.has(name))
50
+ .map((name) => [`ai0x0/${name}`, "error"]),
38
51
  ),
39
52
  curly: "error",
40
53
  "ai0x0/max-lines": [
@@ -0,0 +1,55 @@
1
+ /**
2
+ * no-direct-api-url
3
+ * -----------------
4
+ * 前端不许手写 "/api/..." 地址直连后端,一律走生成的 client。
5
+ *
6
+ * 手写 `fetch("/api/xxx")` 绕掉的不是一层方便,是一整套集中处理(401 重登、错误提示、
7
+ * 作用域头、重复请求去重),而且 TypeScript 对它是瞎的 —— 路由改了形状,编译不红。
8
+ * 所以要调一个端点:先让它进 openapi.json(后端路由走 nrf,见 api-route-via-nrf),
9
+ * 重新生成 client,再调生成出来的方法。
10
+ *
11
+ * 判的是字符串字面量与模板串的开头:`/api/`。注释与 JSDoc 不是 AST 节点,天然不报。
12
+ * 作用范围由调用方在 flat config 里用 files 圈定(比如只挂前端目录),client 封装层
13
+ * 自己(生成文件与拦截器)用 files override 关掉 —— 地址本来就该只写在那儿。
14
+ */
15
+
16
+ const API_PREFIX = "/api/";
17
+
18
+ function startsWithApi(value) {
19
+ return typeof value === "string" && value.startsWith(API_PREFIX);
20
+ }
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ const rule = {
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description:
28
+ '前端禁止手写 "/api/..." 地址直连后端,必须走生成的 client。',
29
+ },
30
+ schema: [],
31
+ messages: {
32
+ directApiUrl:
33
+ '前端不许手写 "/api/..." 直连后端:走生成的 client。端点先进 openapi.json' +
34
+ "(后端路由走 nrf),重新生成 client 之后调生成出来的方法;401 重登、错误提示、" +
35
+ "作用域头都在那一层集中处理,手写 fetch 全部绕开。",
36
+ },
37
+ },
38
+ create(context) {
39
+ return {
40
+ Literal(node) {
41
+ if (startsWithApi(node.value)) {
42
+ context.report({ node, messageId: "directApiUrl" });
43
+ }
44
+ },
45
+ TemplateLiteral(node) {
46
+ // 只看第一段静态文本:`${base}/api/x` 的地址不归这条管(那是封装层的拼法)。
47
+ if (startsWithApi(node.quasis[0]?.value.cooked)) {
48
+ context.report({ node, messageId: "directApiUrl" });
49
+ }
50
+ },
51
+ };
52
+ },
53
+ };
54
+
55
+ export default rule;
@@ -32,8 +32,9 @@ function createGetAction({
32
32
  table
33
33
  }) {
34
34
  return async (params, { scope }) => {
35
+ const exposed = Object.keys(bodySchema.shape);
35
36
  const fields = {};
36
- for (const key of Object.keys(bodySchema.shape)) {
37
+ for (const key of exposed) {
37
38
  const field = table[key];
38
39
  if (field) {
39
40
  fields[key] = field;
@@ -42,6 +43,7 @@ function createGetAction({
42
43
  const { query, countQuery } = (0, import_get_list_query.getListQuery)({
43
44
  db,
44
45
  fields,
46
+ filterable: exposed,
45
47
  jsonArrayFields,
46
48
  params,
47
49
  relations,
@@ -33,8 +33,9 @@ function createGetListAction({
33
33
  }) {
34
34
  return async (params, { scope }) => {
35
35
  const { current, pageSize, ...other } = params;
36
+ const exposed = Object.keys(bodySchema.shape);
36
37
  const fields = {};
37
- for (const key of Object.keys(bodySchema.shape)) {
38
+ for (const key of exposed) {
38
39
  const field = table[key];
39
40
  if (field) {
40
41
  fields[key] = field;
@@ -43,6 +44,7 @@ function createGetListAction({
43
44
  const { query, countQuery } = (0, import_get_list_query.getListQuery)({
44
45
  db,
45
46
  fields,
47
+ filterable: exposed,
46
48
  jsonArrayFields,
47
49
  params: other,
48
50
  relations,
@@ -1,6 +1,6 @@
1
1
  import { ZodSchema } from "zod";
2
2
  import { getListQuery } from "./get-list-query.js";
3
- export declare function getListData<B extends ZodSchema>({ query, countQuery, pageSize, current, bodySchema, }: {
3
+ export declare function getListData<B extends ZodSchema>({ query, countQuery, pageSize: rawPageSize, current: rawCurrent, bodySchema, }: {
4
4
  bodySchema: B;
5
5
  current?: number;
6
6
  pageSize?: number;
@@ -24,20 +24,25 @@ __export(get_list_data_exports, {
24
24
  module.exports = __toCommonJS(get_list_data_exports);
25
25
  var import_next_rest_framework = require("next-rest-framework");
26
26
  var import_schemas = require("../schemas");
27
+ var import_paging = require("./paging");
27
28
  function getListData({
28
29
  query,
29
30
  countQuery,
30
- pageSize = 10,
31
- current = 1,
31
+ pageSize: rawPageSize,
32
+ current: rawCurrent,
32
33
  bodySchema
33
34
  }) {
35
+ const { current, pageSize } = (0, import_paging.normalizePaging)({
36
+ current: rawCurrent,
37
+ pageSize: rawPageSize
38
+ });
34
39
  return (0, import_next_rest_framework.rpcOperation)().outputs([
35
40
  {
36
41
  body: (0, import_schemas.listBodySchema)(bodySchema),
37
42
  contentType: "application/json"
38
43
  }
39
44
  ]).handler(async () => {
40
- const data = await query.limit(+pageSize).offset((+current - 1) * +pageSize).execute();
45
+ const data = await query.limit(pageSize).offset((current - 1) * pageSize).execute();
41
46
  const [{ count }] = await countQuery.execute();
42
47
  return { data, total: Number(count) };
43
48
  });
@@ -15,10 +15,14 @@ export interface ListQueryRelation {
15
15
  }
16
16
  /** 两套方言之间**全部**的差异。加第三种数据库时要写的也只有这两条。 */
17
17
  export interface ListQueryDialect {
18
- /** 大小写不敏感的「包含」。 */
19
- contains(_column: Column, _keyword: string): SQL;
20
- /** 「这个 JSON 数组列里有没有元素含这段文字」。 */
21
- jsonArrayContains(_column: Column, _keyword: string): SQL;
18
+ /**
19
+ * 大小写不敏感的「包含」。收到的 pattern 是**已经转义、已经带好 `%` 的完整模式串** ——
20
+ * 转义收在核心里做(见 escapeLikePattern),免得加第三种方言时漏掉那一步:漏掉不会报错,
21
+ * 只会让 `%` 重新变回通配符。
22
+ */
23
+ contains(_column: Column, _pattern: string): SQL;
24
+ /** 「这个 JSON 数组列里有没有元素匹配这个模式」。pattern 同上,已转义。 */
25
+ jsonArrayContains(_column: Column, _pattern: string): SQL;
22
26
  }
23
27
  export interface ListQueryParams {
24
28
  [field: string]: unknown;
@@ -27,9 +31,26 @@ export interface ListQueryParams {
27
31
  page?: number;
28
32
  pageSize?: number;
29
33
  }
30
- export declare function createGetListQuery(dialect: ListQueryDialect): <TTable extends ListQueryTable, TSelection extends Record<string, unknown>>({ db, fields, jsonArrayFields, params, relations, scope, table, }: {
34
+ /**
35
+ * 把用户输入变成 LIKE 模式里的**字面量**。
36
+ *
37
+ * 不转义的话 `?name=%` 就是「匹配全部」、`?name=a_c` 里的下划线是「任意一个字符」—— 前者只是
38
+ * 筛不准,后者配合「能按某一列筛」就是一个逐字符试探值的通道。反斜杠自己也要转,否则
39
+ * `?name=\` 会把后面那个 `%` 吃掉。
40
+ */
41
+ export declare function escapeLikePattern(keyword: string): string;
42
+ export declare function createGetListQuery(dialect: ListQueryDialect): <TTable extends ListQueryTable, TSelection extends Record<string, unknown>>({ db, fields, filterable, jsonArrayFields, params, relations, scope, table, }: {
31
43
  db: any;
32
44
  fields: TSelection;
45
+ /**
46
+ * 允许被当成筛选条件的键。**不传等于不限制**(直接调 getListQuery 的老调用方行为不变)。
47
+ *
48
+ * 为什么需要它:筛选是按列名在表上找列的,而那跟「这个端点返回哪些列」完全脱钩 —— 于是
49
+ * 一个只返回 id / title 的列表,照样可以 `?secret=sk-a` 让数据库去 `ilike '%sk-a%'`,再从
50
+ * 返回的 total 上把这一列逐字符读出来。列表工厂传进来的是响应 schema 的字段集,也就是
51
+ * 「调用方本来就读得到的那些列」,筛选面与可见面因此对齐。
52
+ */
53
+ filterable?: readonly string[] | undefined;
33
54
  jsonArrayFields?: string[] | undefined;
34
55
  /**
35
56
  * 行级作用域。**必填**,不想隔离就显式传 NO_SCOPE —— 写成可选的话,漏传就退化成查全表,
@@ -19,18 +19,32 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/backend/actions/get-list-query-core.ts
20
20
  var get_list_query_core_exports = {};
21
21
  __export(get_list_query_core_exports, {
22
- createGetListQuery: () => createGetListQuery
22
+ createGetListQuery: () => createGetListQuery,
23
+ escapeLikePattern: () => escapeLikePattern
23
24
  });
24
25
  module.exports = __toCommonJS(get_list_query_core_exports);
25
26
  var import_drizzle_orm = require("drizzle-orm");
27
+ var import_errors = require("../errors");
26
28
  var import_scope = require("../scope");
29
+ var import_paging = require("./paging");
30
+ function escapeLikePattern(keyword) {
31
+ return `%${keyword.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
32
+ }
27
33
  function columnAt(table, key) {
28
- return table[key];
34
+ const value = table[key];
35
+ return (0, import_drizzle_orm.is)(value, import_drizzle_orm.Column) ? value : void 0;
36
+ }
37
+ function isIdKey(key) {
38
+ return key === "id" || key.endsWith("Id");
39
+ }
40
+ function isFuzzyMatchable(column) {
41
+ return column.dataType === "string" && !/uuid|enum/i.test(column.columnType);
29
42
  }
30
43
  function createGetListQuery(dialect) {
31
44
  return function getListQuery({
32
45
  db,
33
46
  fields,
47
+ filterable,
34
48
  jsonArrayFields,
35
49
  params,
36
50
  relations,
@@ -38,12 +52,18 @@ function createGetListQuery(dialect) {
38
52
  table
39
53
  }) {
40
54
  const {
41
- page = 1,
42
- pageSize = 10,
43
- orderBy = "createdAt",
55
+ page,
56
+ pageSize: rawPageSize,
57
+ orderBy,
44
58
  orderDir = "desc",
45
59
  ...filters
46
60
  } = params;
61
+ const { current, pageSize } = (0, import_paging.normalizePaging)({
62
+ current: page,
63
+ pageSize: rawPageSize
64
+ });
65
+ const allowed = filterable ? new Set(filterable) : void 0;
66
+ const isFilterable = (key) => !allowed || allowed.has(key);
47
67
  function buildBaseQuery() {
48
68
  let queryFields = { ...fields };
49
69
  if (relations == null ? void 0 : relations.length) {
@@ -66,7 +86,7 @@ function createGetListQuery(dialect) {
66
86
  ).map((candidate) => columnAt(candidate, key)).find(Boolean);
67
87
  }
68
88
  function addCondition(conditions2, key, value, targetColumn) {
69
- const isIdField = /id/i.test(key);
89
+ const isExact = isIdKey(key) || !isFuzzyMatchable(targetColumn);
70
90
  const isJsonArray = jsonArrayFields == null ? void 0 : jsonArrayFields.includes(key);
71
91
  if (typeof value !== "string") {
72
92
  conditions2.push((0, import_drizzle_orm.eq)(targetColumn, value));
@@ -78,24 +98,35 @@ function createGetListQuery(dialect) {
78
98
  return;
79
99
  }
80
100
  if (isJsonArray) {
81
- conditions2.push(dialect.jsonArrayContains(targetColumn, values[0]));
82
- } else if (isIdField) {
101
+ conditions2.push(
102
+ dialect.jsonArrayContains(
103
+ targetColumn,
104
+ escapeLikePattern(values[0])
105
+ )
106
+ );
107
+ } else if (isExact) {
83
108
  conditions2.push((0, import_drizzle_orm.inArray)(targetColumn, values));
84
109
  } else {
85
110
  conditions2.push(
86
111
  (0, import_drizzle_orm.or)(
87
- ...values.map((item) => dialect.contains(targetColumn, item))
112
+ ...values.map(
113
+ (item) => dialect.contains(targetColumn, escapeLikePattern(item))
114
+ )
88
115
  )
89
116
  );
90
117
  }
91
118
  return;
92
119
  }
93
120
  if (isJsonArray) {
94
- conditions2.push(dialect.jsonArrayContains(targetColumn, value));
95
- } else if (isIdField) {
121
+ conditions2.push(
122
+ dialect.jsonArrayContains(targetColumn, escapeLikePattern(value))
123
+ );
124
+ } else if (isExact) {
96
125
  conditions2.push((0, import_drizzle_orm.eq)(targetColumn, value));
97
126
  } else {
98
- conditions2.push(dialect.contains(targetColumn, value));
127
+ conditions2.push(
128
+ dialect.contains(targetColumn, escapeLikePattern(value))
129
+ );
99
130
  }
100
131
  }
101
132
  function buildConditions() {
@@ -105,16 +136,29 @@ function createGetListQuery(dialect) {
105
136
  continue;
106
137
  }
107
138
  if (key.endsWith("AtFrom") || key.endsWith("AtTo")) {
108
- const baseFieldName = key.replace(/(AtFrom|AtTo)$/, "");
109
- const targetColumn2 = findTargetColumn(`${baseFieldName}At`);
139
+ const baseFieldName = `${key.replace(/(AtFrom|AtTo)$/, "")}At`;
140
+ if (!isFilterable(baseFieldName)) {
141
+ continue;
142
+ }
143
+ const targetColumn2 = findTargetColumn(baseFieldName);
110
144
  if (!targetColumn2) {
111
145
  continue;
112
146
  }
147
+ const at = new Date(value);
148
+ if (Number.isNaN(at.getTime())) {
149
+ throw new import_errors.HttpError(
150
+ 400,
151
+ `${key} 不是一个能识别的时间,请用 ISO 8601(如 2026-08-01T00:00:00Z)。`
152
+ );
153
+ }
113
154
  conditions2.push(
114
- key.endsWith("AtFrom") ? (0, import_drizzle_orm.gte)(targetColumn2, new Date(value)) : (0, import_drizzle_orm.lte)(targetColumn2, new Date(value))
155
+ key.endsWith("AtFrom") ? (0, import_drizzle_orm.gte)(targetColumn2, at) : (0, import_drizzle_orm.lte)(targetColumn2, at)
115
156
  );
116
157
  continue;
117
158
  }
159
+ if (!isFilterable(key)) {
160
+ continue;
161
+ }
118
162
  const targetColumn = findTargetColumn(key);
119
163
  if (!targetColumn) {
120
164
  continue;
@@ -124,7 +168,17 @@ function createGetListQuery(dialect) {
124
168
  return conditions2;
125
169
  }
126
170
  function buildOrderBy() {
127
- const orderColumn = columnAt(table, orderBy);
171
+ const requested = typeof orderBy === "string" && orderBy ? orderBy : void 0;
172
+ const orderKey = requested ?? "createdAt";
173
+ const orderColumn = columnAt(table, orderKey);
174
+ if (!orderColumn) {
175
+ if (requested) {
176
+ throw new import_errors.HttpError(400, `orderBy "${requested}" 不是这张表上的列。`);
177
+ }
178
+ throw new Error(
179
+ `默认排序列 createdAt 不在这张表上。给这张表的列表查询显式传一个 orderBy。`
180
+ );
181
+ }
128
182
  return orderDir === "asc" ? (0, import_drizzle_orm.asc)(orderColumn) : (0, import_drizzle_orm.desc)(orderColumn);
129
183
  }
130
184
  const baseQuery = buildBaseQuery();
@@ -132,7 +186,7 @@ function createGetListQuery(dialect) {
132
186
  const conditions = scopeSql ? [scopeSql] : [];
133
187
  conditions.push(...buildConditions());
134
188
  const where = conditions.length > 0 ? (0, import_drizzle_orm.and)(...conditions) : void 0;
135
- const query = baseQuery.where(where).orderBy(buildOrderBy()).limit(pageSize).offset((page - 1) * pageSize);
189
+ const query = baseQuery.where(where).orderBy(buildOrderBy()).limit(pageSize).offset((current - 1) * pageSize);
136
190
  const countQuery = db.select({ count: import_drizzle_orm.sql`COUNT(DISTINCT ${table.id})` }).from(table).where(where);
137
191
  if (relations == null ? void 0 : relations.length) {
138
192
  for (const { table: relationTable, sql: joinSql } of relations) {
@@ -147,5 +201,6 @@ function createGetListQuery(dialect) {
147
201
  }
148
202
  // Annotate the CommonJS export names for ESM import in node:
149
203
  0 && (module.exports = {
150
- createGetListQuery
204
+ createGetListQuery,
205
+ escapeLikePattern
151
206
  });