@floken-io/engine 0.0.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/CHANGELOG.md +39 -0
- package/LICENSE +153 -0
- package/README.md +138 -0
- package/dist/chunk-RMCROXES.js +376 -0
- package/dist/conformance.d.ts +162 -0
- package/dist/conformance.js +711 -0
- package/dist/index.d.ts +1948 -0
- package/dist/index.js +3645 -0
- package/dist/spi-BABH0Tcs.d.ts +635 -0
- package/package.json +45 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// src/core/errors.ts
|
|
2
|
+
var ENGINE_ERROR_CODES = {
|
|
3
|
+
// —— 动作层:19 项动作的受理、开关、目标与意见校验 ——
|
|
4
|
+
/** 动作名不在 19 项之内 */
|
|
5
|
+
ACTION_UNKNOWN: "ENGINE_ACTION_UNKNOWN",
|
|
6
|
+
/** 设计期开关未开启(`approval.X.allowed === false`,DV-2) */
|
|
7
|
+
ACTION_NOT_ALLOWED: "ENGINE_ACTION_NOT_ALLOWED",
|
|
8
|
+
/**
|
|
9
|
+
* 门 1 `beforeAction` 返回 `false` 否决了本次动作(T12)。
|
|
10
|
+
*
|
|
11
|
+
* ★ 与 `ACTION_NOT_ALLOWED` 的区别:后者是**设计期**开关(读定义就知道),
|
|
12
|
+
* 本码是**运行期**宿主否决(只有跑起来才知道)。合成一个码的话,宿主分不清
|
|
13
|
+
* 「按钮本来就不该显示」与「业务条件不满足」—— 前者是前端 bug,后者要提示用户。
|
|
14
|
+
* ⚠️ 否决**必须**抛错:静默返回空差分会让用户以为办完了(红线:不得静默无效果)。
|
|
15
|
+
*/
|
|
16
|
+
ACTION_VETOED: "ENGINE_ACTION_VETOED",
|
|
17
|
+
/** 驳回 / 退回目标非法(INV-6:须同时满足 ∈ `completedNodes` 且 ∈ `allowedTargets`) */
|
|
18
|
+
ACTION_TARGET_INVALID: "ENGINE_ACTION_TARGET_INVALID",
|
|
19
|
+
/** `requireComment` 为 true 但未填意见(DV-3) */
|
|
20
|
+
ACTION_COMMENT_REQUIRED: "ENGINE_ACTION_COMMENT_REQUIRED",
|
|
21
|
+
/** 审批人解析为空集且 `onEmpty === 'error'`(INV-13,不得产生 0 办待人的 active 节点) */
|
|
22
|
+
ACTION_APPROVER_EMPTY: "ENGINE_ACTION_APPROVER_EMPTY",
|
|
23
|
+
/** 加签超出设计期 `addSign.maxCount`(INV-12) */
|
|
24
|
+
ACTION_ADD_SIGN_LIMIT: "ENGINE_ACTION_ADD_SIGN_LIMIT",
|
|
25
|
+
/** 票签配置非法(INV-7:`mode:'vote'` ⟺ `vote` 存在,且 `count` / `threshold` 恰有其一) */
|
|
26
|
+
ACTION_VOTE_CONFIG: "ENGINE_ACTION_VOTE_CONFIG",
|
|
27
|
+
// —— 状态层:实例定位、生命周期、结构不变量 ——
|
|
28
|
+
/** `StateStore.load()` 返回 null */
|
|
29
|
+
STATE_NOT_FOUND: "ENGINE_STATE_NOT_FOUND",
|
|
30
|
+
/** 实例已终态(completed / terminated / cancelled)后仍尝试推进(INV-2) */
|
|
31
|
+
STATE_TERMINAL: "ENGINE_STATE_TERMINAL",
|
|
32
|
+
/** 实例处于 suspended,除 `resume` 外一律不受理(INV-5) */
|
|
33
|
+
STATE_SUSPENDED: "ENGINE_STATE_SUSPENDED",
|
|
34
|
+
/** 状态结构不合契约(含 AC-E8 / INV-14:出现函数 / Map / Set / 类实例) */
|
|
35
|
+
STATE_SHAPE_INVALID: "ENGINE_STATE_SHAPE_INVALID",
|
|
36
|
+
/** `tokens[].nodeId` 不在该实例**绑定版本**的定义图中(INV-3,不得静默忽略) */
|
|
37
|
+
STATE_TOKEN_ORPHAN: "ENGINE_STATE_TOKEN_ORPHAN",
|
|
38
|
+
/** `DefinitionSource.getDefinition()` 返回 null(AC-E10 要求按实例绑定版本取定义) */
|
|
39
|
+
STATE_DEFINITION_MISSING: "ENGINE_STATE_DEFINITION_MISSING",
|
|
40
|
+
/** 快照结构版本无迁移路径(`stateSchema` 只升不降;升级须登记迁移函数) */
|
|
41
|
+
STATE_SCHEMA_UNSUPPORTED: "ENGINE_STATE_SCHEMA_UNSUPPORTED",
|
|
42
|
+
// —— 持久层:StateStore 的 INSERT / CAS 两条路径 ——
|
|
43
|
+
/** CAS UPDATE 影响 0 行:`expectedRev` 与库中当前 rev 不符(INV-1) */
|
|
44
|
+
PERSIST_CONFLICT: "ENGINE_PERSIST_CONFLICT",
|
|
45
|
+
/** INSERT 冲突:`expectedRev === 0` 但该 `instanceId` 已存在 */
|
|
46
|
+
PERSIST_ALREADY_EXISTS: "ENGINE_PERSIST_ALREADY_EXISTS",
|
|
47
|
+
// —— 选项层:createEngine 配置 ——
|
|
48
|
+
/** 未知配置项(**禁止静默忽略**,与 feel 的 `FEEL_OPTION_UNKNOWN` 同口径) */
|
|
49
|
+
OPTION_UNKNOWN: "ENGINE_OPTION_UNKNOWN",
|
|
50
|
+
/** 配置项取值非法(如 `maxAuditEntries` 非正整数) */
|
|
51
|
+
OPTION_INVALID: "ENGINE_OPTION_INVALID"
|
|
52
|
+
};
|
|
53
|
+
var ENGINE_DIAGNOSTIC_CODES = {
|
|
54
|
+
/** INV-18:`pendingProjectionRev` 存在 —— 该 rev 的投影尚未追平,`load()` 会先 `sync()` 补做 */
|
|
55
|
+
EFFECT_PENDING: "ENGINE_EFFECT_PENDING",
|
|
56
|
+
/** INV-17:`auditTrail` 达 `maxAuditEntries` 上限已裁剪;溢出区间记在 `details.dropped*`,**未静默丢弃**
|
|
57
|
+
* ⚠️ engine **不**把溢出条目投 `EventSink`:事件集由 ADR-006 定死为 10 个,审计不走事件通道
|
|
58
|
+
* (审计主源是 `auditTrail` 本身;被裁掉的部分宿主应从 `diagnostics` 转存到自己的归档) */
|
|
59
|
+
AUDIT_TRUNCATED: "ENGINE_AUDIT_TRUNCATED"
|
|
60
|
+
};
|
|
61
|
+
function engineDiagnostic(init) {
|
|
62
|
+
const out = {
|
|
63
|
+
severity: init.severity ?? "warn",
|
|
64
|
+
code: init.code,
|
|
65
|
+
message: init.message
|
|
66
|
+
};
|
|
67
|
+
if (init.node !== void 0) out.node = init.node;
|
|
68
|
+
if (init.instanceId !== void 0) out.instanceId = init.instanceId;
|
|
69
|
+
if (init.details !== void 0) out.details = init.details;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
var EngineError = class extends Error {
|
|
73
|
+
/** 五包统一印记:宿主可据此判断「这是 floken 的结构化错误」 */
|
|
74
|
+
floken = true;
|
|
75
|
+
pkg = "engine";
|
|
76
|
+
code;
|
|
77
|
+
constructor(message, init) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = new.target.name;
|
|
80
|
+
this.code = init.code;
|
|
81
|
+
if (init.node) this.node = init.node;
|
|
82
|
+
if (init.instanceId) this.instanceId = init.instanceId;
|
|
83
|
+
if (init.hint) this.hint = init.hint;
|
|
84
|
+
if (init.details) this.details = init.details;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var EngineActionError = class extends EngineError {
|
|
88
|
+
};
|
|
89
|
+
var EngineStateError = class extends EngineError {
|
|
90
|
+
};
|
|
91
|
+
var EnginePersistError = class extends EngineError {
|
|
92
|
+
};
|
|
93
|
+
var EngineOptionError = class extends EngineError {
|
|
94
|
+
};
|
|
95
|
+
function actionUnknown(name, allowed) {
|
|
96
|
+
return new EngineActionError(`Unknown action '${name}'`, {
|
|
97
|
+
code: ENGINE_ERROR_CODES.ACTION_UNKNOWN,
|
|
98
|
+
hint: "\u52A8\u4F5C\u540D\u5FC5\u987B\u662F 19 \u9879\u4E4B\u4E00\uFF1B\u5408\u6CD5\u53D6\u503C\u89C1 details.allowed",
|
|
99
|
+
details: { action: name, allowed: [...allowed] }
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function actionNotAllowed(name, allowed) {
|
|
103
|
+
return new EngineActionError(`Action '${name}' is not enabled for this node`, {
|
|
104
|
+
code: ENGINE_ERROR_CODES.ACTION_NOT_ALLOWED,
|
|
105
|
+
hint: "\u5728\u6D41\u7A0B\u5B9A\u4E49\u7684 approval \u914D\u7F6E\u4E2D\u6253\u5F00\u8BE5\u5F00\u5173\uFF0C\u6216\u6539\u7528\u5DF2\u5F00\u542F\u7684\u52A8\u4F5C",
|
|
106
|
+
details: { action: name, allowed: [...allowed] }
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function actionVetoed(action, instanceId) {
|
|
110
|
+
return new EngineActionError(`Action '${action}' was vetoed by hooks.beforeAction`, {
|
|
111
|
+
code: ENGINE_ERROR_CODES.ACTION_VETOED,
|
|
112
|
+
hint: "\u5BBF\u4E3B\u5728 beforeAction \u91CC\u8FD4\u56DE\u4E86 false\uFF1B\u9700\u8981\u5E26\u539F\u56E0\u8BF7\u6539\u4E3A\u5728\u94A9\u5B50\u4E2D throw",
|
|
113
|
+
details: { action, instanceId }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function actionTargetInvalid(action, target, completed, allowedTargets) {
|
|
117
|
+
return new EngineActionError(`Action '${action}' cannot target node '${target}'`, {
|
|
118
|
+
code: ENGINE_ERROR_CODES.ACTION_TARGET_INVALID,
|
|
119
|
+
node: { id: target },
|
|
120
|
+
hint: "\u76EE\u6807\u5FC5\u987B\u540C\u65F6\u5C5E\u4E8E details.completedNodes \u4E0E details.allowedTargets",
|
|
121
|
+
details: {
|
|
122
|
+
action,
|
|
123
|
+
target,
|
|
124
|
+
completedNodes: [...completed],
|
|
125
|
+
allowedTargets: [...allowedTargets]
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function commentRequired(action) {
|
|
130
|
+
return new EngineActionError(`Action '${action}' requires a comment`, {
|
|
131
|
+
code: ENGINE_ERROR_CODES.ACTION_COMMENT_REQUIRED,
|
|
132
|
+
hint: "\u5728 ActionInput.comment \u4E2D\u8865\u5145\u610F\u89C1\uFF1B\u8BE5\u7C7B\u52A8\u4F5C\u9ED8\u8BA4\u8981\u6C42\u7559\u75D5",
|
|
133
|
+
details: { action }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function approverEmpty(nodeId, onEmpty, details = {}) {
|
|
137
|
+
return new EngineActionError(`No approver resolved for node '${nodeId}'`, {
|
|
138
|
+
code: ENGINE_ERROR_CODES.ACTION_APPROVER_EMPTY,
|
|
139
|
+
node: { id: nodeId },
|
|
140
|
+
hint: "\u6539 ApproverSource \u7684\u89E3\u6790\u89C4\u5219\uFF0C\u6216\u628A\u8BE5\u8282\u70B9\u7684 approval.onEmpty \u8BBE\u4E3A 'skip' / \u663E\u5F0F\u4F7F\u7528 {type:'all'}",
|
|
141
|
+
details: { nodeId, onEmpty, ...details }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function addSignLimit(nodeId, limit, current) {
|
|
145
|
+
return new EngineActionError(`Add-sign limit exceeded on node '${nodeId}'`, {
|
|
146
|
+
code: ENGINE_ERROR_CODES.ACTION_ADD_SIGN_LIMIT,
|
|
147
|
+
node: { id: nodeId },
|
|
148
|
+
hint: "\u8C03\u5927\u8BE5\u8282\u70B9 approval.addSign.maxCount\uFF0C\u6216\u51CF\u5C11\u52A0\u7B7E\u4EBA\u6570",
|
|
149
|
+
details: { nodeId, limit, current }
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function deliverNoTarget(instanceId, kind, name, waiting, details = {}) {
|
|
153
|
+
const message = instanceId === void 0 ? `No instance among the candidates is waiting for ${kind} '${name}'` : `No token in instance '${instanceId}' is waiting for ${kind} '${name}'`;
|
|
154
|
+
return new EngineActionError(message, {
|
|
155
|
+
code: ENGINE_ERROR_CODES.ACTION_TARGET_INVALID,
|
|
156
|
+
...instanceId === void 0 ? {} : { instanceId },
|
|
157
|
+
hint: waiting.length === 0 ? "\u6B64\u523B**\u6CA1\u6709**\u4EFB\u4F55\u7B49\u5F85\u4E2D\u7684\u4EE4\u724C\uFF08\u53EF\u80FD\u5DF2\u7ECF\u8D70\u8FC7\u90A3\u4E2A\u8282\u70B9\uFF0C\u6216\u5B9E\u4F8B\u5DF2\u4E0D\u5728\u7B49\u5F85\uFF09\uFF1B\u8BF7\u786E\u8BA4\u6295\u9012\u76EE\u6807\u4E0E\u65F6\u673A" : "\u6D88\u606F / \u4FE1\u53F7\u540D\u5FC5\u987B\u4E0E\u5B9A\u4E49\u91CC\u7684 messageRef / signalRef **\u9010\u5B57\u4E00\u81F4**\uFF1Bdetails.waiting \u662F\u6B64\u523B\u5728\u7B49\u7684\u4E1C\u897F",
|
|
158
|
+
details: { kind, name, waiting: [...waiting], ...details }
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function voteConfigInvalid(message, details = {}) {
|
|
162
|
+
return new EngineActionError(message, {
|
|
163
|
+
code: ENGINE_ERROR_CODES.ACTION_VOTE_CONFIG,
|
|
164
|
+
hint: "mode:'vote' \u8981\u6C42 vote \u5B57\u6BB5\u5B58\u5728\uFF0C\u4E14 count / threshold \u6070\u6709\u5176\u4E00",
|
|
165
|
+
details
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function stateNotFound(instanceId) {
|
|
169
|
+
return new EngineStateError(`Process instance '${instanceId}' not found`, {
|
|
170
|
+
code: ENGINE_ERROR_CODES.STATE_NOT_FOUND,
|
|
171
|
+
instanceId,
|
|
172
|
+
hint: "\u786E\u8BA4 instanceId \u6B63\u786E\uFF0C\u4E14\u8BE5\u5B9E\u4F8B\u5DF2 start() \u5E76\u6210\u529F\u843D\u5E93",
|
|
173
|
+
details: { instanceId }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function stateTerminal(instanceId, status, action) {
|
|
177
|
+
return new EngineStateError(`Process instance '${instanceId}' is already '${status}'`, {
|
|
178
|
+
code: ENGINE_ERROR_CODES.STATE_TERMINAL,
|
|
179
|
+
instanceId,
|
|
180
|
+
hint: "\u7EC8\u6001\u5B9E\u4F8B\u4E0D\u53EF\u518D\u63A8\u8FDB\uFF1B\u5982\u9700\u7EE7\u7EED\u8BF7\u4EE5\u65B0\u5B9E\u4F8B\u53D1\u8D77",
|
|
181
|
+
details: { instanceId, status, action }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function stateSuspended(instanceId, action) {
|
|
185
|
+
return new EngineStateError(`Process instance '${instanceId}' is suspended`, {
|
|
186
|
+
code: ENGINE_ERROR_CODES.STATE_SUSPENDED,
|
|
187
|
+
instanceId,
|
|
188
|
+
hint: "\u5148\u63D0\u4EA4 { action: 'resume' } \u518D\u63A8\u8FDB",
|
|
189
|
+
details: { instanceId, action }
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function stateShapeInvalid(reason, details = {}) {
|
|
193
|
+
return new EngineStateError(`InstanceState shape is invalid: ${reason}`, {
|
|
194
|
+
code: ENGINE_ERROR_CODES.STATE_SHAPE_INVALID,
|
|
195
|
+
hint: "\u72B6\u6001\u5FC5\u987B\u662F\u7EAF\u6570\u636E\uFF08\u65E0\u51FD\u6570 / Map / Set / \u7C7B\u5B9E\u4F8B\uFF09\uFF0C\u4E14 JSON \u5F80\u8FD4\u6DF1\u7B49",
|
|
196
|
+
details: { reason, ...details }
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function tokenOrphan(instanceId, tokenId, nodeId) {
|
|
200
|
+
return new EngineStateError(`Token '${tokenId}' points to unknown node '${nodeId}'`, {
|
|
201
|
+
code: ENGINE_ERROR_CODES.STATE_TOKEN_ORPHAN,
|
|
202
|
+
instanceId,
|
|
203
|
+
node: { id: nodeId },
|
|
204
|
+
hint: "\u68C0\u67E5\u5B9A\u4E49\u7248\u672C\u7ED1\u5B9A\uFF1A\u5B9E\u4F8B\u6309 definitionVersion \u6267\u884C\uFF0C\u8282\u70B9\u5FC5\u987B\u5B58\u5728\u4E8E\u8BE5\u7248\u672C",
|
|
205
|
+
details: { instanceId, tokenId, nodeId }
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function definitionMissing(processId, version) {
|
|
209
|
+
return new EngineStateError(`Definition '${processId}' version ${version} not found`, {
|
|
210
|
+
code: ENGINE_ERROR_CODES.STATE_DEFINITION_MISSING,
|
|
211
|
+
hint: "\u786E\u8BA4 DefinitionSource \u5DF2\u53D1\u5E03\u8BE5 processId \u7684\u8BE5\u7248\u672C",
|
|
212
|
+
details: { processId, definitionVersion: version }
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function persistConflict(instanceId, expectedRev, actualRev) {
|
|
216
|
+
const init = {
|
|
217
|
+
code: ENGINE_ERROR_CODES.PERSIST_CONFLICT,
|
|
218
|
+
instanceId,
|
|
219
|
+
hint: "\u91CD\u65B0 load() \u53D6\u6700\u65B0\u72B6\u6001\u540E\u91CD\u8BD5\uFF1B\u672C\u9519\u8BEF\u8868\u793A\u5E76\u53D1\u7684\u53E6\u4E00\u7B14\u5199\u5165\u5DF2\u5148\u63D0\u4EA4",
|
|
220
|
+
details: { instanceId, expectedRev, ...actualRev === void 0 ? {} : { actualRev } }
|
|
221
|
+
};
|
|
222
|
+
return new EnginePersistError(`Rev conflict on instance '${instanceId}'`, init);
|
|
223
|
+
}
|
|
224
|
+
function persistAlreadyExists(instanceId) {
|
|
225
|
+
return new EnginePersistError(`Process instance '${instanceId}' already exists`, {
|
|
226
|
+
code: ENGINE_ERROR_CODES.PERSIST_ALREADY_EXISTS,
|
|
227
|
+
instanceId,
|
|
228
|
+
hint: "expectedRev === 0 \u8868\u793A\u65B0\u5EFA\uFF1B\u540C\u4E00 instanceId \u4E0D\u5F97\u91CD\u590D\u63D2\u5165",
|
|
229
|
+
details: { instanceId }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function optionUnknown(key, allowed) {
|
|
233
|
+
return new EngineOptionError(`Unknown engine option '${key}'`, {
|
|
234
|
+
code: ENGINE_ERROR_CODES.OPTION_UNKNOWN,
|
|
235
|
+
hint: "\u5408\u6CD5\u914D\u7F6E\u9879\u89C1 details.allowed",
|
|
236
|
+
details: { option: key, allowed: [...allowed] }
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function optionInvalid(key, reason, value) {
|
|
240
|
+
const init = {
|
|
241
|
+
code: ENGINE_ERROR_CODES.OPTION_INVALID,
|
|
242
|
+
hint: "\u6309 details.reason \u4FEE\u6B63\u8BE5\u914D\u7F6E\u9879\u53D6\u503C",
|
|
243
|
+
details: { option: key, reason, ...value === void 0 ? {} : { value } }
|
|
244
|
+
};
|
|
245
|
+
return new EngineOptionError(`Invalid value for engine option '${key}'`, init);
|
|
246
|
+
}
|
|
247
|
+
function conditionInvalid(expression, reason, details = {}) {
|
|
248
|
+
const init = {
|
|
249
|
+
code: ENGINE_ERROR_CODES.OPTION_INVALID,
|
|
250
|
+
hint: "\u6761\u4EF6\u8868\u8FBE\u5F0F\u5FC5\u987B\u662F\u6C42\u503C\u4E3A true / false \u7684 FEEL \u8868\u8FBE\u5F0F\uFF1B\u53D8\u91CF\u76F4\u63A5\u5199\u540D\u5B57\uFF08amount\uFF09\uFF0C\u4E0D\u8981\u5199 ${...}",
|
|
251
|
+
details: { option: "condition", expression, reason, ...details }
|
|
252
|
+
};
|
|
253
|
+
return new EngineOptionError(`Invalid condition expression '${expression}'`, init);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/core/state.ts
|
|
257
|
+
var STATE_SCHEMA_VERSION = 1;
|
|
258
|
+
var INSTANCE_STATUSES = [
|
|
259
|
+
"running",
|
|
260
|
+
"suspended",
|
|
261
|
+
"completed",
|
|
262
|
+
"terminated",
|
|
263
|
+
"cancelled"
|
|
264
|
+
];
|
|
265
|
+
var TERMINAL_STATUSES = [
|
|
266
|
+
"completed",
|
|
267
|
+
"terminated",
|
|
268
|
+
"cancelled"
|
|
269
|
+
];
|
|
270
|
+
function isTerminalStatus(status) {
|
|
271
|
+
return TERMINAL_STATUSES.includes(status);
|
|
272
|
+
}
|
|
273
|
+
var TOKEN_STATES = [
|
|
274
|
+
"active",
|
|
275
|
+
"waiting",
|
|
276
|
+
"completed",
|
|
277
|
+
"cancelled"
|
|
278
|
+
];
|
|
279
|
+
function headerOf(s) {
|
|
280
|
+
const h = {
|
|
281
|
+
instanceId: s.instanceId,
|
|
282
|
+
processId: s.processId,
|
|
283
|
+
definitionVersion: s.definitionVersion,
|
|
284
|
+
status: s.status,
|
|
285
|
+
rev: s.rev,
|
|
286
|
+
stateSchema: s.stateSchema,
|
|
287
|
+
startedAt: s.startedAt,
|
|
288
|
+
updatedAt: s.updatedAt
|
|
289
|
+
};
|
|
290
|
+
if (s.businessKey !== void 0) h.businessKey = s.businessKey;
|
|
291
|
+
if (s.tenantId !== void 0) h.tenantId = s.tenantId;
|
|
292
|
+
if (s.lastAction !== void 0) h.lastAction = s.lastAction;
|
|
293
|
+
if (s.pendingProjectionRev !== void 0) h.pendingProjectionRev = s.pendingProjectionRev;
|
|
294
|
+
if (s.endedAt !== void 0) h.endedAt = s.endedAt;
|
|
295
|
+
return h;
|
|
296
|
+
}
|
|
297
|
+
function findNonSerializable(value, path, ancestors) {
|
|
298
|
+
switch (typeof value) {
|
|
299
|
+
case "string":
|
|
300
|
+
case "boolean":
|
|
301
|
+
return null;
|
|
302
|
+
case "number":
|
|
303
|
+
return Number.isFinite(value) ? null : { path, kind: "non-finite-number", detail: String(value) };
|
|
304
|
+
case "undefined":
|
|
305
|
+
return { path, kind: "undefined" };
|
|
306
|
+
case "bigint":
|
|
307
|
+
return { path, kind: "bigint", detail: `${value}n` };
|
|
308
|
+
case "symbol":
|
|
309
|
+
return { path, kind: "symbol", detail: value.description ?? "" };
|
|
310
|
+
case "function":
|
|
311
|
+
return { path, kind: "function", detail: value.name || "(anonymous)" };
|
|
312
|
+
case "object":
|
|
313
|
+
break;
|
|
314
|
+
default:
|
|
315
|
+
return { path, kind: "class-instance", detail: typeof value };
|
|
316
|
+
}
|
|
317
|
+
if (value === null) return null;
|
|
318
|
+
const obj = value;
|
|
319
|
+
if (ancestors.has(obj)) return { path, kind: "circular" };
|
|
320
|
+
if (obj instanceof Map) return { path, kind: "map" };
|
|
321
|
+
if (obj instanceof Set) return { path, kind: "set" };
|
|
322
|
+
if (obj instanceof Date) return { path, kind: "date", detail: obj.toISOString() };
|
|
323
|
+
ancestors.add(obj);
|
|
324
|
+
try {
|
|
325
|
+
if (Array.isArray(obj)) {
|
|
326
|
+
for (let i = 0; i < obj.length; i += 1) {
|
|
327
|
+
const bad = findNonSerializable(obj[i], `${path}[${i}]`, ancestors);
|
|
328
|
+
if (bad) return bad;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const proto = Object.getPrototypeOf(obj);
|
|
333
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
334
|
+
const ctor = obj.constructor?.name ?? "Object";
|
|
335
|
+
return { path, kind: "class-instance", detail: ctor };
|
|
336
|
+
}
|
|
337
|
+
const syms = Object.getOwnPropertySymbols(obj);
|
|
338
|
+
if (syms.length > 0) {
|
|
339
|
+
return { path, kind: "symbol-key", detail: String(syms[0]) };
|
|
340
|
+
}
|
|
341
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
342
|
+
const bad = findNonSerializable(v, `${path}.${key}`, ancestors);
|
|
343
|
+
if (bad) return bad;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
} finally {
|
|
347
|
+
ancestors.delete(obj);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function findNonSerializableValue(value, path = "$") {
|
|
351
|
+
return findNonSerializable(value, path, /* @__PURE__ */ new Set());
|
|
352
|
+
}
|
|
353
|
+
function assertSerializable(value, path = "$") {
|
|
354
|
+
const bad = findNonSerializableValue(value, path);
|
|
355
|
+
if (!bad) return;
|
|
356
|
+
throw stateShapeInvalid(`non-serializable ${bad.kind} at ${bad.path}`, {
|
|
357
|
+
path: bad.path,
|
|
358
|
+
kind: bad.kind,
|
|
359
|
+
...bad.detail === void 0 ? {} : { detail: bad.detail }
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function cloneState(value) {
|
|
363
|
+
return JSON.parse(JSON.stringify(value));
|
|
364
|
+
}
|
|
365
|
+
function deepEqual(a, b) {
|
|
366
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
367
|
+
}
|
|
368
|
+
function assertRoundTrip(value, path = "$") {
|
|
369
|
+
assertSerializable(value, path);
|
|
370
|
+
const cloned = cloneState(value);
|
|
371
|
+
if (!deepEqual(value, cloned)) {
|
|
372
|
+
throw stateShapeInvalid("state is not JSON round-trip stable", { path });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export { ENGINE_DIAGNOSTIC_CODES, ENGINE_ERROR_CODES, EngineActionError, EngineError, EngineOptionError, EnginePersistError, EngineStateError, INSTANCE_STATUSES, STATE_SCHEMA_VERSION, TERMINAL_STATUSES, TOKEN_STATES, actionNotAllowed, actionTargetInvalid, actionUnknown, actionVetoed, addSignLimit, approverEmpty, assertRoundTrip, assertSerializable, cloneState, commentRequired, conditionInvalid, deepEqual, definitionMissing, deliverNoTarget, engineDiagnostic, headerOf, isTerminalStatus, optionInvalid, optionUnknown, persistAlreadyExists, persistConflict, stateNotFound, stateShapeInvalid, stateSuspended, stateTerminal, tokenOrphan, voteConfigInvalid };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { S as StateStore, T as TaskView, a as TaskProjection, D as DefinitionSource } from './spi-BABH0Tcs.js';
|
|
2
|
+
import { ProcessDefinition } from '@floken-io/moddle';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @floken-io/engine/conformance · 报告与用例驱动器
|
|
6
|
+
*
|
|
7
|
+
* 契约来源:`ARCHITECTURE.md` §9 T6;`03-engine` §9.2(宿主自研实现的两类静默错误)。
|
|
8
|
+
*
|
|
9
|
+
* ═══════════════════════════════════════════════════════════════
|
|
10
|
+
* ★ 三条设计裁决(不是随手写的,改之前先看理由)
|
|
11
|
+
* ═══════════════════════════════════════════════════════════════
|
|
12
|
+
*
|
|
13
|
+
* ① **绝不 import 测试框架**。本套件**随包发布**(`./conformance` 子路径),宿主拿它去验
|
|
14
|
+
* 自己写的 Postgres / MySQL 实现 —— 此时宿主用什么 runner 是宿主的自由(vitest /
|
|
15
|
+
* jest / `node:test` / 一个没框架的脚本都行)。套件若 `import { expect } from 'vitest'`,
|
|
16
|
+
* 等于给整个宿主应用强塞一个测试框架依赖;而且 `check:deps` 的依赖白名单也会直接拦下来。
|
|
17
|
+
* → 所以这里**只返回报告、不抛断言**;怎么把报告变成 CI 上的红/绿,由调用方决定。
|
|
18
|
+
*
|
|
19
|
+
* ② **返回报告而非 fail-fast**。逐条 `try/catch`,一次跑完给出**完整缺口清单**。
|
|
20
|
+
* fail-fast 会让人「修一条跑一次」,而宿主的实现缺陷通常是**成簇**出现的
|
|
21
|
+
* (比如「不抛冲突」往往连带「失败路径不留痕」也不成立)。
|
|
22
|
+
*
|
|
23
|
+
* ③ **断言体可以抛任何错**,套件把它转成 `error: string`。所以套件内部用**纯 `Error`**
|
|
24
|
+
* 做断言失败信号 —— 它**不是** `EngineError`。理由:`EngineError` 是**引擎对宿主**的
|
|
25
|
+
* 错误契约(码表 / 四禁 / 双命名空间),而「你的实现不满足契约」是**开发期**的结论,
|
|
26
|
+
* 不该混进那套码表里去占用码名。详见 `AGENTS.md` §5。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** 单条契约用例的结果 */
|
|
30
|
+
interface ConformanceCase {
|
|
31
|
+
/** 判据名:**人类可读、直接进 CI 日志**(说明"哪条契约没兑现") */
|
|
32
|
+
name: string;
|
|
33
|
+
/** 该用例覆盖的规格锚点(`INV-x` / `AC-Ex` / 文档小节),失败时可直接回溯规范 */
|
|
34
|
+
refs: readonly string[];
|
|
35
|
+
ok: boolean;
|
|
36
|
+
/** 失败原因;`ok === true` 时**不存在该键** */
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
/** 一次契约测试的完整结果(`ok` = 全绿) */
|
|
40
|
+
interface ConformanceReport {
|
|
41
|
+
/** 套件名:`'store'` | `'projection'` | `'definition'` */
|
|
42
|
+
suite: string;
|
|
43
|
+
/** 被测实现的自述(宿主传入,便于日志里区分多套实现) */
|
|
44
|
+
subject?: string;
|
|
45
|
+
cases: ConformanceCase[];
|
|
46
|
+
total: number;
|
|
47
|
+
passed: number;
|
|
48
|
+
failed: number;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** 人类可读结论(宿主 `console.log` 它即可) */
|
|
52
|
+
declare function formatConformanceReport(report: ConformanceReport): string;
|
|
53
|
+
|
|
54
|
+
interface StoreConformanceOptions {
|
|
55
|
+
/** 被测实现的自述(进报告抬头,便于区分多套实现) */
|
|
56
|
+
subject?: string;
|
|
57
|
+
/** 实例 id 前缀,便于在 SQL 里定位这些行(默认 `floken-conf`) */
|
|
58
|
+
idPrefix?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 跑 `StateStore` 全量契约用例。
|
|
62
|
+
*
|
|
63
|
+
* 前置:传入的 `store` 可用即可 —— **不需要是空的**(用例各自用唯一 id)。
|
|
64
|
+
*/
|
|
65
|
+
declare function runStoreConformance(store: StateStore, options?: StoreConformanceOptions): Promise<ConformanceReport>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 宿主提供的「读自己的待办表」。
|
|
69
|
+
* ⚠️ 顺序不作要求(套件内部按 `taskId` 归一),但**必须只返回该实例的行**。
|
|
70
|
+
*/
|
|
71
|
+
type ProjectionReadback = (instanceId: string) => Promise<TaskView[]>;
|
|
72
|
+
interface ProjectionConformanceOptions {
|
|
73
|
+
/** 被测实现的自述(进报告抬头) */
|
|
74
|
+
subject?: string;
|
|
75
|
+
/** 实例 id 前缀(默认 `floken-conf`) */
|
|
76
|
+
idPrefix?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 跑 `TaskProjection` 全量契约用例。
|
|
80
|
+
*
|
|
81
|
+
* @param projection 被测投影实现
|
|
82
|
+
* @param readback 宿主提供的「读自己的待办表」(见文件头说明);**必须**只返回该实例的行
|
|
83
|
+
*/
|
|
84
|
+
declare function runProjectionConformance(projection: TaskProjection, readback: ProjectionReadback, options?: ProjectionConformanceOptions): Promise<ConformanceReport>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @floken-io/engine/conformance · `DefinitionSource` 契约测试套件
|
|
88
|
+
*
|
|
89
|
+
* 契约来源:`ARCHITECTURE.md` §7.2(`DefinitionSource`)/ §6.4(INV-19)/ §9 T19;
|
|
90
|
+
* `03-engine` §8.1、`AC-E10`。
|
|
91
|
+
*
|
|
92
|
+
* ═══════════════════════════════════════════════════════════════
|
|
93
|
+
* 宿主怎么用
|
|
94
|
+
* ═══════════════════════════════════════════════════════════════
|
|
95
|
+
* ```ts
|
|
96
|
+
* import { runDefinitionConformance, formatConformanceReport } from '@floken-io/engine/conformance';
|
|
97
|
+
*
|
|
98
|
+
* const report = await runDefinitionConformance(mySource, [
|
|
99
|
+
* { processId: 'expense', version: 1, definition: defV1 },
|
|
100
|
+
* { processId: 'expense', version: 2, definition: defV2 },
|
|
101
|
+
* ]);
|
|
102
|
+
* console.log(formatConformanceReport(report));
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* ═══════════════════════════════════════════════════════════════
|
|
106
|
+
* ★ 为什么必须由宿主额外交 `fixtures`(与 `store` 套件最大的不同)
|
|
107
|
+
* ═══════════════════════════════════════════════════════════════
|
|
108
|
+
* `StateStore` 的用例能**自己造**假状态(状态是引擎的数据),但**定义是业务的资产** ——
|
|
109
|
+
* 套件随包发布,它不可能知道你的仓库里有哪些流程。所以这里反过来:
|
|
110
|
+
* 由宿主声明「我的仓库里这一格长这样」(`{ processId, version, definition }`),
|
|
111
|
+
* 套件拿着它去**按格取回**并逐格比对。这不是接口缺陷,而是「引擎不认识你的定义库」
|
|
112
|
+
* 这条边界必须付出的代价 —— 与 `TaskProjection` 要宿主交 `readback` 同理
|
|
113
|
+
* (见 `projection.ts` 文件头)。
|
|
114
|
+
*
|
|
115
|
+
* ═══════════════════════════════════════════════════════════════
|
|
116
|
+
* ★ 它在抓什么(`AC-E10` 的两类静默错误)
|
|
117
|
+
* ═══════════════════════════════════════════════════════════════
|
|
118
|
+
* ① **忽略 `version` 参数**(`getDefinition(pid, v)` 里压根没用 `v`,永远返回最新版)。
|
|
119
|
+
* 症状:在途实例跑到了它发起时**还不存在的节点**上,且**没有任何报错** ——
|
|
120
|
+
* 「昨天发起的单子今天忽然多出一个审批人」就是它。
|
|
121
|
+
* 这是本套件的**头号目标**,判据名里直接写明了「防忽略 version」。
|
|
122
|
+
* ② **未知版本回退**(取不到第 v 版就退到上一版 / 最新版)。
|
|
123
|
+
* 返回 `null` 才是「这一版不存在」的唯一诚实表达 —— 引擎会把它翻成
|
|
124
|
+
* `ENGINE_STATE_DEFINITION_MISSING`(可观测、可告警);回退则是一次静默的偷换。
|
|
125
|
+
*
|
|
126
|
+
* ═══════════════════════════════════════════════════════════════
|
|
127
|
+
* ★ 断言边界(诚实说明,**不是**遗漏)
|
|
128
|
+
* ═══════════════════════════════════════════════════════════════
|
|
129
|
+
* 只断言「按格取回的内容与宿主声明的深等」+ 上述两类反例。套件**不验**定义本身的
|
|
130
|
+
* 合法性(能不能建图、节点类型对不对)—— 那是 `@floken-io/moddle` 的职责,
|
|
131
|
+
* 且定义不合法时引擎会在 `createProcessGraph()` 抛错,不需要两套判据。
|
|
132
|
+
*
|
|
133
|
+
* 比较语义与 `assertDeepEqual` **同源**(`core/state.deepEqual` = JSON 串比较,故键序
|
|
134
|
+
* 也算内容的一部分)。这不是偷懒:定义一律由 moddle 产出,键序稳定;
|
|
135
|
+
* 只为"比较两个定义"再发明一套比较器,才是真正会漂移的东西。
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 宿主声明的「一格图纸」。
|
|
140
|
+
*
|
|
141
|
+
* ⚠️ `definition` 必须是**宿主仓库里这一格的真实内容** —— 套件拿它当期望值。
|
|
142
|
+
* 声明错了套件会红(第一条用例就是干这个的),不会静默放过。
|
|
143
|
+
*/
|
|
144
|
+
interface DefinitionFixture {
|
|
145
|
+
processId: string;
|
|
146
|
+
version: number;
|
|
147
|
+
/** 该 `(processId, version)` 期望取回的内容 */
|
|
148
|
+
definition: ProcessDefinition;
|
|
149
|
+
}
|
|
150
|
+
interface DefinitionConformanceOptions {
|
|
151
|
+
/** 被测实现的自述(进报告抬头,便于区分多套实现) */
|
|
152
|
+
subject?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 跑 `DefinitionSource` 全量契约用例。
|
|
156
|
+
*
|
|
157
|
+
* 前置:`fixtures` 须含**同一 `processId` 的 ≥2 个版本**,且这些版本的内容**互不相同**
|
|
158
|
+
* —— 否则「忽略 `version`」这类缺陷**无法被观测**(返回什么都一样),套件第一条会点名。
|
|
159
|
+
*/
|
|
160
|
+
declare function runDefinitionConformance(source: DefinitionSource, fixtures: readonly DefinitionFixture[], options?: DefinitionConformanceOptions): Promise<ConformanceReport>;
|
|
161
|
+
|
|
162
|
+
export { type ConformanceCase, type ConformanceReport, type DefinitionConformanceOptions, type DefinitionFixture, type ProjectionConformanceOptions, type ProjectionReadback, type StoreConformanceOptions, formatConformanceReport, runDefinitionConformance, runProjectionConformance, runStoreConformance };
|