@zhushanwen/pi-ext-guards 0.2.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/package.json +26 -0
- package/src/__tests__/ext-guards.test.ts +97 -0
- package/src/index.ts +73 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-ext-guards",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"guard",
|
|
11
|
+
"idempotency",
|
|
12
|
+
"shared"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"src/"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
20
|
+
"vitest": "^4.1.8"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "npx tsc --noEmit",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/__tests__/ext-guards.test.ts
|
|
2
|
+
//
|
|
3
|
+
// oncePerProcess 单测——验收条款逐条对应(u-guards-pkg ①):
|
|
4
|
+
// a. key 隔离去重:不同 key 互不影响,各自首次调用执行
|
|
5
|
+
// b. 同 key 双调 fn 仅执行一次
|
|
6
|
+
// c. fn 抛错不释放 key:同 key 再调不再执行(每进程至多一次的字面语义)
|
|
7
|
+
// d. 结果缓存形态:值原样重放(严格同一引用)、Promise 实例重放
|
|
8
|
+
// (含 rejected Promise 缓存——不因 rejection 释放 key)
|
|
9
|
+
//
|
|
10
|
+
// 模块级 Map 是被测状态:测试间共享,每个用例用全文件唯一的 key 隔离。
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { oncePerProcess } from "../index.ts";
|
|
15
|
+
|
|
16
|
+
describe("oncePerProcess", () => {
|
|
17
|
+
it("同 key 双调:fn 仅执行一次,两次返回同一结果", () => {
|
|
18
|
+
const fn = vi.fn(() => ({ marker: "result" }));
|
|
19
|
+
|
|
20
|
+
const first = oncePerProcess("dedupe:same-key", fn);
|
|
21
|
+
const second = oncePerProcess("dedupe:same-key", fn);
|
|
22
|
+
|
|
23
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(second).toBe(first);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("key 隔离:不同 key 互不影响,各自首次调用执行", () => {
|
|
28
|
+
const fnA = vi.fn(() => "a");
|
|
29
|
+
const fnB = vi.fn(() => "b");
|
|
30
|
+
|
|
31
|
+
// 先各消费一次 keyA(含二次调用确认去重),keyB 的首次调用不受影响
|
|
32
|
+
expect(oncePerProcess("dedupe:key-a", fnA)).toBe("a");
|
|
33
|
+
expect(oncePerProcess("dedupe:key-a", fnA)).toBe("a");
|
|
34
|
+
expect(oncePerProcess("dedupe:key-b", fnB)).toBe("b");
|
|
35
|
+
|
|
36
|
+
expect(fnA).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(fnB).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("fn 同步抛错:错误原样上抛(不吞不包装),key 不释放——同 key 再调不再执行且重抛同一错误", () => {
|
|
41
|
+
const boom = new Error("reap failed");
|
|
42
|
+
const failing = vi.fn(() => {
|
|
43
|
+
throw boom;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(() => oncePerProcess("dedupe:throws", failing)).toThrow(boom);
|
|
47
|
+
|
|
48
|
+
// 再调换一个「本会成功」的 fn:若 key 被释放,它会执行并返回——这是本条
|
|
49
|
+
// 的核心断言(失败不重置,双跑窗口不重新打开)
|
|
50
|
+
const shouldNeverRun = vi.fn(() => "second attempt");
|
|
51
|
+
expect(() => oncePerProcess("dedupe:throws", shouldNeverRun)).toThrow(boom);
|
|
52
|
+
|
|
53
|
+
expect(failing).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(shouldNeverRun).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("结果缓存形态:对象返回值严格同一引用(重放非重新求值)", () => {
|
|
58
|
+
const shared = { id: 1 };
|
|
59
|
+
const fn = vi.fn(() => shared);
|
|
60
|
+
|
|
61
|
+
const first = oncePerProcess("dedupe:object-ref", fn);
|
|
62
|
+
const second = oncePerProcess("dedupe:object-ref", fn);
|
|
63
|
+
|
|
64
|
+
expect(first).toBe(shared);
|
|
65
|
+
expect(second).toBe(shared);
|
|
66
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("async fn:resolved Promise 实例重放(同一 toBe 实例)", async () => {
|
|
70
|
+
const fn = vi.fn(async () => "settled");
|
|
71
|
+
|
|
72
|
+
const first = oncePerProcess("dedupe:async-resolved", fn);
|
|
73
|
+
const second = oncePerProcess("dedupe:async-resolved", fn);
|
|
74
|
+
|
|
75
|
+
expect(second).toBe(first);
|
|
76
|
+
await expect(first).resolves.toBe("settled");
|
|
77
|
+
await expect(second).resolves.toBe("settled");
|
|
78
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("async fn 抛错:rejected Promise 被缓存——同 key 再调重放同一实例、fn 不再执行", async () => {
|
|
82
|
+
const boom = new Error("async reap failed");
|
|
83
|
+
const fn = vi.fn(async () => {
|
|
84
|
+
throw boom;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const first = oncePerProcess("dedupe:async-rejected", fn);
|
|
88
|
+
// 先 attach 断言再消费,避免 unhandledRejection
|
|
89
|
+
const firstRejection = expect(first).rejects.toThrow(boom);
|
|
90
|
+
const second = oncePerProcess("dedupe:async-rejected", fn);
|
|
91
|
+
|
|
92
|
+
expect(second).toBe(first);
|
|
93
|
+
await firstRejection;
|
|
94
|
+
await expect(second).rejects.toThrow(boom);
|
|
95
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
//
|
|
3
|
+
// @zhushanwen/pi-ext-guards —— pi 运行环境守卫共享库(不是 Pi extension,零依赖纯函数,
|
|
4
|
+
// 无 pi SDK peerDep——供任意 @zhushanwen/pi-* 包与子代理核心消费)。
|
|
5
|
+
//
|
|
6
|
+
// 背景与设计依据:docs/design/file-lock-unification-and-reaper-sink.md §2.2 P3 / §3.2 D3。
|
|
7
|
+
// pi 的 extension 缓存按 cwd 失效:switch_session 时 cwd 不变则 factory 被二次调用且
|
|
8
|
+
// handler 累积注册——session_start handler 的真实派发语义是「每 session × factory
|
|
9
|
+
// 调用次数」,不是「每 session 一次」。handler 体内的跨 session 副作用操作(写非本
|
|
10
|
+
// session 的文件 / 注册定时器 watcher / 扫描目录 / 进程操作)因此会被双跑(2026-09-01
|
|
11
|
+
// reaper 双跑即此形态)。本包把这类「pi 运行环境隐式坑」的守卫集中一处,业务
|
|
12
|
+
// extension 引入即用,防线不再散落各包内联。
|
|
13
|
+
|
|
14
|
+
/** 一次执行的结果记录:正常返回记值、抛错记错误——两者都不释放 key。 */
|
|
15
|
+
type ExecutionRecord =
|
|
16
|
+
| { readonly outcome: "returned"; readonly value: unknown }
|
|
17
|
+
| { readonly outcome: "threw"; readonly error: unknown };
|
|
18
|
+
|
|
19
|
+
// 「按进程去重」的物理载体 = 模块级 Map。有效前提:同一进程内模块级状态跨 factory
|
|
20
|
+
// 二调持久——pi extension 缓存按 cwd 失效时 factory 重跑,但 jiti/Node 模块缓存同图
|
|
21
|
+
// 共享、模块 top-level 不重执行(handler 累积注册正是同一机制的另一面),故 Map 在
|
|
22
|
+
// 二调之间存活,去重成立。
|
|
23
|
+
const executions = new Map<string, ExecutionRecord>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 进程内按 key 去重地执行 fn:同一 key 至多执行一次,后续调用重放首次结果。
|
|
27
|
+
*
|
|
28
|
+
* 语义细节(设计 §3.2 D3 守卫粒度段 + §3.3 D3 守卫语义):
|
|
29
|
+
*
|
|
30
|
+
* - **结果缓存形态(非跳过)**:首次调用执行 fn 并缓存结果;后续同 key 调用不执行
|
|
31
|
+
* fn,返回首次的返回值——原样重放:对象返回严格同一引用(toBe 级,非重新求值);
|
|
32
|
+
* fn 返回 Promise 时重放同一实例,因此 **rejected Promise 同样被缓存**、不因
|
|
33
|
+
* rejection 释放 key。
|
|
34
|
+
* - **fn 抛错不吞、key 不释放**:同步抛错原样上抛(守卫不捕获、不包装),同时记录
|
|
35
|
+
* 该错误——后续同 key 调用重抛同一错误实例、不再执行 fn。验收条款「fn 抛错不阻断
|
|
36
|
+
* 后续 handler」的准确语义是**守卫不吞 fn 的错误**(调用方 catch 守卫调用即可继续
|
|
37
|
+
* handler 后续逻辑,先例:base-tool-enhance runSessionStartMaintenance 的 try/catch
|
|
38
|
+
* 形态),而非把 key 释放给二次执行——失败释放会让 factory 二调双跑窗口重新打开
|
|
39
|
+
* (本次事故形态),与 u-bte-guard 内联 flag 先例「reap 抛错不重置 flag」同语义。
|
|
40
|
+
* 失败重试不归守卫:需要兜底的场景由宿主的其他触发面承接(该先例的失败兜底即交
|
|
41
|
+
* runtime 收殓触发面 B)。
|
|
42
|
+
* - **粒度边界**:只包「跨 session 副作用操作」(进程级全局维护类,正确频率就是每
|
|
43
|
+
* 进程至多一次)。session 级幂等操作(如 pending 对账——读当前 session 的 entries
|
|
44
|
+
* 与 registry,appendEntry 幂等)必须保持每 session_start 执行,不要挂本守卫;需要
|
|
45
|
+
* 「每 session 一次」语义的 handler 属另一设计,本包明确不提供。
|
|
46
|
+
* - **key 是进程内全局命名空间**:跨包共享一个 Map,调用方须用「包名:操作名」前缀
|
|
47
|
+
* (如 "base-tool-enhance:reap")避免撞 key。
|
|
48
|
+
*
|
|
49
|
+
* @param key 去重键(进程内全局,建议带包名前缀)
|
|
50
|
+
* @param fn 无参函数——首次调用时求值一次,后续调用不再执行;需要上下文(pi/ctx)
|
|
51
|
+
* 的调用方在闭包里捕获,被闭包捕获的是首次调用处的值(这正是「每进程至多一次」
|
|
52
|
+
* 的字面语义)
|
|
53
|
+
* @returns 首次执行的返回值(后续调用重放同一结果;async fn 重放同一 Promise 实例)
|
|
54
|
+
*/
|
|
55
|
+
export function oncePerProcess<T>(key: string, fn: () => T): T {
|
|
56
|
+
const hit = executions.get(key);
|
|
57
|
+
if (hit !== undefined) {
|
|
58
|
+
if (hit.outcome === "threw") {
|
|
59
|
+
throw hit.error;
|
|
60
|
+
}
|
|
61
|
+
// 同 key 的值由首次调用的泛型参数化记录,模块级 Map 无法按 key 参数化类型,
|
|
62
|
+
// 断言仅收窄回该泛型(构造点与重放点同函数,无跨来源混装)。
|
|
63
|
+
return hit.value as T;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const result = fn();
|
|
67
|
+
executions.set(key, { outcome: "returned", value: result });
|
|
68
|
+
return result;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
executions.set(key, { outcome: "threw", error });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|