From 25cd58bdbcb0b25a5ec475a2540247b4d9af66f8 Mon Sep 17 00:00:00 2001 From: Diffora Date: Tue, 23 Jun 2026 20:12:17 +0200 Subject: [PATCH] feat(account-management): add child_count to tenant read shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the number of direct children visible to the caller on every tenant read shape — listChildren page rows and the single-tenant get/update/lifecycle responses. - repo: count_children_grouped — one scope-filtered `COUNT ... GROUP BY parent_id` per page (no N+1). Excludes the AM-internal Provisioning status; includes soft-Deleted children. Distinct from the delete-saga count_children guard (allow_all, always counts Provisioning), which is left untouched. - service: lower_to_tenant / lower_to_tenant_page now take the caller scope and fill child_count; list_children passes the original barrier-respecting scope (not the relaxed one) so a self-managed child's own subtree stays scope-filtered and counts to 0. - sdk/REST: child_count: u32 on Tenant + TenantDto, plus the OpenAPI schema and the regenerated docs/api/api.json (make openapi). - tests: grouped-count repo integration (provisioning excluded, deleted included, empty input) + per-child service coverage + dto wire-shape. Signed-off-by: Diffora --- docs/api/api.json | 7 ++ .../account-management-sdk/src/tenant.rs | 10 ++ .../account-management/src/api/rest/dto.rs | 8 ++ .../src/api/rest/dto_tests.rs | 2 + .../src/api/rest/handlers/tenants_tests.rs | 1 + .../src/domain/tenant/repo.rs | 16 ++++ .../src/domain/tenant/service/mod.rs | 51 ++++++++-- .../domain/tenant/service/service_tests.rs | 43 +++++++++ .../src/domain/tenant/test_support/repo.rs | 19 ++++ .../src/infra/storage/repo_impl/mod.rs | 9 ++ .../src/infra/storage/repo_impl/reads.rs | 64 ++++++++++++- .../tests/list_children_integration.rs | 95 +++++++++++++++++++ .../docs/account-management-v1.yaml | 11 +++ 13 files changed, 327 insertions(+), 9 deletions(-) diff --git a/docs/api/api.json b/docs/api/api.json index 43f516b2d..b3bb1a7d9 100644 --- a/docs/api/api.json +++ b/docs/api/api.json @@ -3667,6 +3667,12 @@ "TenantDto": { "description": "AM-internal projection. Wider than `tenant_resolver_sdk::TenantInfo` for\nadmin/UI consumers (carries lifecycle timestamps + depth).\n\nInternal columns (raw `tenant_type_uuid`, retention/claim columns) never\ncross this boundary. `tenant_type: None` only on a transient registry\nblip — reads do not block on it.", "properties": { + "child_count": { + "description": "Number of **direct** children visible to the caller, resolved at\nread time: scope-filtered (children behind a self-managed barrier\nthe caller cannot penetrate are not counted), excludes\nAM-internal `provisioning` rows, includes soft-`deleted`\nchildren. `0` for a leaf. Derived field (not a stored column) —\nnot available in `$filter` / `$orderby`.", + "format": "int32", + "minimum": 0, + "type": "integer" + }, "created_at": { "format": "date-time", "type": "string" @@ -3721,6 +3727,7 @@ "status", "self_managed", "depth", + "child_count", "created_at", "updated_at" ], diff --git a/gears/system/account-management/account-management-sdk/src/tenant.rs b/gears/system/account-management/account-management-sdk/src/tenant.rs index 7b51c2719..ceff0e041 100644 --- a/gears/system/account-management/account-management-sdk/src/tenant.rs +++ b/gears/system/account-management/account-management-sdk/src/tenant.rs @@ -87,6 +87,16 @@ pub struct Tenant { /// Hierarchy depth recorded at insert. `0` for the root, `parent.depth + 1` /// for every child. pub depth: u32, + /// Number of **direct** children of this tenant visible to the + /// caller, resolved at read time. Scope-filtered (direct children + /// behind a self-managed barrier the caller cannot penetrate are + /// not counted), **excludes** AM-internal `Provisioning` rows, and + /// **includes** soft-`Deleted` children. `0` for a leaf. This is a + /// derived, read-only field — not a stored column — so it is + /// unavailable in `$filter` / `$orderby` over the `listChildren` + /// surface. + #[serde(default)] + pub child_count: u32, #[serde(with = "rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "rfc3339")] diff --git a/gears/system/account-management/account-management/src/api/rest/dto.rs b/gears/system/account-management/account-management/src/api/rest/dto.rs index 887e05409..bf32ac9a9 100644 --- a/gears/system/account-management/account-management/src/api/rest/dto.rs +++ b/gears/system/account-management/account-management/src/api/rest/dto.rs @@ -296,6 +296,13 @@ pub struct TenantDto { pub parent_id: Option, pub self_managed: bool, pub depth: u32, + /// Number of **direct** children visible to the caller, resolved at + /// read time: scope-filtered (children behind a self-managed barrier + /// the caller cannot penetrate are not counted), excludes + /// AM-internal `provisioning` rows, includes soft-`deleted` + /// children. `0` for a leaf. Derived field (not a stored column) — + /// not available in `$filter` / `$orderby`. + pub child_count: u32, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] @@ -323,6 +330,7 @@ impl TenantDto { parent_id: tenant.parent_id.map(|t| t.0), self_managed: tenant.self_managed, depth: tenant.depth, + child_count: tenant.child_count, created_at: tenant.created_at, updated_at: tenant.updated_at, deleted_at: tenant.deleted_at, diff --git a/gears/system/account-management/account-management/src/api/rest/dto_tests.rs b/gears/system/account-management/account-management/src/api/rest/dto_tests.rs index 69fe97374..7fa16f143 100644 --- a/gears/system/account-management/account-management/src/api/rest/dto_tests.rs +++ b/gears/system/account-management/account-management/src/api/rest/dto_tests.rs @@ -399,6 +399,7 @@ fn sample_active_tenant() -> Tenant { parent_id: Some(sample_parent_id()), self_managed: false, depth: 2, + child_count: 3, created_at: sample_created(), updated_at: sample_updated_tenant(), deleted_at: None, @@ -419,6 +420,7 @@ fn tenant_dto_active_wire_shape_mirrors_openapi() { "parent_id": "44444444-4444-4444-4444-444444444444", "self_managed": false, "depth": 2, + "child_count": 3, "created_at": "2026-05-01T09:30:00Z", "updated_at": "2026-05-16T12:00:00Z", }), diff --git a/gears/system/account-management/account-management/src/api/rest/handlers/tenants_tests.rs b/gears/system/account-management/account-management/src/api/rest/handlers/tenants_tests.rs index 687f076c4..9c6d8970e 100644 --- a/gears/system/account-management/account-management/src/api/rest/handlers/tenants_tests.rs +++ b/gears/system/account-management/account-management/src/api/rest/handlers/tenants_tests.rs @@ -97,6 +97,7 @@ fn sample_active_tenant() -> Tenant { parent_id: Some(TenantId(sample_parent_id())), self_managed: false, depth: 2, + child_count: 0, created_at: sample_created(), updated_at: sample_updated(), deleted_at: None, diff --git a/gears/system/account-management/account-management/src/domain/tenant/repo.rs b/gears/system/account-management/account-management/src/domain/tenant/repo.rs index 7e0671d94..e2c710c83 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/repo.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/repo.rs @@ -19,6 +19,7 @@ //! status) and rewrites `tenant_closure.descendant_status` atomically //! when `status` changes. +use std::collections::HashMap; use std::time::Duration; use async_trait::async_trait; @@ -367,6 +368,21 @@ pub trait TenantRepo: Send + Sync { filter: ChildCountFilter, ) -> Result; + /// Direct-child counts for a batch of parent ids, keyed by parent. + /// + /// Public-surface counterpart to [`count_children`]: **scope-filtered** + /// (direct children behind a self-managed barrier the caller cannot + /// reach count as `0`) and **excludes `Provisioning`** (no public + /// representation) while **including `Deleted`**. One grouped query + /// covers the whole batch; parents with no matching child are absent + /// from the map (callers default them to `0`). Powers the + /// `child_count` field on the public tenant read shape. + async fn count_children_grouped( + &self, + scope: &AccessScope, + parent_ids: &[Uuid], + ) -> Result, DomainError>; + /// Count live tenants grouped by `(status, self_managed)` for the /// `am_tenants` inventory gauge. Visibility is bounded by `scope` /// (the periodic refresher passes `AccessScope::allow_all` for a diff --git a/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs b/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs index 4c004005b..02732aa17 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs @@ -367,7 +367,11 @@ impl TenantService { /// `TryFrom` so a bypass surfaces as /// [`DomainError::Internal`] (HTTP 500) instead of a process /// panic. - async fn lower_to_tenant(&self, model: TenantModel) -> Result { + async fn lower_to_tenant( + &self, + scope: &AccessScope, + model: TenantModel, + ) -> Result { let tenant_type = self.resolve_tenant_type(model.tenant_type_uuid).await; let status = account_management_sdk::TenantStatus::try_from(model.status).map_err(|_| { @@ -380,6 +384,16 @@ impl TenantService { cause: None, } })?; + // Derived public read-shape field: count of direct children + // visible to the caller — scope-filtered, excludes Provisioning, + // includes Deleted. One indexed grouped COUNT for this single id. + let child_count = self + .repo + .count_children_grouped(scope, &[model.id]) + .await? + .get(&model.id) + .copied() + .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)); Ok(Tenant { id: TenantId(model.id), name: model.name, @@ -388,6 +402,7 @@ impl TenantService { parent_id: model.parent_id.map(TenantId), self_managed: model.self_managed, depth: model.depth, + child_count, created_at: model.created_at, updated_at: model.updated_at, deleted_at: model.deleted_at, @@ -399,6 +414,7 @@ impl TenantService { /// scales with number of pages, not number of rows. async fn lower_to_tenant_page( &self, + scope: &AccessScope, page: Page, ) -> Result, DomainError> { let Page { items, page_info } = page; @@ -423,6 +439,12 @@ impl TenantService { } } + // One grouped COUNT covers the whole page's direct-child tallies + // (scope-filtered, excludes Provisioning, includes Deleted) — no + // N+1 across rows. Ids absent from the map default to 0. + let child_ids: Vec = items.iter().map(|m| m.id).collect(); + let child_counts = self.repo.count_children_grouped(scope, &child_ids).await?; + let mut mapped: Vec = Vec::with_capacity(items.len()); for m in items { let status = @@ -444,6 +466,10 @@ impl TenantService { parent_id: m.parent_id.map(TenantId), self_managed: m.self_managed, depth: m.depth, + child_count: child_counts + .get(&m.id) + .copied() + .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)), created_at: m.created_at, updated_at: m.updated_at, deleted_at: m.deleted_at, @@ -1133,7 +1159,12 @@ impl TenantService { // `is_sdk_visible` invariant is safe. // tenant_type forwarded verbatim — chain already validated // upstream, no need for a registry RTT. - let mut info = self.lower_to_tenant(activated).await?; + // A freshly created tenant is always a 0-child leaf; `child_count` + // resolves to 0 regardless of scope, so the saga's `allow_all` + // posture is fine here. + let mut info = self + .lower_to_tenant(&AccessScope::allow_all(), activated) + .await?; if info.tenant_type.is_none() { // `Tenant.tenant_type` is `Option` per the // tenant-resolver-sdk public shape; lower the typed @@ -1421,7 +1452,7 @@ impl TenantService { resource: id.to_string(), }); } - self.lower_to_tenant(tenant).await + self.lower_to_tenant(&scope, tenant).await } // @cpt-end:cpt-cf-account-management-dod-tenant-hierarchy-management-tenant-read-scope:p1:inst-dod-read-scope-service // @cpt-end:cpt-cf-account-management-flow-tenant-hierarchy-management-read-tenant:p1:inst-flow-read-service @@ -1487,7 +1518,11 @@ impl TenantService { // through the repo filter before we lower the page to the // public shape (where `Provisioning` is unrepresentable). page.items.retain(|r| r.status.is_sdk_visible()); - self.lower_to_tenant_page(page).await + // Pass the original (barrier-respecting) scope, not the + // `relaxed` one used for the listing query: a self-managed + // child's own subtree stays behind its barrier, so its + // `child_count` is scope-filtered to what the caller may see. + self.lower_to_tenant_page(&scope, page).await } // @cpt-end:cpt-cf-account-management-dod-tenant-hierarchy-management-children-query-paginated:p1:inst-dod-children-query-service // @cpt-end:cpt-cf-account-management-flow-tenant-hierarchy-management-list-children:p1:inst-flow-listch-service @@ -1601,7 +1636,7 @@ impl TenantService { ); } - self.lower_to_tenant(updated).await + self.lower_to_tenant(&scope, updated).await } // @cpt-end:cpt-cf-account-management-dod-tenant-hierarchy-management-update-mutable-only:p1:inst-dod-update-mutable-service // @cpt-end:cpt-cf-account-management-flow-tenant-hierarchy-management-update-tenant:p1:inst-flow-update-service @@ -1720,7 +1755,7 @@ impl TenantService { "am tenant state changed" ); } - self.lower_to_tenant(updated).await + self.lower_to_tenant(&scope, updated).await } // ----------------------------------------------------------------- @@ -1796,7 +1831,7 @@ impl TenantService { // downstream filters can distinguish first-write events from // idempotent retries. if matches!(tenant.status, TenantStatus::Deleted) { - return self.lower_to_tenant(tenant).await; + return self.lower_to_tenant(&scope, tenant).await; } // 1. Child-rejection guard. `include_deleted = false` excludes // ONLY rows in `Deleted` status; `Provisioning`, `Active` and @@ -1855,7 +1890,7 @@ impl TenantService { retention_secs = self.cfg.retention.default_window_secs, "am tenant state changed" ); - self.lower_to_tenant(updated).await + self.lower_to_tenant(&scope, updated).await } // @cpt-end:cpt-cf-account-management-dod-tenant-hierarchy-management-data-lifecycle:p1:inst-dod-data-lifecycle-soft-delete // @cpt-end:cpt-cf-account-management-dod-tenant-hierarchy-management-soft-delete-preconditions:p1:inst-dod-soft-delete-preconditions diff --git a/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs b/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs index 661c129d3..83b732b6d 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs @@ -645,6 +645,49 @@ async fn list_children_status_filter_applies() { assert_eq!(suspended_only.items[0].id.0, c2); } +#[tokio::test] +async fn list_children_reports_direct_child_count_per_child() { + let root = Uuid::from_u128(0x100); + let repo = Arc::new(FakeTenantRepo::with_root(root)); + let svc = make_service(repo.clone(), FakeOutcome::Ok); + + // Two direct children of root: `c1` is a branch, `c2` a leaf. + let c1 = Uuid::from_u128(0x301); + let c2 = Uuid::from_u128(0x302); + svc.create_tenant(&ctx_for(root), child_input(c1, root)) + .await + .expect("c1"); + svc.create_tenant(&ctx_for(root), child_input(c2, root)) + .await + .expect("c2"); + + // Two grandchildren under `c1`; `c2` stays a leaf. + svc.create_tenant(&ctx_for(root), child_input(Uuid::from_u128(0x401), c1)) + .await + .expect("gc1"); + svc.create_tenant(&ctx_for(root), child_input(Uuid::from_u128(0x402), c1)) + .await + .expect("gc2"); + + let page = svc + .list_children(&ctx_for(root), root, &ODataQuery::default()) + .await + .expect("list ok"); + + let by_id: std::collections::HashMap = + page.items.iter().map(|t| (t.id.0, t.child_count)).collect(); + assert_eq!( + by_id.get(&c1).copied(), + Some(2), + "c1 surfaces its two direct children" + ); + assert_eq!( + by_id.get(&c2).copied(), + Some(0), + "c2 is a leaf - child_count defaults to 0" + ); +} + #[tokio::test] async fn update_tenant_accepts_name_then_status_transitions_via_dedicated_methods() { let root = Uuid::from_u128(0x100); diff --git a/gears/system/account-management/account-management/src/domain/tenant/test_support/repo.rs b/gears/system/account-management/account-management/src/domain/tenant/test_support/repo.rs index eb9baaa8f..7cf750cd6 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/test_support/repo.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/test_support/repo.rs @@ -1270,6 +1270,25 @@ impl TenantRepo for FakeTenantRepo { Ok(u64::try_from(count).unwrap_or(u64::MAX)) } + async fn count_children_grouped( + &self, + _scope: &AccessScope, + parent_ids: &[Uuid], + ) -> Result, DomainError> { + let wanted: HashSet = parent_ids.iter().copied().collect(); + let state = self.state.lock().expect("lock"); + let mut out: HashMap = HashMap::new(); + for t in state.tenants.values() { + let Some(pid) = t.parent_id else { continue }; + // Public-surface semantics: scope is ignored by this mock, + // `Provisioning` is excluded, `Deleted` is included. + if wanted.contains(&pid) && !matches!(t.status, TenantStatus::Provisioning) { + *out.entry(pid).or_insert(0) += 1; + } + } + Ok(out) + } + async fn count_tenants_by_status( &self, _scope: &AccessScope, diff --git a/gears/system/account-management/account-management/src/infra/storage/repo_impl/mod.rs b/gears/system/account-management/account-management/src/infra/storage/repo_impl/mod.rs index c30d42166..3bd62ab70 100644 --- a/gears/system/account-management/account-management/src/infra/storage/repo_impl/mod.rs +++ b/gears/system/account-management/account-management/src/infra/storage/repo_impl/mod.rs @@ -19,6 +19,7 @@ pub use conversion::ConversionRepoImpl; pub use hierarchy_read::TenantHierarchyReadAdapter; pub use metadata::MetadataRepoImpl; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -201,6 +202,14 @@ impl TenantRepo for TenantRepoImpl { reads::count_children(self, scope, parent_id, filter).await } + async fn count_children_grouped( + &self, + scope: &AccessScope, + parent_ids: &[Uuid], + ) -> Result, DomainError> { + reads::count_children_grouped(self, scope, parent_ids).await + } + async fn count_tenants_by_status( &self, scope: &AccessScope, diff --git a/gears/system/account-management/account-management/src/infra/storage/repo_impl/reads.rs b/gears/system/account-management/account-management/src/infra/storage/repo_impl/reads.rs index 24e5dc05a..a07474a4e 100644 --- a/gears/system/account-management/account-management/src/infra/storage/repo_impl/reads.rs +++ b/gears/system/account-management/account-management/src/infra/storage/repo_impl/reads.rs @@ -5,10 +5,13 @@ //! structural closure question and intentionally bypasses the per-row //! scope (PEP gate is the service-layer guard). +use std::collections::HashMap; + use account_management_sdk::TenantInfoFilterField; use bigdecimal::BigDecimal; use gts::GtsID; -use sea_orm::{ColumnTrait, Condition, EntityTrait, Order}; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, Condition, EntityTrait, FromQueryResult, Order, QuerySelect}; use serde_json::Value; use toolkit_db::odata::sea_orm_filter::{ FieldToColumn, LimitCfg, ODataFieldMapping, PaginateOdataTryError, paginate_odata_try, @@ -437,6 +440,65 @@ pub(super) async fn count_children( .map_err(map_scope_err) } +/// Direct-child counts for a batch of parent ids, keyed by parent. +/// +/// Powers the public `child_count` read-shape field (`list_children` +/// page rows + single-tenant reads). The semantics are deliberately +/// different from [`count_children`], which is an internal +/// delete-saga guard (`allow_all`, always counts `Provisioning`): +/// +/// * **Scope-filtered** — bounded by the caller's `scope` through +/// `SecureORM`, so direct children behind a self-managed barrier the +/// caller cannot penetrate collapse to `0`. This matches the +/// visibility rule the rest of the public read surface obeys. +/// * **`Provisioning` excluded** — those rows have no public +/// representation anywhere on the SDK boundary. `Deleted` rows are +/// *included*, mirroring that they stay reachable via +/// `$filter=status eq 'deleted'`. +/// +/// One grouped `COUNT ... GROUP BY parent_id` for the whole batch — no +/// N+1 across the page. Parents with no matching child are absent from +/// the returned map; callers default those to `0`. +pub(super) async fn count_children_grouped( + repo: &TenantRepoImpl, + scope: &AccessScope, + parent_ids: &[Uuid], +) -> Result, DomainError> { + #[derive(FromQueryResult)] + struct ChildCountRow { + parent_id: Uuid, + cnt: i64, + } + + if parent_ids.is_empty() { + return Ok(HashMap::new()); + } + let connection = repo.db.conn()?; + + let rows = tenants::Entity::find() + .secure() + .scope_with(scope) + .filter( + Condition::all() + .add(tenants::Column::ParentId.is_in(parent_ids.iter().copied())) + .add(tenants::Column::Status.ne(TenantStatus::Provisioning.as_smallint())), + ) + .project_all(&connection, |q| { + q.select_only() + .column(tenants::Column::ParentId) + .column_as(Expr::col(tenants::Column::Id).count(), "cnt") + .group_by(tenants::Column::ParentId) + .into_model::() + }) + .await + .map_err(map_scope_err)?; + + Ok(rows + .into_iter() + .map(|r| (r.parent_id, u64::try_from(r.cnt).unwrap_or(0))) + .collect()) +} + pub(super) async fn count_tenants_by_status( repo: &TenantRepoImpl, scope: &AccessScope, diff --git a/gears/system/account-management/account-management/tests/list_children_integration.rs b/gears/system/account-management/account-management/tests/list_children_integration.rs index f7d507361..5af1c4e64 100644 --- a/gears/system/account-management/account-management/tests/list_children_integration.rs +++ b/gears/system/account-management/account-management/tests/list_children_integration.rs @@ -259,6 +259,101 @@ async fn list_children_explicit_status_filter_returns_deleted() { ); } +// ---- grouped direct-child counts ------------------------------------ + +/// `count_children_grouped` powers the public `child_count` field. It +/// tallies *direct* children per parent in one grouped query, and the +/// public-surface semantics differ from the delete-saga +/// `count_children` guard: `Provisioning` is **excluded**, `Deleted` +/// is **included**, and parents with no matching child are simply +/// absent from the map (callers default them to `0`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn count_children_grouped_excludes_provisioning_includes_deleted() { + let h = setup_sqlite().await.expect("harness"); + let root = Uuid::from_u128(ROOT_ID); + seed_root(&h, root).await; + + let type_a = Uuid::from_u128(0xAA); + // Two direct children of root; `child_a` is a branch, `child_b` a leaf. + let child_a = Uuid::from_u128(0x201); + let child_b = Uuid::from_u128(0x202); + seed_tenant_at(&h, child_a, root, ACTIVE, false, type_a, ts_at(1)).await; + seed_tenant_at(&h, child_b, root, ACTIVE, false, type_a, ts_at(2)).await; + + // Grandchildren under `child_a`: active + suspended + deleted are + // all counted (3); the provisioning row is excluded. + seed_tenant_at( + &h, + Uuid::from_u128(0x301), + child_a, + ACTIVE, + false, + type_a, + ts_at(3), + ) + .await; + seed_tenant_at( + &h, + Uuid::from_u128(0x302), + child_a, + SUSPENDED, + false, + type_a, + ts_at(4), + ) + .await; + seed_tenant_at( + &h, + Uuid::from_u128(0x303), + child_a, + DELETED, + false, + type_a, + ts_at(5), + ) + .await; + seed_tenant_at( + &h, + Uuid::from_u128(0x304), + child_a, + PROVISIONING, + false, + type_a, + ts_at(6), + ) + .await; + + let counts = h + .repo + .count_children_grouped(&allow_all(), &[child_a, child_b]) + .await + .expect("grouped count"); + + assert_eq!( + counts.get(&child_a).copied(), + Some(3), + "child_a: active + suspended + deleted counted; provisioning excluded" + ); + assert_eq!( + counts.get(&child_b).copied(), + None, + "child_b has no children -> absent from the map (caller defaults to 0)" + ); +} + +/// Empty input short-circuits to an empty map (no query, no panic on an +/// empty `IN ()` predicate). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn count_children_grouped_empty_input_is_empty() { + let h = setup_sqlite().await.expect("harness"); + let counts = h + .repo + .count_children_grouped(&allow_all(), &[]) + .await + .expect("grouped count"); + assert!(counts.is_empty(), "empty parent set -> empty map"); +} + /// `status` filter values outside the public SDK contract — including /// the AM-internal `'provisioning'` — surface as a validation error /// from `TenantODataMapper::map_value` before the predicate reaches diff --git a/gears/system/account-management/docs/account-management-v1.yaml b/gears/system/account-management/docs/account-management-v1.yaml index 07a4b0d3d..3ba313445 100644 --- a/gears/system/account-management/docs/account-management-v1.yaml +++ b/gears/system/account-management/docs/account-management-v1.yaml @@ -855,6 +855,7 @@ components: - status - self_managed - depth + - child_count - created_at - updated_at properties: @@ -882,6 +883,16 @@ components: depth: type: integer minimum: 0 + child_count: + type: integer + minimum: 0 + description: | + Number of direct children visible to the caller, resolved at + read time. Scope-filtered (direct children behind a + self-managed barrier the caller cannot penetrate are not + counted); excludes AM-internal `provisioning` rows; includes + soft-`deleted` children. `0` for a leaf. Derived field — not + available in `$filter` / `$orderby`. created_at: type: string format: date-time