@maiyunnet/kebab 9.19.0 → 9.20.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/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * --- 本文件用来定义每个目录实体地址的常量 ---
6
6
  */
7
7
  /** --- 当前系统版本号 --- */
8
- export declare const VER = "9.19.0";
8
+ export declare const VER = "9.20.0";
9
9
  /** --- 框架根目录,以 / 结尾 --- */
10
10
  export declare const ROOT_PATH: string;
11
11
  /** --- 框架的 LIB,以 / 结尾 --- */
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * --- 本文件用来定义每个目录实体地址的常量 ---
7
7
  */
8
8
  /** --- 当前系统版本号 --- */
9
- export const VER = '9.19.0';
9
+ export const VER = '9.20.0';
10
10
  // --- 服务端用的路径 ---
11
11
  const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
12
12
  /** --- /xxx/xxx --- */
package/lib/sql.d.ts CHANGED
@@ -10,6 +10,8 @@ export declare enum ESERVICE {
10
10
  'MYSQL' = 0,
11
11
  'PGSQL' = 1
12
12
  }
13
+ /** --- 非空游标值;大整数和日期请使用数据库可比较的字符串 --- */
14
+ export type TCursorValue = string | number;
13
15
  /** --- JSON 查询操作符 --- */
14
16
  export declare enum EJSON {
15
17
  /** --- 包含值 (MySQL: JSON_CONTAINS, PG: @>) --- */
@@ -209,6 +211,15 @@ export declare class Sql {
209
211
  * @param d 排序规则
210
212
  */
211
213
  by(c: string | Array<string | string[]>, d?: 'DESC' | 'ASC'): this;
214
+ /**
215
+ * --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
216
+ * --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
217
+ * @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
218
+ * @param values 上一页末行的字段值,省略时读取首批
219
+ * @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
220
+ * @returns 当前 SQL 对象
221
+ */
222
+ seek(c: string | string[], values?: TCursorValue[], d?: 'DESC' | 'ASC'): this;
212
223
  /**
213
224
  * --- GROUP BY ---
214
225
  * @param c 字段字符串或数组
@@ -230,6 +241,10 @@ export declare class Sql {
230
241
  */
231
242
  copy(f?: string | string[], opt?: {
232
243
  'where'?: string | kebab.Json;
244
+ /** --- false 时移除本查询的排序,不修改子查询 --- */
245
+ 'order'?: false;
246
+ /** --- false 时移除本查询的 LIMIT/OFFSET,不修改子查询 --- */
247
+ 'limit'?: false;
233
248
  }): Sql;
234
249
  /**
235
250
  * --- 获取 sql 语句 ---
package/lib/sql.js CHANGED
@@ -55,6 +55,7 @@ export class Sql {
55
55
  this._service = opt.service ?? ESERVICE.MYSQL;
56
56
  if (opt.data) {
57
57
  this._data = opt.data;
58
+ this._placeholderCounter = opt.data.length + 1;
58
59
  }
59
60
  if (opt.sql) {
60
61
  this._sql = opt.sql;
@@ -770,6 +771,54 @@ export class Sql {
770
771
  this._sql.push(sql);
771
772
  return this;
772
773
  }
774
+ /**
775
+ * --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
776
+ * --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
777
+ * @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
778
+ * @param values 上一页末行的字段值,省略时读取首批
779
+ * @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
780
+ * @returns 当前 SQL 对象
781
+ */
782
+ seek(c, values, d = 'DESC') {
783
+ const fields = typeof c === 'string' ? [c] : c;
784
+ if (!fields.length || new Set(fields).size !== fields.length ||
785
+ fields.some(field => !/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(field)) ||
786
+ !['ASC', 'DESC'].includes(d)) {
787
+ throw new Error('Invalid cursor ordering');
788
+ }
789
+ if (this._sql.some(part => /^ (ORDER BY|LIMIT|GROUP BY|HAVING|UNION|FOR UPDATE)\b/i.test(part))) {
790
+ throw new Error('seek requires an ungrouped query without ordering or pagination');
791
+ }
792
+ if (values !== undefined) {
793
+ if (values.length !== fields.length || values.some(value => (typeof value !== 'string' && typeof value !== 'number') ||
794
+ (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))))) {
795
+ throw new Error('Invalid cursor values; use strings for large integers');
796
+ }
797
+ const columns = fields.map(field => this.field(field));
798
+ const placeholders = values.map(() => this._placeholder());
799
+ const left = columns.length === 1 ? columns[0] : `(${columns.join(', ')})`;
800
+ const right = placeholders.length === 1 ? placeholders[0] : `(${placeholders.join(', ')})`;
801
+ let condition = `${left} ${d === 'DESC' ? '<' : '>'} ${right}`;
802
+ if (columns.length > 1) {
803
+ // --- 显式收紧首列边界,避免已有较宽范围条件让 PG 在索引内遍历到行比较边界 ---
804
+ condition += ` AND ${columns[0]} ${d === 'DESC' ? '<=' : '>='} ${this._placeholder()}`;
805
+ }
806
+ const index = this._sql.findIndex(part => part.startsWith(' WHERE '));
807
+ if (index === -1) {
808
+ this._whereDataPosition[0] = this._data.length;
809
+ this._sql.push(' WHERE ' + condition);
810
+ }
811
+ else {
812
+ this._sql[index] = ` WHERE (${this._sql[index].slice(7)}) AND ${condition}`;
813
+ }
814
+ this._data.push(...values);
815
+ if (columns.length > 1) {
816
+ this._data.push(values[0]);
817
+ }
818
+ this._whereDataPosition[1] = this._data.length;
819
+ }
820
+ return this.by(fields, d);
821
+ }
773
822
  /**
774
823
  * --- GROUP BY ---
775
824
  * @param c 字段字符串或数组
@@ -918,7 +967,13 @@ export class Sql {
918
967
  sql[i] = sql[i].replace(/\$\d+/g, () => `$${counter++}`);
919
968
  }
920
969
  }
921
- return get({
970
+ for (let i = sql.length - 1; i >= 0; --i) {
971
+ if ((opt.order === false && sql[i].startsWith(' ORDER BY ')) ||
972
+ (opt.limit === false && sql[i].startsWith(' LIMIT '))) {
973
+ sql.splice(i, 1);
974
+ }
975
+ }
976
+ const result = get({
922
977
  'service': this._service,
923
978
  'ctr': this._ctr,
924
979
  'pre': this._pre,
@@ -926,6 +981,10 @@ export class Sql {
926
981
  'sql': sql,
927
982
  'alias': lCore.clone(this._alias),
928
983
  }).hint(this._hint);
984
+ if (opt.where === undefined) {
985
+ result._whereDataPosition = [...this._whereDataPosition];
986
+ }
987
+ return result;
929
988
  }
930
989
  // --- 操作 ---
931
990
  /**
package/lib/text.d.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  import * as kebab from '#kebab/index.js';
2
+ /**
3
+ * --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
4
+ * @param value 待解析的版本
5
+ * @returns 三段安全整数,无效时返回 null
6
+ */
7
+ export declare function parseVersion(value: unknown): [number, number, number] | null;
8
+ /**
9
+ * --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
10
+ * @param version 待比较的版本
11
+ * @param target 目标版本
12
+ * @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
13
+ */
14
+ export declare function compareVersion(version: unknown, target: unknown): -1 | 0 | 1 | null;
2
15
  /**
3
16
  * --- 将文件大小格式化为带单位的字符串 ---
4
17
  * @param size 文件大小
package/lib/text.js CHANGED
@@ -7,6 +7,42 @@ import * as net from 'net';
7
7
  import * as kebab from '#kebab/index.js';
8
8
  import * as lFs from './fs.js';
9
9
  import * as lCore from './core.js';
10
+ /**
11
+ * --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
12
+ * @param value 待解析的版本
13
+ * @returns 三段安全整数,无效时返回 null
14
+ */
15
+ export function parseVersion(value) {
16
+ if ((typeof value !== 'string') || (value.length > 64)) {
17
+ return null;
18
+ }
19
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value);
20
+ // --- $ 可匹配末尾换行前的位置,必须额外确认匹配完整输入 ---
21
+ if (match?.[0] !== value) {
22
+ return null;
23
+ }
24
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
25
+ return parts.every(Number.isSafeInteger) ? parts : null;
26
+ }
27
+ /**
28
+ * --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
29
+ * @param version 待比较的版本
30
+ * @param target 目标版本
31
+ * @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
32
+ */
33
+ export function compareVersion(version, target) {
34
+ const current = parseVersion(version);
35
+ const other = parseVersion(target);
36
+ if (!current || !other) {
37
+ return null;
38
+ }
39
+ for (let index = 0; index < current.length; index++) {
40
+ if (current[index] !== other[index]) {
41
+ return current[index] < other[index] ? -1 : 1;
42
+ }
43
+ }
44
+ return 0;
45
+ }
10
46
  /**
11
47
  * --- 将文件大小格式化为带单位的字符串 ---
12
48
  * @param size 文件大小
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.19.0",
3
+ "version": "9.20.0",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
package/sys/ctr.d.ts CHANGED
@@ -19,6 +19,8 @@ export declare function clearLocaleData(): void;
19
19
  export type TValibotResponse<TIssue extends v.BaseIssue<unknown>> = kebab.Json[] | ((issues: [TIssue, ...TIssue[]]) => kebab.Json[]);
20
20
  /** --- 在 issue 路径完整后,根据 Kebab 生成的语言键翻译消息 --- */
21
21
  export type TValibotTranslate = (key: string, issue: v.BaseIssue<unknown>) => string;
22
+ /** --- HTTP 重定向状态码 --- */
23
+ export type TRedirectHttpCode = 301 | 302 | 303 | 307 | 308;
22
24
  /** --- Valibot 同步校验选项 --- */
23
25
  export interface IValibotOptions<TIssue extends v.BaseIssue<unknown>> {
24
26
  /** --- 传给 Valibot 的校验配置 --- */
@@ -58,6 +60,21 @@ export type TValibotResult<TSchema extends v.GenericSchema, TIssue extends v.Bas
58
60
  readonly issues: [TIssue, ...TIssue[]];
59
61
  readonly response: kebab.Json[];
60
62
  };
63
+ /** --- React SSR 页面选项 --- */
64
+ export interface IReactPageOptions {
65
+ /** --- 是否注入客户端水合 bundle,默认 true;设为 false 时仅输出服务端渲染的 HTML --- */
66
+ 'hydrate'?: boolean;
67
+ /**
68
+ * --- 路由模式,不传则不注入任何 Router,组件自行管理路由(如 MemoryRouter)或无路由 ---
69
+ * --- 'browser':服务端用 StaticRouter,客户端用 BrowserRouter,地址栏与路由联动 ---
70
+ * --- 组件本身只需使用 Routes/Route/Link 等,不要包含任何 Router 包裹层 ---
71
+ */
72
+ 'router'?: 'browser';
73
+ /** --- BrowserRouter 的 basename,相对于 urlBase,默认空字符串 --- */
74
+ 'routerBase'?: string;
75
+ /** --- 静态资源基础路径,覆盖 config.set.staticPath(支持 CDN) --- */
76
+ 'staticPath'?: string;
77
+ }
61
78
  /**
62
79
  * --- 判断当前 HTTP 请求是否仍可响应 ---
63
80
  * @param req 请求对象
@@ -219,26 +236,14 @@ export declare class Ctr {
219
236
  * @param props 传入组件的 props,框架常量自动合并,整体序列化为内联 JSON 供客户端水合复用
220
237
  * @param opt 可选配置
221
238
  */
222
- protected _loadReactPage(path: string, props?: Record<string, kebab.Json>, opt?: {
223
- /** --- 是否注入客户端水合 bundle,默认 true;设为 false 时仅输出服务端渲染的 HTML --- */
224
- 'hydrate'?: boolean;
225
- /**
226
- * --- 路由模式,不传则不注入任何 Router,组件自行管理路由(如 MemoryRouter)或无路由 ---
227
- * --- 'browser':服务端用 StaticRouter,客户端用 BrowserRouter,地址栏与路由联动 ---
228
- * --- 组件本身只需使用 Routes/Route/Link 等,不要包含任何 Router 包裹层 ---
229
- */
230
- 'router'?: 'browser';
231
- /**
232
- * --- BrowserRouter 的 basename,相对于 urlBase,默认空字符串 ---
233
- * --- 例如组件挂载在 /test/react-router-page,则填 'test/react-router-page' ---
234
- */
235
- 'routerBase'?: string;
236
- /**
237
- * --- 静态资源基础路径,覆盖 config.set.staticPath,用于指定 CDN 或自定义路径 ---
238
- * --- 影响 _staticPath prop 以及水合脚本中 JS 文件的 URL 前缀 ---
239
- */
240
- 'staticPath'?: string;
241
- }): Promise<string>;
239
+ protected _loadReactPage(path: string, props?: Record<string, kebab.Json>, opt?: IReactPageOptions): Promise<string>;
240
+ /**
241
+ * --- 记录 React 页面错误并返回脱敏响应 ---
242
+ * @param message 调试信息
243
+ * @param detail 日志详情
244
+ * @returns 脱敏后的 500 页面
245
+ */
246
+ private _reactPageError;
242
247
  /**
243
248
  * --- 设置校验错误返回值 ---
244
249
  * @param rtn 返回值数组
@@ -333,10 +338,11 @@ export declare class Ctr {
333
338
  */
334
339
  protected _loadData<T>(path: string): Promise<T | false | null>;
335
340
  /**
336
- * --- 跳转(302临时跳转),支持相对本项目根路径的路径或绝对路径 ---
341
+ * --- 跳转,支持相对本项目根路径的路径或绝对路径 ---
337
342
  * @param location 相对或绝对网址
343
+ * @param httpCode HTTP 重定向状态码,默认 302
338
344
  */
339
- protected _location(location: string): false;
345
+ protected _location(location: string, httpCode?: TRedirectHttpCode): false;
340
346
  /**
341
347
  * --- 开启 Session ---
342
348
  * @param link Kv 或 Db 实例
package/sys/ctr.js CHANGED
@@ -271,35 +271,28 @@ export class Ctr {
271
271
  * @param opt 可选配置
272
272
  */
273
273
  async _loadReactPage(path, props = {}, opt = {}) {
274
- // --- 约定:传入路径不含 .page 后缀,框架自动补全(对应 build 命令的 *.page.tsx 约定)---
275
- // --- 服务端读取 tsc 生成的组件 JS,浏览器读取 kebab build 生成的 bundle ---
276
- const componentPath = this._config.const.rootPath + 'stc/' + path + '.page.js';
274
+ // --- 校验服务端组件与客户端水合产物 ---
275
+ const hydrate = opt.hydrate !== false;
276
+ const pagePath = `${this._config.const.rootPath}stc/${path}.page`;
277
+ const componentPath = `${pagePath}.js`;
277
278
  if (!await lFs.isFile(componentPath)) {
278
- lCore.debug(`[CTR][_loadReactPage] componentPath ${componentPath} is not a file`);
279
- lCore.log(this, `[CTR][_loadReactPage] componentPath ${componentPath} is not a file`, '-error');
280
- return '';
279
+ return this._reactPageError(`componentPath ${componentPath} is not a file`);
281
280
  }
282
- const bundlePath = this._config.const.rootPath + 'stc/' + path + '.page.bundle.js';
283
- if ((opt.hydrate !== false) && !await lFs.isFile(bundlePath)) {
284
- /** --- 完整错误仅写入服务端日志,避免向客户端暴露文件系统路径 --- */
285
- const message = `[CTR][_loadReactPage] bundlePath ${bundlePath} is not a file; ` +
286
- `run "npx kebab build -d ${this._config.const.rootPath}stc"`;
287
- lCore.debug(message);
288
- lCore.log(this, message, '-error');
289
- this._httpCode = 500;
290
- return '<h1>500 Server Error</h1><hr>Kebab';
281
+ const bundlePath = `${pagePath}.bundle.js`;
282
+ if (hydrate && !await lFs.isFile(bundlePath)) {
283
+ return this._reactPageError(`bundlePath ${bundlePath} is not a file; run "npx kebab build -d ${this._config.const.rootPath}stc"`);
291
284
  }
292
285
  try {
293
- const reactDomServer = await import('react-dom/server');
286
+ // --- 并行加载 SSR 依赖与页面组件 ---
294
287
  const importPath = componentPath.startsWith('/')
295
288
  ? componentPath
296
289
  : `file:///${componentPath.replace(/\\/g, '/')}`;
297
- const mod = await import(importPath);
298
- const component = mod.default;
299
- const react = await import('react');
300
- // --- 语言包数据:当前语种的各命名包已由 _loadLocale 合并 ---
301
- const localeData = { ...(this._localeData[this._locale] ?? {}) };
302
- // --- 把框架常量合并进 props,与 _loadView 行为一致 ---
290
+ const [react, reactDomServer, page] = await Promise.all([
291
+ import('react'),
292
+ import('react-dom/server'),
293
+ import(importPath),
294
+ ]);
295
+ // --- 合并框架 Props 与路由配置 ---
303
296
  const staticPath = opt.staticPath ?? this._config.set.staticPath;
304
297
  const fullProps = {
305
298
  ...props,
@@ -310,50 +303,50 @@ export class Ctr {
310
303
  '_staticPath': staticPath,
311
304
  '_staticPathFull': this._config.set.staticPathFull,
312
305
  '_locale': this._locale,
313
- '_localeData': localeData,
306
+ '_localeData': { ...(this._localeData[this._locale] ?? {}) },
314
307
  };
315
308
  if (opt.router === 'browser') {
316
- // --- BrowserRouter 的 basename 同时用于服务端 StaticRouter 和客户端水合 ---
317
- const base = opt.routerBase ?? '';
318
- const routerBase = this._config.const.urlBase + base.replace(/^\//, '');
309
+ const routerBase = `${this._config.const.urlBase}${(opt.routerBase ?? '').replace(/^\//, '')}`;
319
310
  fullProps['_routerBase'] = routerBase.replace(/\/$/, '');
320
311
  }
321
- // --- 框架自动注入的 HTML 片段,用户组件无需手动渲染 ---
322
- let bodyInject = '';
323
- if (opt.hydrate !== false) {
324
- // --- propsJson 在渲染前序列化,框架直接注入 HTML,组件无需手动渲染 ---
325
- const propsJson = lText.stringifyJson(fullProps).replace(/<\/script>/gi, '<\\/script>');
326
- const clientUrl = `${staticPath}${path}.page.bundle.js?v=${this._config.set.staticVer}`;
327
- // --- props JSON + 水合脚本注入到 </body> 前 ---
328
- bodyInject =
329
- `<script id="__kebab_props__" type="application/json">${propsJson}</script>` +
330
- `<script type="module">import'${clientUrl}';</script>`;
331
- }
332
- // --- BrowserRouter 模式:服务端用 StaticRouter 渲染,与客户端的 BrowserRouter 等价 ---
333
- // --- component 来自动态 import,TypeScript 无法精确推断,需要明确限定 element 类型 ---
334
- let element = react.createElement(component, fullProps);
312
+ // --- 创建页面元素,BrowserRouter 模式下由 StaticRouter 包裹 ---
313
+ const component = page.default;
314
+ const pageElement = react.createElement(component, fullProps);
315
+ let element = pageElement;
335
316
  if (opt.router === 'browser') {
336
- // --- StaticRouter 在 react-router-dom v7 中从主包直接导出,无需 /server 子路径 ---
337
- const lReactRouter = await import('react-router-dom');
338
- const reqUrl = this._req.url ?? '/';
339
- element = react.createElement(lReactRouter.StaticRouter, {
340
- 'location': reqUrl,
317
+ const reactRouter = await import('react-router-dom');
318
+ element = react.createElement(reactRouter.StaticRouter, {
319
+ 'location': this._req.url ?? '/',
341
320
  'basename': fullProps['_routerBase'],
342
- }, react.createElement(component, fullProps));
321
+ }, pageElement);
343
322
  }
344
- // --- 框架将 props JSON + 水合 bundle 注入到 </body> 前 ---
345
- let html = '<!DOCTYPE html>' + reactDomServer.renderToString(element);
346
- if (opt.hydrate !== false) {
347
- html = html.replace('</body>', bodyInject + '</body>');
323
+ // --- 渲染完整 HTML,并按需注入客户端水合数据与 bundle ---
324
+ const html = `<!DOCTYPE html>${reactDomServer.renderToString(element)}`;
325
+ if (!hydrate) {
326
+ return html;
348
327
  }
349
- return html;
328
+ const propsJson = lText.stringifyJson(fullProps).replace(/<\/script>/gi, '<\\/script>');
329
+ const clientUrl = `${staticPath}${path}.page.bundle.js?v=${this._config.set.staticVer}`;
330
+ const propsScript = `<script id="__kebab_props__" type="application/json">${propsJson}</script>`;
331
+ const hydrateScript = `<script type="module">import'${clientUrl}';</script>`;
332
+ return html.replace('</body>', `${propsScript}${hydrateScript}</body>`);
350
333
  }
351
334
  catch (e) {
352
- lCore.debug(`[CTR][_loadReactPage] ${e.message ?? ''}`);
353
- lCore.log(this, '[CTR][_loadReactPage] ' + lText.stringifyError(e), '-error');
354
- return '';
335
+ return this._reactPageError(e.message ?? '', lText.stringifyError(e));
355
336
  }
356
337
  }
338
+ /**
339
+ * --- 记录 React 页面错误并返回脱敏响应 ---
340
+ * @param message 调试信息
341
+ * @param detail 日志详情
342
+ * @returns 脱敏后的 500 页面
343
+ */
344
+ _reactPageError(message, detail = message) {
345
+ lCore.debug(`[CTR][_loadReactPage] ${message}`);
346
+ lCore.log(this, `[CTR][_loadReactPage] ${detail}`, '-error');
347
+ this._httpCode = 500;
348
+ return '<h1>500 Server Error</h1><hr>Kebab';
349
+ }
357
350
  /**
358
351
  * --- 设置校验错误返回值 ---
359
352
  * @param rtn 返回值数组
@@ -783,13 +776,15 @@ export class Ctr {
783
776
  return json;
784
777
  }
785
778
  /**
786
- * --- 跳转(302临时跳转),支持相对本项目根路径的路径或绝对路径 ---
779
+ * --- 跳转,支持相对本项目根路径的路径或绝对路径 ---
787
780
  * @param location 相对或绝对网址
781
+ * @param httpCode HTTP 重定向状态码,默认 302
788
782
  */
789
- _location(location) {
783
+ _location(location, httpCode = 302) {
784
+ this._httpCode = httpCode;
790
785
  if (this._res) {
791
786
  this._res.setHeader('location', lText.urlResolve(this._config.const.urlBase, location));
792
- // this._res.writeHead(302); Kebab 中要在最后设置,否则会报错:ERR_HTTP_HEADERS_SENT
787
+ // this._res.writeHead(httpCode); Kebab 中要在最后设置,否则会报错:ERR_HTTP_HEADERS_SENT
793
788
  }
794
789
  return false;
795
790
  }
package/sys/mod.d.ts CHANGED
@@ -11,6 +11,13 @@ import * as kebab from '#kebab/index.js';
11
11
  export type TOnlyProperties<T> = {
12
12
  [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
13
13
  };
14
+ /** --- 一批游标查询结果,hasMore 表示扫描方向上还有记录 --- */
15
+ export interface ICursorPage<T extends Record<string, unknown> = Record<string, unknown>> {
16
+ 'list': T[];
17
+ 'hasMore': boolean;
18
+ 'next': lSql.TCursorValue[] | null;
19
+ 'previous': lSql.TCursorValue[] | null;
20
+ }
14
21
  /** --- 条数列表 --- */
15
22
  export declare class Rows<T extends Mod> implements IRows<T> {
16
23
  private readonly _items;
@@ -375,6 +382,31 @@ export default class Mod {
375
382
  * --- 获取总条数,自动抛弃 LIMIT,仅用于获取数据的情况(select) ---
376
383
  */
377
384
  total(f?: string): Promise<number>;
385
+ /**
386
+ * --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
387
+ * --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
388
+ * @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
389
+ */
390
+ totalEstimate(): Promise<number | false>;
391
+ /**
392
+ * --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
393
+ * --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
394
+ * @param count 本批数量
395
+ * @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
396
+ * @param opt.by 排序字段,支持表别名
397
+ * @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
398
+ * @param opt.order 展示顺序,默认 DESC
399
+ * @param opt.cursor 边界行的字段值,首批省略
400
+ * @param opt.before 是否读取边界前一批,须提供 cursor
401
+ * @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
402
+ */
403
+ allCursor<T extends Record<string, unknown> = Record<string, unknown>>(count: number, opt: {
404
+ 'by': string | string[];
405
+ 'keys'?: string[];
406
+ 'order'?: 'DESC' | 'ASC';
407
+ 'cursor'?: lSql.TCursorValue[];
408
+ 'before'?: boolean;
409
+ }): Promise<ICursorPage<T> | false>;
378
410
  /**
379
411
  * --- 根据当前条件,筛选出当前条目该有的数据条数 ---
380
412
  */
package/sys/mod.js CHANGED
@@ -1262,6 +1262,89 @@ export default class Mod {
1262
1262
  }
1263
1263
  return count;
1264
1264
  }
1265
+ /**
1266
+ * --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
1267
+ * --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
1268
+ * @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
1269
+ */
1270
+ async totalEstimate() {
1271
+ if (this._db.getService() !== lDb.ESERVICE.PGSQL || this._index !== null || this._contain) {
1272
+ return false;
1273
+ }
1274
+ const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
1275
+ const result = await this._db.query('EXPLAIN (FORMAT JSON) ' + sql.getSql(), sql.getData());
1276
+ if (result.rows === null) {
1277
+ lCore.log(this._ctr ?? {}, '[MOD][totalEstimate] Query failed', '-error');
1278
+ return false;
1279
+ }
1280
+ const plan = result.rows[0]?.['QUERY PLAN'];
1281
+ const parsed = typeof plan === 'string' ? lText.parseJson(plan) : plan;
1282
+ if (!Array.isArray(parsed)) {
1283
+ return false;
1284
+ }
1285
+ const root = parsed[0];
1286
+ if (!root || typeof root !== 'object') {
1287
+ return false;
1288
+ }
1289
+ const node = root['Plan'];
1290
+ if (!node || typeof node !== 'object') {
1291
+ return false;
1292
+ }
1293
+ const rows = node['Plan Rows'];
1294
+ return typeof rows === 'number' && Number.isFinite(rows) && rows >= 0 ? Math.round(rows) : false;
1295
+ }
1296
+ /**
1297
+ * --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
1298
+ * --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
1299
+ * @param count 本批数量
1300
+ * @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
1301
+ * @param opt.by 排序字段,支持表别名
1302
+ * @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
1303
+ * @param opt.order 展示顺序,默认 DESC
1304
+ * @param opt.cursor 边界行的字段值,首批省略
1305
+ * @param opt.before 是否读取边界前一批,须提供 cursor
1306
+ * @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
1307
+ */
1308
+ async allCursor(count, opt) {
1309
+ if (!Number.isSafeInteger(count) || count < 1 || count >= Number.MAX_SAFE_INTEGER ||
1310
+ this._index !== null || this._contain || (opt.before && !opt.cursor) ||
1311
+ (opt.order !== undefined && !['ASC', 'DESC'].includes(opt.order))) {
1312
+ throw new Error('Invalid cursor page options; framework sharding and contain are unsupported');
1313
+ }
1314
+ const fields = typeof opt.by === 'string' ? [opt.by] : opt.by;
1315
+ const keys = opt.keys ?? fields.map(field => field.split('.').pop());
1316
+ if (keys.length !== fields.length || new Set(keys).size !== keys.length) {
1317
+ throw new Error('Cursor result keys must match ordering fields');
1318
+ }
1319
+ const order = opt.order ?? 'DESC';
1320
+ const direction = opt.before ? (order === 'DESC' ? 'ASC' : 'DESC') : order;
1321
+ const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
1322
+ sql.seek(fields, opt.cursor, direction).limit(count + 1);
1323
+ const result = await this._db.query(sql.getSql(), sql.getData());
1324
+ if (result.rows === null) {
1325
+ lCore.log(this._ctr ?? {}, '[MOD][allCursor] Query failed', '-error');
1326
+ return false;
1327
+ }
1328
+ const hasMore = result.rows.length > count;
1329
+ const list = result.rows.slice(0, count);
1330
+ if (opt.before) {
1331
+ list.reverse();
1332
+ }
1333
+ const boundary = (row) => keys.map(key => {
1334
+ const value = row[key];
1335
+ if ((typeof value !== 'string' && typeof value !== 'number') ||
1336
+ (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
1337
+ throw new Error('Cursor columns must be non-null strings or safe numbers');
1338
+ }
1339
+ return value;
1340
+ });
1341
+ return {
1342
+ 'list': list,
1343
+ 'hasMore': hasMore,
1344
+ 'next': list.length ? boundary(list[list.length - 1]) : null,
1345
+ 'previous': list.length ? boundary(list[0]) : null,
1346
+ };
1347
+ }
1265
1348
  /**
1266
1349
  * --- 根据当前条件,筛选出当前条目该有的数据条数 ---
1267
1350
  */
package/sys/route.js CHANGED
@@ -581,6 +581,8 @@ export async function run(data) {
581
581
  data.res.setHeader('expires', new Date(Date.now() + cacheTTL * 1_000).toUTCString());
582
582
  data.res.setHeader('cache-control', 'max-age=' + cacheTTL.toString());
583
583
  }
584
+ // --- 记录控制器是否显式设置状态码;未设置时,带 Location 的空响应仍兼容为 302 ---
585
+ const hasCustomHttpCode = httpCode !== 0;
584
586
  // --- 设置自定义 hcode ---
585
587
  if (httpCode === 0) {
586
588
  httpCode = 200;
@@ -591,7 +593,7 @@ export async function run(data) {
591
593
  // --- 已经自行输出过 writeHead,可能自行处理了内容,如 pipe,则不再 writeHead ---
592
594
  }
593
595
  else {
594
- lCore.writeHead(data.res, data.res.getHeader('location') ? 302 : httpCode);
596
+ lCore.writeHead(data.res, data.res.getHeader('location') && !hasCustomHttpCode ? 302 : httpCode);
595
597
  }
596
598
  if (!data.res.writableEnded) {
597
599
  // --- 如果当前还没结束,则强制关闭连接,一切 pipe 请自行在方法中 await,否则会被中断 ---