@maiyunnet/kebab 9.19.1 → 9.20.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.
- package/doc/kebab-rag.md +644 -201
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/s3.d.ts +13 -3
- package/lib/s3.js +94 -15
- package/lib/sql.d.ts +15 -0
- package/lib/sql.js +60 -1
- package/lib/text.d.ts +13 -0
- package/lib/text.js +36 -0
- package/package.json +1 -1
- package/sys/mod.d.ts +32 -0
- package/sys/mod.js +83 -0
- package/www/example/ctr/test.js +163 -1
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* --- 本文件用来定义每个目录实体地址的常量 ---
|
|
7
7
|
*/
|
|
8
8
|
/** --- 当前系统版本号 --- */
|
|
9
|
-
export const VER = '9.
|
|
9
|
+
export const VER = '9.20.1';
|
|
10
10
|
// --- 服务端用的路径 ---
|
|
11
11
|
const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
|
|
12
12
|
/** --- /xxx/xxx --- */
|
package/lib/s3.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Date: 2024-2-18 18:32:45
|
|
4
4
|
* Last: 2024-2-18 18:32:47, 2024-3-16 16:42:27, 2024-5-31 21:36:26, 2024-7-8 00:28:42, 2024-7-19 11:32:43, 2025-6-10 21:45:34, 2025-12-5 23:42:59
|
|
5
5
|
*/
|
|
6
|
-
import * as s3 from '@aws-sdk/client-s3';
|
|
7
6
|
import * as stream from 'stream';
|
|
7
|
+
import * as s3 from '@aws-sdk/client-s3';
|
|
8
8
|
import * as sCtr from '#kebab/sys/ctr.js';
|
|
9
9
|
/**
|
|
10
10
|
* --- s3 文档:https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3/ ---
|
|
@@ -76,18 +76,28 @@ export declare class S3 {
|
|
|
76
76
|
*/
|
|
77
77
|
setBucket(bucket: string): void;
|
|
78
78
|
/**
|
|
79
|
-
* ---
|
|
79
|
+
* --- 上传对象,流内容可省略 length ---
|
|
80
80
|
* @param key 对象路径
|
|
81
81
|
* @param content 内容
|
|
82
|
-
* @param length
|
|
82
|
+
* @param length contentLength 或上传选项
|
|
83
83
|
* @param bucket bucket 名
|
|
84
|
+
* @returns 上传结果,失败或取消返回 false;传入 signal 时,返回前会尝试清理已知的未完成分片
|
|
84
85
|
*/
|
|
85
86
|
putObject(key: string, content: string | Buffer | stream.Readable, length?: number | {
|
|
87
|
+
/** --- contentLength,流内容可省略 --- */
|
|
86
88
|
'length'?: number;
|
|
87
89
|
/** --- content-type,如 application/javascript --- */
|
|
88
90
|
'type'?: string;
|
|
91
|
+
/** --- content-disposition,如 attachment --- */
|
|
89
92
|
'disposition'?: string;
|
|
93
|
+
/** --- bucket 名,省略时使用预定义 bucket --- */
|
|
90
94
|
'bucket'?: string;
|
|
95
|
+
/**
|
|
96
|
+
* --- 取消上传请求并销毁输入流,等待已知的未完成分片清理后返回 false ---
|
|
97
|
+
* --- 清理使用独立的 30 秒超时信号,失败时记录错误;不传则沿用原上传流程 ---
|
|
98
|
+
* --- 不删除同名对象,上传完成与取消同时发生时,由调用方清理独立任务文件 ---
|
|
99
|
+
*/
|
|
100
|
+
'signal'?: AbortSignal;
|
|
91
101
|
}, bucket?: string): Promise<s3.CompleteMultipartUploadCommandOutput | false>;
|
|
92
102
|
/**
|
|
93
103
|
* --- 批量上传对象,并发控制,单次失败不影响其他项 ---
|
package/lib/s3.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Last: 2024-2-18 18:32:47, 2024-3-16 16:42:27, 2024-5-31 21:36:26, 2024-7-8 00:28:42, 2024-7-19 11:32:43, 2025-6-10 21:45:34, 2025-12-5 23:42:59
|
|
5
5
|
*/
|
|
6
6
|
// --- 库和定义 ---
|
|
7
|
+
import * as stream from 'stream';
|
|
7
8
|
import * as s3 from '@aws-sdk/client-s3';
|
|
8
9
|
import * as ls from '@aws-sdk/lib-storage';
|
|
9
10
|
import * as lCore from '#kebab/lib/core.js';
|
|
@@ -67,28 +68,79 @@ export class S3 {
|
|
|
67
68
|
this._bucket = bucket;
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
|
-
* ---
|
|
71
|
+
* --- 上传对象,流内容可省略 length ---
|
|
71
72
|
* @param key 对象路径
|
|
72
73
|
* @param content 内容
|
|
73
|
-
* @param length
|
|
74
|
+
* @param length contentLength 或上传选项
|
|
74
75
|
* @param bucket bucket 名
|
|
76
|
+
* @returns 上传结果,失败或取消返回 false;传入 signal 时,返回前会尝试清理已知的未完成分片
|
|
75
77
|
*/
|
|
76
78
|
async putObject(key, content, length, bucket) {
|
|
79
|
+
/** --- 对象内容类型 --- */
|
|
80
|
+
let type;
|
|
81
|
+
/** --- 对象下载方式 --- */
|
|
82
|
+
let disposition;
|
|
83
|
+
/** --- 调用方传入的取消信号,仅作用于本次上传 --- */
|
|
84
|
+
let signal;
|
|
85
|
+
if (typeof length !== 'number') {
|
|
86
|
+
type = length?.type;
|
|
87
|
+
disposition = length?.disposition;
|
|
88
|
+
bucket = length?.bucket;
|
|
89
|
+
signal = length?.signal;
|
|
90
|
+
length = length?.length;
|
|
91
|
+
}
|
|
92
|
+
bucket ??= this._bucket;
|
|
93
|
+
/** --- 输入流,传入 signal 时在取消或失败后停止读取;字符串与 Buffer 无需释放 --- */
|
|
94
|
+
const source = content instanceof stream.Readable ? content : undefined;
|
|
95
|
+
/**
|
|
96
|
+
* --- 停止流读取,避免取消时仍阻塞在等待下一块数据的阶段 ---
|
|
97
|
+
* @returns 无返回值
|
|
98
|
+
*/
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
source?.destroy();
|
|
101
|
+
};
|
|
102
|
+
/** --- 尚未确认合并或清理完成的分片上传 ID,用于失败后的兜底清理 --- */
|
|
103
|
+
let uploadId;
|
|
104
|
+
/** --- 是否取得有效上传结果,失败时需要释放输入流 --- */
|
|
105
|
+
let success = false;
|
|
106
|
+
/** --- 分片清理请求的超时毫秒数,避免清理阶段无限等待 --- */
|
|
107
|
+
const cleanupTimeout = 30000;
|
|
77
108
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
/** --- content disposition --- */
|
|
81
|
-
let disposition = undefined;
|
|
82
|
-
if (typeof length !== 'number') {
|
|
83
|
-
type = length?.type;
|
|
84
|
-
disposition = length?.disposition;
|
|
85
|
-
bucket = length?.bucket;
|
|
86
|
-
length = length?.length;
|
|
109
|
+
if (signal?.aborted) {
|
|
110
|
+
return false;
|
|
87
111
|
}
|
|
112
|
+
signal?.addEventListener('abort', onAbort, { 'once': true });
|
|
113
|
+
/** --- 本次上传的客户端入口,共享连接配置但不修改共享客户端的 send --- */
|
|
114
|
+
const client = signal ? Object.create(this._link) : this._link;
|
|
115
|
+
if (signal) {
|
|
116
|
+
/**
|
|
117
|
+
* --- 为上传命令传递取消信号,并跟踪未完成的分片上传 ---
|
|
118
|
+
* --- Upload.abort() 会提前结束 done(),且不会取消正在发送的请求 ---
|
|
119
|
+
* --- 逐条命令传入信号,让 done() 等待分片任务和清理结束 ---
|
|
120
|
+
* @param command SDK 上传或分片清理命令;Upload 仅使用 send 的 Promise 调用形式
|
|
121
|
+
* @returns SDK 命令结果
|
|
122
|
+
*/
|
|
123
|
+
client.send = (async (command) => {
|
|
124
|
+
/** --- 清理请求必须使用独立信号,避免随上传请求一起取消 --- */
|
|
125
|
+
const isCleanup = command instanceof s3.AbortMultipartUploadCommand;
|
|
126
|
+
/** --- 命令成功后再更新分片 ID,失败时保留 ID 供 finally 重试清理 --- */
|
|
127
|
+
const res = await this._link.send(command, {
|
|
128
|
+
'abortSignal': isCleanup ? AbortSignal.timeout(cleanupTimeout) : signal,
|
|
129
|
+
});
|
|
130
|
+
if (command instanceof s3.CreateMultipartUploadCommand) {
|
|
131
|
+
uploadId = res.UploadId;
|
|
132
|
+
}
|
|
133
|
+
else if (isCleanup || (command instanceof s3.CompleteMultipartUploadCommand)) {
|
|
134
|
+
uploadId = undefined;
|
|
135
|
+
}
|
|
136
|
+
return res;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** --- SDK 上传任务,继续由 SDK 管理分片和并发 --- */
|
|
88
140
|
const upload = new ls.Upload({
|
|
89
|
-
'client':
|
|
141
|
+
'client': client,
|
|
90
142
|
'params': {
|
|
91
|
-
'Bucket': bucket
|
|
143
|
+
'Bucket': bucket,
|
|
92
144
|
'Key': key,
|
|
93
145
|
'Body': content,
|
|
94
146
|
'ContentLength': length,
|
|
@@ -96,13 +148,40 @@ export class S3 {
|
|
|
96
148
|
'ContentDisposition': disposition,
|
|
97
149
|
}
|
|
98
150
|
});
|
|
151
|
+
/** --- 上传结果;取消与上传完成同时发生时仍向调用方返回 false --- */
|
|
99
152
|
const res = await upload.done();
|
|
100
|
-
|
|
153
|
+
if (signal?.aborted || !res.Location || !res.Bucket || !res.Key) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
success = true;
|
|
157
|
+
return res;
|
|
101
158
|
}
|
|
102
159
|
catch (e) {
|
|
103
|
-
|
|
160
|
+
if (!signal?.aborted) {
|
|
161
|
+
lCore.log(this._ctr, `[LIB][S3][putObject] ${lText.stringifyError(e)}`, '-error');
|
|
162
|
+
}
|
|
104
163
|
return false;
|
|
105
164
|
}
|
|
165
|
+
finally {
|
|
166
|
+
signal?.removeEventListener('abort', onAbort);
|
|
167
|
+
if (signal && !success) {
|
|
168
|
+
source?.destroy();
|
|
169
|
+
}
|
|
170
|
+
// --- SDK 的合并失败分支不会清理分片;SDK 清理失败时也保留 ID,在这里兜底 ---
|
|
171
|
+
// --- 只清理本次未完成的分片,不删除可能已存在的同名对象 ---
|
|
172
|
+
if (uploadId) {
|
|
173
|
+
try {
|
|
174
|
+
await this._link.send(new s3.AbortMultipartUploadCommand({
|
|
175
|
+
'Bucket': bucket,
|
|
176
|
+
'Key': key,
|
|
177
|
+
'UploadId': uploadId,
|
|
178
|
+
}), { 'abortSignal': AbortSignal.timeout(cleanupTimeout) });
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
lCore.log(this._ctr, `[LIB][S3][putObject][cleanup] ${lText.stringifyError(e)}`, '-error');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
106
185
|
}
|
|
107
186
|
/**
|
|
108
187
|
* --- 批量上传对象,并发控制,单次失败不影响其他项 ---
|
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
|
-
|
|
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
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
|
*/
|