@stalfh233/omc-cli 0.4.102 → 0.5.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/dist/bench/discovery-cost-eval.js +2 -0
- package/dist/cli.js +138 -4
- package/dist/field-change.js +17 -1
- package/dist/graph.js +734 -0
- package/dist/lanes/businessrule.js +7 -1
- package/dist/recipes/field.js +55 -1
- package/package.json +6 -4
- package/skills/omc-write-hazards.md +1 -0
|
@@ -128,6 +128,8 @@ async function runScenario(scenario) {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
export async function runDiscoveryCostBaseline() {
|
|
131
|
+
// r29(2026-10-29):describe 重复提示读项目 ledger,组合测试顺序会改变它——测量面禁用保证字节确定性。
|
|
132
|
+
process.env.OMC_DESC_REPEAT_HINT = "0";
|
|
131
133
|
const scenarios = [];
|
|
132
134
|
for (const scenario of SCENARIOS)
|
|
133
135
|
scenarios.push(await runScenario(scenario));
|
package/dist/cli.js
CHANGED
|
@@ -17,8 +17,8 @@ import { cmdModelPurge } from "./model-purge.js";
|
|
|
17
17
|
import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
|
|
18
18
|
import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, refusalText, requestId } from "./cli-contract.js";
|
|
19
19
|
import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
|
|
20
|
-
import { beginUsage, completeUsage, ensureProjectIgnore, recordUsageOutput, usageReport } from "./usage-log.js";
|
|
21
|
-
import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
|
|
20
|
+
import { beginUsage, completeUsage, currentUsageArgsDigest, ensureProjectIgnore, priorIdenticalSuccess, recordUsageOutput, usageReport } from "./usage-log.js";
|
|
21
|
+
import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitThrown, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
|
|
22
22
|
import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, pruneArtifacts } from "./artifacts.js";
|
|
23
23
|
import { projectJsonPath, projectJsonPathFanout, projectResultExtract } from "./path-projection.js";
|
|
24
24
|
import { basename, join, resolve } from "node:path";
|
|
@@ -44,8 +44,11 @@ import { planIntent, readbackIntent, applyIntent, intentWarnings } from "./inten
|
|
|
44
44
|
import { listSkills, readSkill, readSkillOutline, readSkillSection, searchSkills } from "./skills.js";
|
|
45
45
|
import { parseToolInput, ToolInputError } from "./tool-types.js";
|
|
46
46
|
import { WireError, wireCall } from "./wire.js";
|
|
47
|
+
import { DEFAULT_PULL_CONCURRENCY, GraphIndexAbsentError, GraphPullLockHeldError, MAX_PULL_CONCURRENCY, impactGraph, publishGraphPull, queryGraphReferences, readGraphStatus, searchGraphRules, } from "./graph.js";
|
|
47
48
|
import { ENDPOINTS } from "./endpoints.js";
|
|
49
|
+
import { makeToolContext } from "./context.js";
|
|
48
50
|
import { cmdRollbackList, cmdRollbackPlan, cmdRollbackApply } from "./rollback.js";
|
|
51
|
+
import { SessionManager } from "./session-manager.js";
|
|
49
52
|
async function ask(question) {
|
|
50
53
|
const rl = createInterface({ input: stdin, output: process.stderr });
|
|
51
54
|
try {
|
|
@@ -879,8 +882,20 @@ function cmdDiscovery(kind, transport, value, options = {}) {
|
|
|
879
882
|
// second identical read returns an artifact reference instead of the body.
|
|
880
883
|
result = full ? (repeatFullDescribe(value ?? "", full) ?? full) : full;
|
|
881
884
|
}
|
|
882
|
-
else
|
|
885
|
+
else {
|
|
883
886
|
result = describeTool(registry, value ?? "");
|
|
887
|
+
// r28b 账本(2026-10-28):describe rule.save×11 / form.publish×9——firstCall 教学后
|
|
888
|
+
// 仍反复全量 describe。同参数近期已 ok 时附降载提示,引导 --param/--example 精准取。
|
|
889
|
+
// 无项目 ledger(golden/repo 测试/一次性运行)时 priorIdenticalSuccess 返回
|
|
890
|
+
// undefined,输出保持 byte-stable。
|
|
891
|
+
if (result) {
|
|
892
|
+
const digest = currentUsageArgsDigest();
|
|
893
|
+
const prior = digest === undefined ? undefined : priorIdenticalSuccess(`describe ${String(value ?? "")}`, digest);
|
|
894
|
+
if (prior !== undefined && prior.count >= 1)
|
|
895
|
+
result.describeRepeatHint =
|
|
896
|
+
`同参数 describe 近期已执行过 ${prior.count + 1} 次(含本次)。在找单个参数:omc describe ${String(value ?? "")} --param <参数名>(几百字节);找可抄的完整例子:--example <配方id>;都不必重读整面`;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
884
899
|
if (!result) {
|
|
885
900
|
// Name the closest candidates so a near-miss tool name does not cost a
|
|
886
901
|
// guess-and-retry call (round-4 dogfood: `form.create` / `data.create` /
|
|
@@ -1029,6 +1044,106 @@ async function cmdIntentReadback(file, environment, transport) {
|
|
|
1029
1044
|
return read.code;
|
|
1030
1045
|
return emitResult(await readbackIntent(read.intent, environment), transport);
|
|
1031
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* ADR-0008 — `omc graph` family: the local derived rule-graph index. A CLI
|
|
1049
|
+
* management face like `intent`/`rollback`: NOT a call tool, not in the tool
|
|
1050
|
+
* registry or `commands --json`. Results are candidate pointers only — plan /
|
|
1051
|
+
* apply / readback keep reading the live baseline.
|
|
1052
|
+
*/
|
|
1053
|
+
async function cmdGraphPull(environment, concurrency, transport) {
|
|
1054
|
+
const workers = concurrency ?? DEFAULT_PULL_CONCURRENCY;
|
|
1055
|
+
if (!Number.isSafeInteger(workers) || workers < 1 || workers > MAX_PULL_CONCURRENCY)
|
|
1056
|
+
return emitInvalid(transport, { message: `--concurrency 必须是 1–${MAX_PULL_CONCURRENCY} 的整数(平台负载有界;默认 ${DEFAULT_PULL_CONCURRENCY})` });
|
|
1057
|
+
const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
|
|
1058
|
+
try {
|
|
1059
|
+
const session = await context.sessions.session(environment);
|
|
1060
|
+
const data = await publishGraphPull(session, workers);
|
|
1061
|
+
const edgesByKind = {};
|
|
1062
|
+
for (const edge of data.edges)
|
|
1063
|
+
edgesByKind[edge.kind] = (edgesByKind[edge.kind] ?? 0) + 1;
|
|
1064
|
+
const gapsByKind = {};
|
|
1065
|
+
for (const gap of data.gaps)
|
|
1066
|
+
gapsByKind[gap.kind] = (gapsByKind[gap.kind] ?? 0) + 1;
|
|
1067
|
+
return emitResult({
|
|
1068
|
+
status: "ok",
|
|
1069
|
+
environment: data.meta.environmentName,
|
|
1070
|
+
applicationCode: data.meta.applicationCode,
|
|
1071
|
+
identityFingerprint: data.meta.identityFingerprint,
|
|
1072
|
+
pulledAtIso: data.meta.pulledAtIso,
|
|
1073
|
+
scope: data.meta.scope,
|
|
1074
|
+
freshness: data.gaps.length === 0 ? "complete" : "partial",
|
|
1075
|
+
scanned: { models: data.scanned.models, rules: data.scanned.rules, datarules: data.scanned.datarules, views: data.scanned.views, ...(data.scanned.parseFailures > 0 ? { parseFailures: data.scanned.parseFailures } : {}) },
|
|
1076
|
+
edges: edgesByKind,
|
|
1077
|
+
...(data.gaps.length > 0 ? { gaps: { count: data.gaps.length, byKind: gapsByKind } } : {}),
|
|
1078
|
+
dbBytes: data.dbBytes,
|
|
1079
|
+
timings: data.timings,
|
|
1080
|
+
nextCommand: "omc graph references <kind> <code>",
|
|
1081
|
+
}, transport);
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
if (error instanceof GraphPullLockHeldError)
|
|
1085
|
+
return emitResult({ status: "refused", blocker: "graph-pull-lock-held", message: error.message, nextCommand: "omc graph status" }, transport);
|
|
1086
|
+
if (error instanceof WireError)
|
|
1087
|
+
return emitResult({ status: "uncertain", reason: "wire-transport", message: `graph pull 中断(${error.message});索引未发布,重跑 omc graph pull --env <env>` }, transport);
|
|
1088
|
+
return emitThrown(error, transport);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
function cmdGraphReferences(kind, code, transport) {
|
|
1092
|
+
if (!["service", "model", "field"].includes(kind))
|
|
1093
|
+
return emitInvalid(transport, { message: "kind 必须是 service / model / field(同 rule.references 第一版边界)" });
|
|
1094
|
+
try {
|
|
1095
|
+
const result = queryGraphReferences(kind, code);
|
|
1096
|
+
return emitResult({
|
|
1097
|
+
status: "ok",
|
|
1098
|
+
kind: result.kind,
|
|
1099
|
+
code: result.code,
|
|
1100
|
+
matches: result.matches,
|
|
1101
|
+
index: result.index,
|
|
1102
|
+
...(result.note !== undefined ? { note: result.note } : {}),
|
|
1103
|
+
...(result.gapWarning !== undefined ? { gapWarning: result.gapWarning } : {}),
|
|
1104
|
+
}, transport);
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
if (error instanceof GraphIndexAbsentError)
|
|
1108
|
+
return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
|
|
1109
|
+
return emitThrown(error, transport);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
function cmdGraphStatus(transport) {
|
|
1113
|
+
try {
|
|
1114
|
+
const status = readGraphStatus();
|
|
1115
|
+
return emitResult({ status: "ok", index: status.index, dbBytes: status.dbBytes, gaps: status.gaps }, transport);
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
if (error instanceof GraphIndexAbsentError)
|
|
1119
|
+
return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
|
|
1120
|
+
return emitThrown(error, transport);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
function cmdGraphImpact(kind, code, transport) {
|
|
1124
|
+
if (!["service", "model", "field"].includes(kind))
|
|
1125
|
+
return emitInvalid(transport, { message: "kind 必须是 service / model / field(同 rule.references 第一版边界)" });
|
|
1126
|
+
try {
|
|
1127
|
+
const result = impactGraph(kind, code);
|
|
1128
|
+
return emitResult({ status: "ok", ...result }, transport);
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
if (error instanceof GraphIndexAbsentError)
|
|
1132
|
+
return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
|
|
1133
|
+
return emitThrown(error, transport);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function cmdGraphSearch(term, transport) {
|
|
1137
|
+
try {
|
|
1138
|
+
const result = searchGraphRules(term);
|
|
1139
|
+
return emitResult({ status: "ok", ...result }, transport);
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
if (error instanceof GraphIndexAbsentError)
|
|
1143
|
+
return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
|
|
1144
|
+
return emitThrown(error, transport);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1032
1147
|
/** Ticket 119 — `omc intent apply <plan-file>`: plan integrity + baseline drift gates, then serial execution through the EXISTING apply_changes machinery. */
|
|
1033
1148
|
async function cmdIntentApply(planPath, options, transport) {
|
|
1034
1149
|
return applyIntent(planPath, options, {
|
|
@@ -1714,6 +1829,25 @@ async function main() {
|
|
|
1714
1829
|
intentCommand.command("readback <file>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (file, options) => {
|
|
1715
1830
|
commandExitCode = await withTransport(options, request, (transport) => cmdIntentReadback(file, options.env, transport));
|
|
1716
1831
|
});
|
|
1832
|
+
// ADR-0008: local derived rule-graph index — CLI management face, not a call
|
|
1833
|
+
// tool (so it stays out of `commands --json`/coverage by construction).
|
|
1834
|
+
const graphCommand = command.command("graph").description("本地派生规则图索引(ADR-0008):pull 全量重建(有界并发)/ references 离线引用 / impact 影响面 / search 规则全文 / status 完整性与新鲜度。候选定位器——不进 baseline/plan/写门禁");
|
|
1835
|
+
graphCommand.command("pull").option("--env <environment>").option("--environment <environment>").option("--concurrency <n>", "并发 wire 读数(1–32,默认 16):拉取已流水线化,墙钟 ≈ max(模型面,规则面)/并发 × 延迟;过高可能触发平台限流形成缺口").option("--max-output-bytes <bytes>").addHelpText("after", "\n只读远端(model.list → 每模型 rule.list+datarule.list → 每规则 rule.get,统一任务池流水线并发)+ 本地原子重建;不写环境、不占环境写锁。新鲜度随结果回报(complete|partial),查询走 graph references / impact / search。\n").action(async (options) => {
|
|
1836
|
+
const parsed = options.concurrency === undefined ? undefined : Number.parseInt(String(options.concurrency), 10);
|
|
1837
|
+
commandExitCode = await withTransport(options, request, (transport) => cmdGraphPull(options.environment ?? options.env, parsed, transport));
|
|
1838
|
+
});
|
|
1839
|
+
graphCommand.command("references <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n离线查询(零 wire):kind = service|model|field,语义同 rule.references 第一版边界;结果带索引新鲜度与扫描缺口(有缺口时零命中不构成「无引用」证明)。\n").action(async (kind, code, options) => {
|
|
1840
|
+
commandExitCode = await withTransport(options, request, (transport) => cmdGraphReferences(kind, code, transport));
|
|
1841
|
+
});
|
|
1842
|
+
graphCommand.command("impact <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n影响面(离线):跳1=引用该编码的规则(同 references),跳2=这些规则还读写哪些 service/model/field——改前看爆炸半径。截断上限 50 规则×每类 20 码。\n").action(async (kind, code, options) => {
|
|
1843
|
+
commandExitCode = await withTransport(options, request, (transport) => cmdGraphImpact(kind, code, transport));
|
|
1844
|
+
});
|
|
1845
|
+
graphCommand.command("search <term...>").option("--max-output-bytes <bytes>").addHelpText("after", "\n规则全文检索(code+name,FTS5,末词前缀匹配):找到\"关于 X 的规则\"拿 code,再走 references/impact。中文需给全词或词首(如 报名*)。\n").action(async (term, options) => {
|
|
1846
|
+
commandExitCode = await withTransport(options, request, (transport) => cmdGraphSearch(term.join(" "), transport));
|
|
1847
|
+
});
|
|
1848
|
+
graphCommand.command("status").option("--max-output-bytes <bytes>").action(async (options) => {
|
|
1849
|
+
commandExitCode = await withTransport(options, request, (transport) => cmdGraphStatus(transport));
|
|
1850
|
+
});
|
|
1717
1851
|
// `--jq` expressions the shell split arrive as several tokens; re-join them.
|
|
1718
1852
|
await command.parseAsync(joinSplitFlagValues(stripOutputFlags(argv), ["--jq"]));
|
|
1719
1853
|
return commandExitCode;
|
|
@@ -1742,7 +1876,7 @@ main().then((code) => {
|
|
|
1742
1876
|
// envelope (or, for `env --json`, their own JSON) and were printing a second
|
|
1743
1877
|
// empty envelope after it (live 2026-10-23: `omc rollback apply` answered two
|
|
1744
1878
|
// lines, violating the one-envelope stdout contract).
|
|
1745
|
-
const ownsStdout = ["call", "script", "artifact", "commands", "describe", "contract", "capabilities", "capability", "next", "usage", "intent", "help", "form-default", "compute", "rule-when", "approval-flow", "model-scaffold", "model-purge", "rollback", "env", "--version", "-V"];
|
|
1879
|
+
const ownsStdout = ["call", "script", "artifact", "commands", "describe", "contract", "capabilities", "capability", "next", "usage", "intent", "help", "form-default", "compute", "rule-when", "approval-flow", "model-scaffold", "model-purge", "rollback", "env", "graph", "--version", "-V"];
|
|
1746
1880
|
if (ownsStdout.includes(argv[2] ?? "")) {
|
|
1747
1881
|
completeUsage(code);
|
|
1748
1882
|
finish(code);
|
package/dist/field-change.js
CHANGED
|
@@ -91,7 +91,7 @@ export function prepareFieldUpdate(input) {
|
|
|
91
91
|
message: `子表字段 ${input.schemaCode}.${input.code} 不接受 options/choices/subFields。子表列就是 schemaCode=${input.code} 上的普通字段:增列用 field.create --args '{"schemaCode":"${input.code}","code":"<子列>",...}',改列/删列用 field.update/field.delete(同 schemaCode),改完对父模型 field.publish 即发布`,
|
|
92
92
|
});
|
|
93
93
|
const currentOptions = decodeJsonObjectColumn(input.base.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
|
|
94
|
-
const
|
|
94
|
+
const rebuiltOptions = isChildTable
|
|
95
95
|
? currentOptions
|
|
96
96
|
: ensureRelationDisplayFieldOptions(recordType, {
|
|
97
97
|
...currentOptions,
|
|
@@ -101,6 +101,22 @@ export function prepareFieldUpdate(input) {
|
|
|
101
101
|
...(effectiveOptions ? { options: effectiveOptions } : {}),
|
|
102
102
|
}),
|
|
103
103
|
});
|
|
104
|
+
// 2026-10-29 防御(治豪 issue 的 optionsSet 覆盖风险):带 choices 更新选项族时
|
|
105
|
+
// optionsSet 走保留式合并——以服务端原文 optionsSet 为基线,只换 custom 条目,
|
|
106
|
+
// optionStructure/treeSelect/businessModel 等未声明键(含跨版本导入态)一律继承,
|
|
107
|
+
// 不整体重建。锚定线(8.6/1.1.25)实测无此形状,纯防御。
|
|
108
|
+
const nextOptions = isChildTable
|
|
109
|
+
? currentOptions
|
|
110
|
+
: input.choices !== undefined
|
|
111
|
+
&& rebuiltOptions.optionsSet !== undefined
|
|
112
|
+
&& currentOptions.optionsSet !== undefined
|
|
113
|
+
&& currentOptions.optionsSet !== null
|
|
114
|
+
&& typeof currentOptions.optionsSet === "object"
|
|
115
|
+
? {
|
|
116
|
+
...rebuiltOptions,
|
|
117
|
+
optionsSet: { ...currentOptions.optionsSet, ...rebuiltOptions.optionsSet },
|
|
118
|
+
}
|
|
119
|
+
: rebuiltOptions;
|
|
104
120
|
if (!isChildTable && !effectiveOptions && !input.choices)
|
|
105
121
|
Object.assign(nextOptions, currentOptions);
|
|
106
122
|
if (!isChildTable) {
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { joinConfigRoot } from "./command-transport.js";
|
|
5
|
+
import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "./json-column.js";
|
|
6
|
+
import { ENDPOINTS } from "./endpoints.js";
|
|
7
|
+
import { flattenTreeRows, resolveApplicationCode, sha256 } from "./support.js";
|
|
8
|
+
import { wireCall, wireRows } from "./wire.js";
|
|
9
|
+
/**
|
|
10
|
+
* `node:sqlite` must stay OUT of the CLI's startup module graph: a static
|
|
11
|
+
* import emits the ExperimentalWarning during ESM linking, before any user
|
|
12
|
+
* code (including the stderr filter cli.ts installs as its first import) can
|
|
13
|
+
* run — that is why omc-core is lazily loaded too (sqlite-warning-filter.ts /
|
|
14
|
+
* omc-core-lazy.ts). Load it on first use, inside a command.
|
|
15
|
+
*/
|
|
16
|
+
let sqliteModule;
|
|
17
|
+
function nodeSqlite() {
|
|
18
|
+
if (sqliteModule === undefined)
|
|
19
|
+
sqliteModule = createRequire(import.meta.url)("node:sqlite");
|
|
20
|
+
return sqliteModule;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* ADR-0008 — the local derived rule-graph index.
|
|
24
|
+
*
|
|
25
|
+
* `rule.references` treats the r23 enumeration storm (171 rule.get calls) but
|
|
26
|
+
* re-scans the whole platform on every invocation and swallows per-model read
|
|
27
|
+
* failures, so a repeat question pays full wire cost again and its zero-hit
|
|
28
|
+
* answer can be a silent false negative. This module materializes the same
|
|
29
|
+
* walk ONCE into a local SQLite index:
|
|
30
|
+
*
|
|
31
|
+
* - scope: current-effective business rule graphs only (model.list → rule.list
|
|
32
|
+
* rows → businessRuleGet per row id — the exact wire reads rule.references
|
|
33
|
+
* makes; rows from the list face are current, so no stale-row double-read);
|
|
34
|
+
* - edges are api-fact only, extracted with rule.references' matching
|
|
35
|
+
* semantics (service/model/field — see extractReferenceEdges);
|
|
36
|
+
* - fail-closed completeness: every swallowed read/parse failure is a recorded
|
|
37
|
+
* gap; a zero-hit answer over a gapped index never claims "no references";
|
|
38
|
+
* - derived locator, never authority: results are candidate pointers; plan /
|
|
39
|
+
* apply / readback keep reading the live baseline;
|
|
40
|
+
* - no raw payloads persist: whitelisted rule columns + graph sha256 + edges;
|
|
41
|
+
* - identity-bound: meta carries the canonical environment identity
|
|
42
|
+
* fingerprint (which includes baseUrl/enterpriseId/applicationCode by
|
|
43
|
+
* construction — it is NOT a config-content fingerprint and never certifies
|
|
44
|
+
* "config unchanged"; freshness is complete|partial|unknown only);
|
|
45
|
+
* - each pull rebuilds wholesale into a staging DB and publishes atomically;
|
|
46
|
+
* staleness is handled by reporting pulledAt + identity, refreshed by an
|
|
47
|
+
* explicit re-pull (no incremental watermarks, no write-back hooks).
|
|
48
|
+
*/
|
|
49
|
+
const GRAPH_SCHEMA_VERSION = "4";
|
|
50
|
+
const STALE_LOCK_MS = 15 * 60 * 1000;
|
|
51
|
+
export const DEFAULT_PULL_CONCURRENCY = 16;
|
|
52
|
+
export const MAX_PULL_CONCURRENCY = 32;
|
|
53
|
+
export class GraphIndexAbsentError extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "GraphIndexAbsentError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class GraphPullLockHeldError extends Error {
|
|
60
|
+
constructor(message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "GraphPullLockHeldError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function graphDbPath() {
|
|
66
|
+
return join(joinConfigRoot(), ".omc", "graph", "graph.db");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract api-fact reference edges from one rule's stored node graph. The
|
|
70
|
+
* matching mirrors `rule.references` exactly (businessrule.ts) so the index and
|
|
71
|
+
* the live scan answer the same question; anything rule.references cannot see
|
|
72
|
+
* (condition leaves, datarules — its v1 boundary) is equally invisible here.
|
|
73
|
+
*/
|
|
74
|
+
export function extractReferenceEdges(schemaCode, ruleCode, nodes) {
|
|
75
|
+
const edges = [];
|
|
76
|
+
const push = (kind, code, node, via) => {
|
|
77
|
+
if (code === "")
|
|
78
|
+
return;
|
|
79
|
+
edges.push({
|
|
80
|
+
kind,
|
|
81
|
+
code,
|
|
82
|
+
schemaCode,
|
|
83
|
+
ruleCode,
|
|
84
|
+
nodeCode: String(node.nodeCode ?? ""),
|
|
85
|
+
nodeType: String(node.nodeType ?? ""),
|
|
86
|
+
via,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
for (const node of nodes) {
|
|
90
|
+
const mapping = node.methodMapping;
|
|
91
|
+
if (node.nodeType === "BIZ_ACTION" && mapping) {
|
|
92
|
+
push("service", String(mapping.serviceCode ?? ""), node, "methodMapping.serviceCode");
|
|
93
|
+
}
|
|
94
|
+
if (["CREATE", "UPDATE", "DELETE"].includes(String(node.nodeType ?? ""))) {
|
|
95
|
+
push("model", String(node.targetObjectCode ?? ""), node, "targetObjectCode");
|
|
96
|
+
if (Array.isArray(node.dataActions))
|
|
97
|
+
for (const action of node.dataActions)
|
|
98
|
+
push("field", String(action.targetDataItem ?? ""), node, "dataActions.targetDataItem");
|
|
99
|
+
}
|
|
100
|
+
if (mapping) {
|
|
101
|
+
push("model", String(mapping.schemaCode ?? ""), node, "methodMapping.schemaCode");
|
|
102
|
+
for (const [key, label] of [["inputMappings", "inputMappings.bizCode"], ["outputMappings", "outputMappings.bizCode"]]) {
|
|
103
|
+
if (Array.isArray(mapping[key]))
|
|
104
|
+
for (const row of mapping[key])
|
|
105
|
+
if (String(row.bizCode ?? "") !== "" && (row.codeType === undefined || row.codeType === 1))
|
|
106
|
+
push("field", String(row.bizCode), node, label);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return edges;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* datarule face (phase 2): `dataRuleList` is one call per model and the LIST
|
|
114
|
+
* rows already carry the target fields (`propertyCodes`) and the stored
|
|
115
|
+
* `options` (formulas/conditions with `{field}` tokens) — no per-rule get.
|
|
116
|
+
* Target codes are api-fact field edges; `{token}` inside serialized options
|
|
117
|
+
* is the same class of stored-config reference (the braces are the platform's
|
|
118
|
+
* own field-reference syntax, cf. formulaFromEditorText).
|
|
119
|
+
*/
|
|
120
|
+
export function extractDataRuleEdges(schemaCode, row) {
|
|
121
|
+
const targets = Array.isArray(row.propertyCodes) ? row.propertyCodes.map((code) => String(code)) : typeof row.propertyCode === "string" && row.propertyCode !== "" ? [row.propertyCode] : [];
|
|
122
|
+
const edges = [];
|
|
123
|
+
const locator = targets.join("+");
|
|
124
|
+
for (const code of targets)
|
|
125
|
+
edges.push({ kind: "field", code, schemaCode, ruleCode: "", nodeCode: locator, nodeType: "datarule", via: "datarule.targets" });
|
|
126
|
+
const tokens = new Set();
|
|
127
|
+
try {
|
|
128
|
+
for (const token of JSON.stringify(row.options ?? {}).matchAll(/\{([A-Za-z0-9_.-]+)\}/g))
|
|
129
|
+
tokens.add(token[1]);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// options stringify never throws; guard anyway
|
|
133
|
+
}
|
|
134
|
+
for (const code of tokens)
|
|
135
|
+
edges.push({ kind: "field", code, schemaCode, ruleCode: "", nodeCode: locator, nodeType: "datarule", via: "datarule.options-token" });
|
|
136
|
+
return edges;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* listview face (phase 2b): `listQueryList` is one call per model but carries
|
|
140
|
+
* only code/name — the column/sort/condition field references live in
|
|
141
|
+
* `listQueryGet` per view (queryColumns/querySorts/queryConditions
|
|
142
|
+
* propertyCode; the same projector listview.get reads back). Views are
|
|
143
|
+
* always-live (no draft lifecycle), so a get is the current state.
|
|
144
|
+
*/
|
|
145
|
+
export function extractListViewEdges(schemaCode, viewCode, model) {
|
|
146
|
+
const edges = [];
|
|
147
|
+
const push = (code, via) => {
|
|
148
|
+
if (typeof code === "string" && code !== "")
|
|
149
|
+
edges.push({ kind: "field", code, schemaCode, ruleCode: "", nodeCode: viewCode, nodeType: "listview", via });
|
|
150
|
+
};
|
|
151
|
+
for (const column of Array.isArray(model.queryColumns) ? model.queryColumns : [])
|
|
152
|
+
push(column.propertyCode, "listview.columns");
|
|
153
|
+
for (const sort of Array.isArray(model.querySorts) ? model.querySorts : [])
|
|
154
|
+
push(sort.propertyCode, "listview.sorts");
|
|
155
|
+
for (const condition of Array.isArray(model.queryConditions) ? model.queryConditions : [])
|
|
156
|
+
push(condition.propertyCode, "listview.conditions");
|
|
157
|
+
return edges;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The full pull walk, PIPELINED: one shared bounded worker pool executes both
|
|
161
|
+
* task kinds — per-model face reads (rule.list + dataruleList) and per-rule
|
|
162
|
+
* graph reads. A model face completing submits its rule reads straight into
|
|
163
|
+
* the pool, so wall time ≈ max(modelFaces, ruleReads) / concurrency instead of
|
|
164
|
+
* the phased sum. Read failures are gaps, never silent, and per FACE: a model
|
|
165
|
+
* whose datarule list fails still contributes its business rules (gap kind
|
|
166
|
+
* `datarule-list`) and vice versa (`rule-list`). Output is sorted so the
|
|
167
|
+
* published DB is byte-stable regardless of completion order.
|
|
168
|
+
*/
|
|
169
|
+
export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRENCY) {
|
|
170
|
+
const identity = session.environmentFingerprint();
|
|
171
|
+
const pulledAtIso = new Date().toISOString();
|
|
172
|
+
const totalStart = Date.now();
|
|
173
|
+
let listMs = 0;
|
|
174
|
+
let readMs = 0;
|
|
175
|
+
// Model enumeration mirrors the governed `model.list` tool exactly: the
|
|
176
|
+
// application tree flattened to BizModel rows. The bare `modelList` endpoint
|
|
177
|
+
// returns EMPTY data on a real platform (works only against the fake face)
|
|
178
|
+
// — using it here made the first live pull enumerate zero models and (pre-
|
|
179
|
+
// zero-guard) wedge the scheduler.
|
|
180
|
+
const applicationCode = await resolveApplicationCode(session, undefined).catch(() => "");
|
|
181
|
+
const schemaCodes = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data))
|
|
182
|
+
.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
|
|
183
|
+
.map((row) => String(row.code))
|
|
184
|
+
.sort();
|
|
185
|
+
const rules = [];
|
|
186
|
+
const edges = [];
|
|
187
|
+
const gaps = [];
|
|
188
|
+
let parseFailures = 0;
|
|
189
|
+
let dataruleCount = 0;
|
|
190
|
+
let viewCount = 0;
|
|
191
|
+
// --- shared scheduler: bounded starts, dynamic task submission ---
|
|
192
|
+
class Semaphore {
|
|
193
|
+
// Handoff semantics: a released slot is handed DIRECTLY to the head
|
|
194
|
+
// waiter (free is NOT incremented). The naive `free += 1; wake()` lets a
|
|
195
|
+
// task submitted in the microtask gap steal the slot, driving free
|
|
196
|
+
// negative — a waiter is then never woken, the pull promise never
|
|
197
|
+
// settles, and Node exits 0 SILENTLY when the event loop drains (no
|
|
198
|
+
// envelope, lock left behind). Direct handoff keeps the invariant.
|
|
199
|
+
free = concurrency;
|
|
200
|
+
waiters = [];
|
|
201
|
+
async acquire() {
|
|
202
|
+
if (this.free > 0) {
|
|
203
|
+
this.free -= 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
207
|
+
}
|
|
208
|
+
release() {
|
|
209
|
+
const waiter = this.waiters.shift();
|
|
210
|
+
if (waiter !== undefined) {
|
|
211
|
+
waiter();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.free += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const semaphore = new Semaphore();
|
|
218
|
+
let inflight = 0;
|
|
219
|
+
let resolveAll = () => { };
|
|
220
|
+
const allDone = new Promise((resolve) => {
|
|
221
|
+
resolveAll = resolve;
|
|
222
|
+
});
|
|
223
|
+
const maybeDone = () => {
|
|
224
|
+
if (inflight === 0)
|
|
225
|
+
resolveAll();
|
|
226
|
+
};
|
|
227
|
+
const submit = (task) => {
|
|
228
|
+
inflight += 1;
|
|
229
|
+
void (async () => {
|
|
230
|
+
await semaphore.acquire();
|
|
231
|
+
try {
|
|
232
|
+
await task();
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
gaps.push({ kind: "pull-task", ref: "internal", note: error instanceof Error ? error.message : String(error) });
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
semaphore.release();
|
|
239
|
+
inflight -= 1;
|
|
240
|
+
maybeDone();
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
};
|
|
244
|
+
// --- task kinds ---
|
|
245
|
+
const runModelFace = async (schemaCode) => {
|
|
246
|
+
const faceStart = Date.now();
|
|
247
|
+
// Independent per-face reads: one face failing must not drop the other's
|
|
248
|
+
// rows (review: Promise.all here mis-filed datarule failures as rule gaps).
|
|
249
|
+
let rows = [];
|
|
250
|
+
let ruleListOk = true;
|
|
251
|
+
try {
|
|
252
|
+
rows = wireRows((await wireCall(session, { path: ENDPOINTS.businessRuleListBySchemaCode, query: { schemaCode } })).data);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
ruleListOk = false;
|
|
256
|
+
gaps.push({ kind: "rule-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
|
|
257
|
+
}
|
|
258
|
+
let dataruleRows = [];
|
|
259
|
+
try {
|
|
260
|
+
dataruleRows = wireRows((await wireCall(session, { path: ENDPOINTS.dataRuleList, query: { schemaCode } })).data);
|
|
261
|
+
dataruleCount += dataruleRows.length;
|
|
262
|
+
for (const row of dataruleRows)
|
|
263
|
+
edges.push(...extractDataRuleEdges(schemaCode, row));
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
gaps.push({ kind: "datarule-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const views = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode, clientType: "PC" } })).data);
|
|
270
|
+
for (const view of views) {
|
|
271
|
+
const viewCode = typeof view.code === "string" ? view.code : "";
|
|
272
|
+
if (viewCode === "")
|
|
273
|
+
continue;
|
|
274
|
+
submit(() => runListViewRead(schemaCode, viewCode));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
gaps.push({ kind: "listview-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
|
|
279
|
+
}
|
|
280
|
+
listMs += Date.now() - faceStart;
|
|
281
|
+
if (!ruleListOk)
|
|
282
|
+
return;
|
|
283
|
+
const readSubmit = Date.now();
|
|
284
|
+
let queued = 0;
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
const ruleCode = String(row.code ?? "");
|
|
287
|
+
const ruleId = row.id;
|
|
288
|
+
if (ruleCode === "" || typeof ruleId !== "string" || ruleId === "") {
|
|
289
|
+
gaps.push({ kind: "rule-row-id", ref: `${schemaCode}/${ruleCode || "?"}` });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
queued += 1;
|
|
293
|
+
submit(() => runRuleRead(schemaCode, ruleCode, ruleId));
|
|
294
|
+
}
|
|
295
|
+
if (queued > 0)
|
|
296
|
+
readMs += Date.now() - readSubmit;
|
|
297
|
+
};
|
|
298
|
+
const runRuleRead = async (schemaCode, ruleCode, ruleId) => {
|
|
299
|
+
const start = Date.now();
|
|
300
|
+
try {
|
|
301
|
+
const record = (await wireCall(session, { path: ENDPOINTS.businessRuleGet, query: { id: ruleId } })).data;
|
|
302
|
+
if (!record) {
|
|
303
|
+
gaps.push({ kind: "rule-get", ref: `${schemaCode}/${ruleCode}`, note: "empty readback" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const nodes = decodeJsonColumn(record.node, JSON_COLUMN_NULL_COALESCE);
|
|
307
|
+
if (!Array.isArray(nodes)) {
|
|
308
|
+
parseFailures += 1;
|
|
309
|
+
gaps.push({ kind: "rule-graph-parse", ref: `${schemaCode}/${ruleCode}` });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
rules.push({
|
|
313
|
+
schemaCode,
|
|
314
|
+
ruleCode,
|
|
315
|
+
name: String(record.name ?? ruleCode),
|
|
316
|
+
enabled: typeof record.enabled === "boolean" ? record.enabled : null,
|
|
317
|
+
status: typeof record.status === "string" ? record.status : null,
|
|
318
|
+
defaultRule: typeof record.defaultRule === "boolean" ? record.defaultRule : null,
|
|
319
|
+
rowId: ruleId,
|
|
320
|
+
graphSha256: sha256({ nodes, routes: decodeJsonColumn(record.route, JSON_COLUMN_NULL_COALESCE) }),
|
|
321
|
+
});
|
|
322
|
+
edges.push(...extractReferenceEdges(schemaCode, ruleCode, nodes));
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
gaps.push({ kind: "rule-get", ref: `${schemaCode}/${ruleCode}`, note: error instanceof Error ? error.message : String(error) });
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
readMs += Date.now() - start;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const runListViewRead = async (schemaCode, viewCode) => {
|
|
332
|
+
try {
|
|
333
|
+
const got = (await wireCall(session, { path: ENDPOINTS.listQueryGet, query: { schemaCode, code: viewCode, clientType: "PC" } })).data;
|
|
334
|
+
if (got === null || !got.id) {
|
|
335
|
+
gaps.push({ kind: "listview-get", ref: `${schemaCode}/${viewCode}`, note: "empty readback" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
viewCount += 1;
|
|
339
|
+
edges.push(...extractListViewEdges(schemaCode, viewCode, got));
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
gaps.push({ kind: "listview-get", ref: `${schemaCode}/${viewCode}`, note: error instanceof Error ? error.message : String(error) });
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
for (const schemaCode of schemaCodes)
|
|
346
|
+
submit(() => runModelFace(schemaCode));
|
|
347
|
+
// Zero-scope guard: with no models there are no tasks, so no finally ever
|
|
348
|
+
// calls maybeDone — awaiting allDone would wedge the pull until the event
|
|
349
|
+
// loop drained and Node exited 0 silently. Resolve explicitly.
|
|
350
|
+
maybeDone();
|
|
351
|
+
await allDone;
|
|
352
|
+
rules.sort((a, b) => (a.schemaCode === b.schemaCode ? a.ruleCode.localeCompare(b.ruleCode) : a.schemaCode.localeCompare(b.schemaCode)));
|
|
353
|
+
edges.sort((a, b) => a.kind === b.kind
|
|
354
|
+
? a.code === b.code
|
|
355
|
+
? a.schemaCode === b.schemaCode
|
|
356
|
+
? a.ruleCode === b.ruleCode
|
|
357
|
+
? a.nodeCode === b.nodeCode
|
|
358
|
+
? a.via.localeCompare(b.via)
|
|
359
|
+
: a.nodeCode.localeCompare(b.nodeCode)
|
|
360
|
+
: a.ruleCode.localeCompare(b.ruleCode)
|
|
361
|
+
: a.schemaCode.localeCompare(b.schemaCode)
|
|
362
|
+
: a.code.localeCompare(b.code)
|
|
363
|
+
: a.kind.localeCompare(b.kind));
|
|
364
|
+
gaps.sort((a, b) => (a.kind === b.kind ? a.ref.localeCompare(b.ref) : a.kind.localeCompare(b.kind)));
|
|
365
|
+
return {
|
|
366
|
+
meta: {
|
|
367
|
+
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
368
|
+
scope: "businessrule",
|
|
369
|
+
identityFingerprint: identity,
|
|
370
|
+
environmentName: session.environmentName,
|
|
371
|
+
applicationCode: session.profile.applicationCode,
|
|
372
|
+
pulledAtIso,
|
|
373
|
+
},
|
|
374
|
+
rules,
|
|
375
|
+
edges,
|
|
376
|
+
gaps,
|
|
377
|
+
scanned: { models: schemaCodes.length, rules: rules.length, datarules: dataruleCount, views: viewCount, parseFailures },
|
|
378
|
+
timings: { listMs, readMs, totalMs: Date.now() - totalStart },
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function ensureGraphDir() {
|
|
382
|
+
const dir = dirname(graphDbPath());
|
|
383
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
384
|
+
return dir;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Cross-process pull lock: O_EXCL create; a lock older than STALE_LOCK_MS is
|
|
388
|
+
* considered abandoned and taken over. This is a LOCAL index lock — pull never
|
|
389
|
+
* writes the environment, so it must not touch the environment write lock.
|
|
390
|
+
*/
|
|
391
|
+
async function withGraphPullLock(run) {
|
|
392
|
+
ensureGraphDir();
|
|
393
|
+
const lockPath = `${graphDbPath()}.lock`;
|
|
394
|
+
const owner = `${process.pid} ${new Date().toISOString()}`;
|
|
395
|
+
let fd;
|
|
396
|
+
try {
|
|
397
|
+
try {
|
|
398
|
+
fd = openSync(lockPath, "wx");
|
|
399
|
+
writeFileSync(fd, owner);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (error.code !== "EEXIST")
|
|
403
|
+
throw error;
|
|
404
|
+
let stale = false;
|
|
405
|
+
try {
|
|
406
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > STALE_LOCK_MS;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
stale = false;
|
|
410
|
+
}
|
|
411
|
+
if (!stale)
|
|
412
|
+
throw new GraphPullLockHeldError("graph pull 已在进行(本地索引锁被占用);稍后重试,或确认无进行中拉取后删除 <配置根>/.omc/graph/graph.db.lock");
|
|
413
|
+
rmSync(lockPath, { force: true });
|
|
414
|
+
try {
|
|
415
|
+
fd = openSync(lockPath, "wx");
|
|
416
|
+
writeFileSync(fd, owner);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// Lost the stale-takeover race to another pull — still a lock refusal
|
|
420
|
+
// (exit 12), never a generic thrown error (exit 10).
|
|
421
|
+
if (error.code === "EEXIST")
|
|
422
|
+
throw new GraphPullLockHeldError("graph pull 已在进行(陈旧锁接管竞争失败);稍后重试");
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return await run();
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
if (fd !== undefined)
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
try {
|
|
432
|
+
unlinkSync(lockPath);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// already gone — another stale takeover won the race
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Wholesalerebuild + atomic publish (staging file + rename), 0600. */
|
|
440
|
+
export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCURRENCY) {
|
|
441
|
+
try {
|
|
442
|
+
return await withGraphPullLock(async () => {
|
|
443
|
+
const data = await pullRuleGraph(session, concurrency);
|
|
444
|
+
const staging = `${graphDbPath()}.staging-${process.pid}`;
|
|
445
|
+
rmSync(staging, { force: true });
|
|
446
|
+
const DatabaseSyncCtor = nodeSqlite().DatabaseSync;
|
|
447
|
+
const db = new DatabaseSyncCtor(staging);
|
|
448
|
+
try {
|
|
449
|
+
// The index is a rebuildable cache: crash durability is worthless here,
|
|
450
|
+
// and the default per-transaction journal fsync costs MINUTES on a
|
|
451
|
+
// network filesystem (observed 165s wall for a 2s pull on NFS /vol1 —
|
|
452
|
+
// D-state in folio_wait_bit_common). One transaction + memory journal
|
|
453
|
+
// + synchronous=OFF makes the build effectively free.
|
|
454
|
+
db.exec("PRAGMA journal_mode=MEMORY; PRAGMA synchronous=OFF;");
|
|
455
|
+
db.exec(`BEGIN;
|
|
456
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
457
|
+
CREATE TABLE rules (
|
|
458
|
+
schema_code TEXT NOT NULL,
|
|
459
|
+
rule_code TEXT NOT NULL,
|
|
460
|
+
name TEXT,
|
|
461
|
+
enabled INTEGER,
|
|
462
|
+
status TEXT,
|
|
463
|
+
default_rule INTEGER,
|
|
464
|
+
row_id TEXT NOT NULL,
|
|
465
|
+
graph_sha256 TEXT NOT NULL,
|
|
466
|
+
PRIMARY KEY (schema_code, rule_code)
|
|
467
|
+
);
|
|
468
|
+
CREATE TABLE edges (
|
|
469
|
+
kind TEXT NOT NULL,
|
|
470
|
+
code TEXT NOT NULL,
|
|
471
|
+
schema_code TEXT NOT NULL,
|
|
472
|
+
rule_code TEXT NOT NULL,
|
|
473
|
+
node_code TEXT,
|
|
474
|
+
node_type TEXT,
|
|
475
|
+
via TEXT NOT NULL
|
|
476
|
+
);
|
|
477
|
+
CREATE INDEX edges_lookup ON edges(kind, code);
|
|
478
|
+
CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
|
|
479
|
+
`);
|
|
480
|
+
const insertMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)");
|
|
481
|
+
for (const [key, value] of Object.entries(data.meta))
|
|
482
|
+
insertMeta.run(key, String(value));
|
|
483
|
+
const insertRule = db.prepare("INSERT INTO rules (schema_code, rule_code, name, enabled, status, default_rule, row_id, graph_sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
484
|
+
for (const rule of data.rules)
|
|
485
|
+
insertRule.run(rule.schemaCode, rule.ruleCode, rule.name, rule.enabled === null ? null : rule.enabled ? 1 : 0, rule.status, rule.defaultRule === null ? null : rule.defaultRule ? 1 : 0, rule.rowId, rule.graphSha256);
|
|
486
|
+
const insertEdge = db.prepare("INSERT INTO edges (kind, code, schema_code, rule_code, node_code, node_type, via) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
487
|
+
for (const edge of data.edges)
|
|
488
|
+
insertEdge.run(edge.kind, edge.code, edge.schemaCode, edge.ruleCode, edge.nodeCode, edge.nodeType, edge.via);
|
|
489
|
+
const insertGap = db.prepare("INSERT INTO gaps (kind, ref, note) VALUES (?, ?, ?)");
|
|
490
|
+
for (const gap of data.gaps)
|
|
491
|
+
insertGap.run(gap.kind, gap.ref, gap.note ?? null);
|
|
492
|
+
// FTS5 mirrors rules (code+name) for `graph search`; one rebuild after
|
|
493
|
+
// the bulk insert is cheaper than per-row triggers on a wholesale rebuild.
|
|
494
|
+
db.exec(`
|
|
495
|
+
CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
|
|
496
|
+
INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
|
|
497
|
+
`);
|
|
498
|
+
db.exec("COMMIT;");
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
db.close();
|
|
502
|
+
}
|
|
503
|
+
chmodSync(staging, 0o600);
|
|
504
|
+
const dbBytes = statSync(staging).size;
|
|
505
|
+
renameSync(staging, graphDbPath());
|
|
506
|
+
return { ...data, dbBytes };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
// A failed build must not leave staging garbage next to the index.
|
|
511
|
+
rmSync(`${graphDbPath()}.staging-${process.pid}`, { force: true });
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function openGraphIndex() {
|
|
516
|
+
const dbPath = graphDbPath();
|
|
517
|
+
if (!existsSync(dbPath))
|
|
518
|
+
throw new GraphIndexAbsentError("没有本地规则图索引(<配置根>/.omc/graph/graph.db);先运行 omc graph pull --env <env> 全量重建");
|
|
519
|
+
let db;
|
|
520
|
+
try {
|
|
521
|
+
const DatabaseSyncCtor = nodeSqlite().DatabaseSync;
|
|
522
|
+
db = new DatabaseSyncCtor(dbPath, { readOnly: true });
|
|
523
|
+
const meta = {};
|
|
524
|
+
for (const row of db.prepare("SELECT key, value FROM meta").all())
|
|
525
|
+
meta[row.key] = row.value;
|
|
526
|
+
if (meta.schemaVersion !== GRAPH_SCHEMA_VERSION)
|
|
527
|
+
throw new GraphIndexAbsentError(`索引 schema 版本不匹配(${meta.schemaVersion} ≠ ${GRAPH_SCHEMA_VERSION});重跑 omc graph pull`);
|
|
528
|
+
const ruleCount = db.prepare("SELECT COUNT(*) AS n FROM rules").get().n;
|
|
529
|
+
const modelCount = db.prepare("SELECT COUNT(DISTINCT schema_code) AS n FROM rules").get().n;
|
|
530
|
+
const edgeRows = db.prepare("SELECT kind, COUNT(*) AS n FROM edges GROUP BY kind").all();
|
|
531
|
+
const edges = {};
|
|
532
|
+
for (const row of edgeRows)
|
|
533
|
+
edges[row.kind] = row.n;
|
|
534
|
+
const gapRows = db.prepare("SELECT kind, COUNT(*) AS n FROM gaps GROUP BY kind").all();
|
|
535
|
+
const byKind = {};
|
|
536
|
+
let gapCount = 0;
|
|
537
|
+
for (const row of gapRows) {
|
|
538
|
+
byKind[row.kind] = row.n;
|
|
539
|
+
gapCount += row.n;
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
db,
|
|
543
|
+
summary: {
|
|
544
|
+
environmentName: meta.environmentName ?? "",
|
|
545
|
+
applicationCode: meta.applicationCode ?? "",
|
|
546
|
+
identityFingerprint: meta.identityFingerprint ?? "",
|
|
547
|
+
pulledAtIso: meta.pulledAtIso ?? "",
|
|
548
|
+
freshness: gapCount === 0 ? "complete" : "partial",
|
|
549
|
+
models: modelCount,
|
|
550
|
+
rules: ruleCount,
|
|
551
|
+
edges,
|
|
552
|
+
gaps: { count: gapCount, byKind },
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
db?.close();
|
|
558
|
+
throw error instanceof GraphIndexAbsentError ? error : new GraphIndexAbsentError(`索引不可读(schema 不匹配或文件损坏):${error instanceof Error ? error.message : String(error)};重跑 omc graph pull`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
export function queryGraphReferences(kind, code) {
|
|
562
|
+
const { db, summary } = openGraphIndex();
|
|
563
|
+
try {
|
|
564
|
+
const rows = db
|
|
565
|
+
.prepare("SELECT schema_code, rule_code, node_code, node_type, via FROM edges WHERE kind = ? AND code = ? ORDER BY schema_code, rule_code, node_code, via")
|
|
566
|
+
.all(kind, code);
|
|
567
|
+
const matches = rows.map((row) => ({
|
|
568
|
+
schemaCode: row.schema_code,
|
|
569
|
+
ruleCode: row.rule_code,
|
|
570
|
+
nodeCode: row.node_code ?? "",
|
|
571
|
+
nodeType: row.node_type ?? "",
|
|
572
|
+
via: row.via,
|
|
573
|
+
}));
|
|
574
|
+
const gapped = summary.gaps.count > 0;
|
|
575
|
+
return {
|
|
576
|
+
kind,
|
|
577
|
+
code,
|
|
578
|
+
matches,
|
|
579
|
+
index: summary,
|
|
580
|
+
...(matches.length === 0
|
|
581
|
+
? {
|
|
582
|
+
note: gapped
|
|
583
|
+
? "零命中,但索引存在扫描缺口——不可作为「无引用」证明;先 omc graph pull 重建再查"
|
|
584
|
+
: "零命中=索引覆盖范围内没有引用它(规则面:service→methodMapping.serviceCode;model→targetObjectCode/methodMapping.schemaCode;field→dataActions.targetDataItem/映射 bizCode;数据规则面:propertyCodes 目标与 options {字段} 引用;视图面:列/排序/条件的 propertyCode。不含规则条件叶子/流程绑定)",
|
|
585
|
+
}
|
|
586
|
+
: {}),
|
|
587
|
+
...(gapped
|
|
588
|
+
? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口(${Object.entries(summary.gaps.byKind).map(([k, v]) => `${k}:${v}`).join(", ")});命中列表可能不完整` }
|
|
589
|
+
: {}),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
db.close();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
export function readGraphStatus() {
|
|
597
|
+
const { db, summary } = openGraphIndex();
|
|
598
|
+
try {
|
|
599
|
+
const gaps = db.prepare("SELECT kind, ref, note FROM gaps ORDER BY kind, ref LIMIT 50").all();
|
|
600
|
+
return { index: summary, dbBytes: statSync(graphDbPath()).size, gaps };
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
db.close();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Bounded FTS5 lookup over rule code + name — the "find the rule about X"
|
|
608
|
+
* entry point (references/impact need an exact code; search gets you the
|
|
609
|
+
* code). Tokens are quoted (no FTS syntax injection); the LAST token becomes
|
|
610
|
+
* a prefix match (`引用*`), which is also what makes CJK usable: unicode61
|
|
611
|
+
* stores a CJK run as one token, so a full-run query hits only by prefix.
|
|
612
|
+
*/
|
|
613
|
+
export function searchGraphRules(term, limit = 20) {
|
|
614
|
+
const { db, summary } = openGraphIndex();
|
|
615
|
+
try {
|
|
616
|
+
const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
|
|
617
|
+
let matches = [];
|
|
618
|
+
if (tokens.length > 0) {
|
|
619
|
+
const quoted = tokens.map((token, index) => `"${token.replaceAll('"', '""')}"${index === tokens.length - 1 ? "*" : ""}`).join(" ");
|
|
620
|
+
const rows = db
|
|
621
|
+
.prepare(`SELECT r.schema_code, r.rule_code, r.name, r.enabled, r.status
|
|
622
|
+
FROM rules_fts JOIN rules r ON r.rowid = rules_fts.rowid
|
|
623
|
+
WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ?`)
|
|
624
|
+
.all(quoted, limit);
|
|
625
|
+
const refsFor = db.prepare("SELECT kind, code FROM edges WHERE schema_code = ? AND rule_code = ? ORDER BY kind, code");
|
|
626
|
+
matches = rows.map((row) => {
|
|
627
|
+
const refs = { service: [], model: [], field: [] };
|
|
628
|
+
for (const edge of refsFor.all(row.schema_code, row.rule_code))
|
|
629
|
+
if (edge.kind in refs)
|
|
630
|
+
refs[edge.kind].push(edge.code);
|
|
631
|
+
return {
|
|
632
|
+
schemaCode: row.schema_code,
|
|
633
|
+
ruleCode: row.rule_code,
|
|
634
|
+
name: row.name ?? "",
|
|
635
|
+
enabled: row.enabled === null ? null : row.enabled === 1,
|
|
636
|
+
status: row.status,
|
|
637
|
+
refs,
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const gapped = summary.gaps.count > 0;
|
|
642
|
+
return {
|
|
643
|
+
term,
|
|
644
|
+
rules: matches,
|
|
645
|
+
index: summary,
|
|
646
|
+
...(matches.length === 0
|
|
647
|
+
? { note: tokens.length === 0 ? "检索词为空" : "没有命中规则(code+name 全文;引用检索用 references/impact <kind> <code>)" }
|
|
648
|
+
: {}),
|
|
649
|
+
...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口;命中列表可能不完整` } : {}),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
db.close();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Reverse blast radius over the reference edges: hop 1 = rules referencing the
|
|
658
|
+
* given code (what `references` answers), hop 2 = everything those rules touch
|
|
659
|
+
* (services/models/fields). "Change field X → these rules fire, and they also
|
|
660
|
+
* read/write these codes — verify those too." Bounded: 50 rules, 20 codes per
|
|
661
|
+
* kind; the same fail-closed gap semantics as references apply.
|
|
662
|
+
*/
|
|
663
|
+
export function impactGraph(kind, code) {
|
|
664
|
+
const { db, summary } = openGraphIndex();
|
|
665
|
+
try {
|
|
666
|
+
const hop1 = db
|
|
667
|
+
.prepare("SELECT DISTINCT schema_code, rule_code FROM edges WHERE kind = ? AND code = ? AND node_type NOT IN ('datarule', 'listview') ORDER BY schema_code, rule_code LIMIT 51")
|
|
668
|
+
.all(kind, code);
|
|
669
|
+
const dataruleHits = db
|
|
670
|
+
.prepare("SELECT DISTINCT schema_code, node_code AS targets, via FROM edges WHERE kind = ? AND code = ? AND node_type = 'datarule' ORDER BY schema_code, node_code, via LIMIT 21")
|
|
671
|
+
.all(kind, code);
|
|
672
|
+
const listViewHits = db
|
|
673
|
+
.prepare("SELECT DISTINCT schema_code, node_code AS view_code, via FROM edges WHERE kind = ? AND code = ? AND node_type = 'listview' ORDER BY schema_code, node_code, via LIMIT 21")
|
|
674
|
+
.all(kind, code);
|
|
675
|
+
const capped = hop1.length > 50;
|
|
676
|
+
const refsFor = db.prepare("SELECT kind, code, via FROM edges WHERE schema_code = ? AND rule_code = ? AND node_type <> 'datarule' ORDER BY kind, code, via");
|
|
677
|
+
const ruleName = db.prepare("SELECT name FROM rules WHERE schema_code = ? AND rule_code = ?");
|
|
678
|
+
const services = new Set();
|
|
679
|
+
const models = new Set();
|
|
680
|
+
const fields = new Set();
|
|
681
|
+
const rules = hop1.slice(0, 50).map((row) => {
|
|
682
|
+
const via = [];
|
|
683
|
+
const touches = { service: [], model: [], field: [] };
|
|
684
|
+
for (const edge of refsFor.all(row.schema_code, row.rule_code)) {
|
|
685
|
+
if (edge.kind === kind && edge.code === code) {
|
|
686
|
+
if (!via.includes(edge.via))
|
|
687
|
+
via.push(edge.via);
|
|
688
|
+
continue; // the queried code itself is not "additional blast radius"
|
|
689
|
+
}
|
|
690
|
+
const bucket = touches[edge.kind];
|
|
691
|
+
if (bucket && !bucket.includes(edge.code))
|
|
692
|
+
bucket.push(edge.code);
|
|
693
|
+
if (edge.kind === "service")
|
|
694
|
+
services.add(edge.code);
|
|
695
|
+
else if (edge.kind === "model")
|
|
696
|
+
models.add(edge.code);
|
|
697
|
+
else if (edge.kind === "field")
|
|
698
|
+
fields.add(edge.code);
|
|
699
|
+
}
|
|
700
|
+
for (const key of ["service", "model", "field"]) {
|
|
701
|
+
const bucket = touches[key];
|
|
702
|
+
if (bucket.length > 20)
|
|
703
|
+
touches[key] = bucket.slice(0, 20);
|
|
704
|
+
}
|
|
705
|
+
const name = ruleName.get(row.schema_code, row.rule_code)?.name ?? "";
|
|
706
|
+
return { schemaCode: row.schema_code, ruleCode: row.rule_code, name, matchedVia: via, touches };
|
|
707
|
+
});
|
|
708
|
+
const gapped = summary.gaps.count > 0;
|
|
709
|
+
return {
|
|
710
|
+
kind,
|
|
711
|
+
code,
|
|
712
|
+
rules,
|
|
713
|
+
datarules: dataruleHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, targets: hit.targets ?? "", via: hit.via })),
|
|
714
|
+
listviews: listViewHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, viewCode: hit.view_code ?? "", via: hit.via })),
|
|
715
|
+
totals: { rules: hop1.length, datarules: dataruleHits.length, listviews: listViewHits.length, touchedServices: services.size, touchedModels: models.size, touchedFields: fields.size },
|
|
716
|
+
index: summary,
|
|
717
|
+
...(hop1.length === 0 && dataruleHits.length === 0 && listViewHits.length === 0
|
|
718
|
+
? {
|
|
719
|
+
note: gapped
|
|
720
|
+
? "零命中,但索引存在扫描缺口——不可作为「无影响」证明;先 omc graph pull 重建再查"
|
|
721
|
+
: "零命中=索引覆盖范围内没有引用它(规则面+数据规则面+视图列/排序/条件面;流程绑定暂不在索引内)",
|
|
722
|
+
}
|
|
723
|
+
: {}),
|
|
724
|
+
...(capped || dataruleHits.length > 20 || listViewHits.length > 20
|
|
725
|
+
? { truncated: true, note: `命中超过上限已截断(规则 50/实际 ${hop1.length},数据规则 20/实际 ${dataruleHits.length},视图 20/实际 ${listViewHits.length});完整规则清单用 references` }
|
|
726
|
+
: {}),
|
|
727
|
+
...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口(${Object.entries(summary.gaps.byKind).map(([k, v]) => `${k}:${v}`).join(", ")});影响面可能不完整` } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
finally {
|
|
731
|
+
db.close();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
//# sourceMappingURL=graph.js.map
|
|
@@ -4,6 +4,7 @@ import { capabilityArg, environmentArg } from "../args.js";
|
|
|
4
4
|
import { governedRead } from "../read.js";
|
|
5
5
|
import { writeTool } from "../write.js";
|
|
6
6
|
import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../json-column.js";
|
|
7
|
+
import { flattenTreeRows, resolveApplicationCode } from "../support.js";
|
|
7
8
|
import { executeRuleSaveWrite, ruleNodeGraphArg } from "../rule-support.js";
|
|
8
9
|
import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
|
|
9
10
|
import { resolveRuleRowId } from "../identity.js";
|
|
@@ -135,8 +136,13 @@ export function registerBusinessRuleLane(tools) {
|
|
|
135
136
|
// schema×ruleCode 组合(171 次 rule.get / 229KB)。没有引用检索面时,
|
|
136
137
|
// 全库枚举是理性选择——所以给它一个。客户端聚合:模型清单 → 每模型
|
|
137
138
|
// rule.list → 每规则 rule.get → 图内匹配,一次调用、只回命中行。
|
|
139
|
+
// Model enumeration mirrors `model.list`: the bare modelList endpoint
|
|
140
|
+
// returns EMPTY rows on a real platform (live 2026-10-29: a full scan
|
|
141
|
+
// "found" 0 models → false zero-hit), so walk the application tree.
|
|
138
142
|
const scoped = args.schemaCodes === undefined
|
|
139
|
-
? wireRows((await wireCall(session, { path: ENDPOINTS.
|
|
143
|
+
? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: await resolveApplicationCode(session, undefined).catch(() => "") } })).data))
|
|
144
|
+
.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
|
|
145
|
+
.map((row) => String(row.code))
|
|
140
146
|
: (Array.isArray(args.schemaCodes) ? args.schemaCodes : [args.schemaCodes]);
|
|
141
147
|
const wanted = args.code;
|
|
142
148
|
const matches = [];
|
package/dist/recipes/field.js
CHANGED
|
@@ -277,8 +277,62 @@ const formulaAggregationCrossModel = Object.freeze({
|
|
|
277
277
|
requiresEnvironmentDerivedIds: true,
|
|
278
278
|
prerequisiteData: { source: "field.list(published:true) ×2(来源模型 + 目标模型)", fields: ["source.model", "source.dataItemCode", "source.matchField", "target.matchField"] },
|
|
279
279
|
});
|
|
280
|
+
/**
|
|
281
|
+
* r29(2026-10-29 中冶终轮)+ 用户领域知识:外部数据(企查查/NC 等)在表单上
|
|
282
|
+
* 「填表时即带出」的标准模式——两段式组合。此前三轮探针(r24/r28b/r29)都发明
|
|
283
|
+
* 了「按钮触发规则回写」的重方案,因为没人知道标准模式;r29 实证 GetList 规则
|
|
284
|
+
* 实时调接口替换数据源在 1.1.25 不成立(CREATE 节点被校验器禁止指向本模型),
|
|
285
|
+
* 但「数据段落表 + 表单段关联下拉查表带出」完全成立且是平台正解。
|
|
286
|
+
*
|
|
287
|
+
* 2026-10-29 用户定案修正:GetList/Load 规则**实时调接口也是成立的**——触发点是
|
|
288
|
+
* 填表时**前端发起**的关联控件查询链(接口方法配 frontEndExtensionEnable:true,
|
|
289
|
+
* 前端直调);r29 判「不成立」是因为用 data.list(后端 REST 查询)验证,该链不
|
|
290
|
+
* 执行 BIZ_ACTION 出参替换。验证必须走门户填表(关键字回车→候选→选中带出)。
|
|
291
|
+
*/
|
|
292
|
+
const externalLookupModel = Object.freeze({
|
|
293
|
+
id: "external-lookup-model",
|
|
294
|
+
title: "外部数据映射模型:接口数据落表 + 关联单选下拉带出(填表时即映射)",
|
|
295
|
+
aliases: ["外部数据源", "接口映射模型", "企查查下拉", "关联带出", "映射模型", "外部数据带出"],
|
|
296
|
+
status: "requires-prior-readback",
|
|
297
|
+
tool: "field.create",
|
|
298
|
+
args: {
|
|
299
|
+
environment,
|
|
300
|
+
schemaCode: model,
|
|
301
|
+
code: "ext_lookup_ref",
|
|
302
|
+
name: "外部数据引用",
|
|
303
|
+
propertyType: "WORK_SHEET",
|
|
304
|
+
options: {
|
|
305
|
+
schemaCode: "<映射模型code>",
|
|
306
|
+
queryCode: "<映射模型code>",
|
|
307
|
+
displayField: "<映射模型显示字段>",
|
|
308
|
+
mappings: [{ source: "<映射模型.业务字段>", target: "<本模型.落点字段>" }],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
preconditions: [
|
|
312
|
+
"映射模型已建且字段与接口返回字段一一对应(如 qcc_company: company_name/credit_code)",
|
|
313
|
+
"数据段已通:接口数据已能落进映射模型表(见 conditionalRequirements 第 1 条的三个已验证组合)",
|
|
314
|
+
],
|
|
315
|
+
conditionalRequirements: [
|
|
316
|
+
"**数据段首选(平台标准模式,2026-10-29 playwright 门户实证定案)**:映射模型的 GetList/Load 规则挂 BIZ_ACTION 实时调接口(接口方法配 frontEndExtensionEnable:true)——门户关联控件**打开即触发**前端查询链→GetList 规则→BIZ_ACTION 实时调外部接口(引擎日志 bizBusService.invoke + 外部网关日志双侧铁证)。**验证必须走门户填表;data.list 后端查询不反映此链,别用它判死**(r29 曾据此误判不成立)。配置侧三个收尾点:① WORK_SHEET 字段必须带 relativeCode(仅 options.schemaCode 发布表单会 301015 关联业务模型为空);② GetList 规则的 BIZ_ACTION 入参映射要接前端搜索词(实测未配词时 invoke 参数为空 {});③ 出参→列表行的映射配完候选才回显(未配时弹窗 Total 0 但接口已调)。「下拉框形态(输关键字回车查询)」是表单 attributes 层的控件形态设置,默认弹窗形态。备选(不需要实时时):预置 Create/Update 规则落映射表 / TIMING 定时同步。图内「BIZ_ACTION→CREATE 落表→再查」回环在 1.1.25 不可表达,不要走这条岔路。",
|
|
317
|
+
"**表单段是平台原生能力**:关联单选指向映射模型后,门户上输入关键字回车即查映射表、选中即 mappings 带出——前台填表时映射自然出现,无需按钮/脚本。",
|
|
318
|
+
"下拉框形态(输入关键字回车查询而非弹窗选择)在表单 attributes 层配置(form.draft 改控件形态),不在字段 options。",
|
|
319
|
+
"映射模型表要有数据下拉才有选项——空表=空下拉;数据段先通再配表单段。",
|
|
320
|
+
],
|
|
321
|
+
priorToolReferences: [
|
|
322
|
+
{ tool: "model.create", args: { environment }, purpose: "建映射模型(字段与接口返回一一对应)" },
|
|
323
|
+
{ tool: "rule.save", args: { environment }, purpose: "数据段规则(三选一,见 conditionalRequirements)" },
|
|
324
|
+
],
|
|
325
|
+
dryRun: { supported: true, command: `omc call field.create --env ${environment} --dry-run --args '<recipe.args>'`, notes: "校验 WORK_SHEET options 与 mappings 形状" },
|
|
326
|
+
expectedReadback: { readback: { field: { propertyType: "WORK_SHEET", options: { mappings: [{ source: "<映射模型.业务字段>" }] } } }, next: ["field.publish", "form.draft(控件形态配下拉框)", "form.publish", "门户填表验证:输关键字回车→选中→字段带出"] },
|
|
327
|
+
refusalRecovery: [
|
|
328
|
+
{ code: "field-family-options-invalid", action: "WORK_SHEET options 必带 schemaCode/queryCode/displayField;mappings 用数组或 JSON 字符串均可(CLI 就地归一)" },
|
|
329
|
+
...commonRecovery,
|
|
330
|
+
],
|
|
331
|
+
notes: "r29 终轮定案:此模式是「外部数据填表带出」的平台正解;按钮触发回写是下策(多一次交互且体验差)。映射模型数据段的三个组合全部基于已验证的出参回写三前提(平铺端点/预置规则上下文/data 触发)。",
|
|
332
|
+
requiresEnvironmentDerivedIds: true,
|
|
333
|
+
});
|
|
280
334
|
export const FIELD_METADATA = Object.freeze({
|
|
281
335
|
"field.update": Object.freeze({ recipes: [fieldSerialNumber, formConditionalRequired, formAlwaysReadonly, formConditionalReadonly], relatedTools: ["field.list", "field.publish", "form.draft", "form.publish"] }),
|
|
282
|
-
"field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown, formulaAggregationCrossModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
|
|
336
|
+
"field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, childTable, businessModelDropdown, formulaAggregationCrossModel, externalLookupModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
|
|
283
337
|
});
|
|
284
338
|
//# sourceMappingURL=field.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"test:live-permission": "node conformance/live-permission-acceptance.mjs",
|
|
42
42
|
"sandbox:dogfood": "node conformance/setup-dogfood-sandbox.mjs",
|
|
43
43
|
"payload:report": "tsx scripts/payload-report.ts",
|
|
44
|
-
"prepublishOnly": "npm run build"
|
|
44
|
+
"prepublishOnly": "npm run build",
|
|
45
|
+
"build:fast": "tsc --project tsconfig.build.json --incremental",
|
|
46
|
+
"typecheck:fast": "tsc --noEmit --incremental --tsBuildInfoFile .typecheck.tsbuildinfo"
|
|
45
47
|
},
|
|
46
48
|
"engines": {
|
|
47
49
|
"node": ">=20"
|
|
@@ -50,9 +52,9 @@
|
|
|
50
52
|
"access": "public"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@stalfh233/omc-fake-platform": "0.
|
|
55
|
+
"@stalfh233/omc-fake-platform": "0.5.0",
|
|
54
56
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.
|
|
57
|
+
"omc-core": "0.5.0",
|
|
56
58
|
"proper-lockfile": "4.1.2",
|
|
57
59
|
"write-file-atomic": "6.0.0",
|
|
58
60
|
"zod": "^3.23.8",
|
|
@@ -21,6 +21,7 @@ omc help omc-write-hazards
|
|
|
21
21
|
- `form.draft rebuild:true` 会重建布局,但会保留已存在的非结构性控件 options;Sheet201 columns 以最新 subSchema 为准。
|
|
22
22
|
- 空布局 `[]`/`{}` 默认拒绝;只有显式 `allowEmptyLayout:true` 才能清空。
|
|
23
23
|
- **列数由 viewJson 行分组决定,不是 span**(2026-10-27 r24 真机实证):`viewJson` 是行的数组,一行内 N 个 code 即 N 列;`span` 只是 24 栅格下的控件宽度。只把字段 `span` 改 8 而不动行分组 → 控件变窄但每行仍两个,不是三列。改列数必须同时改行分组(三列=每行 3 个 code,字段 span=8)→ `form.publish` → `form.get` 回读 viewJson 核对。系统字段行与业务字段行分开调整。
|
|
24
|
+
- **下拉/单选/复选的选项在表单控件有两份独立副本**(2026-10-29 治豪 issue 同源事实,8.6/1.1.25 机制一致):数据项 `options.optionsSet.custom` 之外,表单控件的 `options.options.custom` 与 `options.optionsSet.custom` 是独立副本(× draft/published 双版本)——**只改字段不重存表单,门户/设计器不会变**。标准生效链:`field.update`(选项)→ `field.publish` → `form.draft`(rebuild 重建控件同步副本)→ `form.publish`;验收以表单面回读为准,数据项一致 ≠ 表单生效。
|
|
24
25
|
- **模型不支持跨应用迁移**(1.1.25 真机实证:`folder-other-application` 拒绝):模型建错应用时走「新应用重建 + 关联引用改指 + 旧模型删除」,不存在物理迁移命令。
|
|
25
26
|
- **流程编排器是线性的,无并行会签**(1.1.25 实证):多部门会签只能建成顺序节点链;需求里的「并行会签」按顺序节点落地并在交付说明里披露。
|
|
26
27
|
- **BIZ_ACTION 服务出参只认响应 JSON 根级平铺键**(p7/r26 双实证):嵌套在信封里(如 `{data:{level}}`)静默映射不上、目标字段全空——先把这类判断列进方案:接口形态要在「平铺 vs 信封」间做选择时,需要回写本平台的出参一律平铺。外部系统本身回信封的,扩展整形转发或放弃出参回写。
|