Skip to main content

rust_store_core/schema/
registry.rs

1//! Schema 注册表:注册/定位/配置(数据结构与规范化见 [`super::definition`])。
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde_json::{json, Map, Value};
7
8use crate::types::{is_truthy, str_list, COMPUTE_AGG_OPS};
9
10use super::definition::{
11    normalize_fields, validate_vector_fields, ComputeDef, FieldDef, Location, RelationDef, Schema,
12};
13use super::load::LoadConfig;
14
15use crate::command::WriteLinkPolicy;
16use crate::error::{CoreError, ERR_POLICY_VIEW_READONLY};
17
18/// 查询档位:判决唯一在 core(照 [`Registry::require_context`] 既有范式)。
19///
20/// - [`Profile::Standard`](默认):标准调用 —— 跨方言对齐的公共能力集;
21///   DB 独有能力可用但须代码注释标注「不建议业务查询」(见执行文档 §4.5)。
22/// - [`Profile::Text2Query`]:AI 问数 —— 功能收缩 + 硬性限制(行数/深度/准入全收紧)。
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Profile {
25    /// 标准调用:跨方言对齐的公共能力集;DB 独有能力可用但须注释标注(不建议业务查询)
26    #[default]
27    Standard,
28    /// AI 问数:功能收缩 + 硬性限制
29    Text2Query,
30}
31
32impl Profile {
33    /// 档位字符串(Host / 绑定层单点取用)
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Profile::Standard => "standard",
37            Profile::Text2Query => "text2query",
38        }
39    }
40
41    /// 字符串 → 档位;未知值 **Err**(禁静默回落 `Standard`,见执行文档 §7)
42    pub fn from_str_or_err(s: &str) -> Result<Profile, String> {
43        match s {
44            "standard" => Ok(Profile::Standard),
45            "text2query" => Ok(Profile::Text2Query),
46            other => Err(format!("未知 profile: {other}(仅 standard / text2query)")),
47        }
48    }
49}
50
51/// 一条注册项:一份主结构 + 主/从链路 + 版本(D4/D5/D13)。
52#[derive(Debug, Clone)]
53struct Entry {
54    schema: Schema,
55    /// 主 + 从链路;`links[0]` 恒为主落点(= `schema` 的定位投影)
56    links: Vec<Location>,
57    /// 版本:首次注册 = 1;跨批次 reload 同名 ⇒ +1
58    version: u64,
59}
60
61/// 批次分组结果(判据单点输出;`register_batch` 与 `schema::load::plan_load` 共用)。
62pub(super) struct BatchClass {
63    /// 组名(按首次出现顺序)
64    pub order: Vec<String>,
65    /// name → (主定义, 落点, collection);每组至多一份主
66    pub primaries: HashMap<String, (Value, Location, String)>,
67    /// name → 从项(原序)
68    pub replicas: HashMap<String, Vec<(Value, Location)>>,
69}
70
71/// 批次判据错误(调用方各自格式化文案:注册期与装载期文案口径不同)。
72pub(super) enum BatchError {
73    /// 解析错误(非对象 / 缺 name)
74    Parse(String),
75    /// 同名主 ≥2(A1):组名 + 主项在输入中的下标
76    PrimaryDuplicate { name: String, indices: Vec<usize> },
77    /// 批内四元组冲突
78    LocationConflict(Box<LocationConflict>),
79}
80
81/// 批次判据单点:**分组 + 主唯一(≥2 ⇒ `Err`)+ 批内四元组冲突(⇒ `Err`)**。
82///
83/// **0 主不在此报错**(交由调用方决定:`register_batch` 走既有 entry 追加从链路,
84/// `plan_load` 直接拒——「主定义缺失」)。`register_batch` 与 `plan_load` 共用本函数,
85/// 禁各写一份(判据单点,防 A1/A3 漂移)。
86pub(super) fn classify_batch(items: &[(Value, Location)]) -> Result<BatchClass, BatchError> {
87    let mut order: Vec<String> = Vec::new();
88    let mut primaries: HashMap<String, (Value, Location, String)> = HashMap::new();
89    let mut primary_idx: HashMap<String, usize> = HashMap::new();
90    let mut replicas: HashMap<String, Vec<(Value, Location)>> = HashMap::new();
91
92    for (idx, (defn, loc)) in items.iter().enumerate() {
93        let obj = defn
94            .as_object()
95            .ok_or_else(|| BatchError::Parse("schema 定义必须是对象".to_string()))?;
96        let name = obj
97            .get("name")
98            .and_then(|v| v.as_str())
99            .ok_or_else(|| BatchError::Parse("schema 缺少 name".to_string()))?
100            .to_string();
101        if !order.iter().any(|n| n == &name) {
102            order.push(name.clone());
103        }
104
105        if obj.get("replica").map(is_truthy).unwrap_or(false) {
106            replicas
107                .entry(name)
108                .or_default()
109                .push((defn.clone(), loc.clone()));
110            continue;
111        }
112
113        let collection = obj
114            .get("collection")
115            .and_then(|v| v.as_str())
116            .filter(|s| !s.is_empty())
117            .map(String::from)
118            .unwrap_or_else(|| name.clone());
119        if let Some(first) = primary_idx.get(&name) {
120            // 同名主 ≥2 ⇒ Err(A1);携带下标供调用方定位(rel 路径 / 提示)
121            return Err(BatchError::PrimaryDuplicate {
122                name,
123                indices: vec![*first, idx],
124            });
125        }
126        primary_idx.insert(name.clone(), idx);
127        primaries.insert(name, (defn.clone(), loc.clone(), collection));
128    }
129
130    // 批内四元组冲突:主 + 从全部链路(判据函数 `detect_location_conflict`)
131    let mut quads: Vec<(String, String, Location)> = Vec::new();
132    for name in &order {
133        if let Some((_, loc, coll)) = primaries.get(name) {
134            quads.push((name.clone(), coll.clone(), loc.clone()));
135        }
136        if let Some(rs) = replicas.get(name) {
137            for (defn, loc) in rs {
138                let coll = defn
139                    .get("name")
140                    .and_then(|v| v.as_str())
141                    .unwrap_or(name)
142                    .to_string();
143                quads.push((name.clone(), coll, loc.clone()));
144            }
145        }
146    }
147    if let Some(c) = detect_location_conflict(&quads) {
148        return Err(BatchError::LocationConflict(Box::new(c)));
149    }
150
151    Ok(BatchClass {
152        order,
153        primaries,
154        replicas,
155    })
156}
157
158/// 四元组冲突信息(供调用方格式化文案)
159pub(super) struct LocationConflict {
160    pub collection: String,
161    pub prev: String,
162    pub name: String,
163    pub source: String,
164    pub database: Option<String>,
165    pub schema: Option<String>,
166}
167
168/// 四元组 `(source, database, schema, collection)` 冲突检测(**判据单点**):
169/// 同一四元组被两个不同 schema 名占用 ⇒ `Some(冲突)`。`register_batch`(含既有 entry)
170/// 与 `plan_load`(批内)共用。
171pub(super) fn detect_location_conflict(
172    entries: &[(String, String, Location)],
173) -> Option<LocationConflict> {
174    let mut seen: HashMap<(String, Option<String>, Option<String>, String), String> =
175        HashMap::new();
176    for (name, collection, loc) in entries {
177        let key = (
178            loc.source.clone(),
179            loc.database.clone(),
180            loc.schema.clone(),
181            collection.clone(),
182        );
183        if let Some(prev) = seen.get(&key) {
184            if prev != name {
185                return Some(LocationConflict {
186                    collection: collection.clone(),
187                    prev: prev.clone(),
188                    name: name.clone(),
189                    source: loc.source.clone(),
190                    database: loc.database.clone(),
191                    schema: loc.schema.clone(),
192                });
193            }
194        } else {
195            seen.insert(key, name.clone());
196        }
197    }
198    None
199}
200
201/// `BatchError` → 注册期文案(保留既有子串:`同名主定义重复` / `定位冲突`)。
202fn batch_err_to_msg(e: BatchError) -> String {
203    match e {
204        BatchError::Parse(m) => m,
205        BatchError::PrimaryDuplicate { name, .. } => format!(
206            "同名主定义重复: {}(同一装载批次内 name 必须唯一,从定义请用 replica: true)",
207            name
208        ),
209        BatchError::LocationConflict(c) => format!(
210            "定位冲突: ({}, {:?}, {:?}, {}) 已被 schema `{}` 占用",
211            c.source, c.database, c.schema, c.collection, c.prev
212        ),
213    }
214}
215
216/// schema 目录快照:全局唯一真源的数据体(注册一次,派生视图共享同一 `Arc`)。
217#[derive(Debug, Clone, Default)]
218pub struct Catalog {
219    entries: HashMap<String, Entry>,
220    order: Vec<String>,
221}
222
223/// 策略束:六项策略的载体(base 可变;视图为派生时点快照,`set` 只影响自身)。
224#[derive(Debug, Clone, Default)]
225pub struct PolicyBundle {
226    /// 上下文强制开关(默认关闭 = fail-open,与 JS 原版 parity);
227    /// 开启后所有 plan 入口对 `ctx: None` 显式报错(fail-secure,见 `permission` 模块文档)。
228    /// 内部调用请传显式系统上下文(JSON `{"internal": true}` / `Context::system()`)。
229    pub(crate) require_context: bool,
230    /// 查询档位(默认 [`Profile::Standard`];text2query 由 AI 问数链路显式进入)。
231    pub(crate) profile: Profile,
232    /// RBAC 动态策略(`None` = 未启用,判决原语直通、行为与现状一致)。见 [`crate::rbac`]
233    pub(crate) rbac: Option<crate::rbac::RbacPolicy>,
234    /// 角色清单与未配置姿态(豁免 / 拒写 / Open|Closed,默认 []/[]/Open)。见 [`crate::permission::RoleRules`]
235    pub(crate) role_rules: crate::permission::RoleRules,
236    /// 定义层门禁策略(默认 Open —— 全放行,保持既有 parity)。见 [`crate::permission::MetaPolicy`]
237    pub(crate) meta_policy: crate::permission::MetaPolicy,
238    /// 跨连接写策略(默认 [`WriteLinkPolicy::Reject`])。见 [`crate::command::write_links`]
239    pub(crate) write_link_policy: WriteLinkPolicy,
240}
241
242/// Schema 注册表:**共享目录快照**(`Arc<Catalog>`)+ **策略束**(`PolicyBundle`)。
243///
244/// - `is_base = true`:目录唯一真源,注册 / 清空写路径仅在此开放;
245/// - 派生视图(`with_policy` 产出)持同一 `Arc<Catalog>` 快照 + 叠加策略,`is_base = false`。
246#[derive(Debug, Clone)]
247pub struct Registry {
248    catalog: Arc<Catalog>,
249    policy: PolicyBundle,
250    /// 是否目录唯一真源(`Default` 即 base);派生视图为 `false`。
251    is_base: bool,
252}
253
254// `is_base` 不允许 derive 默认(`false` = 视图是错误语义),手写 Default(即 base)。
255impl Default for Registry {
256    fn default() -> Self {
257        Self {
258            catalog: Arc::new(Catalog::default()),
259            policy: PolicyBundle::default(),
260            is_base: true,
261        }
262    }
263}
264
265/// 策略覆盖项(派生视图的差分输入):`None` = 继承来源视图;
266/// `rbac` 的 `Some(None)` = 显式清除(其余字段无「清除」语义)。
267#[derive(Debug, Clone, Default)]
268pub struct PolicyOverrides {
269    require_context: Option<bool>,
270    profile: Option<Profile>,
271    rbac: Option<Option<crate::rbac::RbacPolicy>>,
272    role_rules: Option<crate::permission::RoleRules>,
273    meta_policy: Option<crate::permission::MetaPolicy>,
274    write_link_policy: Option<WriteLinkPolicy>,
275}
276
277impl PolicyOverrides {
278    /// JSON → 覆盖项(**唯一策略 JSON 解析点**,02 双绑定共用;禁在别处重复解析策略配置)。
279    ///
280    /// 已知键(camelCase,与宿主 config 键风格一致):`profile` / `requireContext` /
281    /// `rbac` / `roleRules` / `metaPolicy` / `writeLinkPolicy`;空对象 = 无覆盖。
282    /// 未知键 **Err**(禁静默忽略,对齐 `no-error-masking`)。
283    pub fn from_value(v: &Value) -> Result<Self, CoreError> {
284        let obj = v
285            .as_object()
286            .ok_or_else(|| CoreError::Other("策略覆盖必须是 JSON 对象".to_string()))?;
287        let mut o = PolicyOverrides::default();
288        for (k, val) in obj {
289            match k.as_str() {
290                "profile" => {
291                    let s = val
292                        .as_str()
293                        .ok_or_else(|| CoreError::Other("profile 必须是字符串".to_string()))?;
294                    o.profile = Some(Profile::from_str_or_err(s).map_err(CoreError::Other)?);
295                }
296                "requireContext" => {
297                    o.require_context = Some(val.as_bool().ok_or_else(|| {
298                        CoreError::Other("requireContext 必须是布尔".to_string())
299                    })?);
300                }
301                "rbac" => {
302                    // 对象 = 注入;null = 显式清除(Some(None),与缺键 None 区分)
303                    o.rbac = Some(match val {
304                        Value::Null => None,
305                        other => Some(
306                            crate::rbac::RbacPolicy::from_json(other).map_err(CoreError::Other)?,
307                        ),
308                    });
309                }
310                "roleRules" => {
311                    o.role_rules = Some(parse_role_rules(val)?);
312                }
313                "metaPolicy" => {
314                    o.meta_policy = Some(parse_meta_policy(val)?);
315                }
316                "writeLinkPolicy" => {
317                    let s = val.as_str().ok_or_else(|| {
318                        CoreError::Other("writeLinkPolicy 必须是字符串".to_string())
319                    })?;
320                    o.write_link_policy = Some(match s {
321                        "reject" => WriteLinkPolicy::Reject,
322                        "primaryOnly" => WriteLinkPolicy::PrimaryOnly,
323                        other => {
324                            return Err(CoreError::Other(format!(
325                                "未知 writeLinkPolicy: {other}(仅 reject / primaryOnly)"
326                            )))
327                        }
328                    });
329                }
330                other => {
331                    return Err(CoreError::Other(format!(
332                        "{ERR_POLICY_VIEW_READONLY} 未知策略覆盖键 `{other}`"
333                    )))
334                }
335            }
336        }
337        Ok(o)
338    }
339}
340
341/// `roleRules` 覆盖解析(复用 `UnconfiguredPolicy::from_str_or_err`;未知子键 Err)。
342fn parse_role_rules(v: &Value) -> Result<crate::permission::RoleRules, CoreError> {
343    let obj = v
344        .as_object()
345        .ok_or_else(|| CoreError::Other("roleRules 必须是 JSON 对象".to_string()))?;
346    let mut rr = crate::permission::RoleRules::default();
347    for (k, val) in obj {
348        match k.as_str() {
349            "exemptRoles" => rr.exempt_roles = str_list_strict(val, "roleRules.exemptRoles")?,
350            "denyWriteRoles" => {
351                rr.deny_write_roles = str_list_strict(val, "roleRules.denyWriteRoles")?
352            }
353            "unconfigured" => {
354                let s = val.as_str().ok_or_else(|| {
355                    CoreError::Other("roleRules.unconfigured 必须是字符串".to_string())
356                })?;
357                rr.unconfigured = crate::permission::UnconfiguredPolicy::from_str_or_err(s)
358                    .map_err(CoreError::Other)?;
359            }
360            other => {
361                return Err(CoreError::Other(format!(
362                    "{ERR_POLICY_VIEW_READONLY} 未知 roleRules 键 `{other}`"
363                )))
364            }
365        }
366    }
367    Ok(rr)
368}
369
370/// `metaPolicy` 覆盖解析(未知子键 Err)。
371fn parse_meta_policy(v: &Value) -> Result<crate::permission::MetaPolicy, CoreError> {
372    let obj = v
373        .as_object()
374        .ok_or_else(|| CoreError::Other("metaPolicy 必须是 JSON 对象".to_string()))?;
375    let mut mp = crate::permission::MetaPolicy::default();
376    for (k, val) in obj {
377        match k.as_str() {
378            "closed" => {
379                mp.closed = val
380                    .as_bool()
381                    .ok_or_else(|| CoreError::Other("metaPolicy.closed 必须是布尔".to_string()))?;
382            }
383            "roles" => mp.roles = str_list_strict(val, "metaPolicy.roles")?,
384            other => {
385                return Err(CoreError::Other(format!(
386                    "{ERR_POLICY_VIEW_READONLY} 未知 metaPolicy 键 `{other}`"
387                )))
388            }
389        }
390    }
391    Ok(mp)
392}
393
394/// 严格字符串数组解析(既有 `str_list` 会静默丢弃非字符串元素,此处显式 Err)。
395fn str_list_strict(v: &Value, key: &str) -> Result<Vec<String>, CoreError> {
396    let arr = v
397        .as_array()
398        .ok_or_else(|| CoreError::Other(format!("{key} 必须是字符串数组")))?;
399    arr.iter()
400        .map(|x| {
401            x.as_str()
402                .map(String::from)
403                .ok_or_else(|| CoreError::Other(format!("{key} 的元素必须都是字符串")))
404        })
405        .collect()
406}
407
408impl PolicyBundle {
409    /// 叠加覆盖项:`None` 继承自身;`rbac` 特例——`Some(v)`(含 `Some(None)` = 显式清除)。
410    fn overridden(&self, o: &PolicyOverrides) -> PolicyBundle {
411        PolicyBundle {
412            require_context: o.require_context.unwrap_or(self.require_context),
413            profile: o.profile.unwrap_or(self.profile),
414            rbac: match &o.rbac {
415                Some(v) => v.clone(),
416                None => self.rbac.clone(),
417            },
418            role_rules: o
419                .role_rules
420                .clone()
421                .unwrap_or_else(|| self.role_rules.clone()),
422            meta_policy: o
423                .meta_policy
424                .clone()
425                .unwrap_or_else(|| self.meta_policy.clone()),
426            write_link_policy: o.write_link_policy.unwrap_or(self.write_link_policy),
427        }
428    }
429}
430
431/// 读链路解析结果:已排序链路 + 跳过/降级声明(随 `plan.degraded` 透出,禁静默)。
432#[derive(Debug, Clone)]
433pub struct ReadLinks {
434    /// 已排序链路;`links[0]` = 本次读的选中主链路
435    pub links: Vec<Location>,
436    /// 跳过/降级声明,随 `plan.degraded` 透出(禁静默)
437    pub degraded: Vec<Value>,
438}
439
440impl Registry {
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// 派生策略视图(**O(1)**):共享目录快照 `Arc<Catalog>` + 叠加策略覆盖;派生视图 `is_base = false`。
446    ///
447    /// 快照语义:视图持**派生时点**目录,base 后续注册经 `Arc::make_mut` 写时复制,对**后续派生**
448    /// 视图即时可见,对在途视图不承诺(与 S2 现状口径一致:`store-api/spec/01-routing.md` 运行期新注册不做承诺)。
449    pub fn with_policy(&self, overrides: &PolicyOverrides) -> Registry {
450        Registry {
451            catalog: Arc::clone(&self.catalog),
452            policy: self.policy.overridden(overrides),
453            is_base: false,
454        }
455    }
456
457    /// 是否派生视图(`false` = base 目录唯一真源)
458    pub fn is_view(&self) -> bool {
459        !self.is_base
460    }
461
462    /// 视图只读守卫:非 base 视图禁止写目录(模型唯一真源在 base Registry)。
463    ///
464    /// 拦截必须显式报错、禁静默降级(对齐 `no-error-masking`)。返回 `Err` 供
465    /// 注册类入口 `?` 直通;`clear`(返回 `()`,受「公开签名零改动」约束)调用本
466    /// 函数并在 `Err` 时 `panic!`(消息同源,携带 [`ERR_POLICY_VIEW_READONLY`] 前缀)。
467    fn ensure_base_catalog(&self) -> Result<(), String> {
468        if !self.is_base {
469            return Err(format!(
470                "{ERR_POLICY_VIEW_READONLY} 非 base 策略视图禁止注册/清空 schema(目录唯一真源在 base Registry)"
471            ));
472        }
473        Ok(())
474    }
475
476    /// 注册一个 schema(含自动注册 `<Name>Deleted` 归档表),对应 JS `register`。
477    ///
478    /// 兼容入口:单条 + 缺省落点(`source="default"`、`database/schema=None`)。
479    /// 定义层门禁按当前 [`MetaPolicy`](crate::permission::MetaPolicy) 判决
480    /// (默认 Open → 全放行)。需带 ctx 显式过门禁请用 [`Self::register_with_ctx`]。
481    pub fn register(&mut self, defn: &Value) -> Result<(), String> {
482        self.register_with_ctx(defn, None)
483    }
484
485    /// 带 ctx 的注册(单条 + 缺省落点):内部转 [`Self::register_batch`]。
486    pub fn register_with_ctx(
487        &mut self,
488        defn: &Value,
489        ctx: Option<&crate::permission::Context>,
490    ) -> Result<(), String> {
491        self.register_batch(&[(defn.clone(), Location::default())], ctx)
492    }
493
494    /// 带定位的批量注册(D13:唯一性校验落「装载批次集合」)。
495    ///
496    /// 算法(**先构造后落库,任一步 Err ⇒ 零副作用**):
497    /// 1. 判据单点 [`classify_batch`](分组 / 主唯一 ≥2 ⇒ `Err` / 批内四元组冲突),与
498    ///    `schema::load::plan_load` 同源。
499    /// 2. `can_register` 门禁(判决先于 `build_schema`)。
500    /// 3. 逐组:有主 ⇒ `build_schema` 为 **主**;0 主 ⇒ 追加既有同名 entry 的从链路,无既有 ⇒ `Err`。
501    /// 4. 全批 + 既有 `(source, database, schema, collection)` 跨名冲突 ⇒ `Err`
502    ///    (判据函数 [`detect_location_conflict`],替代旧 `check_location_unique`)。
503    /// 5. 提交:`links = [primary.loc] ++ replicas`;命中既有 entry ⇒ `version + 1`
504    ///    (`order` 位置不变)、替换结构与链路;新名 ⇒ `version = 1`、`order.push`。
505    ///    `Schema.source/database/schema` 由 `primary.loc` 注入(`source == "default"` ⇒ `None`)。
506    /// 6. 归档派生:主 `name` 不以 `"Deleted"` 结尾且非 `_isArchive` ⇒ 派生 `<Name>Deleted`
507    ///    (`links` 继承主链路,定位同主)。
508    pub fn register_batch(
509        &mut self,
510        items: &[(Value, Location)],
511        ctx: Option<&crate::permission::Context>,
512    ) -> Result<(), String> {
513        self.ensure_base_catalog()?; // 视图只读守卫:目录唯一真源在 base
514                                     // ── 1:判据单点(分组 / 主唯一 / 批内四元组冲突),与 plan_load 同源 ──
515        let class = classify_batch(items).map_err(batch_err_to_msg)?;
516
517        // 定义层门禁:判决先于 build_schema —— 拒绝即返回,绝不部分写入
518        for (defn, _loc) in items {
519            let name = defn.get("name").and_then(|v| v.as_str()).unwrap_or("");
520            if !crate::permission::can_register(&self.policy.meta_policy, ctx) {
521                return Err(format!("ERR_PERMISSION: 无权注册或覆盖定义 {name}"));
522            }
523        }
524
525        // ── 2:逐组拟定 entry 序列(主 + 归档附表,保序) ──
526        struct Planned {
527            name: String,
528            schema: Schema,
529            links: Vec<Location>,
530        }
531        let mut planned: Vec<Planned> = Vec::new();
532        for name in &class.order {
533            let rs: Vec<Location> = class
534                .replicas
535                .get(name)
536                .map(|v| v.iter().map(|(_, l)| l.clone()).collect())
537                .unwrap_or_default();
538            if let Some((raw, loc, collection)) = class.primaries.get(name) {
539                let obj = raw.as_object().unwrap();
540                let schema = build_schema(obj, name.clone(), collection.clone(), loc)?;
541                let is_archive = obj.get("_isArchive").map(is_truthy).unwrap_or(false);
542                let mut links = vec![loc.clone()];
543                links.extend(rs);
544                planned.push(Planned {
545                    name: name.clone(),
546                    schema: schema.clone(),
547                    links: links.clone(),
548                });
549                // 归档附表紧随主定义派生(`links` 继承主链路,定位同主);
550                // archive:false(漉金Core 接入扩展 02)完全不派生(不进模型表、
551                // 不建集合、不占名称冲突检查名额)
552                let archive_on = obj.get("archive").map(is_truthy).unwrap_or(true);
553                if !is_archive && !name.ends_with("Deleted") && archive_on {
554                    let aobj = archive_defn(obj, name, &schema.collection)
555                        .as_object()
556                        .cloned()
557                        .unwrap();
558                    let aname = format!("{}Deleted", name);
559                    let acoll = format!("{}_deleted", schema.collection);
560                    let aschema = build_schema(&aobj, aname.clone(), acoll, &links[0])?;
561                    planned.push(Planned {
562                        name: aname,
563                        schema: aschema,
564                        links,
565                    });
566                }
567            } else {
568                // 0 主:只能是对既有 entry 追加从链路(跨批演进)
569                let Some(existing) = self.catalog.entries.get(name) else {
570                    return Err(format!(
571                        "未找到主 schema: {}(从定义 `replica` 必须伴随同批主定义,或该 name 已注册)",
572                        name
573                    ));
574                };
575                let mut links = existing.links.clone();
576                links.extend(rs);
577                planned.push(Planned {
578                    name: name.clone(),
579                    schema: existing.schema.clone(),
580                    links,
581                });
582            }
583        }
584
585        // ── 3:四元组唯一性(本批 + 既有,跨名冲突即 Err;判据函数单点) ──
586        let batch_names: std::collections::HashSet<&str> =
587            planned.iter().map(|p| p.name.as_str()).collect();
588        let mut quads: Vec<(String, String, Location)> = Vec::new();
589        for (n, e) in &self.catalog.entries {
590            if batch_names.contains(n.as_str()) {
591                continue; // 本批将整体替换该名,旧链路不参与冲突判定
592            }
593            for l in &e.links {
594                quads.push((n.clone(), e.schema.collection.clone(), l.clone()));
595            }
596        }
597        for p in &planned {
598            for l in &p.links {
599                quads.push((p.name.clone(), p.schema.collection.clone(), l.clone()));
600            }
601        }
602        if let Some(c) = detect_location_conflict(&quads) {
603            return Err(format!(
604                "定位冲突: ({}, {:?}, {:?}, {}) 已被 schema `{}` 占用",
605                c.source, c.database, c.schema, c.collection, c.prev
606            ));
607        }
608
609        // ── 4:提交(命中既有 ⇒ version + 1 且 order 位置不变;新名 ⇒ version = 1 + push) ──
610        let catalog = Arc::make_mut(&mut self.catalog); // 写时复制(仅 base 走到此)
611        for p in planned {
612            if let Some(e) = catalog.entries.get_mut(&p.name) {
613                e.schema = p.schema;
614                e.links = p.links;
615                e.version += 1;
616            } else {
617                catalog.entries.insert(
618                    p.name.clone(),
619                    Entry {
620                        schema: p.schema,
621                        links: p.links,
622                        version: 1,
623                    },
624                );
625                catalog.order.push(p.name);
626            }
627        }
628
629        Ok(())
630    }
631
632    /// 把 `config.links` 的角色/优先级附着到各 entry 的链路集;声明但缺失的链路按声明序补建。
633    ///
634    /// 算法(**先校验后落库,任一 `Err` ⇒ 零副作用**,与 [`Self::register_batch`] 同规):
635    /// 1. 逐项取 [`Self::get_entry`](未注册 ⇒ `Err`,禁猜——该名可能由其它批次注册)。
636    /// 2. 每条声明按 [`Location::same_place`] 在 `entry.links` 中匹配来源(只比三元组):
637    ///    命中 ⇒ 写回 `role`/`priority`;未命中 ⇒ 按**声明序**追加 `Location`。
638    /// 3. 归档随附表 `<Name>Deleted`(若已注册且未在 `links` 中独立声明)继承主的重算链路。
639    /// 4. 未在 `cfg.links` 中出现的 entry / 链路:保持原样(零回归);`links` 为空 ⇒ no-op。
640    pub fn apply_link_decls(&mut self, cfg: &LoadConfig) -> Result<(), String> {
641        if cfg.links.is_empty() {
642            return Ok(());
643        }
644        self.ensure_base_catalog()?; // 目录唯一真源在 base(视图只读)
645
646        // ── 1/2:拟定期望链路(全程不落库) ──
647        let declared: std::collections::HashSet<&str> =
648            cfg.links.iter().map(|(n, _)| n.as_str()).collect();
649        let mut planned: Vec<(String, Vec<Location>)> = Vec::new();
650        for (name, decls) in &cfg.links {
651            let entry = self.get_entry(name).map_err(|_| {
652                format!("未注册 schema: {name}(config.links 引用了不存在的 schema)")
653            })?;
654            let mut links = entry.links.clone();
655            for decl in decls {
656                let target = Location {
657                    source: decl.source.clone(),
658                    database: decl.database.clone(),
659                    schema: decl.schema.clone(),
660                    role: decl.role,
661                    priority: decl.priority,
662                };
663                match links.iter_mut().find(|l| l.same_place(&target)) {
664                    Some(hit) => {
665                        hit.role = decl.role;
666                        hit.priority = decl.priority;
667                    }
668                    None => links.push(target),
669                }
670            }
671            // 归档随附表继承主的重算链路(主名独立声明同名归档时不覆盖)
672            if !name.ends_with("Deleted") {
673                let aname = format!("{}Deleted", name);
674                if !declared.contains(aname.as_str()) && self.catalog.entries.contains_key(&aname) {
675                    planned.push((aname, links.clone()));
676                }
677            }
678            planned.push((name.clone(), links));
679        }
680
681        // ── 3:提交(仅替换 links;非重注册 ⇒ version 不变) ──
682        let catalog = Arc::make_mut(&mut self.catalog);
683        for (name, links) in planned {
684            if let Some(e) = catalog.entries.get_mut(&name) {
685                e.links = links;
686            }
687        }
688        Ok(())
689    }
690
691    /// 主链路 + 从链路(只读)
692    pub fn links_of(&self, name: &str) -> Result<&[Location], String> {
693        Ok(&self.get_entry(name)?.links)
694    }
695
696    /// 读链路解析:排序(`role` → `priority` → **声明序**)+ 跳过不可达 + 产降级声明。
697    ///
698    /// 排序用稳定 `sort_by_key`:键为 `(role.map_or(4u8, |r| r.rank()), priority.unwrap_or(i64::MAX))`
699    /// ——未声明 `role` 视作最低层(4)、未声明 `priority` 视作最大;稳定排序 ⇒ 同级保声明序,
700    /// 无任何 `links` 声明时全为 `(4, MAX)` ⇒ 顺序 = 原序(零回归)。
701    ///
702    /// `unreachable`:本次调用不可达的 source 列表(由 Host 下发;`None` = 不限制);
703    /// 命中某链路 `source` ⇒ 从结果剔除并 push 降级声明(禁静默)。全部链路被跳过 ⇒
704    /// `Err:ERR_READ_NO_REACHABLE_LINK: <name>`(绝不回落主链路)。
705    ///
706    /// `links[0]` 即读路径的选中落点(替代 `primary_location` 在读路径的角色;
707    /// `primary_location` 保留供写路径与兼容入口)。可达过滤判据单点在
708    /// [`federation::plan::select::filter_reachable`](crate::federation::plan::select::filter_reachable),
709    /// 本函数**不重复**该判据。
710    pub fn resolve_read_links(
711        &self,
712        name: &str,
713        unreachable: Option<&[String]>,
714    ) -> Result<ReadLinks, String> {
715        let mut links = self.links_of(name)?.to_vec();
716        links.sort_by_key(|l| {
717            (
718                l.role.map_or(4u8, |r| r.rank()),
719                l.priority.unwrap_or(i64::MAX),
720            )
721        });
722
723        let mut degraded: Vec<Value> = Vec::new();
724        if let Some(unreachable) = unreachable {
725            links.retain(|l| {
726                if unreachable.iter().any(|s| s == &l.source) {
727                    degraded.push(json!({
728                        "code": "readLinkUnreachable",
729                        "layer": "schema",
730                        "message": format!("schema {} 的链路 {} 不可达,已跳过", name, l.source),
731                        "hint": "检查该数据源连接;或调整 store.config.json 的 links 优先级",
732                        "schema": name,
733                        "source": l.source,
734                    }));
735                    false
736                } else {
737                    true
738                }
739            });
740        }
741
742        if links.is_empty() {
743            return Err(format!(
744                "ERR_READ_NO_REACHABLE_LINK: {} 的全部链路不可达(已按 unreachable 跳过)",
745                name
746            ));
747        }
748        Ok(ReadLinks { links, degraded })
749    }
750
751    /// 主落点(= `links[0]`)
752    pub fn primary_location(&self, name: &str) -> Result<&Location, String> {
753        self.get_entry(name)?
754            .links
755            .first()
756            .ok_or_else(|| format!("Schema 无落点链路: {}", name))
757    }
758
759    /// 版本(首次 = 1)
760    pub fn version_of(&self, name: &str) -> Result<u64, String> {
761        Ok(self.get_entry(name)?.version)
762    }
763
764    fn get_entry(&self, name: &str) -> Result<&Entry, String> {
765        self.catalog
766            .entries
767            .get(name)
768            .ok_or_else(|| format!("Schema 未注册: {}", name))
769    }
770
771    /// 按名称获取 schema
772    pub fn get(&self, name: &str) -> Result<&Schema, String> {
773        self.get_entry(name).map(|e| &e.schema)
774    }
775
776    pub fn has(&self, name: &str) -> bool {
777        self.catalog.entries.contains_key(name)
778    }
779
780    /// 清空 schema 注册表(`schemas` + `order`),对应绑定层的测试隔离 / 动态重建场景。
781    ///
782    /// 只清 schema,**不动**配置开关(`require_context` / `profile` / `rbac` /
783    /// `role_rules`)与回调表(`clear_fns` 对称:各清各的)——开关生命周期属
784    /// Registry 配置面,不随 schema 集合重建而丢。
785    ///
786    /// 非 base 视图调用即 `panic!`(消息携带 [`ERR_POLICY_VIEW_READONLY`] 前缀)——
787    /// 本方法返回 `()`(受「core 公开签名零改动」约束,无法回 `Result`),守卫仍显式、不静默。
788    pub fn clear(&mut self) {
789        if let Err(e) = self.ensure_base_catalog() {
790            panic!("{e}");
791        }
792        let catalog = Arc::make_mut(&mut self.catalog);
793        catalog.entries.clear();
794        catalog.order.clear();
795    }
796
797    /// 开关「上下文强制」(默认关闭 = fail-open,保持 JS parity)。
798    /// 开启后:plan 入口遇 `ctx: None` 报 `ERR_NO_CONTEXT`(fail-secure)。
799    /// 内部调用须显式传系统上下文(`{"internal": true}`)。
800    pub fn set_require_context(&mut self, require: bool) {
801        self.policy.require_context = require;
802    }
803
804    /// 「上下文强制」开关当前值
805    pub fn require_context(&self) -> bool {
806        self.policy.require_context
807    }
808
809    /// 设置查询档位(`standard` / `text2query`)。
810    ///
811    /// 档位为**单值状态**(非栈):由 Host 的 `text2query()` 上下文管理器负责
812    /// 进入时设档、退出时恢复(见 `py-store` / `nodejs-store` 门面)。判决一律在 core。
813    pub fn set_profile(&mut self, profile: Profile) {
814        self.policy.profile = profile;
815    }
816
817    /// 当前查询档位
818    pub fn profile(&self) -> Profile {
819        self.policy.profile
820    }
821
822    /// 豁免角色清单:命中者在一切判决环节(静态 + RBAC)直接放行。默认空——无豁免。
823    pub fn set_exempt_roles(&mut self, roles: Vec<String>) {
824        self.policy.role_rules.exempt_roles = roles;
825    }
826
827    /// 拒写角色清单:命中者一切写路径拒绝(读不受影响)。默认空——无拒写。
828    pub fn set_deny_write_roles(&mut self, roles: Vec<String>) {
829        self.policy.role_rules.deny_write_roles = roles;
830    }
831
832    /// schema 白名单缺失/为空时的默认姿态。默认 Open(保持现状语义)。
833    pub fn set_unconfigured_policy(&mut self, policy: crate::permission::UnconfiguredPolicy) {
834        self.policy.role_rules.unconfigured = policy;
835    }
836
837    /// 当前角色规则(静态判决函数与 RBAC decide 的共用取参入口)
838    pub fn role_rules(&self) -> &crate::permission::RoleRules {
839        &self.policy.role_rules
840    }
841
842    /// 定义层门禁策略(`closed=true` 时仅 internal 或 `roles` 白名单可注册/覆盖)。
843    /// 默认 Open —— 全放行(保持既有 parity;须在首次业务注册前调用方生效于该次注册)。
844    pub fn set_meta_policy(&mut self, closed: bool, roles: Vec<String>) {
845        self.policy.meta_policy = crate::permission::MetaPolicy { closed, roles };
846    }
847
848    /// 当前定义层门禁策略
849    pub fn meta_policy(&self) -> &crate::permission::MetaPolicy {
850        &self.policy.meta_policy
851    }
852
853    /// 设置跨连接写策略(默认 [`WriteLinkPolicy::Reject`])。
854    ///
855    /// 判决唯一在 core([`crate::command::write_links::resolve_write_links`]);
856    /// 宿主仅需在初始化期把它透传到 registry。
857    pub fn set_write_link_policy(&mut self, policy: WriteLinkPolicy) {
858        self.policy.write_link_policy = policy;
859    }
860
861    /// 当前跨连接写策略
862    pub fn write_link_policy(&self) -> WriteLinkPolicy {
863        self.policy.write_link_policy
864    }
865
866    /// 只读定义层判决(`workflow` 等宿主侧定义面复用同一门禁;判决唯一在 core)。
867    ///
868    /// 不改变任何注册状态:Open 全放行;Closed 仅 `internal` 或 `roles` 白名单角色。
869    /// 语义与 [`Self::register_with_ctx`] 的门禁判据**完全一致**(同一
870    /// [`can_register`](crate::permission::can_register)),仅作只读暴露。
871    pub fn can_register(&self, ctx: Option<&crate::permission::Context>) -> bool {
872        crate::permission::can_register(&self.policy.meta_policy, ctx)
873    }
874
875    /// 注入/清除 RBAC 动态策略;`None` = 关闭(判决原语直通)。
876    /// 解析失败 Err(fail-fast,禁静默吞配置错误),成功后判决链路即刻生效。
877    pub fn set_rbac(&mut self, policy: Option<&Value>) -> Result<(), String> {
878        match policy {
879            None => {
880                self.policy.rbac = None;
881                Ok(())
882            }
883            Some(v) => {
884                let p = crate::rbac::RbacPolicy::from_json(v)?;
885                self.policy.rbac = Some(p);
886                Ok(())
887            }
888        }
889    }
890
891    /// 当前 RBAC 策略(`None` = 未启用)
892    pub fn rbac(&self) -> Option<&crate::rbac::RbacPolicy> {
893        self.policy.rbac.as_ref()
894    }
895
896    /// 按定位四元组精确获取 schema(命令路由的唯一定位入口)
897    ///
898    /// 命中判据:`collection` 相等 **且** 四元组命中该 entry 的**任一链路**。
899    /// **不做任何猜测回落**。
900    pub fn get_by_location(
901        &self,
902        source: &str,
903        database: Option<&str>,
904        schema: Option<&str>,
905        collection: &str,
906    ) -> Result<&Schema, String> {
907        let db = database.filter(|s| !s.is_empty());
908        let sc = schema.filter(|s| !s.is_empty());
909        self.catalog
910            .entries
911            .values()
912            .find(|e| {
913                e.schema.collection == collection
914                    && e.links.iter().any(|l| {
915                        l.source == source
916                            && l.database.as_deref().filter(|s| !s.is_empty()) == db
917                            && l.schema.as_deref().filter(|s| !s.is_empty()) == sc
918                    })
919            })
920            .map(|e| &e.schema)
921            .ok_or_else(|| {
922                format!(
923                    "Schema 未定位(source = {}, database = {:?}, schema = {:?}, collection = {})",
924                    source, db, sc, collection
925                )
926            })
927    }
928
929    /// 命令结构定位(方言翻译用):优先四元组精确命中;未命中时(`route_override`
930    /// 改写落点的多租户场景)回落 `(source, collection)` 定位 —— 结构 schema
931    /// 与定位落点正交(见 multi-datasource-routing-plan.md §6:权限/字段校验仍按结构 schema)。
932    ///
933    /// `(source, collection)` 恰一候选 → 命中;多候选时取 `database`/`schema` 均 `null` 的
934    /// 结构声明,无该声明 → 报错(拒绝猜测,铁律 3)。
935    pub fn get_for_command(
936        &self,
937        source: &str,
938        database: Option<&str>,
939        schema: Option<&str>,
940        collection: &str,
941    ) -> Result<&Schema, String> {
942        if let Ok(s) = self.get_by_location(source, database, schema, collection) {
943            return Ok(s);
944        }
945        let candidates: Vec<&Entry> = self
946            .catalog
947            .entries
948            .values()
949            .filter(|e| {
950                e.schema.collection == collection && e.links.iter().any(|l| l.source == source)
951            })
952            .collect();
953        match candidates.len() {
954            0 => self.get_by_location(source, database, schema, collection),
955            1 => Ok(&candidates[0].schema),
956            _ => candidates
957                .iter()
958                .find(|e| e.schema.database().is_none() && e.schema.schema().is_none())
959                .map(|e| &e.schema)
960                .ok_or_else(|| {
961                    format!(
962                        "命令定位 ({}, {:?}, {:?}, {}) 存在多个结构 schema(多租户 override 需唯一的 (source, collection) 结构声明)",
963                        source, database, schema, collection
964                    )
965                }),
966        }
967    }
968
969    pub fn list(&self) -> Vec<String> {
970        self.catalog.order.clone()
971    }
972
973    /// schema 绑定的数据源名(= 主链路 source;`default` → `None`)
974    pub fn schema_datasource(&self, name: &str) -> Result<Option<String>, String> {
975        Ok(self.get(name)?.source.clone())
976    }
977
978    /// 解析 schema 绑定的数据源(`config` 为 `{ "sources": { name: kind } }`)
979    ///
980    /// 纯逻辑:不持有连接(连接句柄留在 Host)。`null` / 空配置下等价单源 Mongo,
981    /// 保证既有调用方与 parity fixture 零变更。见 [`crate::datasource`]。
982    pub fn resolve_datasource(
983        &self,
984        schema_name: &str,
985        config: &Value,
986    ) -> Result<crate::datasource::DataSource, String> {
987        let declared = self.get(schema_name)?.source.as_deref();
988        let cfg = crate::datasource::DataSourceConfig::from_json(config)?;
989        cfg.resolve(declared)
990    }
991}
992
993/// 由原始定义构建 [`Schema`](字段/关系/计算列/元数据分块解析,保持单一职责)。
994/// 落点(`source`/`database`/`schema`)由 `loc` 注入(定义文件零落点)。
995fn build_schema(
996    obj: &Map<String, Value>,
997    name: String,
998    collection: String,
999    loc: &Location,
1000) -> Result<Schema, String> {
1001    let timestamps = parse_timestamps(obj)?;
1002    let mut fields = normalize_fields(obj.get("fields"))?;
1003    // 向量字段注册期校验(A1):任一步 Err ⇒ 零副作用
1004    validate_vector_fields(&fields)?;
1005    if timestamps {
1006        add_timestamp_fields(&mut fields);
1007    }
1008    let relations = parse_relations(obj);
1009    // 计算列的 agg 需要校验「路径首段命中关系或数组字段」→ 必须晚于 relations/fields 解析
1010    let computes = parse_computes(obj, &relations, &fields)?;
1011    // 写触发器声明(onFields 校验依赖已归一 fields;非法声明注册期 Err)
1012    let triggers = super::triggers::parse_triggers(obj, &name, &fields)?;
1013    // `_score` 是向量检索评分列(04),与 `fields ∪ relations ∪ computes` 同属结果文档命名空间;
1014    // 字面保留名必须在注册期拒绝,避免用户字段被评分列覆盖或反之。
1015    // 注意不能用 `RESERVED_KEYS`:其 `norm_key` 会把 `_score` 归一为 `score`,
1016    // 连带误拒合法字段名 `score`(存量 schema 常见)。
1017    for (k, ns) in fields
1018        .keys()
1019        .map(|k| (k, "fields"))
1020        .chain(relations.keys().map(|k| (k, "relations")))
1021        .chain(computes.iter().map(|(k, _)| (k, "computes")))
1022    {
1023        if k == "_score" {
1024            return Err(format!(
1025                "{}名称 \"_score\" 为向量检索评分保留键,不得在 schema {ns} 中声明",
1026                crate::naming::ERR_NAME_CONFLICT
1027            ));
1028        }
1029    }
1030    // 命名冲突检测(A7,分组口径):`detect_conflicts` 的 `seen` 是调用内局部量,
1031    // 分组调用天然实现「组内 + 指定跨组」拦截:
1032    //   ① fields / relations / computes 各自组内归一撞名 ⇒ 报错(不变);
1033    //   ② fields ∩ relations、relations ∩ computes ⇒ 报错(不变);
1034    //   ③ fields ∩ computes **放行**:computed 值拥有同名键的输出所有权,fields 仍保留
1035    //      该键的类型声明(写入校验 / DDL / `$inc` 等原生增量更新),两者职责不同层。
1036    //      nodejs-store 的 schema 注册不做此检测,py-store / go-store 由此对齐同名覆盖语义。
1037    // `_id` 已在 detect_conflicts 内豁免;`_score` 由上方字面拦截。
1038    // fields 键空间完全开放、无字段名保留字——`type` 等契约键作字段名合法,
1039    // 契约键位于字段定义对象内部,与本层标识符不同层无冲突。
1040    crate::naming::detect_conflicts(fields.keys().map(String::as_str))?;
1041    crate::naming::detect_conflicts(relations.keys().map(String::as_str))?;
1042    crate::naming::detect_conflicts(computes.iter().map(|(k, _)| k.as_str()))?;
1043    crate::naming::detect_conflicts(
1044        fields
1045            .keys()
1046            .map(String::as_str)
1047            .chain(relations.keys().map(String::as_str)),
1048    )?;
1049    crate::naming::detect_conflicts(
1050        relations
1051            .keys()
1052            .map(String::as_str)
1053            .chain(computes.iter().map(|(k, _)| k.as_str())),
1054    )?;
1055    // accessKeys(声明式行级权限):外层键必须在已声明字段中,read/write/bypassRoles
1056    // 形态非法即注册期 Err(禁静默回落)。归档模型不继承(与 ownerKey 现状一致)。
1057    let access_keys = super::access_keys::parse_access_keys(obj, &fields)?;
1058    // ownerKey(漉金Core 接入扩展 01):归属键,缺省 = `createdBy`(零破坏);
1059    // 存在时必须为非空字符串,非法注册期显式 Err(禁静默回落)。
1060    // 不要求键必须出现在 fields 中(`_id` 等系统键合法)。
1061    let owner_key = match obj.get("ownerKey") {
1062        None | Some(Value::Null) => None,
1063        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
1064        Some(other) => {
1065            return Err(format!(
1066                "schema \"{name}\" 的 ownerKey 必须为非空字符串,实际 {other}"
1067            ))
1068        }
1069    };
1070    // archive(漉金Core 接入扩展 02):归档开关,可选 bool,缺省 true(零破坏);
1071    // false 时注册期不派生 `<Name>Deleted` 归档模型(plan_remove 按 registry.has
1072    // 判定,自然跳过归档副作用,删除行为本身不变)。
1073    match obj.get("archive") {
1074        None | Some(Value::Null) | Some(Value::Bool(_)) => {}
1075        Some(other) => {
1076            return Err(format!(
1077                "schema \"{name}\" 的 archive 必须为可选布尔值,实际 {other}"
1078            ))
1079        }
1080    }
1081    Ok(Schema {
1082        name,
1083        collection,
1084        id_prefix: obj
1085            .get("idPrefix")
1086            .and_then(|v| v.as_str())
1087            .unwrap_or("")
1088            .to_string(),
1089        timestamps,
1090        fields,
1091        relations,
1092        computes,
1093        triggers,
1094        read: str_list(obj.get("read")),
1095        write: str_list(obj.get("write")),
1096        indexes: obj
1097            .get("indexes")
1098            .and_then(|v| v.as_array())
1099            .cloned()
1100            .unwrap_or_default(),
1101        source: if loc.source == crate::datasource::DEFAULT_SOURCE {
1102            None
1103        } else {
1104            Some(loc.source.clone())
1105        },
1106        database: loc.database.clone().filter(|s| !s.is_empty()),
1107        schema: loc.schema.clone().filter(|s| !s.is_empty()),
1108        owner_key,
1109        access_keys,
1110    })
1111}
1112
1113/// `timestamps` 仅接受 true/false/'ms'/'s';单位换算在 Host(core 无时钟,now 由 Host 传入)
1114fn parse_timestamps(obj: &Map<String, Value>) -> Result<bool, String> {
1115    match obj.get("timestamps") {
1116        None | Some(Value::Null) | Some(Value::Bool(true)) => Ok(true),
1117        Some(Value::Bool(false)) => Ok(false),
1118        Some(Value::String(s)) if s == "ms" || s == "s" => Ok(true),
1119        Some(Value::String(s)) => Err(format!(
1120            "timestamps 仅支持 true/false/'ms'/'s',实际 {:?}",
1121            s
1122        )),
1123        Some(other) => Err(format!(
1124            "timestamps 仅支持 true/false/'ms'/'s',实际 {}",
1125            other
1126        )),
1127    }
1128}
1129
1130/// 自动补时间戳字段(`timestamps !== false` 时;已声明则不覆盖)
1131fn add_timestamp_fields(fields: &mut HashMap<String, FieldDef>) {
1132    for key in ["createdAt", "updatedAt"] {
1133        fields.entry(key.to_string()).or_insert_with(|| FieldDef {
1134            field_type: "number".to_string(),
1135            required: false,
1136            default: None,
1137            read: None,
1138            write: None,
1139            fields: None,
1140            strategy: None,
1141            dim: None,
1142            metric: None,
1143        });
1144    }
1145}
1146
1147fn parse_relations(obj: &Map<String, Value>) -> HashMap<String, RelationDef> {
1148    let mut relations = HashMap::new();
1149    if let Some(Value::Object(rm)) = obj.get("relations") {
1150        for (key, val) in rm {
1151            let vo = val.as_object();
1152            let get_str = |k: &str| -> Option<String> {
1153                vo.and_then(|o| o.get(k))
1154                    .and_then(|v| v.as_str())
1155                    .filter(|s| !s.is_empty())
1156                    .map(String::from)
1157            };
1158            relations.insert(
1159                key.clone(),
1160                RelationDef {
1161                    model: get_str("model").unwrap_or_default(),
1162                    rel_type: get_str("type").unwrap_or_else(|| "many".to_string()),
1163                    local_field: get_str("localField").unwrap_or_else(|| "_id".to_string()),
1164                    foreign_field: get_str("foreignField").unwrap_or_else(|| key.clone()),
1165                    read: str_list(vo.and_then(|o| o.get("read"))),
1166                },
1167            );
1168        }
1169    }
1170    relations
1171}
1172
1173fn parse_computes(
1174    obj: &Map<String, Value>,
1175    relations: &HashMap<String, RelationDef>,
1176    fields: &HashMap<String, FieldDef>,
1177) -> Result<Vec<(String, ComputeDef)>, String> {
1178    let mut computes = Vec::new();
1179    if let Some(Value::Object(cm)) = obj.get("computes") {
1180        for (key, val) in cm {
1181            let vo = val.as_object();
1182            let get = |k: &str| vo.and_then(|o| o.get(k)).cloned();
1183            let has_fn = get("fn").map(|v| is_truthy(&v)).unwrap_or(false);
1184            let has_async_fn = get("asyncFn").map(|v| is_truthy(&v)).unwrap_or(false);
1185            let agg = parse_agg(
1186                key,
1187                get("agg").as_ref(),
1188                relations,
1189                fields,
1190                str_list(get("depends").as_ref())
1191                    .unwrap_or_default()
1192                    .as_slice(),
1193                has_fn || has_async_fn,
1194            )?;
1195            computes.push((
1196                key.clone(),
1197                ComputeDef {
1198                    comp_type: get("type")
1199                        .and_then(|v| v.as_str().map(String::from))
1200                        .filter(|s| !s.is_empty())
1201                        .unwrap_or_else(|| "any".to_string()),
1202                    has_fn,
1203                    has_async_fn,
1204                    fn_ref: get("fnRef")
1205                        .and_then(|v| v.as_str().map(String::from))
1206                        .filter(|s| !s.is_empty()),
1207                    agg,
1208                    depends: str_list(get("depends").as_ref()).unwrap_or_default(),
1209                    read: str_list(get("read").as_ref()),
1210                },
1211            ));
1212        }
1213    }
1214    Ok(computes)
1215}
1216
1217/// 解析并校验归一聚合计算列(§9.2(2) + 漉金Core 接入扩展 04)。
1218///
1219/// - 算子白名单 [`COMPUTE_AGG_OPS`],不在白名单 → Err(绝不静默);
1220/// - agg 路径首段二分:
1221///   - 命中 **relations** → relation-agg(现状不变):`$count` 仅关系整行计数
1222///     (`lessons`,不接受字段),其余算子必须带字段(`lessons.duration`);
1223///   - 命中 **fields 且为数组类型** → inline-array agg:`$size` 仅数组本身
1224///     (`bidders`,不接受元素字段),其余算子必须带单级元素字段
1225///     (`bidders.amount`);`$count` 不适用(文档内数组计数用 `$size`);
1226///     且 `depends` 必须包含路径首段(宿主本地重算依据,缺失报错不兜底);
1227///   - 首段命中字段但非数组类型 / 两者皆未命中 → Err;
1228/// - 单级元素字段即止(`a.b.c` 中 b 为数组的深层再入数组不做,§7);
1229/// - 与 `fn` / `asyncFn` 互斥。
1230fn parse_agg(
1231    key: &str,
1232    raw: Option<&Value>,
1233    relations: &HashMap<String, RelationDef>,
1234    fields: &HashMap<String, FieldDef>,
1235    depends: &[String],
1236    has_callback: bool,
1237) -> Result<Option<Value>, String> {
1238    let Some(v) = raw.filter(|v| is_truthy(v)) else {
1239        return Ok(None);
1240    };
1241    if has_callback {
1242        return Err(format!(
1243            "计算列 \"{key}\" 不能同时声明 agg 与 fn/asyncFn(二选一)"
1244        ));
1245    }
1246    let obj = v.as_object().ok_or_else(|| {
1247        format!("计算列 \"{key}\" 的 agg 必须是对象,如 {{\"$count\": \"lessons\"}}")
1248    })?;
1249    let mut it = obj.iter();
1250    let (op, arg) = match (it.next(), it.next()) {
1251        (Some(kv), None) => kv,
1252        _ => return Err(format!("计算列 \"{key}\" 的 agg 必须且只能声明一个算子键")),
1253    };
1254    if !COMPUTE_AGG_OPS.contains(&op.as_str()) {
1255        return Err(format!(
1256            "计算列 \"{key}\" 的 agg 算子 {op} 不在白名单($count/$size/$sum/$avg/$min/$max/$countDistinct)"
1257        ));
1258    }
1259    let path = arg
1260        .as_str()
1261        .filter(|s| !s.is_empty() && *s != "*")
1262        .ok_or_else(|| {
1263            format!("计算列 \"{key}\" 的 agg 取值必须是路径字符串(如 \"lessons\" / \"lessons.duration\" / \"bidders.amount\")")
1264        })?;
1265    let (rel, field) = match path.split_once('.') {
1266        Some((r, f)) => (r, Some(f)),
1267        None => (path, None),
1268    };
1269    if field
1270        .map(|f| f.is_empty() || f.contains('.'))
1271        .unwrap_or(false)
1272    {
1273        return Err(format!(
1274            "计算列 \"{key}\" 的 agg 路径 \"{path}\" 非法:仅支持「关系.字段」/「数组.元素字段」单级路径(深层路径首批不支持)"
1275        ));
1276    }
1277    if relations.contains_key(rel) {
1278        // relation-agg:现状校验原样(04 红线:relation 分支零改动)
1279        match op.as_str() {
1280            "$count" if field.is_some() => Err(format!(
1281                "计算列 \"{key}\" 的 $count 仅支持关系整行计数(如 \"lessons\"),按字段计数待 P5"
1282            )),
1283            "$count" => Ok(Some(v.clone())),
1284            _ if field.is_none() => Err(format!(
1285                "计算列 \"{key}\" 的 agg 算子 {op} 必须引用关系字段(如 \"lessons.duration\")"
1286            )),
1287            _ => Ok(Some(v.clone())),
1288        }
1289    } else {
1290        match fields.get(rel) {
1291            Some(f) if f.field_type == "array" => {
1292                // inline-array agg
1293                if !depends.iter().any(|d| d == rel) {
1294                    return Err(format!(
1295                        "计算列 \"{key}\" 的 depends 必须包含 inline agg 路径首段 \"{rel}\"(宿主本地重算依据)"
1296                    ));
1297                }
1298                match op.as_str() {
1299                    "$size" if field.is_some() => Err(format!(
1300                        "计算列 \"{key}\" 的 $size 仅支持数组本身(如 \"{rel}\"),不接受元素字段"
1301                    )),
1302                    "$size" => Ok(Some(v.clone())),
1303                    "$count" => Err(format!(
1304                        "计算列 \"{key}\" 的 $count 不适用于数组字段:文档内数组计数用 $size(如 {{\"$size\": \"{rel}\"}})"
1305                    )),
1306                    _ if field.is_none() => Err(format!(
1307                        "计算列 \"{key}\" 的 agg 算子 {op} 必须引用数组元素字段(如 \"{rel}.amount\")"
1308                    )),
1309                    _ => Ok(Some(v.clone())),
1310                }
1311            }
1312            Some(f) => Err(format!(
1313                "计算列 \"{key}\" 的 agg 路径首段 \"{rel}\" 是字段但类型为 \"{}\"(非数组),inline agg 仅支持数组字段",
1314                f.field_type
1315            )),
1316            None => Err(format!(
1317                "计算列 \"{key}\" 的 agg 引用的路径首段 \"{rel}\" 既非已定义关系也非数组字段"
1318            )),
1319        }
1320    }
1321}
1322
1323/// 构造 `<Name>Deleted` 归档表 schema 定义(字段 = 原字段 + `deletedAt`)。
1324/// 归档表与原表同落点 —— 落点不由定义文件携带,改由批次继承主链路(见 `register_batch`)。
1325fn archive_defn(obj: &Map<String, Value>, name: &str, collection: &str) -> Value {
1326    let mut arch_fields = obj
1327        .get("fields")
1328        .and_then(|v| v.as_object())
1329        .cloned()
1330        .unwrap_or_default();
1331    arch_fields.insert("deletedAt".to_string(), json!({ "type": "number" }));
1332    // 剔除 `_id` 的自增策略:归档表显式拷贝源行 `_id` 值(upsert-by-_id 幂等),
1333    // 不走数据库自增;DDL 侧也据此保持归档表 `_id` 为普通主键列
1334    if let Some(idf) = arch_fields.get_mut("_id") {
1335        if let Some(o) = idf.as_object_mut() {
1336            o.remove("strategy");
1337        }
1338    }
1339    let arch = json!({
1340        "name": format!("{}Deleted", name),
1341        "collection": format!("{}_deleted", collection),
1342        "idPrefix": "",
1343        "_isArchive": true,
1344        "fields": Value::Object(arch_fields),
1345        "indexes": obj.get("indexes").cloned().unwrap_or_else(|| json!([])),
1346        // 归档表继承主表 timestamps 语义(true/false/'ms'/'s';缺省 = true 同 parse_timestamps)。
1347        // 此前派生 def 丢失该键 ⇒ 归档表恒按 true 注入 createdAt/updatedAt,主表为
1348        // timestamps:false 且字段含 created_at/updated_at(introspect 同步 snake_case 存量库)
1349        // 时归档构建即 ERR_NAME_CONFLICT(既有缺陷,4.9.0 修复)。
1350        "timestamps": obj.get("timestamps").cloned().unwrap_or(json!(true)),
1351    });
1352    arch
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::*;
1358
1359    #[test]
1360    fn write_link_policy_defaults_to_reject() {
1361        let r = Registry::new();
1362        assert_eq!(r.write_link_policy(), WriteLinkPolicy::Reject);
1363        let mut r2 = Registry::new();
1364        assert_eq!(r2.write_link_policy(), WriteLinkPolicy::Reject);
1365        r2.set_write_link_policy(WriteLinkPolicy::PrimaryOnly);
1366        assert_eq!(r2.write_link_policy(), WriteLinkPolicy::PrimaryOnly);
1367    }
1368
1369    // ─── R2 D1:目录快照 + 策略束 + 派生视图(V4 落地) ──────────────
1370
1371    use crate::permission::UnconfiguredPolicy;
1372
1373    /// T1 全覆盖覆盖项(六字段悉数改写;`rbac: null` = 显式清除)
1374    fn overrides_all() -> PolicyOverrides {
1375        PolicyOverrides::from_value(&json!({
1376            "profile": "standard",
1377            "requireContext": false,
1378            "rbac": null,
1379            "roleRules": { "exemptRoles": ["x"], "denyWriteRoles": ["y"], "unconfigured": "open" },
1380            "metaPolicy": { "closed": false, "roles": [] },
1381            "writeLinkPolicy": "reject"
1382        }))
1383        .expect("合法覆盖 JSON")
1384    }
1385
1386    /// T1:with_policy 覆盖矩阵——「缺省继承」与「全覆盖」两态 + base 零污染
1387    #[test]
1388    fn with_policy_override_matrix() {
1389        let mut base = Registry::new();
1390        base.set_require_context(true);
1391        base.set_profile(Profile::Text2Query);
1392        base.set_rbac(Some(&json!({}))).expect("合法 RBAC");
1393        base.set_exempt_roles(vec!["a".to_string()]);
1394        base.set_deny_write_roles(vec!["b".to_string()]);
1395        base.set_unconfigured_policy(UnconfiguredPolicy::Closed);
1396        base.set_meta_policy(true, vec!["m".to_string()]);
1397        base.set_write_link_policy(WriteLinkPolicy::PrimaryOnly);
1398
1399        // 缺省继承态:空覆盖 = 全继承来源视图
1400        let inherit = base.with_policy(&PolicyOverrides::default());
1401        assert!(inherit.is_view(), "派生视图 is_view 应为真");
1402        assert!(inherit.require_context());
1403        assert_eq!(inherit.profile(), Profile::Text2Query);
1404        assert!(inherit.rbac().is_some());
1405        assert_eq!(inherit.role_rules().exempt_roles, vec!["a".to_string()]);
1406        assert_eq!(inherit.role_rules().deny_write_roles, vec!["b".to_string()]);
1407        assert_eq!(
1408            inherit.role_rules().unconfigured,
1409            UnconfiguredPolicy::Closed
1410        );
1411        assert!(inherit.meta_policy().closed);
1412        assert_eq!(inherit.meta_policy().roles, vec!["m".to_string()]);
1413        assert_eq!(inherit.write_link_policy(), WriteLinkPolicy::PrimaryOnly);
1414
1415        // 全覆盖态:六字段全部改写(rbac 走 Some(None) 清除)
1416        let full = base.with_policy(&overrides_all());
1417        assert!(!full.require_context());
1418        assert_eq!(full.profile(), Profile::Standard);
1419        assert!(full.rbac().is_none(), "rbac:null 应清除");
1420        assert_eq!(full.role_rules().exempt_roles, vec!["x".to_string()]);
1421        assert_eq!(full.role_rules().deny_write_roles, vec!["y".to_string()]);
1422        assert_eq!(full.role_rules().unconfigured, UnconfiguredPolicy::Open);
1423        assert!(!full.meta_policy().closed);
1424        assert_eq!(full.write_link_policy(), WriteLinkPolicy::Reject);
1425
1426        // base 零污染
1427        assert!(base.require_context());
1428        assert_eq!(base.profile(), Profile::Text2Query);
1429        assert!(base.rbac().is_some());
1430        assert!(base.meta_policy().closed);
1431    }
1432
1433    /// T2a:视图注册类方法报 `ERR_POLICY_VIEW_READONLY`(前缀可匹配)
1434    #[test]
1435    fn view_register_is_readonly_err() {
1436        let base = Registry::new();
1437        let mut view = base.with_policy(&PolicyOverrides::default());
1438        let err = view
1439            .register(&json!({ "name": "A", "fields": {} }))
1440            .expect_err("视图注册应 Err");
1441        assert!(
1442            err.starts_with(ERR_POLICY_VIEW_READONLY),
1443            "前缀可匹配: {err}"
1444        );
1445        let err2 = view
1446            .register_with_ctx(&json!({ "name": "A", "fields": {} }), None)
1447            .expect_err("视图注册应 Err");
1448        assert!(
1449            err2.starts_with(ERR_POLICY_VIEW_READONLY),
1450            "前缀可匹配: {err2}"
1451        );
1452        let err3 = view
1453            .register_batch(
1454                &[(json!({ "name": "A", "fields": {} }), Location::default())],
1455                None,
1456            )
1457            .expect_err("视图批量注册应 Err");
1458        assert!(
1459            err3.starts_with(ERR_POLICY_VIEW_READONLY),
1460            "前缀可匹配: {err3}"
1461        );
1462    }
1463
1464    /// T2b:视图 `clear`(返回 `()`,受签名零改动约束)以 panic 显式守卫
1465    #[test]
1466    #[should_panic(expected = "ERR_POLICY_VIEW_READONLY")]
1467    fn view_clear_panics_readonly() {
1468        let base = Registry::new();
1469        let mut view = base.with_policy(&PolicyOverrides::default());
1470        view.clear();
1471    }
1472
1473    /// fields 键空间完全开放(原初设计修复回归):契约键 `type` 作字段名合法,
1474    /// 类型声明在定义对象内部的 `type` 键——不同对象层,注册成功且定义保真。
1475    #[test]
1476    fn field_named_type_is_allowed() {
1477        let mut r = Registry::new();
1478        r.register(&json!({
1479            "name": "Msg", "collection": "msg", "timestamps": false,
1480            "fields": {
1481                "_id": { "type": "string" },
1482                "type": { "type": "string", "default": "text" },
1483                "content": { "type": "string" }
1484            },
1485            "relations": {}
1486        }))
1487        .expect("字段名 type 合法,注册应成功");
1488        let s = r.get("Msg").expect("可取");
1489        let f = s.fields.get("type").expect("type 字段存在");
1490        assert_eq!(f.field_type, "string");
1491        assert_eq!(f.default, Some(json!("text")));
1492    }
1493
1494    /// 归档派生继承主 def 的 timestamps 语义(4.9.0 修复回归):timestamps:false +
1495    /// snake_case 时间戳列(introspect 同步存量库场景)。修复前派生 def 丢 timestamps
1496    /// 键 ⇒ 归档表恒按 true 注入 createdAt/updatedAt,与主表 created_at/updated_at
1497    /// 归一撞名 ERR_NAME_CONFLICT。
1498    #[test]
1499    fn archive_derivation_inherits_timestamps() {
1500        let mut r = Registry::new();
1501        r.register_batch(
1502            &[(
1503                json!({
1504                    "name": "Vector", "collection": "vector", "timestamps": false,
1505                    "fields": {
1506                        "_id": { "type": "string" },
1507                        "created_at": { "type": "string" },
1508                        "updated_at": { "type": "string" }
1509                    }
1510                }),
1511                Location::default(),
1512            )],
1513            None,
1514        )
1515        .expect("timestamps:false + snake_case 时间戳列应注册成功(归档派生不撞名)");
1516        // 主表:不自动注入
1517        let s = r.get("Vector").expect("主表可取");
1518        assert!(!s.timestamps);
1519        assert!(s.fields.contains_key("created_at"));
1520        assert!(
1521            !s.fields.contains_key("createdAt"),
1522            "timestamps:false 不注入 createdAt"
1523        );
1524        // 归档表:继承 timestamps:false,保留主表字段且不自动注入
1525        let arch = r.get("VectorDeleted").expect("归档表自动派生");
1526        assert_eq!(arch.collection, "vector_deleted");
1527        assert!(!arch.timestamps, "归档表应继承主表 timestamps:false");
1528        assert!(arch.fields.contains_key("created_at"), "归档表保留主表字段");
1529        assert!(
1530            !arch.fields.contains_key("createdAt"),
1531            "归档表继承 timestamps:false,不注入 createdAt"
1532        );
1533    }
1534
1535    /// T3:目录快照——派生视图见 base 已注册目录;快照不承诺 base 后续注册可见
1536    #[test]
1537    fn view_sees_catalog_snapshot() {
1538        let mut base = Registry::new();
1539        base.register(&json!({ "name": "A", "fields": { "x": { "type": "number" } } }))
1540            .expect("base 注册成功");
1541        let view = base.with_policy(&PolicyOverrides::default());
1542        assert!(view.has("A"), "派生视图应见 base 已注册目录");
1543        assert_eq!(view.get("A").expect("视图可取").name, "A");
1544        assert_eq!(view.list(), base.list());
1545
1546        // 快照语义:在途视图不承诺看到 base 后续注册;新派生视图可见
1547        let mut base2 = Registry::new();
1548        base2
1549            .register(&json!({ "name": "A", "fields": {} }))
1550            .unwrap();
1551        let snap = base2.with_policy(&PolicyOverrides::default());
1552        base2
1553            .register(&json!({ "name": "B", "fields": {} }))
1554            .unwrap();
1555        assert!(snap.has("A"), "在途视图仍持其快照");
1556        let fresh = base2.with_policy(&PolicyOverrides::default());
1557        assert!(fresh.has("B"), "新派生视图应见 base 后续注册");
1558    }
1559
1560    /// T4:视图 `set_*` 只影响自身(base 与兄弟视图不受影响)
1561    #[test]
1562    fn view_policy_set_isolated() {
1563        let base = Registry::new();
1564        let mut view = base.with_policy(&PolicyOverrides::default());
1565        view.set_profile(Profile::Text2Query);
1566        assert_eq!(view.profile(), Profile::Text2Query);
1567        assert_eq!(base.profile(), Profile::Standard, "视图 set 不影响 base");
1568        let sibling = base.with_policy(&PolicyOverrides::default());
1569        assert_eq!(sibling.profile(), Profile::Standard, "不影响兄弟视图");
1570    }
1571
1572    /// T5:`from_value` 未知键 / 非法值 Err;`rbac:null` 清除、缺键继承、空对象合法
1573    #[test]
1574    fn from_value_unknown_key_and_rbac_null() {
1575        let e = PolicyOverrides::from_value(&json!({ "nope": 1 })).expect_err("未知键应 Err");
1576        assert!(e.to_string().starts_with(ERR_POLICY_VIEW_READONLY), "{e}");
1577        assert!(
1578            PolicyOverrides::from_value(&json!({ "profile": "bogus" })).is_err(),
1579            "非法 profile 应 Err(禁静默回落)"
1580        );
1581        assert!(
1582            PolicyOverrides::from_value(&json!({ "writeLinkPolicy": "bogus" })).is_err(),
1583            "非法 writeLinkPolicy 应 Err"
1584        );
1585        assert!(
1586            PolicyOverrides::from_value(&json!({})).is_ok(),
1587            "空对象合法"
1588        );
1589
1590        let mut base = Registry::new();
1591        base.set_rbac(Some(&json!({}))).unwrap();
1592        let cleared =
1593            base.with_policy(&PolicyOverrides::from_value(&json!({ "rbac": null })).unwrap());
1594        assert!(cleared.rbac().is_none(), "rbac:null 应清除");
1595        let kept = base.with_policy(&PolicyOverrides::from_value(&json!({})).unwrap());
1596        assert!(kept.rbac().is_some(), "缺键应继承");
1597        assert!(base.rbac().is_some(), "base 不受影响");
1598    }
1599
1600    // ─── 04 inline-array agg:parse_agg 路径首段二分校验 ─────────────
1601
1602    /// 仅解析 computes(供 parse_agg 各分支直接断言,不经 Registry 全链路)
1603    fn agg_computes(obj: &Value) -> Result<Vec<(String, ComputeDef)>, String> {
1604        let o = obj.as_object().expect("obj");
1605        let rels = parse_relations(o);
1606        let fields = normalize_fields(o.get("fields"))?;
1607        parse_computes(o, &rels, &fields)
1608    }
1609
1610    /// 七算子 inline 合法形态全通过($size/$max/$countDistinct/$sum/$avg/$min)
1611    #[test]
1612    fn inline_agg_all_ops_ok() {
1613        let def = json!({
1614            "name": "Auction", "collection": "auctions", "timestamps": false,
1615            "fields": { "bidders": { "type": "array" } },
1616            "relations": {},
1617            "computes": {
1618                "biddersCount": { "type": "int", "agg": { "$size": "bidders" }, "depends": ["bidders"] },
1619                "maxBid": { "type": "number", "agg": { "$max": "bidders.amount" }, "depends": ["bidders"] },
1620                "bidderCount": { "type": "int", "agg": { "$countDistinct": "bidders.bidderId" }, "depends": ["bidders"] },
1621                "sumBid": { "type": "number", "agg": { "$sum": "bidders.amount" }, "depends": ["bidders"] },
1622                "avgBid": { "type": "number", "agg": { "$avg": "bidders.amount" }, "depends": ["bidders"] },
1623                "minBid": { "type": "number", "agg": { "$min": "bidders.amount" }, "depends": ["bidders"] }
1624            }
1625        });
1626        let computes = agg_computes(&def).expect("六算子 inline agg 均合法");
1627        assert_eq!(computes.len(), 6);
1628        for (_, c) in &computes {
1629            assert!(c.agg.is_some(), "inline agg 应保留");
1630        }
1631    }
1632
1633    /// inline agg 的 depends 必须包含路径首段(宿主本地重算依据)
1634    #[test]
1635    fn inline_agg_without_depends_head_is_err() {
1636        let def = json!({
1637            "name": "A", "collection": "a", "timestamps": false,
1638            "fields": { "tags": { "type": "array" } },
1639            "relations": {},
1640            "computes": { "n": { "type": "int", "agg": { "$size": "tags" } } }
1641        });
1642        let err = agg_computes(&def).expect_err("缺 depends 应 Err");
1643        assert!(err.contains("depends"), "{err}");
1644    }
1645
1646    /// $size 仅支持数组本身,不接受元素字段
1647    #[test]
1648    fn inline_agg_size_with_element_field_is_err() {
1649        let def = json!({
1650            "name": "A", "collection": "a", "timestamps": false,
1651            "fields": { "tags": { "type": "array" } },
1652            "relations": {},
1653            "computes": { "n": { "type": "int", "agg": { "$size": "tags.x" }, "depends": ["tags"] } }
1654        });
1655        let err = agg_computes(&def).expect_err("$size 带元素字段应 Err");
1656        assert!(err.contains("仅支持数组本身"), "{err}");
1657    }
1658
1659    /// 文档内数组计数用 $size,$count 不适用于数组字段
1660    #[test]
1661    fn inline_agg_count_is_err() {
1662        let def = json!({
1663            "name": "A", "collection": "a", "timestamps": false,
1664            "fields": { "tags": { "type": "array" } },
1665            "relations": {},
1666            "computes": { "n": { "type": "int", "agg": { "$count": "tags" }, "depends": ["tags"] } }
1667        });
1668        let err = agg_computes(&def).expect_err("数组 $count 应 Err");
1669        assert!(err.contains("$size"), "{err}");
1670    }
1671
1672    /// $max 等元素算子必须引用数组元素字段
1673    #[test]
1674    fn inline_agg_element_op_without_field_is_err() {
1675        let def = json!({
1676            "name": "A", "collection": "a", "timestamps": false,
1677            "fields": { "tags": { "type": "array" } },
1678            "relations": {},
1679            "computes": { "n": { "type": "int", "agg": { "$max": "tags" }, "depends": ["tags"] } }
1680        });
1681        let err = agg_computes(&def).expect_err("$max 无元素字段应 Err");
1682        assert!(err.contains("元素字段"), "{err}");
1683    }
1684
1685    /// 路径首段是字段但类型非数组 → Err
1686    #[test]
1687    fn agg_head_non_array_field_is_err() {
1688        let def = json!({
1689            "name": "A", "collection": "a", "timestamps": false,
1690            "fields": { "state": { "type": "string" } },
1691            "relations": {},
1692            "computes": { "n": { "type": "int", "agg": { "$size": "state" }, "depends": ["state"] } }
1693        });
1694        let err = agg_computes(&def).expect_err("非数组字段应 Err");
1695        assert!(err.contains("非数组"), "{err}");
1696    }
1697
1698    /// 路径首段既非关系也非字段 → Err
1699    #[test]
1700    fn agg_head_unknown_is_err() {
1701        let def = json!({
1702            "name": "A", "collection": "a", "timestamps": false,
1703            "fields": { "state": { "type": "string" } },
1704            "relations": {},
1705            "computes": { "n": { "type": "int", "agg": { "$size": "ghost" }, "depends": ["ghost"] } }
1706        });
1707        let err = agg_computes(&def).expect_err("未知首段应 Err");
1708        assert!(err.contains("既非已定义关系也非数组字段"), "{err}");
1709    }
1710
1711    /// relation 分支零改动红线:$count 整行 / $sum 字段合法;$count 带字段仍 Err;
1712    /// relation 不做 inline 的 depends 校验
1713    #[test]
1714    fn relation_agg_unchanged_by_inline_extension() {
1715        let def = json!({
1716            "name": "Shop", "collection": "shops", "timestamps": false,
1717            "fields": { "name": { "type": "string" } },
1718            "relations": { "orders": { "model": "Order", "type": "many", "localField": "_id", "foreignField": "shopId" } },
1719            "computes": {
1720                "orderCount": { "type": "int", "agg": { "$count": "orders" } },
1721                "totalAmount": { "type": "number", "agg": { "$sum": "orders.amount" } }
1722            }
1723        });
1724        let computes = agg_computes(&def).expect("relation agg 不受 04 扩展影响");
1725        assert_eq!(computes.len(), 2);
1726
1727        let bad = json!({
1728            "name": "Shop", "collection": "shops", "timestamps": false,
1729            "fields": { "name": { "type": "string" } },
1730            "relations": { "orders": { "model": "Order", "type": "many", "localField": "_id", "foreignField": "shopId" } },
1731            "computes": { "n": { "type": "int", "agg": { "$count": "orders.orderId" } } }
1732        });
1733        let err = agg_computes(&bad).expect_err("relation $count 带字段仍应 Err");
1734        assert!(err.contains("整行计数"), "{err}");
1735    }
1736}