diff --git a/.changeset/29606-business-unit-member-reads-complete.md b/.changeset/29606-business-unit-member-reads-complete.md new file mode 100644 index 00000000000..6f6c347c1c7 --- /dev/null +++ b/.changeset/29606-business-unit-member-reads-complete.md @@ -0,0 +1,27 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/plugin-approvals': patch +--- + +fix(plugins)!: business-unit recipients and approvers reach every member past 10,000 rows, a failed business-unit member read is refused loudly instead of answered with nobody, and a failed `resolveAs` expansion says so + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** only where a `sys_business_unit_member` read fails: the business-unit expansions used to answer nobody (an empty list) then, and now refuse. + +This extends the family the team, position and role fixes closed to the last membership object, `sys_business_unit_member`. Its reads still read once with a 10,000-row bound and turned any read error into nobody. + +- **Every member, nobody else.** In plugin-sharing, `BusinessUnitGraphService.expandUnitMembers` (every `business_unit` sharing recipient) and `BusinessUnitGraphService.expandUsers` (every `unit_and_subordinates` recipient) now read in pages of 10,000 ordered by id until the members are exhausted. In plugin-approvals, the `department` / `business_unit` / `bu` approver and an `expression` approver with `resolveAs: 'department'` do the same. Past 10,000 members, the members past the 10,000th row used to get no grant and no approval slot. They now do. +- **The primary-business-unit backfill projects every primary membership.** The boot backfill of `sys_user.primary_business_unit_id` used to scan the `is_primary` rows once at the same bound, so users past the 10,000th row were never projected and nothing said so. It now pages to completion. A scan that fails, or a page the driver returned without honouring the id cursor, writes nothing and logs why (a failed scan logged before too, now with the consequence). +- **Smaller units are unchanged.** A unit or subtree with fewer than 10,000 members issues one read, as before, and answers the same set. +- **Loud, never short or empty.** A member read that fails, or a page the driver returned without honouring the id cursor, now throws. + - Sharing, whole-rule passes: the expansion rejects, so that rule's reconcile or revoke pass fails, its callers log their existing per-rule warning, and the grants it already holds stay. Before, a failed read answered `[]`, so the retired-recipient revoke a business-unit graph write triggers took the rule's grants away from every member. + - Approvals: the approver slot falls back to its literal (`department:ID`) and logs `could not be expanded — a directory or read-filter lookup failed` with its `error`. Before, a failed read logged "expanded to nobody", indistinguishable from an unstaffed unit. + - Ownership queues: a `business_unit` or `unit_and_subordinates` queue specification whose member read fails still contributes nobody, the posture the queue declares for a failed member-table read, and now logs it once. +- **A failed `resolveAs` expansion says so.** An `expression` approver whose `resolveAs` value could not be expanded (a `department`, `position` or `team` read failed) kept its `KIND:VALUE` literal slot with no log line. It now logs the same `could not be expanded` warning the static approver types log, with the `error` and the `expression` source. +- **A missing object is still no members.** A stack that never registers, or never creates, `sys_business_unit_member` keeps answering no members through it, and the backfill has nothing to project. That is judged by `isObjectAbsentError(error, 'sys_business_unit_member')`; any other error stays loud. + +**The remedy.** None is needed. A refusal means the membership table could not be read: fix that read, and the next expansion succeeds. A failed read is never cached. diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index c5e6a64b29f..c9460f9669c 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -456,11 +456,13 @@ What an entry that resolves to nobody leaves on the slate depends on its kind: [Acting on requests in the console](#acting-on-requests-in-the-console)). Beside entries that do resolve, a literal is a slot like any other, so a `behavior` that needs every slot (`unanimous`) waits on it too. A `team`, - `position` or `org_membership_level` entry whose holder read failed - (`sys_team_member`, `sys_user_position` or `sys_member`) keeps its literal - the same way. It logs `could not be expanded — a directory or read-filter - lookup failed` with the `error`, instead of `expanded to nobody`, and never - routes to the holders that happened to read. + `position`, `department` or `org_membership_level` entry whose holder read + failed (`sys_team_member`, `sys_user_position`, `sys_business_unit_member` + or `sys_member`) keeps its literal the same way. It logs `could not be + expanded — a directory or read-filter lookup failed` with the `error`, + instead of `expanded to nobody`, and never routes to the holders that + happened to read. An `expression` value whose `resolveAs` read failed keeps + its literal and logs the same line, with the `expression` source. - **A slate left empty, or holding only literals,** goes to the node's `onEmptyApprovers` policy, whose four values are listed under [Approving across organizations](#cross-org-approvers). Under the default, diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index d3c805bb674..6d3a54231f3 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -1121,7 +1121,7 @@ const MEMBER_SCREEN_READ_LIMIT = 50000; /** * [#29362, #29503] Rows per page of the directory holder reads * ({@link ApprovalService.readHoldersToCompletion}: the membership tier, the - * position assignments, the team members) — the bound each read always + * position assignments, the team members, the business-unit members) — the bound each read always * carried, so a group held by fewer people issues the one read it always did, * and one held by more is read in further pages rather than cut. */ @@ -2695,12 +2695,7 @@ export class ApprovalService implements IApprovalService { // this is, and carry the error, so an operator does not go staffing a // position whose holders simply could not be read. The try above also // wraps the cross-org read filter (`bounded`), so the line names both. - this.logger?.warn?.( - `[approvals] approver '${type}:${a.value}' could not be expanded — a directory or read-filter lookup failed, so the ` - + `slot falls back to its literal rather than route to part of its people. Fix the read (see error); ` - + `a slate left naming no one is decided by the node's onEmptyApprovers.`, - { type, value: a.value, organizationId: organizationId ?? null, error: directoryFailure }, - ); + this.warnExpansionFailed(type, a.value, organizationId, directoryFailure); } else { this.logger?.warn?.( `[approvals] approver '${type}:${a.value}' expanded to nobody — the slot names no one, so no one is ` @@ -2717,6 +2712,32 @@ export class ApprovalService implements IApprovalService { return [`${type}:${a.value}`]; } + /** + * [#29362, #29606] The one warning an approver slot logs when its expansion + * FAILED — a directory or read-filter lookup threw — rather than came back + * empty: the slot falls back to its literal, and the line carries the error. + * Shared by the static graph types ({@link resolveApproverSpec}) and an + * `expression` approver's `resolveAs` values ({@link resolveExpressionApprovers}, + * which passes its source as `expression`), so the two cannot drift. + */ + private warnExpansionFailed( + type: string, + value: unknown, + organizationId: string | null | undefined, + error: string, + expression?: string, + ): void { + this.logger?.warn?.( + `[approvals] approver '${type}:${value}' could not be expanded — a directory or read-filter lookup failed, so the ` + + `slot falls back to its literal rather than route to part of its people. Fix the read (see error); ` + + `a slate left naming no one is decided by the node's onEmptyApprovers.`, + { + type, value, organizationId: organizationId ?? null, error, + ...(expression === undefined ? {} : { expression }), + }, + ); + } + /** * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at * node entry against the three explicit roots — `current` (live record), @@ -2859,6 +2880,11 @@ export class ApprovalService implements IApprovalService { } } catch (err: any) { if (String(err?.message ?? '').startsWith('VALIDATION_FAILED')) throw err; + // [#29606] The literal slot below is the same fallback the static graph + // types take, and it says why exactly as they do: a failed read is not + // an unstaffed value, so an operator is pointed at the read rather than + // at staffing it. + this.warnExpansionFailed(resolveAs, key, organizationId, err instanceof Error ? err.message : String(err), source); users = []; } if (crossOrg && users.length) { @@ -3210,20 +3236,22 @@ export class ApprovalService implements IApprovalService { if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); } } } - let rows: any[] = []; - try { - rows = await this.engine.find('sys_business_unit_member', { - // #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT - // on purpose and is not {@link businessUnitOrgScope}. The units above - // proved their tenancy; these rows have not, and the - // shared seeded unit id is exactly where other tenants' rows sit. - where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId), - fields: ['user_id'], - limit: 10000, - context: SYSTEM_CTX, - } as any); - } catch { rows = []; } - return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); + // [#29606] Every member of the subtree, read to completion — or a refusal + // that {@link resolveApproverSpec}'s directory catch reports as a failed + // read, never "expanded to nobody". It read once at a 10,000-row bound and + // answered `[]` on any failure: past the bound the slate silently lost the + // rest, and a failed read logged as an unstaffed unit. A stack with no + // `sys_business_unit_member` has no members. + return this.readHoldersToCompletion( + 'sys_business_unit_member', + // #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT + // on purpose and is not {@link businessUnitOrgScope}. The units above + // proved their tenancy; these rows have not, and the + // shared seeded unit id is exactly where other tenants' rows sit. + this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId), + `the members of business unit '${businessUnitId}' and its descendant units` + + `${organizationId ? ` in organization '${organizationId}'` : ''}`, + ); } /** @@ -3445,8 +3473,8 @@ export class ApprovalService implements IApprovalService { /** * [#29362, #29503] The `user_id`s of every `object` row matching `where`, * read to COMPLETION — or a refusal, never a shortened or an invented empty - * slate. The one read behind the membership tier, the position assignments - * and the team members. + * slate. The one read behind the membership tier, the position assignments, + * the team members and the business-unit members (#29606). * * Pages of {@link HOLDER_PAGE_ROWS} through the platform's one keyset walk * (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a group held past diff --git a/packages/plugins/plugin-approvals/src/business-unit-holders-complete.integration.test.ts b/packages/plugins/plugin-approvals/src/business-unit-holders-complete.integration.test.ts new file mode 100644 index 00000000000..45da10bbfd0 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/business-unit-holders-complete.integration.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29606] The approver business-unit expansion (`sys_business_unit_member`) + * answers EVERY member of the subtree, or refuses — never a shortened or an + * invented empty slate. The family #29362 and #29503 closed for the tier, + * position and team reads. + * + * It read once at a 10,000-row bound and took that read as the slate, and it + * turned a read that threw into nothing: past the bound the slate was missing + * every member after the 10,000th row, with no signal, and a failed read + * logged "expanded to nobody", indistinguishable from an unstaffed unit. + * + * What a caller sees now: the expansion throws, the approver-spec resolver's + * directory catch takes it, and the slot falls back to its literal with the + * warning that names the failed read and carries its `error`. A membership + * object that does not exist is still no members. + * + * Item 3 of the card rides here too: an `expression` approver whose + * `resolveAs` expansion fails kept its `:` literal slot with no + * warning at all. It now logs the same line, with the error. + * + * Measured on a REAL engine (ObjectQL + SqlDriver over in-memory SQLite) + * carrying the shipped `SysBusinessUnit` and `SysBusinessUnitMember`. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + SysBusinessUnit, SysBusinessUnitMember, SysMember, SysOrganization, SysTeamMember, +} from '@objectstack/platform-objects/identity'; +import { ApprovalService } from './approval-service.js'; + +const ORG = 'org_large'; +const ELSEWHERE = 'org_elsewhere'; +const ROOT = 'bu_root'; +const CHILD = 'bu_child'; +const OTHER = 'bu_other'; +/** The bound each read is paged at. */ +const PAGE = 10000; + +const pad = (n: number) => String(n).padStart(6, '0'); + +interface Rig { + engine: ObjectQL; + driver: SqlDriver; + svc: ApprovalService; + warn: ReturnType; + holders: string[]; + reads: () => number; + failReadsOf: (object: string) => void; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * `n` members in {@link ORG}: the even ones on {@link ROOT}, the odd ones on + * its child {@link CHILD}. Plus a member of another unit and a member of ROOT + * stamped with another organization, which the slate must keep out. Row ids + * are scrambled against user ids. + */ +async function boot(n: number, opts: { omit?: string[] } = {}): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); + const engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + const omit = new Set(opts.omit ?? []); + engine.registerApp({ + id: 'com.objectstack.qa.business-unit-holders-complete', + name: 'business unit holders complete', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysOrganization, SysMember, SysTeamMember, SysBusinessUnit, SysBusinessUnitMember] + .filter((o) => !omit.has(o.name)), + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const e = engine as any; + await e.insert('sys_organization', { id: ORG, name: ORG, slug: 'large' }, { context: { isSystem: true } }); + await e.insert('sys_organization', { id: ELSEWHERE, name: ELSEWHERE, slug: 'elsewhere' }, { context: { isSystem: true } }); + await e.getDriverForObject('sys_business_unit').bulkCreate('sys_business_unit', [ + { id: ROOT, name: 'Root', kind: 'division', active: true, organization_id: ORG }, + { id: CHILD, name: 'Child', kind: 'department', active: true, parent_business_unit_id: ROOT, organization_id: ORG }, + { id: OTHER, name: 'Other', kind: 'department', active: true, organization_id: ORG }, + ]); + const holders = Array.from({ length: n }, (_, i) => `usr_${pad(i)}`); + if (!omit.has('sys_business_unit_member')) { + const id = (i: number) => `bum_${pad((i * 7919) % 1000003)}`; + const rows: Record[] = holders.map((user_id, i) => ({ + id: id(i), user_id, business_unit_id: i % 2 ? CHILD : ROOT, organization_id: ORG, + })); + rows.push({ id: 'bum_other_unit', user_id: 'usr_other_unit', business_unit_id: OTHER, organization_id: ORG }); + rows.push({ id: 'bum_elsewhere', user_id: 'usr_elsewhere', business_unit_id: ROOT, organization_id: ELSEWHERE }); + const d = e.getDriverForObject('sys_business_unit_member'); + for (let i = 0; i < rows.length; i += 250) await d.bulkCreate('sys_business_unit_member', rows.slice(i, i + 250)); + } + + let count = 0; + (driver as any).knex.on('query', (q: { sql: string }) => { + if (/^select .* from [`"]sys_business_unit_member[`"]/i.test(q.sql)) count += 1; + }); + const failReadsOf = (object: string) => { + const d = e.getDriverForObject(object); + const real = d.find.bind(d); + vi.spyOn(d, 'find').mockImplementation(((obj: string, ...rest: unknown[]) => + obj === object + ? Promise.reject(Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' })) + : real(obj, ...rest)) as never); + }; + const warn = vi.fn(); + const svc = new ApprovalService({ + engine: engine as any, + logger: { warn, info: () => {}, error: () => {}, debug: () => {} } as any, + }); + return { engine, driver, svc, warn, holders: [...holders].sort(), reads: () => count, failReadsOf }; +} + +/** The private expansion and the resolvers, as the request opener calls them. */ +const department = (rig: Rig, value = ROOT) => + (rig.svc as any).expandBusinessUnitUsers(value, ORG) as Promise; +const resolveSpec = (rig: Rig, a: { type: string; value: string }) => + (rig.svc as any).resolveApproverSpec(a, undefined, ORG, Date.now()) as Promise; +const resolveExpression = (rig: Rig, a: { type: string; value: string; resolveAs: string }) => + (rig.svc as any).resolveExpressionApprovers(a, {}, ORG, Date.now()) as Promise<{ + slots: Array<{ id: string; subGroup?: string }>; + raw: string[]; + }>; +const failureWarnings = (rig: Rig) => rig.warn.mock.calls.filter(([, meta]) => typeof meta?.error === 'string'); + +describe('[#29606] approvals business-unit expansion reads every member (driver-sql, shipped SysBusinessUnitMember)', () => { + it('⭐ past the 10,000-row bound: every member of the unit and its child is on the slate, and no one else', async () => { + const rig = await boot(PAGE + 1); + const users = await department(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_other_unit'); + expect(users).not.toContain('usr_elsewhere'); + expect(rig.reads()).toBe(2); + }, 30_000); + + it('⭐ a department approver routes to every member past the bound', async () => { + const rig = await boot(PAGE + 1); + const slate = await resolveSpec(rig, { type: 'department', value: ROOT }); + + expect([...slate].sort()).toEqual(rig.holders); + }, 30_000); + + it('control — under the bound: one read, the same slate as before', async () => { + const rig = await boot(PAGE - 1); + const users = await department(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads()).toBe(1); + }, 30_000); + + it('⛔ a member read that fails refuses — it never answers an invented empty set', async () => { + const rig = await boot(3); + rig.failReadsOf('sys_business_unit_member'); + + await expect(department(rig)).rejects.toThrow( + /members of business unit 'bu_root' and its descendant units in organization 'org_large' could not be read/, + ); + }); + + it('⛔ what the caller sees: the department slot falls back to its literal, and the warning names the failed read', async () => { + const rig = await boot(3); + rig.failReadsOf('sys_business_unit_member'); + + expect(await resolveSpec(rig, { type: 'department', value: ROOT })).toEqual([`department:${ROOT}`]); + const failed = failureWarnings(rig); + expect(failed.map(([, meta]) => meta.type)).toEqual(['department']); + expect(failed[0][0]).toMatch(/could not be expanded — a directory or read-filter lookup failed/); + expect(failed[0][1].error).toMatch(/SQLITE_IOERR/); + }); + + it('a composition that never registers sys_business_unit_member has no members: the literal, with no failure warning', async () => { + const rig = await boot(0, { omit: ['sys_business_unit_member'] }); + + expect(await department(rig)).toEqual([]); + expect(await resolveSpec(rig, { type: 'department', value: ROOT })).toEqual([`department:${ROOT}`]); + expect(failureWarnings(rig)).toEqual([]); + }); + + it('a stack whose sys_business_unit_member table was never created has no members', async () => { + const rig = await boot(0); + await (rig.driver as any).knex.schema.dropTable('sys_business_unit_member'); + + expect(await department(rig)).toEqual([]); + }); +}); + +describe('[#29606] item 3 — an expression approver whose resolveAs expansion fails says so', () => { + it('control — a resolveAs department value expands to its members, with no failure warning', async () => { + const rig = await boot(3); + const { slots } = await resolveExpression(rig, { type: 'expression', value: `'${ROOT}'`, resolveAs: 'department' }); + + expect(slots.map((s) => s.id).sort()).toEqual(rig.holders); + expect(failureWarnings(rig)).toEqual([]); + }); + + it('⛔ a failed department read keeps the literal slot and logs the "could not be expanded" warning, with the error', async () => { + const rig = await boot(3); + rig.failReadsOf('sys_business_unit_member'); + const { slots } = await resolveExpression(rig, { type: 'expression', value: `'${ROOT}'`, resolveAs: 'department' }); + + expect(slots).toEqual([{ id: `department:${ROOT}`, subGroup: ROOT }]); + const failed = failureWarnings(rig); + expect(failed).toHaveLength(1); + expect(failed[0][0]).toMatch(/approver 'department:bu_root' could not be expanded — a directory or read-filter lookup failed/); + expect(failed[0][1]).toMatchObject({ type: 'department', value: ROOT, organizationId: ORG, expression: `'${ROOT}'` }); + expect(failed[0][1].error).toMatch(/SQLITE_IOERR/); + }); + + it('⛔ a failed team read through resolveAs warns the same way', async () => { + const rig = await boot(0); + rig.failReadsOf('sys_team_member'); + const { slots } = await resolveExpression(rig, { type: 'expression', value: `'team_x'`, resolveAs: 'team' }); + + expect(slots).toEqual([{ id: 'team:team_x', subGroup: 'team_x' }]); + const failed = failureWarnings(rig); + expect(failed.map(([, meta]) => meta.type)).toEqual(['team']); + expect(failed[0][1].error).toMatch(/SQLITE_IOERR/); + }); + + it('a resolveAs value whose unit has no members keeps the literal with no failure warning', async () => { + const rig = await boot(0, { omit: ['sys_business_unit_member'] }); + const { slots } = await resolveExpression(rig, { type: 'expression', value: `'${ROOT}'`, resolveAs: 'department' }); + + expect(slots).toEqual([{ id: `department:${ROOT}`, subGroup: ROOT }]); + expect(failureWarnings(rig)).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/business-unit-graph.test.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.test.ts index 2f8514faa6a..2b4a8185703 100644 --- a/packages/plugins/plugin-sharing/src/business-unit-graph.test.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.test.ts @@ -60,15 +60,21 @@ interface UnitRow { active?: boolean; manager_user_id?: string | null; } -interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null } +interface MemberRow { id?: string; business_unit_id: string; user_id: string; organization_id?: string | null } /** * Minimal engine over `sys_business_unit` + `sys_business_unit_member`. * Mirrors the real filter surface the service uses: plain equality, `$ne`, * `$in`, and `$or` (so a widened predicate would actually be exercised here * rather than silently ignored by the stub). + * + * [#29606] The member reads are a keyset walk (`id > cursor`, ordered by `id`, + * a page `limit`), so the member side also models `$and`, `$gt`, `orderBy` + * and `limit`, and every member row carries the `id` a stored row has — + * stamped here when a fixture names none. */ function makeEngine(units: UnitRow[], members: MemberRow[]) { + const memberRows = members.map((m, i) => ({ id: `bum_${String(i).padStart(4, '0')}`, ...m })); const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; for (const [k, v] of Object.entries(filter)) { @@ -76,8 +82,16 @@ function makeEngine(units: UnitRow[], members: MemberRow[]) { if (!(v as any[]).some((sub) => matches(row, sub))) return false; continue; } + if (k === '$and') { + if (!(v as any[]).every((sub) => matches(row, sub))) return false; + continue; + } if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); const rv = row[k]; + if (v && typeof v === 'object' && '$gt' in (v as any)) { + if (!(String(rv) > String((v as any).$gt))) return false; + continue; + } if (v && typeof v === 'object' && '$ne' in (v as any)) { if (rv === (v as any).$ne) return false; continue; @@ -101,7 +115,11 @@ function makeEngine(units: UnitRow[], members: MemberRow[]) { async find(object: string, options: any) { const filter = options?.where ?? options?.filter ?? {}; if (object === 'sys_business_unit') return units.filter((u) => matches(u, filter)); - if (object === 'sys_business_unit_member') return members.filter((m) => matches(m, filter)); + if (object === 'sys_business_unit_member') { + const rows = memberRows.filter((m) => matches(m, filter)); + if (options?.orderBy?.[0]?.field === 'id') rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + } return []; }, } as any; diff --git a/packages/plugins/plugin-sharing/src/business-unit-graph.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.ts index 60f84c1efcd..ddbfccfdd9f 100644 --- a/packages/plugins/plugin-sharing/src/business-unit-graph.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.ts @@ -2,7 +2,7 @@ import type { IBusinessUnitGraphService } from '@objectstack/spec/contracts'; import type { SharingEngine } from './sharing-service.js'; -import { TeamGraphService, managerIsProvablyOutsideOrg } from './team-graph.js'; +import { TeamGraphService, managerIsProvablyOutsideOrg, readMembershipToCompletion } from './team-graph.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -176,21 +176,14 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService { return []; } - let rows: any[] = []; - try { - rows = await this.engine.find('sys_business_unit_member', { - // [#14547] Tenant-screened — see {@link memberScope}, which is STRICT - // on purpose and is not {@link orgScope}. - where: this.memberScope({ business_unit_id: businessUnitId }), - fields: ['user_id'], - limit: 10000, - context: SYSTEM_CTX, - }); - } catch { - rows = []; - } - const users = Array.from( - new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)), + // [#29606] Read to completion, and a failed or incomplete read throws — so + // nothing is cached for it, and the next caller reads again. A stack with + // no `sys_business_unit_member` has no unit members. + const users = await this.readMembers( + // [#14547] Tenant-screened — see {@link memberScope}, which is STRICT + // on purpose and is not {@link orgScope}. + this.memberScope({ business_unit_id: businessUnitId }), + `the members of business unit '${businessUnitId}'`, ); this.cache.unitMembers!.set(businessUnitId, users); return users; @@ -204,29 +197,46 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService { const units = await this.descendants(businessUnitId); if (units.length === 0) return []; - let rows: any[] = []; - try { - rows = await this.engine.find('sys_business_unit_member', { - // [#14547] Tenant-screened — see {@link memberScope}. The WIDE width - // needs the screen exactly as much as the narrow one: the reported - // reproduction used `unit_and_subordinates`, and a subtree walk that - // now admits org-less (seeded) units reaches MORE unscreened member - // rows than the single-unit read, not fewer. - where: this.memberScope({ business_unit_id: { $in: units } }), - fields: ['user_id'], - limit: 10000, - context: SYSTEM_CTX, - }); - } catch { - rows = []; - } - const users = Array.from( - new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)), + // [#29606] Read to completion, and a failed or incomplete read throws — + // nothing is cached for it. A stack with no `sys_business_unit_member` has + // no members in the subtree. + const users = await this.readMembers( + // [#14547] Tenant-screened — see {@link memberScope}. The WIDE width + // needs the screen exactly as much as the narrow one: the reported + // reproduction used `unit_and_subordinates`, and a subtree walk that + // now admits org-less (seeded) units reaches MORE unscreened member + // rows than the single-unit read, not fewer. + this.memberScope({ business_unit_id: { $in: units } }), + `the members of business unit '${businessUnitId}' and its descendant units`, ); this.cache.expandUsers!.set(businessUnitId, users); return users; } + /** + * [#29606] The `user_id`s of every `sys_business_unit_member` row matching + * `where`, read to completion through the one walk the team, role and + * position reads share ({@link readMembershipToCompletion}) — or a refusal, + * never a shortened or an invented empty set. + * + * Both widths read here. They read once at a 10,000-row bound and answered + * `[]` on any failure, and the set feeds sharing GRANTS that the reconcilers + * diff against it: a member past the bound lost the grant, and a failed read + * revoked every member's. A failed read, or a walk that cannot vouch for + * completion, now throws; a stack that never materialises the object has no + * members through it and answers `[]`. + */ + private async readMembers(where: Record, subject: string): Promise { + const rows = await readMembershipToCompletion(this.engine, { + object: 'sys_business_unit_member', + where, + fields: ['user_id'], + subject, + }); + if (!rows) return []; + return Array.from(new Set(rows.map((r) => String(r?.user_id ?? '')).filter(Boolean))); + } + async headOf(businessUnitId: string): Promise { if (!businessUnitId) return null; if (this.cache.head!.has(businessUnitId)) return this.cache.head!.get(businessUnitId) ?? null; diff --git a/packages/plugins/plugin-sharing/src/business-unit-member-reads-complete.test.ts b/packages/plugins/plugin-sharing/src/business-unit-member-reads-complete.test.ts new file mode 100644 index 00000000000..036daebe5d5 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/business-unit-member-reads-complete.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29606] The `sys_business_unit_member` reads answer EVERY member, or refuse + * — never a shortened or an invented empty set. The family #29362 and #29503 + * closed for the team, role and position reads. + * + * 1. `BusinessUnitGraphService.expandUnitMembers` (the `business_unit` + * recipient) and `expandUsers` (`unit_and_subordinates`) read once at a + * 10,000-row bound and answered `[]` on any failure. The set feeds + * sharing GRANTS the reconcilers diff against it: a member past the bound + * lost the grant, and a failed read revoked every member's. + * 2. The boot backfill of `sys_user.primary_business_unit_id` scanned the + * `is_primary` rows once at the same bound, so a stack with more primary + * memberships never projected the rest, with no signal. + * + * Absence is the one failure that IS an answer — the object is not + * registered, or its table was never created — checked by + * `isObjectAbsentError` against the object read. + * + * Measured on a REAL engine (ObjectQL + SqlDriver over in-memory SQLite) + * carrying the shipped `SysBusinessUnit` and `SysBusinessUnitMember`, because + * completion is a keyset read (`id > cursor`, ordered by `id`) that a double + * ignoring the cursor cannot vouch for. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysBusinessUnit, SysBusinessUnitMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { BusinessUnitGraphService } from './business-unit-graph.js'; +import { backfillPrimaryBu } from './primary-bu-projection.js'; + +const ORG = 'org_large'; +const ELSEWHERE = 'org_elsewhere'; +const ROOT = 'bu_root'; +const CHILD = 'bu_child'; +const OTHER = 'bu_other'; +/** The bound each member read is paged at. */ +const PAGE = 10000; + +const pad = (n: number) => String(n).padStart(6, '0'); + +interface Rig { + engine: ObjectQL; + driver: SqlDriver; + /** Every seeded member, sorted. */ + holders: string[]; + /** The `sys_business_unit_member` reads the driver ran. */ + reads: () => number; + /** Make every driver read of `object` throw; other objects read as before. */ + failReadsOf: (object: string) => void; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * `n` members in {@link ORG}, every one flagged `is_primary`: the even ones on + * {@link ROOT}, the odd ones on its child {@link CHILD} — or all on ROOT with + * `split: false`. Plus the rows the predicates must keep out: a member of + * another unit, and a member of ROOT stamped with another organization. Row + * ids are scrambled against user ids, so the 10,001st row by id is not the + * 10,001st user. + */ +async function boot(n: number, opts: { omit?: string[]; split?: boolean } = {}): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); + const engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + const omit = new Set(opts.omit ?? []); + engine.registerApp({ + id: 'com.objectstack.qa.business-unit-member-reads-complete', + name: 'business unit member reads complete', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysOrganization, SysBusinessUnit, SysBusinessUnitMember].filter((o) => !omit.has(o.name)), + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const e = engine as any; + await e.insert('sys_organization', { id: ORG, name: ORG, slug: 'large' }, { context: { isSystem: true } }); + await e.insert('sys_organization', { id: ELSEWHERE, name: ELSEWHERE, slug: 'elsewhere' }, { context: { isSystem: true } }); + const units = e.getDriverForObject('sys_business_unit'); + await units.bulkCreate('sys_business_unit', [ + { id: ROOT, name: 'Root', kind: 'division', active: true, organization_id: ORG }, + { id: CHILD, name: 'Child', kind: 'department', active: true, parent_business_unit_id: ROOT, organization_id: ORG }, + { id: OTHER, name: 'Other', kind: 'department', active: true, organization_id: ORG }, + ]); + + const holders = Array.from({ length: n }, (_, i) => `usr_${pad(i)}`); + if (!omit.has('sys_business_unit_member')) { + const id = (i: number) => `bum_${pad((i * 7919) % 1000003)}`; + const split = opts.split ?? true; + const rows: Record[] = holders.map((user_id, i) => ({ + id: id(i), user_id, business_unit_id: split && i % 2 ? CHILD : ROOT, is_primary: true, organization_id: ORG, + })); + rows.push({ id: 'bum_other_unit', user_id: 'usr_other_unit', business_unit_id: OTHER, is_primary: false, organization_id: ORG }); + rows.push({ id: 'bum_elsewhere', user_id: 'usr_elsewhere', business_unit_id: ROOT, is_primary: false, organization_id: ELSEWHERE }); + const d = e.getDriverForObject('sys_business_unit_member'); + for (let i = 0; i < rows.length; i += 250) await d.bulkCreate('sys_business_unit_member', rows.slice(i, i + 250)); + } + + let count = 0; + (driver as any).knex.on('query', (q: { sql: string }) => { + if (/^select .* from [`"]sys_business_unit_member[`"]/i.test(q.sql)) count += 1; + }); + const failReadsOf = (object: string) => { + const d = e.getDriverForObject(object); + const real = d.find.bind(d); + vi.spyOn(d, 'find').mockImplementation(((obj: string, ...rest: unknown[]) => + obj === object + ? Promise.reject(Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' })) + : real(obj, ...rest)) as never); + }; + return { engine, driver, holders: [...holders].sort(), reads: () => count, failReadsOf }; +} + +const graph = (rig: Rig) => new BusinessUnitGraphService({ engine: rig.engine as never, organizationId: ORG }); + +describe('[#29606] site 1 — BusinessUnitGraphService.expandUnitMembers reads every member of the unit', () => { + it('⭐ past the 10,000-row bound: every member is in the set, and no one else', async () => { + const rig = await boot(PAGE + 1, { split: false }); + const users = await graph(rig).expandUnitMembers(ROOT); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_other_unit'); + expect(users).not.toContain('usr_elsewhere'); + expect(rig.reads()).toBe(2); + }, 30_000); + + it('control — under the bound: one read, the same set as before', async () => { + const rig = await boot(PAGE - 1, { split: false }); + const users = await graph(rig).expandUnitMembers(ROOT); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads()).toBe(1); + }, 30_000); + + it('⛔ a read that fails refuses — it never answers an invented empty set', async () => { + const rig = await boot(3, { split: false }); + rig.failReadsOf('sys_business_unit_member'); + + await expect(graph(rig).expandUnitMembers(ROOT)).rejects.toThrow( + /members of business unit 'bu_root' could not be read: SQLITE_IOERR/, + ); + }); + + it('⛔ a failed read is not cached: the next read answers the real members', async () => { + const rig = await boot(3, { split: false }); + // One driver serves every object, so the fault is keyed to the member read + // and fires once: the unit's seed check before it reads as normal. + const d = (rig.engine as any).getDriverForObject('sys_business_unit_member'); + const real = d.find.bind(d); + let faults = 1; + vi.spyOn(d, 'find').mockImplementation(((obj: string, ...rest: unknown[]) => + obj === 'sys_business_unit_member' && faults-- > 0 + ? Promise.reject(new Error('SQLITE_BUSY: database is locked')) + : real(obj, ...rest)) as never); + const g = graph(rig); + + await expect(g.expandUnitMembers(ROOT)).rejects.toThrow(/could not be read/); + expect([...(await g.expandUnitMembers(ROOT))].sort()).toEqual(rig.holders); + }); + + it('a composition that never registers sys_business_unit_member has no members: [] as before', async () => { + const rig = await boot(0, { omit: ['sys_business_unit_member'] }); + + expect(await graph(rig).expandUnitMembers(ROOT)).toEqual([]); + }); + + it('a stack whose sys_business_unit_member table was never created has no members: [] as before', async () => { + const rig = await boot(0); + await (rig.driver as any).knex.schema.dropTable('sys_business_unit_member'); + + expect(await graph(rig).expandUnitMembers(ROOT)).toEqual([]); + }); +}); + +describe('[#29606] site 2 — BusinessUnitGraphService.expandUsers reads every member of the subtree', () => { + it('⭐ past the 10,000-row bound: every member of the unit and its child is in the set, and no one else', async () => { + const rig = await boot(PAGE + 1); + const users = await graph(rig).expandUsers(ROOT); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_other_unit'); + expect(users).not.toContain('usr_elsewhere'); + expect(rig.reads()).toBe(2); + }, 30_000); + + it('control — under the bound: one read, the same set as before', async () => { + const rig = await boot(PAGE - 1); + const users = await graph(rig).expandUsers(ROOT); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads()).toBe(1); + }, 30_000); + + it('⛔ a read that fails refuses — it never answers an invented empty set', async () => { + const rig = await boot(3); + rig.failReadsOf('sys_business_unit_member'); + + await expect(graph(rig).expandUsers(ROOT)).rejects.toThrow( + /members of business unit 'bu_root' and its descendant units could not be read: SQLITE_IOERR/, + ); + }); + + it('a composition that never registers sys_business_unit_member has no members: [] as before', async () => { + const rig = await boot(0, { omit: ['sys_business_unit_member'] }); + + expect(await graph(rig).expandUsers(ROOT)).toEqual([]); + }); +}); + +describe('[#29606] site 3 — the primary-business-unit backfill scans every primary membership', () => { + /** The real engine's reads; the `sys_user` writes counted, not performed. */ + const backfillEngine = (rig: Rig) => { + const update = vi.fn(async (_object: string, data: Record) => data); + const engine = { + registerHook: vi.fn(), + unregisterHooksByPackage: vi.fn(() => 0), + find: (object: string, query?: unknown) => (rig.engine as any).find(object, query), + update, + }; + return { engine, update }; + }; + const logger = () => ({ info: vi.fn(), warn: vi.fn() }); + + it('⭐ past the 10,000-row bound: every primary membership is projected', async () => { + const rig = await boot(PAGE + 1); + const { engine, update } = backfillEngine(rig); + const log = logger(); + + expect(await backfillPrimaryBu(engine, log)).toEqual({ updated: PAGE + 1, refused: 0 }); + const projected = update.mock.calls.map(([, data]) => String(data.id)).sort(); + expect(projected).toEqual(rig.holders); + expect(rig.reads()).toBe(2); + expect(log.warn).not.toHaveBeenCalled(); + }, 30_000); + + it('control — under the bound: one read, every primary membership projected', async () => { + const rig = await boot(PAGE - 1); + const { engine, update } = backfillEngine(rig); + + expect(await backfillPrimaryBu(engine, logger())).toEqual({ updated: PAGE - 1, refused: 0 }); + expect(update).toHaveBeenCalledTimes(PAGE - 1); + expect(rig.reads()).toBe(1); + }, 30_000); + + it('⛔ a scan that fails writes nothing and says so, carrying the error', async () => { + const rig = await boot(3); + rig.failReadsOf('sys_business_unit_member'); + const { engine, update } = backfillEngine(rig); + const log = logger(); + + expect(await backfillPrimaryBu(engine, log)).toEqual({ updated: 0, refused: 0 }); + expect(update).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledTimes(1); + const [message, meta] = log.warn.mock.calls[0]; + expect(message).toMatch(/backfill scan of sys_business_unit_member failed/); + expect(meta.error).toMatch(/SQLITE_IOERR/); + }); + + it('a composition that never registers sys_business_unit_member has nothing to project, and says nothing', async () => { + const rig = await boot(0, { omit: ['sys_business_unit_member'] }); + const { engine, update } = backfillEngine(rig); + const log = logger(); + + expect(await backfillPrimaryBu(engine, log)).toEqual({ updated: 0, refused: 0 }); + expect(update).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/primary-bu-projection.test.ts b/packages/plugins/plugin-sharing/src/primary-bu-projection.test.ts index b860c4e9328..3b53cb036d9 100644 --- a/packages/plugins/plugin-sharing/src/primary-bu-projection.test.ts +++ b/packages/plugins/plugin-sharing/src/primary-bu-projection.test.ts @@ -31,9 +31,11 @@ function makeLogger() { return { info: vi.fn(), warn: vi.fn() }; } +// [#29606] Each row carries its `id`: the scan is a keyset walk, which seeks +// past the last id it read and cannot vouch for a page without one. const MEMBERS: Row[] = [ - { user_id: 'u1', business_unit_id: 'bu1' }, - { user_id: 'u2', business_unit_id: 'bu2' }, + { id: 'm1', user_id: 'u1', business_unit_id: 'bu1' }, + { id: 'm2', user_id: 'u2', business_unit_id: 'bu2' }, ]; describe('backfillPrimaryBu — refused writes are counted and reported', () => { @@ -114,5 +116,19 @@ describe('backfillPrimaryBu — refused writes are counted and reported', () => expect(await backfillPrimaryBu(engine, logger)).toEqual({ updated: 0, refused: 0 }); expect(engine.update).not.toHaveBeenCalled(); + // [#29606] …and says so, with the error: a refused scan is not "nothing to do". + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][1]).toEqual({ error: 'scan refused' }); + }); + + it('[#29606] a scan that cannot vouch for completion writes nothing and says so', async () => { + const logger = makeLogger(); + // No `id` on the rows: the walk cannot seek past this page. + const engine = makeEngine(MEMBERS.map(({ id: _id, ...row }) => row), () => {}); + + expect(await backfillPrimaryBu(engine, logger)).toEqual({ updated: 0, refused: 0 }); + expect(engine.update).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toMatch(/could not be read to completion/); }); }); diff --git a/packages/plugins/plugin-sharing/src/primary-bu-projection.ts b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts index c3455d0524d..8ccd37de0e7 100644 --- a/packages/plugins/plugin-sharing/src/primary-bu-projection.ts +++ b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts @@ -23,6 +23,7 @@ * membership is usable single-tenant too). */ +import { isObjectAbsentError, keysetWalk } from '@objectstack/types'; import type { OptionalSharingLogger } from './logger-shapes.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -123,6 +124,65 @@ export function bindPrimaryBuHooks(engine: MinimalEngine, logger?: OptionalShari logger?.info?.('[primary-bu] projection hooks bound on sys_business_unit_member'); } +/** + * [#29606] Rows per page of the backfill scan — the bound the scan always + * carried, so a stack with fewer primary memberships issues the one read it + * always did, and one with more is read in further pages rather than cut. + */ +const BACKFILL_PAGE_ROWS = 10000; + +/** + * [#29606] The consequence half of a scan that could not answer, shared by the + * failed and the incomplete scan so an operator reads one sentence family. + */ +const SCAN_NOT_APPLIED = 'so the boot backfill of sys_user.primary_business_unit_id wrote nothing. Users whose ' + + 'primary membership predates the projection hooks keep a stale or absent value, and every sharing rule keyed ' + + 'on the primary business unit evaluates against it; the projection self-heals for a user on their next ' + + 'sys_business_unit_member write, and a full repair is another boot once the scan can be read.'; + +/** + * Every `is_primary` member row, read to completion — `null` when the scan + * could not answer (it says why, once), `[]` when the stack has no + * `sys_business_unit_member` at all. + * + * [#29606] The scan read once at a 10,000-row bound, so a stack with more + * primary memberships than that never projected the rest, with no signal. + * It now pages through the platform's one keyset walk (`keysetWalk`, + * `@objectstack/types`), and a scan that fails or cannot vouch for completion + * (a row without an `id`, a page that repeated the cursor) writes nothing and + * says so — never a half-built projection that reads as a clean boot. Absence + * of the object is the one failure that IS an answer (no memberships to + * project), checked by `isObjectAbsentError` against the object read. + */ +async function readPrimaryMemberships(engine: MinimalEngine, logger?: OptionalSharingLogger): Promise { + const rows: any[] = []; + const walk = keysetWalk>( + (page) => engine.find('sys_business_unit_member', { + ...page, fields: ['id', 'user_id', 'business_unit_id'], context: SYSTEM_CTX, + }), + { where: { is_primary: true }, pageSize: BACKFILL_PAGE_ROWS }, + ); + try { + for await (const page of walk.pages()) rows.push(...page); + } catch (err: any) { + if (isObjectAbsentError(err, 'sys_business_unit_member')) return []; + logger?.warn?.( + `[primary-bu] the backfill scan of sys_business_unit_member failed, ${SCAN_NOT_APPLIED}`, + { error: err?.message ?? String(err) }, + ); + return null; + } + if (walk.truncated) { + logger?.warn?.( + '[primary-bu] the backfill scan of sys_business_unit_member could not be read to completion (a row without ' + + `an id, or a page that repeated the cursor), ${SCAN_NOT_APPLIED}`, + { scanned: walk.scanned }, + ); + return null; + } + return rows; +} + /** * One-time boot reconcile: set every user's primary_business_unit_id from their * `is_primary` member row, so pre-existing memberships (seeds, prior data) @@ -132,18 +192,8 @@ export async function backfillPrimaryBu( engine: MinimalEngine, logger?: OptionalSharingLogger, ): Promise<{ updated: number; refused: number }> { - let rows: any[] = []; - try { - rows = await engine.find('sys_business_unit_member', { - where: { is_primary: true }, - fields: ['user_id', 'business_unit_id'], - limit: 10000, - context: SYSTEM_CTX, - }); - } catch (err: any) { - logger?.warn?.('[primary-bu] backfill scan failed', { error: err?.message }); - return { updated: 0, refused: 0 }; - } + const rows = await readPrimaryMemberships(engine, logger); + if (rows === null) return { updated: 0, refused: 0 }; let updated = 0; // [#12981] Refused row writes are COUNTED, never swallowed. `catch { }` alone // made a backfill in which EVERY row was refused byte-identical to one with diff --git a/packages/plugins/plugin-sharing/src/team-graph.ts b/packages/plugins/plugin-sharing/src/team-graph.ts index 8d3256d9967..b8325c0938d 100644 --- a/packages/plugins/plugin-sharing/src/team-graph.ts +++ b/packages/plugins/plugin-sharing/src/team-graph.ts @@ -176,8 +176,9 @@ const WALK_INCOMPLETE = 'the read by id could not continue past a page (a row wi * and answers `null`, so each caller says what absence means for it * (`isObjectAbsentError`, `@objectstack/types`). * - * ⛔ Not exported from the package index — the one walk the team, role and - * position reads share, not a public affordance. + * ⛔ Not exported from the package index — the one walk the team, role, + * position and business-unit member reads share (#29606), not a public + * affordance. */ export async function readMembershipToCompletion( engine: SharingEngine,