@zhushanwen/pi-ext-guards 0.4.1 → 0.4.2
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/package.json +1 -1
- package/src/__tests__/ext-guards.test.ts +67 -25
- package/src/index.ts +42 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ext-guards",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Shared runtime-environment guards for Pi extensions — oncePerProcess(key, fn) dedupes cross-session side-effect operations against pi factory re-invocation and handler accumulation (shared library, not a Pi extension, zero dependencies).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// src/__tests__/ext-guards.test.ts
|
|
2
2
|
//
|
|
3
|
-
// oncePerProcess 单测——验收条款逐条对应(u-guards-pkg
|
|
3
|
+
// oncePerProcess 单测——验收条款逐条对应(u-guards-pkg ①;失败语义按加固审查裁决
|
|
4
|
+
// 修订为「失败不缓存、下次调用可重试」):
|
|
4
5
|
// a. key 隔离去重:不同 key 互不影响,各自首次调用执行
|
|
5
6
|
// b. 同 key 双调 fn 仅执行一次
|
|
6
|
-
// c. fn
|
|
7
|
+
// c. fn 抛错不吞不包装,且不缓存——同 key 再调重新执行(可重试)
|
|
7
8
|
// d. 结果缓存形态:值原样重放(严格同一引用)、Promise 实例重放
|
|
8
|
-
//
|
|
9
|
+
// e. rejected Promise:落定后释放 key,下次调用重新执行
|
|
9
10
|
//
|
|
10
11
|
// 模块级 Map 是被测状态:测试间共享,每个用例用全文件唯一的 key 隔离。
|
|
11
12
|
|
|
@@ -37,21 +38,68 @@ describe("oncePerProcess", () => {
|
|
|
37
38
|
expect(fnB).toHaveBeenCalledTimes(1);
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
it("fn
|
|
41
|
+
it("fn 同步抛错:错误原样上抛(不吞不包装),不写缓存——同 key 再调重新执行(可重试)", () => {
|
|
41
42
|
const boom = new Error("reap failed");
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
let attempts = 0;
|
|
44
|
+
const fn = vi.fn(() => {
|
|
45
|
+
attempts += 1;
|
|
46
|
+
if (attempts === 1) throw boom;
|
|
47
|
+
return "recovered";
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
expect(() => oncePerProcess("dedupe:throws",
|
|
50
|
+
expect(() => oncePerProcess("dedupe:throws-retry", fn)).toThrow(boom);
|
|
51
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
52
|
+
|
|
53
|
+
// 失败不缓存:下次同 key 调用重新执行 fn,成功结果照常缓存
|
|
54
|
+
expect(oncePerProcess("dedupe:throws-retry", fn)).toBe("recovered");
|
|
55
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
56
|
+
// 成功后去重恢复:不再执行
|
|
57
|
+
expect(oncePerProcess("dedupe:throws-retry", fn)).toBe("recovered");
|
|
58
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("async fn 抛错:rejected Promise 落定后释放 key——下次调用重新执行(可重试)", async () => {
|
|
62
|
+
const boom = new Error("async reap failed");
|
|
63
|
+
let attempts = 0;
|
|
64
|
+
const fn = vi.fn(async () => {
|
|
65
|
+
attempts += 1;
|
|
66
|
+
if (attempts === 1) throw boom;
|
|
67
|
+
return "recovered";
|
|
68
|
+
});
|
|
47
69
|
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
expect(
|
|
70
|
+
const first = oncePerProcess("dedupe:async-rejected-retry", fn);
|
|
71
|
+
// 先 attach 断言再消费,避免 unhandledRejection
|
|
72
|
+
await expect(first).rejects.toThrow(boom);
|
|
73
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
// rejection 已落定、key 已释放:下次调用重新执行 fn
|
|
76
|
+
const second = oncePerProcess("dedupe:async-rejected-retry", fn);
|
|
77
|
+
expect(second).not.toBe(first);
|
|
78
|
+
await expect(second).resolves.toBe("recovered");
|
|
79
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("async fn:rejection 落定前的同 key 双调仍重放同一实例(落定后释放)", async () => {
|
|
83
|
+
const boom = new Error("async reap failed");
|
|
84
|
+
const fn = vi.fn(async () => {
|
|
85
|
+
throw boom;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const first = oncePerProcess("dedupe:async-inflight", fn);
|
|
89
|
+
// 落定前第二次调用:同一 Promise 实例(在飞去重不双跑)
|
|
90
|
+
const second = oncePerProcess("dedupe:async-inflight", fn);
|
|
91
|
+
expect(second).toBe(first);
|
|
92
|
+
// 先 attach 断言再消费,避免 unhandledRejection
|
|
93
|
+
const firstRejection = expect(first).rejects.toThrow(boom);
|
|
94
|
+
await firstRejection;
|
|
95
|
+
await Promise.resolve(); // 让释放 handler 的微任务走完
|
|
96
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
97
|
+
|
|
98
|
+
// 落定后第三次调用:key 已释放,fn 重新执行
|
|
99
|
+
const third = oncePerProcess("dedupe:async-inflight", fn);
|
|
100
|
+
expect(third).not.toBe(first);
|
|
101
|
+
await expect(third).rejects.toThrow(boom);
|
|
102
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
55
103
|
});
|
|
56
104
|
|
|
57
105
|
it("结果缓存形态:对象返回值严格同一引用(重放非重新求值)", () => {
|
|
@@ -78,20 +126,14 @@ describe("oncePerProcess", () => {
|
|
|
78
126
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
79
127
|
});
|
|
80
128
|
|
|
81
|
-
it("
|
|
82
|
-
const
|
|
83
|
-
const fn = vi.fn(async () => {
|
|
84
|
-
throw boom;
|
|
85
|
-
});
|
|
129
|
+
it("fn 返回 undefined:值仍被缓存(不因 undefined 误判未执行)", () => {
|
|
130
|
+
const fn = vi.fn(() => undefined);
|
|
86
131
|
|
|
87
|
-
const first = oncePerProcess("dedupe:
|
|
88
|
-
|
|
89
|
-
const firstRejection = expect(first).rejects.toThrow(boom);
|
|
90
|
-
const second = oncePerProcess("dedupe:async-rejected", fn);
|
|
132
|
+
const first = oncePerProcess("dedupe:undefined-value", fn);
|
|
133
|
+
const second = oncePerProcess("dedupe:undefined-value", fn);
|
|
91
134
|
|
|
92
|
-
expect(
|
|
93
|
-
|
|
94
|
-
await expect(second).rejects.toThrow(boom);
|
|
135
|
+
expect(first).toBeUndefined();
|
|
136
|
+
expect(second).toBeUndefined();
|
|
95
137
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
96
138
|
});
|
|
97
139
|
});
|
package/src/index.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// reaper 双跑即此形态)。本包把这类「pi 运行环境隐式坑」的守卫集中一处,业务
|
|
12
12
|
// extension 引入即用,防线不再散落各包内联。
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
/** 一次执行的结果记录:只缓存成功结果;失败(同步抛错 / Promise rejection)不缓存,下次调用可重试。 */
|
|
15
|
+
interface ExecutionRecord {
|
|
16
|
+
readonly value: unknown;
|
|
17
|
+
}
|
|
18
18
|
|
|
19
19
|
// 「按进程去重」的物理载体 = 模块级 Map。有效前提:同一进程内模块级状态跨 factory
|
|
20
20
|
// 二调持久——pi extension 缓存按 cwd 失效时 factory 重跑,但 jiti/Node 模块缓存同图
|
|
@@ -23,22 +23,24 @@ type ExecutionRecord =
|
|
|
23
23
|
const executions = new Map<string, ExecutionRecord>();
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* 进程内按 key 去重地执行 fn:同一 key
|
|
26
|
+
* 进程内按 key 去重地执行 fn:同一 key 至多执行一次**成功**,后续调用重放首次成功结果。
|
|
27
27
|
*
|
|
28
|
-
* 语义细节(设计 §3.2 D3 守卫粒度段 + §3.3 D3
|
|
28
|
+
* 语义细节(设计 §3.2 D3 守卫粒度段 + §3.3 D3 守卫语义;失败语义按加固审查裁决修订
|
|
29
|
+
* ——「恢复幂等,下次启动重试」承诺优先,见下方失败段):
|
|
29
30
|
*
|
|
30
31
|
* - **结果缓存形态(非跳过)**:首次调用执行 fn 并缓存结果;后续同 key 调用不执行
|
|
31
32
|
* fn,返回首次的返回值——原样重放:对象返回严格同一引用(toBe 级,非重新求值);
|
|
32
|
-
* fn 返回 Promise
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
33
|
+
* fn 返回 Promise 时重放同一实例。
|
|
34
|
+
* - **失败不缓存、可重试**:fn 同步抛错原样上抛(守卫不捕获、不包装)且**不写缓存**;
|
|
35
|
+
* fn 返回的 Promise rejection 落定后释放 key(仅释放本条失败记录,期间同 key 重试
|
|
36
|
+
* 成功的新记录不受影响)——下次同 key 调用重新执行 fn。守卫不自动重试,重试节奏
|
|
37
|
+
* 由调用方决定(宿主的其他触发面承接,如下一个 session_start)。代价(有意接受):
|
|
38
|
+
* 首次失败到重试成功之间,factory 二调的双跑窗口重新打开——重试的是**失败过**的
|
|
39
|
+
* 操作,成功即缓存、窗口随即关闭;对幂等的进程级维护(recoverCrashedRuns「恢复
|
|
40
|
+
* 幂等,下次启动重试」、配置迁移、索引重建等)这正是期望行为。验收条款「fn 抛错
|
|
41
|
+
* 不阻断后续 handler」的准确语义仍是**守卫不吞 fn 的错误**(调用方 catch 守卫调用
|
|
42
|
+
* 即可继续 handler 后续逻辑,先例:base-tool-enhance runSessionStartMaintenance
|
|
43
|
+
* 的 try/catch 形态)。
|
|
42
44
|
* - **粒度边界**:只包「跨 session 副作用操作」(进程级全局维护类,正确频率就是每
|
|
43
45
|
* 进程至多一次)。session 级幂等操作(如 pending 对账——读当前 session 的 entries
|
|
44
46
|
* 与 registry,appendEntry 幂等)必须保持每 session_start 执行,不要挂本守卫;需要
|
|
@@ -47,29 +49,40 @@ const executions = new Map<string, ExecutionRecord>();
|
|
|
47
49
|
* (如 "base-tool-enhance:reap")避免撞 key。
|
|
48
50
|
*
|
|
49
51
|
* @param key 去重键(进程内全局,建议带包名前缀)
|
|
50
|
-
* @param fn
|
|
51
|
-
*
|
|
52
|
-
*
|
|
52
|
+
* @param fn 无参函数——首次调用时求值一次(直到成功),后续调用不再执行;需要上下文
|
|
53
|
+
* (pi/ctx)的调用方在闭包里捕获,被闭包捕获的是首次调用处的值(这正是「每进程
|
|
54
|
+
* 至多一次成功」的字面语义)
|
|
53
55
|
* @returns 首次执行的返回值(后续调用重放同一结果;async fn 重放同一 Promise 实例)
|
|
54
56
|
*/
|
|
55
57
|
export function oncePerProcess<T>(key: string, fn: () => T): T {
|
|
56
58
|
const hit = executions.get(key);
|
|
57
59
|
if (hit !== undefined) {
|
|
58
|
-
if (hit.outcome === "threw") {
|
|
59
|
-
throw hit.error;
|
|
60
|
-
}
|
|
61
60
|
// 同 key 的值由首次调用的泛型参数化记录,模块级 Map 无法按 key 参数化类型,
|
|
62
61
|
// 断言仅收窄回该泛型(构造点与重放点同函数,无跨来源混装)。
|
|
63
62
|
return hit.value as T;
|
|
64
63
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
const result = fn();
|
|
65
|
+
// 只缓存成功结果:同步抛错不经过本行(原样上抛 + 无缓存,下次调用可重试);
|
|
66
|
+
// Promise 的 rejection 在落定后经下方 handler 释放 key(同样可重试)。
|
|
67
|
+
executions.set(key, { value: result });
|
|
68
|
+
if (isPromiseLike(result)) {
|
|
69
|
+
// 内部挂 rejection 释放 handler(同时消除本条 rejection 的 unhandled 面——
|
|
70
|
+
// 调用方是否自行接 rejection 均安全)。仅当缓存里仍是本条失败记录时删除:
|
|
71
|
+
// rejection 落定前同 key 已被重试成功覆盖的场景不误删新记录。
|
|
72
|
+
void Promise.resolve(result).catch(() => {
|
|
73
|
+
const record = executions.get(key);
|
|
74
|
+
if (record !== undefined && record.value === result) executions.delete(key);
|
|
75
|
+
});
|
|
72
76
|
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 测试专用:清空 oncePerProcess 的进程级守卫 Map。供静态 import 形态的测试重置
|
|
82
|
+
* 去重状态(无 resetModules 重建模块图时的等价手段);生产代码禁止调用。
|
|
83
|
+
*/
|
|
84
|
+
export function _resetOncePerProcessForTest(): void {
|
|
85
|
+
executions.clear();
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/**
|