@stalfh233/omc-cli 0.6.16 → 0.6.18
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/batch-call.js +39 -5
- package/dist/capability-overview.js +2 -2
- package/dist/cli.js +25 -13
- package/dist/compute.js +1 -1
- package/dist/field-change.js +7 -1
- package/dist/field-families.js +102 -1
- package/dist/graph.js +142 -24
- package/dist/lanes/app.js +17 -4
- package/dist/lanes/field.js +118 -5
- package/dist/lanes/model-contract.js +1 -1
- package/dist/lanes/model.js +57 -30
- package/dist/lanes/online-js-contract.js +1 -1
- package/dist/lanes/online-js.js +41 -9
- package/dist/lanes/organization.js +9 -2
- package/dist/lanes/programming.js +67 -14
- package/dist/lanes/runtime-data.js +177 -20
- package/dist/lanes/workflow-contract.js +5 -1
- package/dist/lanes/workflow.js +104 -3
- package/dist/mcp-graph.js +45 -15
- package/dist/programming-workflow.js +16 -3
- package/dist/read-shapes.js +3 -3
- package/dist/rollback.js +54 -19
- package/dist/rule-when.js +42 -12
- package/dist/skills.js +1 -1
- package/dist/workflow-support.js +2 -1
- package/dist/write.js +6 -2
- package/package.json +3 -3
package/dist/graph.js
CHANGED
|
@@ -46,10 +46,25 @@ function nodeSqlite() {
|
|
|
46
46
|
* staleness is handled by reporting pulledAt + identity, refreshed by an
|
|
47
47
|
* explicit re-pull (no incremental watermarks, no write-back hooks).
|
|
48
48
|
*/
|
|
49
|
-
const GRAPH_SCHEMA_VERSION = "
|
|
49
|
+
const GRAPH_SCHEMA_VERSION = "5";
|
|
50
50
|
const STALE_LOCK_MS = 15 * 60 * 1000;
|
|
51
51
|
export const DEFAULT_PULL_CONCURRENCY = 16;
|
|
52
52
|
export const MAX_PULL_CONCURRENCY = 32;
|
|
53
|
+
/** 索引超过这个天数就提示刷新(配置类查询,1 天足够新)。 */
|
|
54
|
+
export const STALE_INDEX_DAYS = 1;
|
|
55
|
+
export const STALE_AFTER_MINUTES = STALE_INDEX_DAYS * 24 * 60;
|
|
56
|
+
export function indexAgeDays(pulledAtIso, now = Date.now()) {
|
|
57
|
+
const pulled = Date.parse(pulledAtIso);
|
|
58
|
+
if (Number.isNaN(pulled))
|
|
59
|
+
return 0;
|
|
60
|
+
return Math.max(0, Math.floor((now - pulled) / 86_400_000));
|
|
61
|
+
}
|
|
62
|
+
export function indexAgeMinutes(pulledAtIso, now = Date.now()) {
|
|
63
|
+
const pulled = Date.parse(pulledAtIso);
|
|
64
|
+
if (Number.isNaN(pulled))
|
|
65
|
+
return 0;
|
|
66
|
+
return Math.max(0, Math.floor((now - pulled) / 60_000));
|
|
67
|
+
}
|
|
53
68
|
export class GraphIndexAbsentError extends Error {
|
|
54
69
|
constructor(message) {
|
|
55
70
|
super(message);
|
|
@@ -179,13 +194,39 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
|
|
|
179
194
|
// zero-guard) wedge the scheduler.
|
|
180
195
|
const scope = await resolveApplicationScope(session, undefined).catch(() => undefined);
|
|
181
196
|
const applicationCode = scope?.code ?? "";
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
const gaps = [];
|
|
198
|
+
const treeRows = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
|
|
199
|
+
const modelEntities = treeRows.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "").map((row) => ({ schemaCode: String(row.code), name: String(row.name ?? row.code) })).sort((a, b) => a.schemaCode.localeCompare(b.schemaCode));
|
|
200
|
+
const schemaCodes = modelEntities.map((row) => row.schemaCode);
|
|
201
|
+
const fields = [];
|
|
202
|
+
const services = [];
|
|
203
|
+
await Promise.all(schemaCodes.map(async (schemaCode) => {
|
|
204
|
+
try {
|
|
205
|
+
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const code = String(row.code ?? row.propertyCode ?? "");
|
|
208
|
+
if (code)
|
|
209
|
+
fields.push({ schemaCode, code, name: String(row.name ?? row.propertyName ?? code) });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
gaps.push({ kind: "field-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
try {
|
|
217
|
+
await wireCall(session, { path: ENDPOINTS.bizServiceCategoryList });
|
|
218
|
+
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceListByCategoryId, query: { categoryId: "" } })).data);
|
|
219
|
+
for (const row of rows) {
|
|
220
|
+
const code = String(row.code ?? "");
|
|
221
|
+
if (code)
|
|
222
|
+
services.push({ code, name: String(row.name ?? code) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
gaps.push({ kind: "service-list", ref: "all", note: error instanceof Error ? error.message : String(error) });
|
|
227
|
+
}
|
|
186
228
|
const rules = [];
|
|
187
229
|
const edges = [];
|
|
188
|
-
const gaps = [];
|
|
189
230
|
let parseFailures = 0;
|
|
190
231
|
let dataruleCount = 0;
|
|
191
232
|
let viewCount = 0;
|
|
@@ -370,11 +411,15 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
|
|
|
370
411
|
identityFingerprint: identity,
|
|
371
412
|
environmentName: session.environmentName,
|
|
372
413
|
applicationCode,
|
|
414
|
+
coverage: { applicationCodes: [applicationCode] },
|
|
373
415
|
...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
|
|
374
416
|
pulledAtIso,
|
|
375
417
|
},
|
|
376
418
|
rules,
|
|
377
419
|
edges,
|
|
420
|
+
models: modelEntities,
|
|
421
|
+
fields: fields.sort((a, b) => `${a.schemaCode}/${a.code}`.localeCompare(`${b.schemaCode}/${b.code}`)),
|
|
422
|
+
services: services.sort((a, b) => a.code.localeCompare(b.code)),
|
|
378
423
|
gaps,
|
|
379
424
|
scanned: { models: schemaCodes.length, rules: rules.length, datarules: dataruleCount, views: viewCount, parseFailures },
|
|
380
425
|
timings: { listMs, readMs, totalMs: Date.now() - totalStart },
|
|
@@ -489,7 +534,10 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
|
|
|
489
534
|
via TEXT NOT NULL
|
|
490
535
|
);
|
|
491
536
|
CREATE INDEX edges_lookup ON edges(kind, code);
|
|
492
|
-
|
|
537
|
+
CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
|
|
538
|
+
CREATE TABLE models (schema_code TEXT PRIMARY KEY, name TEXT NOT NULL);
|
|
539
|
+
CREATE TABLE fields (schema_code TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL);
|
|
540
|
+
CREATE TABLE services (code TEXT PRIMARY KEY, name TEXT NOT NULL);
|
|
493
541
|
`);
|
|
494
542
|
const insertMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)");
|
|
495
543
|
// `applicationResolution` is an object; String(value) would persist
|
|
@@ -506,12 +554,21 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
|
|
|
506
554
|
const insertGap = db.prepare("INSERT INTO gaps (kind, ref, note) VALUES (?, ?, ?)");
|
|
507
555
|
for (const gap of data.gaps)
|
|
508
556
|
insertGap.run(gap.kind, gap.ref, gap.note ?? null);
|
|
557
|
+
const insertModel = db.prepare("INSERT INTO models (schema_code, name) VALUES (?, ?)");
|
|
558
|
+
for (const row of data.models)
|
|
559
|
+
insertModel.run(row.schemaCode, row.name);
|
|
560
|
+
const insertField = db.prepare("INSERT INTO fields (schema_code, code, name) VALUES (?, ?, ?)");
|
|
561
|
+
for (const row of data.fields)
|
|
562
|
+
insertField.run(row.schemaCode, row.code, row.name);
|
|
563
|
+
const insertService = db.prepare("INSERT INTO services (code, name) VALUES (?, ?)");
|
|
564
|
+
for (const row of data.services)
|
|
565
|
+
insertService.run(row.code, row.name);
|
|
509
566
|
// FTS5 mirrors rules (code+name) for `graph search`; one rebuild after
|
|
510
567
|
// the bulk insert is cheaper than per-row triggers on a wholesale rebuild.
|
|
511
568
|
db.exec(`
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
569
|
+
CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
|
|
570
|
+
INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
|
|
571
|
+
`);
|
|
515
572
|
db.exec("COMMIT;");
|
|
516
573
|
}
|
|
517
574
|
finally {
|
|
@@ -544,6 +601,11 @@ function openGraphIndex() {
|
|
|
544
601
|
throw new GraphIndexAbsentError(`索引 schema 版本不匹配(${meta.schemaVersion} ≠ ${GRAPH_SCHEMA_VERSION});重跑 omc graph pull`);
|
|
545
602
|
const ruleCount = db.prepare("SELECT COUNT(*) AS n FROM rules").get().n;
|
|
546
603
|
const modelCount = db.prepare("SELECT COUNT(DISTINCT schema_code) AS n FROM rules").get().n;
|
|
604
|
+
const entityCounts = {
|
|
605
|
+
models: db.prepare("SELECT COUNT(*) AS n FROM models").get().n,
|
|
606
|
+
fields: db.prepare("SELECT COUNT(*) AS n FROM fields").get().n,
|
|
607
|
+
services: db.prepare("SELECT COUNT(*) AS n FROM services").get().n,
|
|
608
|
+
};
|
|
547
609
|
const edgeRows = db.prepare("SELECT kind, COUNT(*) AS n FROM edges GROUP BY kind").all();
|
|
548
610
|
const edges = {};
|
|
549
611
|
for (const row of edgeRows)
|
|
@@ -555,19 +617,33 @@ function openGraphIndex() {
|
|
|
555
617
|
byKind[row.kind] = row.n;
|
|
556
618
|
gapCount += row.n;
|
|
557
619
|
}
|
|
620
|
+
const pulledAtIso = meta.pulledAtIso ?? "";
|
|
621
|
+
const ageDays = indexAgeDays(pulledAtIso);
|
|
622
|
+
const ageMinutes = indexAgeMinutes(pulledAtIso);
|
|
623
|
+
const coverage = { applicationCodes: meta.applicationCode ? [meta.applicationCode] : [] };
|
|
558
624
|
return {
|
|
559
625
|
db,
|
|
560
626
|
summary: {
|
|
561
627
|
environmentName: meta.environmentName ?? "",
|
|
562
628
|
applicationCode: meta.applicationCode ?? "",
|
|
629
|
+
coverage,
|
|
563
630
|
// Persisted as JSON by the writer; a legacy index (pre-0.5.4) has no
|
|
564
631
|
// such key, so this stays optional rather than failing the read.
|
|
565
632
|
...(meta.applicationResolution ? { applicationResolution: parseApplicationResolution(meta.applicationResolution) } : {}),
|
|
566
633
|
identityFingerprint: meta.identityFingerprint ?? "",
|
|
567
|
-
pulledAtIso
|
|
634
|
+
pulledAtIso,
|
|
568
635
|
freshness: gapCount === 0 ? "complete" : "partial",
|
|
636
|
+
ageDays,
|
|
637
|
+
ageMinutes,
|
|
638
|
+
staleAfterMinutes: STALE_AFTER_MINUTES,
|
|
639
|
+
// r31a-F16:旧索引 + complete 标签 = 静默错答。给可直接照抄的刷新命令,
|
|
640
|
+
// 并明确「complete 只指扫描无缺口」。
|
|
641
|
+
...(ageDays >= STALE_INDEX_DAYS
|
|
642
|
+
? { staleWarning: `索引是 ${ageDays} 天前的快照(pulledAt ${pulledAtIso});freshness:"complete" 只表示扫描无缺口、不表示最新。先 \`omc graph pull --env ${meta.environmentName ?? "<env>"}\` 再查,或给结论加时效限定` }
|
|
643
|
+
: {}),
|
|
569
644
|
models: modelCount,
|
|
570
645
|
rules: ruleCount,
|
|
646
|
+
entities: entityCounts,
|
|
571
647
|
edges,
|
|
572
648
|
gaps: { count: gapCount, byKind },
|
|
573
649
|
},
|
|
@@ -578,6 +654,17 @@ function openGraphIndex() {
|
|
|
578
654
|
throw error instanceof GraphIndexAbsentError ? error : new GraphIndexAbsentError(`索引不可读(schema 不匹配或文件损坏):${error instanceof Error ? error.message : String(error)};重跑 omc graph pull`);
|
|
579
655
|
}
|
|
580
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* r35a-F4 / r35a-F4b(真机 2026-11-06):探针拿 `matches:[]` 差点把一个**被表单引用**
|
|
659
|
+
* 的字段判成「可安全删除」,紧接着发现 `graph impact` 的 `totals` 全 0 也有同样误读风险。
|
|
660
|
+
* 三个面(CLI references / CLI impact / MCP explore)必须共用同一份声明——
|
|
661
|
+
* 分散定义就会被漏掉一处(MCP 面第一版就漏了)。
|
|
662
|
+
*/
|
|
663
|
+
export const GRAPH_COVERAGE = Object.freeze({
|
|
664
|
+
scanned: Object.freeze(["businessrule", "datarule", "listview"]),
|
|
665
|
+
notScanned: Object.freeze(["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"]),
|
|
666
|
+
note: "零命中/全零 totals 只代表 scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
|
|
667
|
+
});
|
|
581
668
|
/**
|
|
582
669
|
* Zero-hit disambiguation (2026-10-31 capability-test friction, hit twice in
|
|
583
670
|
* one session): querying `impact field mcc_supplier` returns an empty result
|
|
@@ -611,11 +698,7 @@ export function queryGraphReferences(kind, code) {
|
|
|
611
698
|
// 字段判成「可安全删除」——note 只说了「不含规则条件叶子/流程绑定」,从没
|
|
612
699
|
// 明说表单与流程权限表不在扫描范围。散文字段不承担安全契约:这里给结构化
|
|
613
700
|
// coverage,让「没人引用」与「这一面没扫」在机器可读层面可分。
|
|
614
|
-
const coverage =
|
|
615
|
-
scanned: ["businessrule", "datarule", "listview"],
|
|
616
|
-
notScanned: ["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"],
|
|
617
|
-
note: "matches:[] 只代表 scanned 面零命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除",
|
|
618
|
-
};
|
|
701
|
+
const coverage = GRAPH_COVERAGE;
|
|
619
702
|
return {
|
|
620
703
|
kind,
|
|
621
704
|
code,
|
|
@@ -626,7 +709,7 @@ export function queryGraphReferences(kind, code) {
|
|
|
626
709
|
? {
|
|
627
710
|
note: (gapped
|
|
628
711
|
? "零命中,但索引存在扫描缺口——不可作为「无引用」证明;先 omc graph pull 重建再查"
|
|
629
|
-
:
|
|
712
|
+
: `零命中=仅 scanned 面(规则/数据规则/视图)没有引用它;本索引覆盖应用:${summary.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有引用;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定可安全删除`) +
|
|
630
713
|
(zeroHitKindHint(db, kind, code, "references") ?? ""),
|
|
631
714
|
}
|
|
632
715
|
: {}),
|
|
@@ -643,7 +726,18 @@ export function readGraphStatus() {
|
|
|
643
726
|
const { db, summary } = openGraphIndex();
|
|
644
727
|
try {
|
|
645
728
|
const gaps = db.prepare("SELECT kind, ref, note FROM gaps ORDER BY kind, ref LIMIT 50").all();
|
|
646
|
-
|
|
729
|
+
const dbPath = graphDbPath();
|
|
730
|
+
return {
|
|
731
|
+
index: summary,
|
|
732
|
+
dbBytes: statSync(dbPath).size,
|
|
733
|
+
dbPath,
|
|
734
|
+
dbId: sha256(dbPath).slice(0, 12),
|
|
735
|
+
ageMinutes: summary.ageMinutes,
|
|
736
|
+
staleAfterMinutes: summary.staleAfterMinutes,
|
|
737
|
+
...(summary.staleWarning ? { warning: summary.staleWarning } : {}),
|
|
738
|
+
coverage: summary.coverage,
|
|
739
|
+
gaps,
|
|
740
|
+
};
|
|
647
741
|
}
|
|
648
742
|
finally {
|
|
649
743
|
db.close();
|
|
@@ -656,18 +750,23 @@ export function readGraphStatus() {
|
|
|
656
750
|
* a prefix match (`引用*`), which is also what makes CJK usable: unicode61
|
|
657
751
|
* stores a CJK run as one token, so a full-run query hits only by prefix.
|
|
658
752
|
*/
|
|
659
|
-
export function searchGraphRules(term, limit = 20) {
|
|
753
|
+
export function searchGraphRules(term, limit = 20, offset = 0) {
|
|
660
754
|
const { db, summary } = openGraphIndex();
|
|
661
755
|
try {
|
|
662
756
|
const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
|
|
663
757
|
let matches = [];
|
|
758
|
+
const entityMatches = {
|
|
759
|
+
model: { count: 0, total: 0, items: [] },
|
|
760
|
+
field: { count: 0, total: 0, items: [] },
|
|
761
|
+
service: { count: 0, total: 0, items: [] },
|
|
762
|
+
};
|
|
664
763
|
if (tokens.length > 0) {
|
|
665
764
|
const quoted = tokens.map((token, index) => `"${token.replaceAll('"', '""')}"${index === tokens.length - 1 ? "*" : ""}`).join(" ");
|
|
666
765
|
const rows = db
|
|
667
766
|
.prepare(`SELECT r.schema_code, r.rule_code, r.name, r.enabled, r.status
|
|
668
767
|
FROM rules_fts JOIN rules r ON r.rowid = rules_fts.rowid
|
|
669
|
-
WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ?`)
|
|
670
|
-
.all(quoted, limit);
|
|
768
|
+
WHERE rules_fts MATCH ? ORDER BY bm25(rules_fts), r.schema_code, r.rule_code LIMIT ? OFFSET ?`)
|
|
769
|
+
.all(quoted, limit, offset);
|
|
671
770
|
const refsFor = db.prepare("SELECT kind, code FROM edges WHERE schema_code = ? AND rule_code = ? ORDER BY kind, code");
|
|
672
771
|
matches = rows.map((row) => {
|
|
673
772
|
const refs = { service: [], model: [], field: [] };
|
|
@@ -683,20 +782,38 @@ export function searchGraphRules(term, limit = 20) {
|
|
|
683
782
|
refs,
|
|
684
783
|
};
|
|
685
784
|
});
|
|
785
|
+
const searchEntity = (countSql, rowsSql, params, map) => {
|
|
786
|
+
const total = Number(db.prepare(countSql).get(...params).n);
|
|
787
|
+
const rows = db.prepare(rowsSql.replace("LIMIT ?", "LIMIT ? OFFSET ?")).all(...params, limit, offset);
|
|
788
|
+
return {
|
|
789
|
+
count: rows.length,
|
|
790
|
+
total,
|
|
791
|
+
items: rows.map(map),
|
|
792
|
+
offset,
|
|
793
|
+
limit,
|
|
794
|
+
...(offset + rows.length < total ? { truncated: true, nextOffset: offset + rows.length } : {}),
|
|
795
|
+
};
|
|
796
|
+
};
|
|
797
|
+
entityMatches.model = searchEntity("SELECT COUNT(*) AS n FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0", "SELECT schema_code, name FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0 ORDER BY schema_code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, name: row.name }));
|
|
798
|
+
entityMatches.field = searchEntity("SELECT COUNT(*) AS n FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT schema_code, code, name FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY schema_code, code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, code: row.code, name: row.name }));
|
|
799
|
+
entityMatches.service = searchEntity("SELECT COUNT(*) AS n FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT code, name FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY code LIMIT ?", [term.trim(), term.trim()], (row) => ({ code: row.code, name: row.name }));
|
|
686
800
|
}
|
|
687
801
|
const gapped = summary.gaps.count > 0;
|
|
688
802
|
return {
|
|
689
803
|
term,
|
|
690
804
|
rules: matches,
|
|
805
|
+
matches: entityMatches,
|
|
691
806
|
index: summary,
|
|
692
|
-
|
|
807
|
+
coverage: { scanned: ["rule.code", "rule.name", "model.code", "model.name", "field.code", "field.name", "service.code", "service.name"], notScanned: ["rule.body", "form", "workflow.permissions"], note: "检索覆盖规则与实体的 code/name;规则体、表单与流程权限未扫描" },
|
|
808
|
+
...(term.trim() !== "" && !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(term.trim()) ? { note: "疑似业务词;本工具同时检索实体 name/code 与规则 code/name,引用明细请使用匹配到的 code 再查 references/impact" } : {}),
|
|
809
|
+
...(matches.length === 0 && entityMatches.model.total === 0 && entityMatches.field.total === 0 && entityMatches.service.total === 0
|
|
693
810
|
? {
|
|
694
811
|
note: tokens.length === 0
|
|
695
812
|
? "检索词为空"
|
|
696
813
|
// 实测摩擦(2026-10-30,zlt测试):中文子串 0 命中后没有下一步提示,
|
|
697
814
|
// 调用方在「换个词再试」里打转。把两个出口都写进 note:CJK 词首
|
|
698
815
|
// 通配 + 找引用走 references/impact。
|
|
699
|
-
:
|
|
816
|
+
: `实体检索:没有命中模型/字段/服务的 name 或 code;本索引覆盖应用:${summary.coverage.applicationCodes.join("、") || "(未知)"},不代表其他应用没有该实体。\n规则检索:没有命中规则 code/name;规则搜索只匹配词首(例如「增加预算」是一个词,「预算」匹配不到),请给全词、词首或英文 code 前缀。找"谁引用了 X"请走 references/impact <kind> <code>。`,
|
|
700
817
|
}
|
|
701
818
|
: {}),
|
|
702
819
|
...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口;命中列表可能不完整` } : {}),
|
|
@@ -767,11 +884,12 @@ export function impactGraph(kind, code) {
|
|
|
767
884
|
listviews: listViewHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, viewCode: hit.view_code ?? "", via: hit.via })),
|
|
768
885
|
totals: { rules: hop1.length, datarules: dataruleHits.length, listviews: listViewHits.length, touchedServices: services.size, touchedModels: models.size, touchedFields: fields.size },
|
|
769
886
|
index: summary,
|
|
887
|
+
coverage: GRAPH_COVERAGE,
|
|
770
888
|
...(hop1.length === 0 && dataruleHits.length === 0 && listViewHits.length === 0
|
|
771
889
|
? {
|
|
772
890
|
note: (gapped
|
|
773
891
|
? "零命中,但索引存在扫描缺口——不可作为「无影响」证明;先 omc graph pull 重建再查"
|
|
774
|
-
: "
|
|
892
|
+
: "零命中=仅 scanned 面(规则/数据规则/视图)没有影响;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定「删除无影响」") +
|
|
775
893
|
(zeroHitKindHint(db, kind, code, "impact") ?? ""),
|
|
776
894
|
}
|
|
777
895
|
: {}),
|
package/dist/lanes/app.js
CHANGED
|
@@ -5,10 +5,22 @@ import { governedRead } from "../read.js";
|
|
|
5
5
|
import { writeTool } from "../write.js";
|
|
6
6
|
import { defineTool, ToolRefusalError } from "../tool-types.js";
|
|
7
7
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
8
|
+
import { resolveApplicationScope } from "../support.js";
|
|
8
9
|
import { APP_LANE_CONTRACT } from "./app-contract.js";
|
|
9
10
|
import { readAppTree } from "./folder.js";
|
|
10
11
|
const APP_LANE = APP_LANE_CONTRACT.lane;
|
|
11
12
|
const APP_TOOLS = APP_LANE_CONTRACT.tools;
|
|
13
|
+
export async function resolveValidatedApplicationScope(session, requested) {
|
|
14
|
+
const scope = await resolveApplicationScope(session, requested);
|
|
15
|
+
const availableApps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data)
|
|
16
|
+
.map((row) => String(row.appCode ?? row.code ?? "")).filter((code) => code !== "");
|
|
17
|
+
if (availableApps.length > 0 && !availableApps.includes(scope.code)) {
|
|
18
|
+
throw new ToolRefusalError({ status: "refused", blocker: "app-not-found", applicationCode: scope.code,
|
|
19
|
+
applicationResolution: { status: "unresolved", source: scope.source, requested: scope.requested ?? scope.code, available: availableApps.slice(0, 40) },
|
|
20
|
+
message: `应用「${scope.code}」不在租户 app.list 里(不是「该应用没有模型」)。可用:${availableApps.slice(0, 12).join("、")}${availableApps.length > 12 ? " 等" : ""};先 app.list 取正确 applicationCode。` });
|
|
21
|
+
}
|
|
22
|
+
return scope;
|
|
23
|
+
}
|
|
12
24
|
function appRollbackSpec(toolName) {
|
|
13
25
|
return {
|
|
14
26
|
target: (args) => `app:${String(args.code ?? "")}`,
|
|
@@ -48,10 +60,11 @@ export function registerAppLane(tools) {
|
|
|
48
60
|
description: APP_TOOLS["app.list"].description,
|
|
49
61
|
inputSchema: { environment: environmentArg, capability: capabilityArg },
|
|
50
62
|
handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
|
|
51
|
-
const applications = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).map((row) =>
|
|
52
|
-
code
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
const applications = await Promise.all(wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data).map(async (row) => {
|
|
64
|
+
const code = String(row.appCode ?? row.code);
|
|
65
|
+
const tree = await readAppTree(session, code);
|
|
66
|
+
const modelCount = tree.reduce((count, node) => count + (node.type === "BizModel" ? 1 : 0), 0);
|
|
67
|
+
return { code, name: row.appName ?? row.name, groupId: row.groupId, modelCount };
|
|
55
68
|
}));
|
|
56
69
|
// r29f F2:与 discover_environment 同构——一眼判断「内联是否全集」。
|
|
57
70
|
// 应用通常个位数,默认内联 + omitted:false;大租户由 governedRead 外置时
|
package/dist/lanes/field.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ENDPOINTS } from "../endpoints.js";
|
|
3
3
|
import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
|
|
4
|
-
import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
|
|
4
|
+
import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, assertRelationTargetExists, relationPublishFailureMessage, } from "../field-families.js";
|
|
5
5
|
import { capabilityArg, environmentArg } from "../args.js";
|
|
6
6
|
import { governedRead } from "../read.js";
|
|
7
7
|
import { writeTool } from "../write.js";
|
|
@@ -12,10 +12,29 @@ import { defineTool, ToolRefusalError } from "../tool-types.js";
|
|
|
12
12
|
import { wireCall, wireRows, WireError } from "../wire.js";
|
|
13
13
|
import { executeFormDraftWrite, executeFormPublishWrite, readFormOrNull } from "../form-support.js";
|
|
14
14
|
import { FIELD_LANE_CONTRACT } from "./field-contract.js";
|
|
15
|
-
import { mapBounded } from "../support.js";
|
|
15
|
+
import { flattenTreeRows, mapBounded, resolveApplicationScope } from "../support.js";
|
|
16
16
|
import { extractBizObjectPage } from "./runtime-data.js";
|
|
17
17
|
const FIELD_LANE = FIELD_LANE_CONTRACT.lane;
|
|
18
18
|
const FIELD_TOOLS = FIELD_LANE_CONTRACT.tools;
|
|
19
|
+
/** Read actual model schema codes; bare modelList may expose internal node ids. */
|
|
20
|
+
export async function modelSchemaCodes(session) {
|
|
21
|
+
try {
|
|
22
|
+
const scope = await resolveApplicationScope(session);
|
|
23
|
+
const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: scope.code } })).data));
|
|
24
|
+
const codes = tree
|
|
25
|
+
.filter((row) => row.type === "BizModel")
|
|
26
|
+
.map((row) => String(row.code ?? row.schemaCode ?? row.bizSchemaCode ?? ""))
|
|
27
|
+
.filter((code) => code.length > 0);
|
|
28
|
+
if (codes.length > 0)
|
|
29
|
+
return codes;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Fall through to the compatibility endpoint below.
|
|
33
|
+
}
|
|
34
|
+
return wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data)
|
|
35
|
+
.map((row) => String(row.schemaCode ?? row.bizSchemaCode ?? row.code ?? ""))
|
|
36
|
+
.filter((code) => code.length > 0);
|
|
37
|
+
}
|
|
19
38
|
async function readFieldRollback(session, args) {
|
|
20
39
|
for (const published of [false, true]) {
|
|
21
40
|
const row = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: String(args.schemaCode), isPublish: String(published) } })).data).find((entry) => String(entry.code ?? entry.propertyCode) === String(args.code));
|
|
@@ -377,6 +396,8 @@ export function registerFieldLane(tools) {
|
|
|
377
396
|
// wire body (src/field-change.ts), so configure_model and intent share it.
|
|
378
397
|
assertNoSelfSchemaAggregation(args, published);
|
|
379
398
|
await assertNoChildSchemaAggregation(session, args.schemaCode, args.propertyType, args.options ?? {});
|
|
399
|
+
const models = await modelSchemaCodes(session);
|
|
400
|
+
assertRelationTargetExists({ ...args }, models);
|
|
380
401
|
// 目标版本决定 optionsSet 形状:8.2 以下要补 optionStructure/treeSelect
|
|
381
402
|
// (cpdocs issue #8:缺键让设计器选项弹窗硬崩)。
|
|
382
403
|
const targetVersion = session.detectedVersion();
|
|
@@ -434,6 +455,7 @@ export function registerFieldLane(tools) {
|
|
|
434
455
|
readback: {
|
|
435
456
|
face: "draft",
|
|
436
457
|
draftFieldPresent: rows.some((row) => (row.code ?? row.propertyCode) === args.code),
|
|
458
|
+
...(prepared.normalized ? { normalized: prepared.normalized } : {}),
|
|
437
459
|
...(textLike
|
|
438
460
|
? {
|
|
439
461
|
sideEffectsNote: "平台可能为文本/下拉字段自动生成一条 datarule type1 checkType=4(maxLength≈200);本调用未显式创建。清理/对账请用 datarule.list 核对,不要把自动行当成重复写入。",
|
|
@@ -442,6 +464,10 @@ export function registerFieldLane(tools) {
|
|
|
442
464
|
},
|
|
443
465
|
};
|
|
444
466
|
},
|
|
467
|
+
preflight: (args) => {
|
|
468
|
+
assertSelectionChoicesAllowed(args);
|
|
469
|
+
assertFieldFamilyAllowed(args);
|
|
470
|
+
},
|
|
445
471
|
rollback: fieldRollbackSpec("field.create"),
|
|
446
472
|
});
|
|
447
473
|
writeTool({
|
|
@@ -633,6 +659,15 @@ async function loadFieldRowForUpdate(session, schemaCode, code) {
|
|
|
633
659
|
const record = rows.find((row) => (row.code ?? row.propertyCode) === code);
|
|
634
660
|
if (!record)
|
|
635
661
|
throw new WireError("business", `field ${code} not found on ${schemaCode}`, 301001);
|
|
662
|
+
// Some platform list faces are diet projections and omit the display name;
|
|
663
|
+
// update readback must still preserve the existing name/name_i18n.
|
|
664
|
+
try {
|
|
665
|
+
const detail = (await wireCall(session, { path: ENDPOINTS.fieldGet, query: { schemaCode, propertyCode: code } })).data;
|
|
666
|
+
return { ...detail, ...record, name: record.name ?? detail.name, name_i18n: record.name_i18n ?? detail.name_i18n };
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// The detail face is optional; retain the list row.
|
|
670
|
+
}
|
|
636
671
|
return record;
|
|
637
672
|
}
|
|
638
673
|
async function countModelDataRows(session, schemaCode) {
|
|
@@ -1027,6 +1062,58 @@ async function runFieldUpdate(session, args) {
|
|
|
1027
1062
|
...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
|
|
1028
1063
|
};
|
|
1029
1064
|
const prepared = prepareFieldUpdate(update);
|
|
1065
|
+
const currentOptions = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
|
|
1066
|
+
const requestedChanges = [];
|
|
1067
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
1068
|
+
if (args.name !== undefined && !same(record.name ?? null, prepared.name ?? args.name))
|
|
1069
|
+
requestedChanges.push("name");
|
|
1070
|
+
if (args.name_i18n !== undefined && !same(record.name_i18n ?? null, prepared.nameI18n ?? args.name_i18n))
|
|
1071
|
+
requestedChanges.push("name_i18n");
|
|
1072
|
+
if (args.relativeCode !== undefined && !same(record.relativeCode ?? null, args.relativeCode))
|
|
1073
|
+
requestedChanges.push("relativeCode");
|
|
1074
|
+
if (args.propertyEmpty !== undefined && !same(record.propertyEmpty ?? null, args.propertyEmpty))
|
|
1075
|
+
requestedChanges.push("propertyEmpty");
|
|
1076
|
+
if (args.options !== undefined) {
|
|
1077
|
+
for (const key of new Set([...Object.keys(currentOptions), ...Object.keys(prepared.options)]))
|
|
1078
|
+
if (!same(currentOptions[key] ?? null, prepared.options[key] ?? null))
|
|
1079
|
+
requestedChanges.push(`options.${key}`);
|
|
1080
|
+
}
|
|
1081
|
+
const mutableOptionKeys = new Set([
|
|
1082
|
+
// These are established field-row update surfaces (tickets 60/61 and the
|
|
1083
|
+
// condition pairing round). Everything else stays fail-closed: the
|
|
1084
|
+
// platform accepts many keys and silently drops them.
|
|
1085
|
+
"dictionaryData", "optionsSet", "mappings", "schemaCode", "queryCode", "displayField", "displayFieldType", "displayFieldFormat",
|
|
1086
|
+
"relativePropertyMatchCode", "relativePropertyCode", "linkMode", "showStyle", "searchFormula", "conditions",
|
|
1087
|
+
"requiredFormula", "readonlyCondition", "displayFormula", "readonlyFormula", "scale",
|
|
1088
|
+
]);
|
|
1089
|
+
const changedOptionKeys = requestedChanges.filter((field) => field.startsWith("options.")).map((field) => field.slice("options.".length));
|
|
1090
|
+
const immutableOptionKeys = changedOptionKeys.filter((key) => !mutableOptionKeys.has(key));
|
|
1091
|
+
if (immutableOptionKeys.length > 0) {
|
|
1092
|
+
throw new ToolRefusalError({
|
|
1093
|
+
status: "refused",
|
|
1094
|
+
blocker: "field-options-immutable",
|
|
1095
|
+
schemaCode: args.schemaCode,
|
|
1096
|
+
code: args.code,
|
|
1097
|
+
fields: immutableOptionKeys.map((key) => `options.${key}`),
|
|
1098
|
+
mutableFields: [...mutableOptionKeys].map((key) => `options.${key}`),
|
|
1099
|
+
message: `field.update 不允许经此面修改 options.${immutableOptionKeys.join("、")};这类字段配置会被平台静默丢弃。可改白名单:${[...mutableOptionKeys].map((key) => `options.${key}`).join("、")}。关联结构(schemaCode/queryCode/displayField 等)请 field.delete + field.create 重建;条件请用字段条件配置。`,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
if (args.choices !== undefined && !same(choiceCodesOf(currentOptions) ?? [], [...args.choices]))
|
|
1103
|
+
requestedChanges.push("choices");
|
|
1104
|
+
if (requestedChanges.length === 0) {
|
|
1105
|
+
return {
|
|
1106
|
+
performed: { errcode: 0, unchanged: true },
|
|
1107
|
+
readback: {
|
|
1108
|
+
face: "draft",
|
|
1109
|
+
unchanged: true,
|
|
1110
|
+
changedFields: [],
|
|
1111
|
+
...(record.name !== undefined ? { name: record.name } : {}),
|
|
1112
|
+
...(record.name_i18n !== undefined ? { name_i18n: record.name_i18n } : {}),
|
|
1113
|
+
availableFields: ["name", "name_i18n", "options", "choices", "relativeCode", "propertyEmpty"],
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1030
1117
|
const performed = await wireCall(session, { path: ENDPOINTS.fieldUpdate, method: "POST", body: fieldUpdateWireBody(update, prepared) });
|
|
1031
1118
|
const readback = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } });
|
|
1032
1119
|
const after = wireRows(readback.data).find((row) => (row.code ?? row.propertyCode) === args.code);
|
|
@@ -1038,7 +1125,9 @@ async function runFieldUpdate(session, args) {
|
|
|
1038
1125
|
// failure never hides the field write (it is surfaced as formConditionSync.error).
|
|
1039
1126
|
let formConditionSync;
|
|
1040
1127
|
let formConditionSyncError;
|
|
1041
|
-
|
|
1128
|
+
const conditionKeys = new Set(Object.keys(FORM_CONDITION_OPTION_KEYS));
|
|
1129
|
+
const conditionChanged = effectiveOptions !== undefined && [...conditionKeys].some((key) => !fieldValuesEqual((decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {})[key], prepared.options[key]));
|
|
1130
|
+
if (conditionChanged && !prepared.isChildTable) {
|
|
1042
1131
|
const previous = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
|
|
1043
1132
|
try {
|
|
1044
1133
|
const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, previous, prepared.options);
|
|
@@ -1054,6 +1143,19 @@ async function runFieldUpdate(session, args) {
|
|
|
1054
1143
|
// confirm the write landed — the readback only named the field. Field
|
|
1055
1144
|
// options are the form-interaction contract (ADR 0005), so read them back.
|
|
1056
1145
|
const storedOptions = after !== undefined ? parseFieldOptionsForReadback(after.options) : undefined;
|
|
1146
|
+
if (args.options !== undefined && storedOptions && typeof storedOptions === "object" && !Array.isArray(storedOptions)) {
|
|
1147
|
+
const missed = Object.keys(args.options).filter((key) => !same(storedOptions[key] ?? null, prepared.options[key] ?? null));
|
|
1148
|
+
if (missed.length > 0) {
|
|
1149
|
+
throw new ToolRefusalError({
|
|
1150
|
+
status: "refused",
|
|
1151
|
+
blocker: "field-options-immutable",
|
|
1152
|
+
schemaCode: args.schemaCode,
|
|
1153
|
+
code: args.code,
|
|
1154
|
+
fields: missed.map((key) => `options.${key}`),
|
|
1155
|
+
message: `field.update 的 options.${missed.join("、")} 经此面提交后未生效;options 属于不可经此面修改的字段配置。请按字段族契约使用 field.delete + field.create 重建,或走对应的表单/数据规则工具(不要把 DELIVERED 当成已生效)。`,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1057
1159
|
// r32(真机复现:field.update propertyEmpty:true 回 ok/DELIVERED,field.list --full
|
|
1058
1160
|
// 与 model.inspect 都看不到该值):平台字段行读面(bizproperty/list 与
|
|
1059
1161
|
// bizproperty/get)都不回 propertyEmpty,这是**只写属性**。不编造读回值:
|
|
@@ -1079,8 +1181,12 @@ async function runFieldUpdate(session, args) {
|
|
|
1079
1181
|
face: "draft",
|
|
1080
1182
|
impact,
|
|
1081
1183
|
fieldPresent: after !== undefined,
|
|
1082
|
-
...(after?.name !== undefined
|
|
1083
|
-
|
|
1184
|
+
...(after?.name !== undefined || prepared.name !== undefined || record.name !== undefined || record.propertyName !== undefined
|
|
1185
|
+
? { name: after?.name ?? prepared.name ?? record.name ?? record.propertyName }
|
|
1186
|
+
: {}),
|
|
1187
|
+
...(typeof after?.name_i18n === "string" || typeof prepared.nameI18n === "string" || typeof record.name_i18n === "string"
|
|
1188
|
+
? { name_i18n: after?.name_i18n ?? prepared.nameI18n ?? record.name_i18n }
|
|
1189
|
+
: {}),
|
|
1084
1190
|
...(after?.type !== undefined || after?.propertyType !== undefined ? { type: after?.type ?? after?.propertyType } : {}),
|
|
1085
1191
|
...(hasReadBackPropertyEmpty ? { propertyEmpty: after?.propertyEmpty } : {}),
|
|
1086
1192
|
committed: committedKeys,
|
|
@@ -1345,6 +1451,13 @@ export async function executeFieldPublish(session, args) {
|
|
|
1345
1451
|
const preRead = await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
|
|
1346
1452
|
const prePublishedRows = wireRows(preRead.data);
|
|
1347
1453
|
const prePublished = prePublishedRows.map(codeOf).filter((code) => code.length > 0);
|
|
1454
|
+
// Older servers accept a dangling relation row and only reveal it as the
|
|
1455
|
+
// misleading 300010 at model publish. Inspect the draft before POST so an
|
|
1456
|
+
// existing poisoned model gets the same field-specific diagnosis as create.
|
|
1457
|
+
const relationCandidates = await modelSchemaCodes(session);
|
|
1458
|
+
const relationFailure = relationPublishFailureMessage(wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data), relationCandidates);
|
|
1459
|
+
if (relationFailure)
|
|
1460
|
+
throw new ToolRefusalError({ status: "refused", blocker: "relation-target-not-found", schemaCode: args.schemaCode, message: relationFailure, candidates: relationCandidates.slice(0, 5) });
|
|
1348
1461
|
// r29e F10/F11:旧短路只看「code 在发布面存在」,改了 options 仍报
|
|
1349
1462
|
// already-published + verification.ok(假绿)。再比草稿/发布 options 指纹;
|
|
1350
1463
|
// 不一致就真发布。name/type 仍由 verifyFieldDetails 核对。
|
|
@@ -116,7 +116,7 @@ export const MODEL_LANE_CONTRACT = Object.freeze({
|
|
|
116
116
|
}),
|
|
117
117
|
configure_model: Object.freeze({
|
|
118
118
|
scope: MODEL_SCOPE,
|
|
119
|
-
description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists
|
|
119
|
+
description: "High-level: create a model with one field (fieldCode/fieldName/propertyType, legacy) or N fields (fields:[{code,name,propertyType,choices?,relativeCode?,options?}]) and drive it to the structurally usable state (model.create → field.create×N → model-scoped field.publish), optionally drafting and publishing the default form in the same package (publishForm:true → form.draft → form.publish) and configuring the default list view (`listview:{fieldCodes,...}` → listview.configure). Resumable: if the model already exists, the readback declares `created:false` and `mergedChanges`; `createOnly:true` refuses with `model-already-exists`. Model code is tenant-global and cannot be reused across applications. Family gates run per field BEFORE the write permission gate; for the form-only path use configure_form.",
|
|
120
120
|
}),
|
|
121
121
|
"model.deleteMany": Object.freeze({
|
|
122
122
|
scope: MODEL_SCOPE,
|