Skip to content

Commit 4898162

Browse files
fix(plugins)!: a failed business-unit hierarchy read refuses instead of answering nobody, and a unit keeps every child unit past 1,000 (#29703)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8SKXzvp7veJe4SDeJ1JoSty)_ Fixes #29655 domain:server#1 · os-warren · session_01JQwxbXcxCoeCAw1FyTWwwf Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here. `content/docs/automation/approvals.mdx` now says a `department` entry also keeps its literal and logs the failed-read line when the `sys_business_unit` read of the unit or of its child units fails. No skill teaches what a failed business-unit hierarchy read answers. ## 维护者速读 - 改了什么:共享规则与审批的「业务单元」展开里,读 `sys_business_unit` 本身(种子单元是否存在且启用、逐层读下级单元)失败时不再当作「没人」,而是明确报错,且失败结果不进本轮缓存;下级单元的读改为按 id 分页读完(每页 1,000),超过 1,000 个下级单元不再被截断。 - 为什么改:上一单(成员表)修完后,单元表这条路仍会把读失败吞成「没人」并缓存一整轮:业务单元结构一变,重算就把 `business_unit` / `unit_and_subordinates` 规则的授权全部收回;审批则记成「expanded to nobody」或悄悄派给缩水的名单。 - 风险与回滚:唯一「从有答案变成报错」的情形是 `sys_business_unit` 的读本身失败(原来答空),属收窄,标题带 `!`;表或对象不存在仍答无人;1,000 个下级以内仍是一次读、结果不变。队列文件未改动,其失败姿态(记一次日志、贡献 0 人)照旧。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-sharing/src/business-unit-graph.ts`(`seedIsUsable`、`readChildUnits`)、`packages/plugins/plugin-approvals/src/approval-service.ts`(`expandBusinessUnitUsers`、`readRowsToCompletion`)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/business-unit-hierarchy-reads-complete.test.ts`,预期 9/9 通过(种子读失败报错且不缓存、下级读失败报错且不缓存、1,001 个下级单元全部在内、对象缺失仍答无人)。 - 需要你做的:无,席位落地。 ## What changes The `sys_business_unit` reads behind the business-unit expansions now answer every unit or refuse, the shape the member reads (`sys_business_unit_member`) got in #29647, through the same walk. - **plugin-sharing, `BusinessUnitGraphService`** - `seedIsUsable` reads the seed row through `readMembershipToCompletion` (the one `keysetWalk` the member reads share). A failed read throws; it used to answer `false`, which both widths cached as `[]` for the pass. A missing `sys_business_unit` (`isObjectAbsentError`) still answers `false`. - `descendants` reads each parent's child units through the new private `readChildUnits`, the same walk in pages of 1,000 (`CHILD_UNIT_PAGE_ROWS`, the bound the read always carried). A failed read throws before anything is cached; it used to answer `[]` for that parent. A parent with more than 1,000 active children keeps every child. - The per-pass cache: an absent or inactive seed is still cached as `[]`. A failed read throws before any `set`, so the next call on the same pass reads again. - **plugin-approvals, `ApprovalService.expandBusinessUnitUsers`** - The seed check (`businessUnitIsLive`, unchanged, already throws) is no longer wrapped in a catch-all: absence answers `[]`, any other failure refuses with `[approvals] business unit 'ID' in organization 'ORG' could not be read: …`. - The child-unit descent reads through the new private `readRowsToCompletion`, factored out of `readHoldersToCompletion` (which now calls it), in pages of 1,000. The refusal sentence is the existing one, now in one helper `holderReadRefusal`. - The caller is unchanged: `resolveApproverSpec`'s directory catch turns the refusal into the literal slot plus the `could not be expanded — a directory or read-filter lookup failed` warning, as for a failed member read. - No new export from any package index, no `packages/spec` change, `ownership-queue-service.ts` untouched (its `expandSpec` catch already turns a refused expansion into nobody plus one warning). ## Tests Two new real-engine files (ObjectQL + SqlDriver over in-memory SQLite, shipped `SysBusinessUnit` / `SysBusinessUnitMember`), the pins the card asks for per site: | Pin | sharing (`business-unit-hierarchy-reads-complete.test.ts`) | approvals (`business-unit-hierarchy-complete.integration.test.ts`) | |:--|:--|:--| | failed seed read | refuses on `expandUnitMembers`, `expandUsers`, `descendants`; not cached | refuses; the slot is the literal with the failed-read warning | | failed descent read | refuses on `descendants` and `expandUsers`; not cached; the single-unit width is untouched | refuses; literal plus warning, not a shrunk slate | | 1,001 child units | all 1,002 units and every member; 2 child reads (control: 999 children, 1 read) | every member on the slate; 2 child reads (control: 1 read) | | absence | unregistered object and dropped table both answer `[]` | unregistered object answers `[]` with no failure warning; dropped table answers `[]` | - `pnpm --filter @objectstack/plugin-sharing --filter @objectstack/plugin-approvals test` at `483976d8`: plugin-sharing 64 files / 1369 tests passed, plugin-approvals 111 files / 1442 tests passed. - `pnpm --filter @objectstack/plugin-sharing --filter @objectstack/plugin-approvals typecheck`: exit 0. <details><summary>Ablations: each negative pin with its catch put back (red), restored by ablation-replace (blob == HEAD, git diff HEAD empty)</summary> The tests import the subject from `src/` by relative path, so no build sits between the mutation and the run. A1, sharing seed: `seedIsUsable` wrapped back in `try … catch { return false; }` ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob a27bf6e -> 7cb3ff7b49566876bfe98e5c3fc4241a69bf4cd2 × ⛔ both widths and the descent refuse — none answers an invented empty set 170ms × ⛔ a failed seed read is not cached: the next read on the same pass answers the real set 25ms AssertionError: promise resolved "[]" instead of rejecting AssertionError: promise resolved "[]" instead of rejecting Tests 2 failed | 7 passed (9) ablation-replace: ok restored: blob == HEAD (a27bf6e) and `git diff HEAD` is empty ``` A2, sharing descent: `readChildUnits` wrapped back in `try … catch { return []; }` ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob a27bf6e -> a6e5df6cd8631cd961094f92c51ed1647811455e × ⛔ the subtree width and the descent refuse — the subtree never shrinks to the seed 20ms × ⛔ a failed descent read is not cached: the next read on the same pass answers the whole subtree 18ms AssertionError: promise resolved "[ 'bu_root' ]" instead of rejecting AssertionError: promise resolved "[ 'bu_root' ]" instead of rejecting Tests 2 failed | 7 passed (9) ablation-replace: ok restored: blob == HEAD (a27bf6e) and `git diff HEAD` is empty ``` A3, approvals seed: the refusal replaced by `return [];` ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 4f51e37 -> c3abb7bdd78284efca1f23c01ffb68705f92735e × ⛔ the expansion refuses — it never answers an invented empty slate 208ms × ⛔ what the caller sees: the department slot falls back to its literal, and the warning names the failed read 30ms AssertionError: promise resolved "[]" instead of rejecting AssertionError: expected [] to deeply equal [ 'department' ] Tests 2 failed | 6 passed (8) ablation-replace: ok restored: blob == HEAD (4f51e37) and `git diff HEAD` is empty ``` A4, approvals descent: `.catch(() => [])` put back on the child-unit read ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 4f51e37 -> a3210bc8c8e5dbe0a645f1aac84b45ed25c90491 × ⛔ the expansion refuses — the slate never shrinks to the seed unit 44ms × ⛔ what the caller sees: the literal and the failed-read warning, not a shrunk slate 23ms AssertionError: promise resolved "[ 'usr_of_bu_root' ]" instead of rejecting AssertionError: expected [ 'usr_of_bu_root' ] to deeply equal [ 'department:bu_root' ] Tests 2 failed | 6 passed (8) ablation-replace: ok restored: blob == HEAD (4f51e37) and `git diff HEAD` is empty ``` </details> ## Acceptance notes - `headOf` (`business-unit-graph.ts`) still turns a failed `sys_business_unit` read into `null` and caches it for the pass. It is outside this card's sites (it names a department head, not a recipient set) and is noted, not filed. - The descent stays one walk per unit (breadth-first, a read per parent), as before; only each parent's read is now complete. - The content/docs line is outside the claim's file surface: one sentence in `content/docs/automation/approvals.mdx` beside the member-read sentence #29647 added, so the page does not describe the old answer. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands` at `483976d8`: 92 derived families, all run; `--ran` reconciliation exit 0, "92 run, 0 NOT-MEASURED". Four first exited 3 on prerequisites (teardown-shape self-test on a shallow clone; skill-examples, dual-build-cjs-loads and i18n before a package build) and were re-run green after `git fetch --unshallow` and `turbo run build --filter='./packages/**'`. - `pnpm lint`: exit 0. - Dogfood battery: `483976d8` · `os-verify-lock: VERDICT command-exit 0 · held the lock 1498s` · Test Files 399 passed, 3 skipped (402) · Tests 3297 passed, 28 skipped (3325) · no failures. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXcxCoeCAw1FyTWwwf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b824a66 commit 4898162

6 files changed

Lines changed: 597 additions & 59 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/plugin-approvals': patch
4+
---
5+
6+
fix(plugins)!: a failed `sys_business_unit` read refuses instead of answering nobody, and a business unit with more than 1,000 child units keeps every child
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata changes: the business-unit expansions read the unit rows they already read, to completion, and refuse a read that already failed. No author has anything to rewrite. -->
12+
13+
**BREAKING** only where a `sys_business_unit` read fails: the business-unit expansions used to answer nobody (an empty list) then, and now refuse.
14+
15+
The member reads of a business unit (`sys_business_unit_member`) read to completion and refuse on failure. The reads of the unit table they are keyed by did not: the check that the unit exists and is active, and the read of each unit's child units, still turned any read error into nobody, and the child-unit read stopped at 1,000 rows.
16+
17+
- **A failed unit read refuses.** In plugin-sharing, `BusinessUnitGraphService.descendants`, `expandUnitMembers` (every `business_unit` sharing recipient) and `expandUsers` (every `unit_and_subordinates` recipient) now throw when the unit's own row or a child-unit read fails. In plugin-approvals, the `department` / `business_unit` / `bu` approver and an `expression` approver with `resolveAs: 'department'` do the same.
18+
- Sharing: 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 unit read answered `[]` and that answer was kept for the rest of the pass, so the recompute a business-unit graph write triggers revoked every grant the rule gave.
19+
- 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 seed read logged "expanded to nobody", and a failed child-unit read routed to the units that did read, with no signal.
20+
- Ownership queues: a `business_unit` or `unit_and_subordinates` queue specification whose unit read fails contributes nobody, the posture the queue declares for a failed expansion, and logs it once.
21+
- **Nothing is cached after a failed read.** The next expansion on the same pass reads again.
22+
- **Every child unit.** The child units of each unit are read in pages of 1,000 ordered by id until they are exhausted. A unit with more than 1,000 active child units used to lose the children past the 1,000th row, with all their members and descendants. A unit with fewer issues one read, as before.
23+
- **A missing object is still nobody.** A stack that never registers, or never creates, `sys_business_unit` keeps answering no members. That is judged by `isObjectAbsentError(error, 'sys_business_unit')`; any other error stays loud.
24+
25+
**The remedy.** None is needed. A refusal means the business-unit table could not be read: fix that read, and the next expansion succeeds.

‎content/docs/automation/approvals.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,8 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
458458
`behavior` that needs every slot (`unanimous`) waits on it too. A `team`,
459459
`position`, `department` or `org_membership_level` entry whose holder read
460460
failed (`sys_team_member`, `sys_user_position`, `sys_business_unit_member`
461-
or `sys_member`) keeps its literal the same way. It logs `could not be
461+
or `sys_member`, and for a `department` also the `sys_business_unit` read of
462+
the unit and of its child units) keeps its literal the same way. It logs `could not be
462463
expanded — a directory or read-filter lookup failed` with the `error`,
463464
instead of `expanded to nobody`, and never routes to the holders that
464465
happened to read. An `expression` value whose `resolveAs` read failed keeps

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,25 @@ const MEMBER_SCREEN_READ_LIMIT = 50000;
11271127
*/
11281128
const HOLDER_PAGE_ROWS = 10000;
11291129

1130+
/**
1131+
* [#29655] Rows per page of the business-unit descent's child-unit read — the
1132+
* bound that read always carried, so a parent with fewer children issues the
1133+
* one read it always did, and one with more is read in further pages.
1134+
*/
1135+
const CHILD_UNIT_PAGE_ROWS = 1000;
1136+
1137+
/**
1138+
* [#29362, #29655] Why a directory read refused: what could not be read, the
1139+
* driver's reason, and why the answer is a refusal rather than nobody. The
1140+
* driver's error rides along as `cause`.
1141+
*/
1142+
function holderReadRefusal(scope: string, err: unknown): Error {
1143+
return Object.assign(new Error(
1144+
`[approvals] ${scope} could not be read: ${err instanceof Error ? err.message : String(err)}. `
1145+
+ 'Routing to a shortened slate would leave holders unasked, so the expansion refuses instead.',
1146+
), { cause: err });
1147+
}
1148+
11301149
/**
11311150
* Approver types resolved by QUERYING a graph rather than by taking `value`
11321151
* literally (#3807). Each can legitimately come back empty — an unstaffed
@@ -3214,25 +3233,37 @@ export class ApprovalService implements IApprovalService {
32143233
*/
32153234
private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
32163235
if (!businessUnitId) return [];
3236+
const inOrg = organizationId ? ` in organization '${organizationId}'` : '';
32173237
// Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
3238+
// [#29655] A failed seed read refuses — the slot then says the read failed
3239+
// rather than "expanded to nobody". A stack with no `sys_business_unit`
3240+
// has no departments.
3241+
let live: boolean;
32183242
try {
3219-
if (!(await this.businessUnitIsLive(businessUnitId, organizationId))) return [];
3220-
} catch { return []; }
3243+
live = await this.businessUnitIsLive(businessUnitId, organizationId);
3244+
} catch (err) {
3245+
if (isObjectAbsentError(err, 'sys_business_unit')) return [];
3246+
throw holderReadRefusal(`business unit '${businessUnitId}'${inOrg}`, err);
3247+
}
3248+
if (!live) return [];
32213249

32223250
const seen = new Set<string>([businessUnitId]);
32233251
const queue: string[] = [businessUnitId];
32243252
while (queue.length) {
32253253
const parent = queue.shift()!;
3226-
let kids: any[] = [];
3227-
try {
3228-
const filter = this.businessUnitOrgScope(
3229-
{ parent_business_unit_id: parent, active: { $ne: false } },
3230-
organizationId,
3231-
);
3232-
kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
3233-
} catch { kids = []; }
3254+
// [#29655] Every active child unit, read to completion — or a refusal.
3255+
// It read once at a 1,000-row bound and answered `[]` on any failure:
3256+
// a child past the bound, or every child of a parent whose read failed,
3257+
// left the slate with its members and no signal.
3258+
const kids = await this.readRowsToCompletion(
3259+
'sys_business_unit',
3260+
this.businessUnitOrgScope({ parent_business_unit_id: parent, active: { $ne: false } }, organizationId),
3261+
[],
3262+
`the child units of business unit '${parent}'${inOrg}`,
3263+
CHILD_UNIT_PAGE_ROWS,
3264+
);
32343265
for (const k of kids ?? []) {
3235-
const kid = String((k as any).id ?? '');
3266+
const kid = String(k?.id ?? '');
32363267
if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); }
32373268
}
32383269
}
@@ -3249,8 +3280,7 @@ export class ApprovalService implements IApprovalService {
32493280
// proved their tenancy; these rows have not, and the
32503281
// shared seeded unit id is exactly where other tenants' rows sit.
32513282
this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
3252-
`the members of business unit '${businessUnitId}' and its descendant units`
3253-
+ `${organizationId ? ` in organization '${organizationId}'` : ''}`,
3283+
`the members of business unit '${businessUnitId}' and its descendant units${inOrg}`,
32543284
);
32553285
}
32563286

@@ -3488,24 +3518,40 @@ export class ApprovalService implements IApprovalService {
34883518
* attributed to `object`, so an absence of another object is still loud.
34893519
*/
34903520
private async readHoldersToCompletion(object: string, where: Record<string, unknown>, scope: string): Promise<string[]> {
3521+
const rows = await this.readRowsToCompletion(object, where, ['user_id'], scope, HOLDER_PAGE_ROWS);
34913522
const users = new Set<string>();
3523+
for (const row of rows ?? []) {
3524+
const uid = String(row?.user_id ?? '');
3525+
if (uid) users.add(uid);
3526+
}
3527+
return Array.from(users);
3528+
}
3529+
3530+
/**
3531+
* [#29362, #29503, #29655] Every `object` row matching `where` (its `id`
3532+
* plus `fields`), read to COMPLETION through the platform's one keyset walk
3533+
* — or a refusal; `null` when `object` does not exist on this stack, so each
3534+
* caller says what absence means for it. The one walk behind
3535+
* {@link readHoldersToCompletion} and the business-unit descent of
3536+
* {@link expandBusinessUnitUsers}.
3537+
*/
3538+
private async readRowsToCompletion(
3539+
object: string,
3540+
where: Record<string, unknown>,
3541+
fields: readonly string[],
3542+
scope: string,
3543+
pageSize: number,
3544+
): Promise<Record<string, unknown>[] | null> {
3545+
const rows: Record<string, unknown>[] = [];
34923546
const walk = keysetWalk<Record<string, unknown>>(
3493-
(page) => this.engine.find(object, { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }),
3494-
{ where, pageSize: HOLDER_PAGE_ROWS },
3547+
(page) => this.engine.find(object, { ...page, fields: ['id', ...fields], context: SYSTEM_CTX }),
3548+
{ where, pageSize },
34953549
);
34963550
try {
3497-
for await (const page of walk.pages()) {
3498-
for (const row of page) {
3499-
const uid = String(row?.user_id ?? '');
3500-
if (uid) users.add(uid);
3501-
}
3502-
}
3551+
for await (const page of walk.pages()) rows.push(...page);
35033552
} catch (err) {
3504-
if (isObjectAbsentError(err, object)) return [];
3505-
throw Object.assign(new Error(
3506-
`[approvals] ${scope} could not be read: ${err instanceof Error ? err.message : String(err)}. `
3507-
+ 'Routing to a shortened slate would leave holders unasked, so the expansion refuses instead.',
3508-
), { cause: err });
3553+
if (isObjectAbsentError(err, object)) return null;
3554+
throw holderReadRefusal(scope, err);
35093555
}
35103556
if (walk.truncated) {
35113557
throw new Error(
@@ -3514,7 +3560,7 @@ export class ApprovalService implements IApprovalService {
35143560
+ 'holders unasked, so the expansion refuses instead.',
35153561
);
35163562
}
3517-
return Array.from(users);
3563+
return rows;
35183564
}
35193565

35203566
/**
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29655] The approver business-unit expansion's `sys_business_unit` reads —
5+
* the seed check and the child-unit descent — answer EVERY unit, or refuse.
6+
* #29606 closed the member read; this closes the unit reads it is keyed by.
7+
*
8+
* The seed check answered `[]` on a read that threw, and the descent read
9+
* each parent's child units once at a 1,000-row bound and answered `[]` on any
10+
* failure: a failed seed read logged "expanded to nobody", indistinguishable
11+
* from an unstaffed unit, and a failed or cut descent routed to a shrunk slate
12+
* with no signal at all.
13+
*
14+
* What a caller sees now: the expansion throws, the approver-spec resolver's
15+
* directory catch takes it, and the slot falls back to its literal with the
16+
* warning that names the failed read and carries its `error`. A unit object
17+
* that does not exist is still no members.
18+
*
19+
* Measured on a REAL engine (ObjectQL + SqlDriver over in-memory SQLite)
20+
* carrying the shipped `SysBusinessUnit` and `SysBusinessUnitMember`.
21+
*/
22+
23+
import { describe, it, expect, afterEach, vi } from 'vitest';
24+
import { ObjectQL } from '@objectstack/objectql';
25+
import { SqlDriver } from '@objectstack/driver-sql';
26+
import { SysBusinessUnit, SysBusinessUnitMember, SysMember, SysOrganization } from '@objectstack/platform-objects/identity';
27+
import { ApprovalService } from './approval-service.js';
28+
29+
const ORG = 'org_tree';
30+
const ROOT = 'bu_root';
31+
const CHILD = 'bu_child';
32+
/** The bound each child-unit read is paged at. */
33+
const PAGE = 1000;
34+
35+
const pad = (n: number) => String(n).padStart(5, '0');
36+
37+
/** Which `sys_business_unit` read a fault targets. */
38+
type UnitRead = 'seed' | 'descent';
39+
40+
interface Rig {
41+
engine: ObjectQL;
42+
driver: SqlDriver;
43+
svc: ApprovalService;
44+
warn: ReturnType<typeof vi.fn>;
45+
/** Every member of the tree, sorted. */
46+
members: string[];
47+
/** The child-unit reads the driver ran for ROOT's children. */
48+
rootChildReads: () => number;
49+
/** Fail every read of one kind; every other read runs as before. */
50+
failUnitReads: (which: UnitRead) => void;
51+
}
52+
53+
const engines: ObjectQL[] = [];
54+
afterEach(async () => {
55+
vi.restoreAllMocks();
56+
while (engines.length) {
57+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
58+
}
59+
});
60+
61+
/**
62+
* ROOT, its child CHILD, and `extra` more child units of ROOT, each with one
63+
* member. Unit ids are scrambled against creation order.
64+
*/
65+
async function boot(extra: number, opts: { omit?: string[] } = {}): Promise<Rig> {
66+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
67+
const engine = new ObjectQL();
68+
engine.registerDriver(driver as never, true);
69+
await engine.init();
70+
const omit = new Set(opts.omit ?? []);
71+
engine.registerApp({
72+
id: 'com.objectstack.qa.business-unit-hierarchy-complete',
73+
name: 'business unit hierarchy complete',
74+
version: '1.0.0',
75+
type: 'plugin',
76+
scope: 'system',
77+
objects: [SysOrganization, SysMember, SysBusinessUnit, SysBusinessUnitMember].filter((o) => !omit.has(o.name)),
78+
} as never);
79+
await engine.syncSchemas();
80+
engines.push(engine);
81+
82+
const e = engine as any;
83+
await e.insert('sys_organization', { id: ORG, name: ORG, slug: 'tree' }, { context: { isSystem: true } });
84+
const kids = Array.from({ length: extra }, (_, i) => `bu_kid_${pad((i * 7919) % 100003)}`);
85+
const units = [ROOT, CHILD, ...kids];
86+
if (!omit.has('sys_business_unit')) {
87+
const rows: Record<string, unknown>[] = [
88+
{ id: ROOT, name: 'Root', kind: 'division', active: true, organization_id: ORG },
89+
{ id: CHILD, name: 'Child', kind: 'department', active: true, parent_business_unit_id: ROOT, organization_id: ORG },
90+
...kids.map((id) => ({ id, name: id, kind: 'department', active: true, parent_business_unit_id: ROOT, organization_id: ORG })),
91+
];
92+
const d = e.getDriverForObject('sys_business_unit');
93+
for (let i = 0; i < rows.length; i += 250) await d.bulkCreate('sys_business_unit', rows.slice(i, i + 250));
94+
}
95+
const members = units.map((u) => `usr_of_${u}`);
96+
const memberRows = units.map((u, i) => ({
97+
id: `bum_${pad(i)}`, user_id: `usr_of_${u}`, business_unit_id: u, organization_id: ORG,
98+
}));
99+
const md = e.getDriverForObject('sys_business_unit_member');
100+
for (let i = 0; i < memberRows.length; i += 250) await md.bulkCreate('sys_business_unit_member', memberRows.slice(i, i + 250));
101+
102+
let rootChildReads = 0;
103+
(driver as any).knex.on('query', (q: { sql: string; bindings?: unknown[] }) => {
104+
if (/^select .* from [`"]sys_business_unit[`"]/i.test(q.sql) && /parent_business_unit_id/.test(q.sql)
105+
&& (q.bindings ?? []).includes(ROOT)) rootChildReads += 1;
106+
});
107+
const failUnitReads = (which: UnitRead) => {
108+
const d = e.getDriverForObject('sys_business_unit');
109+
const real = d.find.bind(d);
110+
vi.spyOn(d, 'find').mockImplementation(((obj: string, ...rest: unknown[]) => {
111+
const descent = JSON.stringify(rest).includes('parent_business_unit_id');
112+
if (obj === 'sys_business_unit' && (which === 'descent') === descent) {
113+
return Promise.reject(Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' }));
114+
}
115+
return real(obj, ...rest);
116+
}) as never);
117+
};
118+
const warn = vi.fn();
119+
const svc = new ApprovalService({
120+
engine: engine as any,
121+
logger: { warn, info: () => {}, error: () => {}, debug: () => {} } as any,
122+
});
123+
return { engine, driver, svc, warn, members: [...members].sort(), rootChildReads: () => rootChildReads, failUnitReads };
124+
}
125+
126+
/** The private expansion and the resolver, as the request opener calls them. */
127+
const department = (rig: Rig, value = ROOT) =>
128+
(rig.svc as any).expandBusinessUnitUsers(value, ORG) as Promise<string[]>;
129+
const resolveSpec = (rig: Rig, a: { type: string; value: string }) =>
130+
(rig.svc as any).resolveApproverSpec(a, undefined, ORG, Date.now()) as Promise<string[]>;
131+
const failureWarnings = (rig: Rig) => rig.warn.mock.calls.filter(([, meta]) => typeof meta?.error === 'string');
132+
133+
describe('[#29655] approvals business-unit expansion: a failed seed read refuses', () => {
134+
it('⛔ the expansion refuses — it never answers an invented empty slate', async () => {
135+
const rig = await boot(0);
136+
rig.failUnitReads('seed');
137+
138+
await expect(department(rig)).rejects.toThrow(
139+
/\[approvals\] business unit 'bu_root' in organization 'org_tree' could not be read: SQLITE_IOERR/,
140+
);
141+
});
142+
143+
it('⛔ what the caller sees: the department slot falls back to its literal, and the warning names the failed read', async () => {
144+
const rig = await boot(0);
145+
rig.failUnitReads('seed');
146+
147+
expect(await resolveSpec(rig, { type: 'department', value: ROOT })).toEqual([`department:${ROOT}`]);
148+
const failed = failureWarnings(rig);
149+
expect(failed.map(([, meta]) => meta.type)).toEqual(['department']);
150+
expect(failed[0][0]).toMatch(/could not be expanded — a directory or read-filter lookup failed/);
151+
expect(failed[0][1].error).toMatch(/SQLITE_IOERR/);
152+
});
153+
});
154+
155+
describe('[#29655] approvals business-unit expansion: a failed descent read refuses', () => {
156+
it('⛔ the expansion refuses — the slate never shrinks to the seed unit', async () => {
157+
const rig = await boot(0);
158+
rig.failUnitReads('descent');
159+
160+
await expect(department(rig)).rejects.toThrow(
161+
/\[approvals\] the child units of business unit 'bu_root' in organization 'org_tree' could not be read: SQLITE_IOERR/,
162+
);
163+
});
164+
165+
it('⛔ what the caller sees: the literal and the failed-read warning, not a shrunk slate', async () => {
166+
const rig = await boot(0);
167+
rig.failUnitReads('descent');
168+
169+
expect(await resolveSpec(rig, { type: 'department', value: ROOT })).toEqual([`department:${ROOT}`]);
170+
expect(failureWarnings(rig).map(([, meta]) => meta.type)).toEqual(['department']);
171+
});
172+
});
173+
174+
describe('[#29655] approvals business-unit expansion reads every child unit past the 1,000-row bound', () => {
175+
it('⭐ 1,001 child units: every member of every child is on the slate', async () => {
176+
// CHILD plus 1,000 more: 1,001 children of ROOT.
177+
const rig = await boot(PAGE);
178+
179+
expect([...(await department(rig))].sort()).toEqual(rig.members);
180+
expect(rig.members).toHaveLength(PAGE + 2);
181+
expect(rig.rootChildReads()).toBe(2);
182+
}, 60_000);
183+
184+
it('control — under the bound: one child-unit read, the same slate as before', async () => {
185+
// CHILD plus 998 more: 999 children of ROOT.
186+
const rig = await boot(PAGE - 2);
187+
188+
expect([...(await department(rig))].sort()).toEqual(rig.members);
189+
expect(rig.rootChildReads()).toBe(1);
190+
}, 60_000);
191+
});
192+
193+
describe('[#29655] approvals business-unit expansion: absence still answers nobody', () => {
194+
it('a composition that never registers sys_business_unit has no departments: the literal, with no failure warning', async () => {
195+
const rig = await boot(0, { omit: ['sys_business_unit'] });
196+
197+
expect(await department(rig)).toEqual([]);
198+
expect(await resolveSpec(rig, { type: 'department', value: ROOT })).toEqual([`department:${ROOT}`]);
199+
expect(failureWarnings(rig)).toEqual([]);
200+
});
201+
202+
it('a stack whose sys_business_unit table was never created has no departments', async () => {
203+
const rig = await boot(0);
204+
await (rig.driver as any).knex.schema.dropTable('sys_business_unit');
205+
206+
expect(await department(rig)).toEqual([]);
207+
});
208+
});

0 commit comments

Comments
 (0)