Skip to content

Commit 2789ba7

Browse files
fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody (#29497)
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95XUAuk2vpvE7ZaE2sQ18jS)_ Fixes #29362 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill or doc teaches a bound on role or membership-tier holders, or what a failed membership read answers; `content/docs/automation/approvals.mdx` documents the "expanded to nobody" warning for an unstaffed slot, which stays true (a failed read now gets its own warning). ## 维护者速读 - 改了什么:共享规则的「角色持有人」展开(plugin-sharing)和审批的「成员级别」展开(plugin-approvals,也是 position 审批人的第二来源)现在分页读完全部成员;超过 1 万人的角色/级别不再静默漏人,读失败时不再假装「没人」,而是明确报错。 - 为什么改:两处都只读一次、上限 1 万行,读失败还会变成空列表:共享规则会漏授权,读失败甚至会把规则已有的授权全部撤销;审批会把单子派给缩水的名单,看起来却像完整名单。 - 风险与回滚:唯一会「从有答案变成报错」的情形是 `sys_member` 读本身失败(原来答空列表),属收窄,标题带 `!`;1 万人以下仍是一次读、结果不变。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-sharing/src/team-graph.ts`(`readRoleHoldersToCompletion`)、`packages/plugins/plugin-approvals/src/approval-service.ts`(`expandMembershipTierUsers` 和 `resolveApproverSpec` 的 catch/告警)、两个新测试文件。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/team-graph-role-holders-complete.test.ts`,预期 8/8 通过(含 10,001 人全部在集合内、读失败抛错)。 - 需要你做的:无,席位落地。 ## What changes Both expansions read `sys_member` once with a 10,000-row bound and took that read as the whole holder list, and both turned any read error into `[]`. Same class as #29281 (PR #29356). - **plugin-sharing `TeamGraphService.expandRoleUsers`** (the card's `expandRole`): the `sys_member.role` limb of every `position` sharing recipient (`PositionGraphService.expandPositionUsers`), of `expandPrincipal('role')`, and of queue members that resolve through a position. It now reads through `readRoleHoldersToCompletion`. - **plugin-approvals `expandMembershipTierUsers`** (the card's role-tier recipients): the whole of an `org_membership_level` approver, and the second limb of every `position` approver. - **Completion.** Each read pages through the platform's existing keyset walk, `keysetWalk` from `@objectstack/types`. That helper is already public and already imported by both plugins. Pages are 10,000 rows, ordered by `id`, with `fields: ['id', 'user_id']`. Under the bound this is still one read with the same set. Past the bound the walk reads further pages until a short one. The core helper (`readOrganizationMembersToCompletion`) is neither exported nor copied, so core's surface does not change; see Acceptance notes. - **Loud failure.** A read that throws, or a walk that reports `truncated` (a row with no `id`, or a page that did not honour the cursor), now throws. The thrown error carries the driver's error as `cause`, and nothing is cached for a failed read. What each caller now sees: - **Sharing:** the expansion rejects, so that rule's reconcile or revoke pass fails. The existing per-rule catches (`evaluateAllRulesForObject`, `bu-tree-recompute`, `queue-hooks`) log their warning, and the grants the rule already holds stay. Before, `revokeRuleGrantsForRetiredRecipients` diffed the rule's grants against `[]` and revoked every one. **Narrowing:** a call that answered `[]` now refuses. - **Approvals:** the throw reaches `resolveApproverSpec`'s directory catch, and the slot falls back to its literal. `position:NAME` can still be acted on by whoever holds the position at decision time. `org_membership_level:NAME` is decided by the override or `onEmptyApprovers`. The catch now keeps the error, and the graph-type warning becomes `approver 'TYPE:VALUE' could not be expanded — a directory read failed …`, with `error` on the record. Before, a `position` approver routed to its `sys_user_position` holders alone, a shrunk slate that looked complete, and an `org_membership_level` approver logged "expanded to nobody". **Narrowing:** a position slate that routed to part of its people now routes to the literal. - **Absence is still an answer.** A stack with no membership object keeps answering `[]`. `membershipObjectAbsent` accepts exactly two errors, each tied to `sys_member`: the shared `isMissingTableError(err, 'sys_member')`, and the engine's `OBJECT_NOT_FOUND` with `object: 'sys_member'` (a composition that never registers it, refused before any driver is asked). The second follows the precedent `sys-metadata-repository.ts` set for its history object. The full approvals suite surfaced that composition in `actor-lookups-hold-ids.integration.test.ts` and `approval-requested-notification.integration.test.ts`. - **Per-rule isolation on the record pass (seat round 2).** `SharingRuleService.evaluateAllForRecord` now catches per rule, as `evaluateAllRulesForObject` already did. A rule whose recipients cannot be read keeps its grants and logs `[sharing-rule] record reconcile failed for rule` with `object`, `recordId`, `rule` and `error`. The record's remaining rules still reconcile. - The approvals warning now reads `could not be expanded — a directory or read-filter lookup failed`, because the try also wraps the cross-org read filter (`bounded`). `content/docs/automation/approvals.mdx` names it for exactly what this head does: a `position` or `org_membership_level` entry whose `sys_member` read failed keeps its literal, logs that warning with the `error`, and never routes to the holders that happened to read. The general promise (team, department, the `sys_user_position` limb) belongs to #29503. - The changeset now names the second behaviour change: `SharingRuleService.evaluateAllForRecord` resolves per rule. A rule whose recipients cannot be read is left out of the results with a warning, where before the whole call rejected. Its missing-object bullet also names the engine's `OBJECT_NOT_FOUND` arm. **Forced test edit (one file):** `packages/plugins/plugin-sharing/src/position-graph.test.ts`. Its two fake `sys_member` fixtures (`MEMBERS`, and `MEMBER` in the `holdsPosition` matrix) now carry an `id` on each row, as every stored row does. `keysetWalk` reports a row without its key as `truncated`, and the expansion refuses such a walk. No assertion changed. ## Tests - `packages/plugins/plugin-sharing/src/team-graph-role-holders-complete.test.ts` (new, real engine: ObjectQL + SqlDriver over in-memory SQLite, shipped `SysMember`, scrambled membership ids): - Past the bound: 10,001 holders all in the set, the other-org and other-role rows out, 2 role reads. - The position recipient carries the completed limb. - Control: 9,999 holders, 1 read, the same set. - A failed read rejects. - A failed read is not cached. - A position whose role limb fails rejects. - An unregistered `sys_member` answers `[]`, and so does a dropped table. - `packages/plugins/plugin-approvals/src/member-tier-holders-complete.integration.test.ts` (new, same rig): - Past the bound: every holder, 2 reads. - An `org_membership_level` approver routes to all 10,001. - Control: 1 read. - A failed tier read rejects. - A position whose tier limb fails rejects. - What the caller sees: both slots fall back to their literals, and the warning names the failed read and carries `error`. - An unregistered `sys_member` answers `[]` with no failure warning. - Reds on `origin/main` `7b1ae338` (tests committed first, code untouched): - sharing 5 failed / 2 passed: `expected [ 'usr_000000', …(9998) ] to deeply equal [ …(9999) ]`, and `promise resolved "[]" instead of rejecting`; - approvals 5 failed / 1 passed: the same two shapes, plus `expected [] to deeply equal [ 'position', 'org_membership_level' ]`. - Ablations: each leg mutated through `scripts/ablation-replace.mjs`. The anchor hit 1→0 and the blob moved; after each leg the tool restored the file and proved blob == HEAD with an empty `git diff HEAD`. No dist leg is needed, because both suites import the plugin source directly. - S1, sharing, `break` after the first page: the 2 past-bound pins are red. - S2, sharing, catch back to `return []`: the 3 failure pins are red. - A1, approvals, `break` after the first page: the 2 past-bound pins are red. - A2, approvals, catch back to `return []`: the 3 failure pins are red, the caller-sees pin among them. - `recipient-width.test.ts` › `#29362 — the per-record pass is best-effort per rule`. The position rule is reconciled first, and its `sys_member` read fails. Its grant stays. The user rule after it restores its missing grant. Exactly one `record reconcile failed for rule` warning names the position rule. Ablation: rethrowing in the catch, via `ablation-replace.mjs` (anchor 1→0, restored to blob == HEAD), turns it red with `[sharing] the holders of role 'sales_rep' could not be read: SQLITE_IOERR …`. ## Acceptance notes - **Share vs local paging (delta question).** The paging mechanism is shared. Both sites use `keysetWalk` (`@objectstack/types`, already exported, already imported by both plugins), so there is no third copy of the cursor loop and no core export. What each plugin keeps locally is the glue: page size, the absence predicate, and the refusal text. That is about 25 lines per site. Exporting core's `readOrganizationMembersToCompletion` would not have fitted as is: it is keyed to `organization_id`, throws core's `AuthzStoreUnavailableError` (503), and warns about `org_user_ids`. Generalising it would widen core's public surface for two callers that already have a shared walker. Put to the seat in `open_questions`. - **#29398 is not folded in, and stays open.** It covers core's helper, which this PR does not touch. Its should-fix 1 (a monotonic id check per page) also conflicts with `keysetWalk`'s recorded design: "Judged by EQUALITY, never by order". PG and MySQL collations do not order string ids the way JS compares them, so an order check would refuse healthy walks. - **Same-family reads left as they are** (out of this card's `sys_member` scope; listed in the report for the seat): - sharing `expandUsers` (`sys_team_member`, `limit: 10000`, catch → `[]`); - both plugins' `sys_user_position` limb (`limit: 10000`, catch swallowed); - `holdsRole` / `teamHasMember`, the user-scoped twins (`limit: 1`, catch → `false`), which now differ from `expandRoleUsers` only on a failed read. ## Verification Head: `435089b8`. Round 3 is prose only: `approvals.mdx` and the changeset, no code. - The 30 docs and changeset families derived for this head all ran `exit 0`. They include `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:docs` and `check:doc-anchors`. - The other 63 derived families read code that has not changed since `4c34c3c8`, where all 93 ran green. They were not re-run on this head (a declared narrowing). - The readings below were taken on `4c34c3c8`; no code moved since. It merges `origin/main` after #29451 (`6af3d295`), the fix for main-red #29426. <details><summary>Package suites and typecheck</summary> - Typecheck: both plugins exit 0 on `8fa01d1c`; the merge touched neither plugin. - plugin-sharing tests: Test Files 61/61, Tests 1325/1325. - plugin-approvals tests: Test Files 108/108, Tests 1388/1388. </details> <details><summary>Derived gates</summary> - Derived gates on `4c34c3c8`: 93 derived, 93 run, 0 NOT-MEASURED (`✓ dispatch-gates --ran: 93 derived famil(ies) accounted for`). The docs file added 30 docs families. `check:type-check-debt` first ran `exit 3` because it was queued behind the dogfood lock, then `exit 0`. - `pnpm lint`: exit 0. </details> <details><summary>Dogfood battery (narrowing)</summary> - Dogfood battery on `4c34c3c8` (`turbo run build --filter='@objectstack/dogfood^...' && pnpm --filter @objectstack/dogfood test`, under the lock, 37m15s): Test Files 397 passed / 3 skipped (400); Tests 3277 passed / 28 skipped (3305). Clean. - The earlier four failures on `bf9ca7d7` were main-red #29426, fixed on `main` by #29451. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmS2E1xdkLMJPnabYgC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ab9136 commit 2789ba7

9 files changed

Lines changed: 658 additions & 36 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 role or membership tier held by more than 10,000 members reaches every holder in sharing and approvals, and a membership read that fails is refused loudly instead of answered with an empty set
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 export, type or metadata changes: two expansions read a list they already read, to completion. No author has anything to rewrite; the one new refusal is a membership read that already failed, which used to answer an empty list. -->
12+
13+
**BREAKING** only where a `sys_member` read fails: the role expansion in sharing and the membership-tier expansion in approvals used to answer an empty list then, and now refuse.
14+
15+
Both expansions read `sys_member` once with a 10,000-row bound and took that read as the whole holder list. They also turned any read error into `[]`.
16+
17+
- **Every holder, nobody else.** `TeamGraphService.expandRoleUsers` (plugin-sharing: the `sys_member.role` limb of every `position` sharing recipient, and `expandPrincipal('role')`) and the approvals membership-tier expansion (an `org_membership_level` approver, and the second limb of every `position` approver) now read `sys_member` in pages of 10,000 ordered by id until the holders are exhausted. Past 10,000 holders, a sharing rule used to grant nothing to the holders past the 10,000th row, and an approval used to route without them. Both now reach every holder.
18+
- **Smaller roles are unchanged.** A role or tier held by fewer than 10,000 members issues one read, as before, and answers the same set.
19+
- **Loud, never short or empty.** A read that fails, or a page the driver returned without honouring the id cursor, now throws instead of answering `[]`.
20+
- 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 as they were. Before, the retired-recipient revoke compared a rule's materialised grants against that empty answer and revoked every one of them.
21+
- Approvals: the approver slot falls back to its literal (`position:NAME`, which whoever holds the position can still act on, or `org_membership_level:NAME`), and the warning now reads `could not be expanded — a directory or read-filter lookup failed` and carries its `error`. Before, a `position` approver routed to its assignment-table holders alone, which looked like a complete slate, and an `org_membership_level` approver logged "expanded to nobody".
22+
- **The per-record sharing pass now resolves per rule.** `SharingRuleService.evaluateAllForRecord` (the pass a record write runs) used to reject as a whole when any one rule threw. Now a rule whose recipients cannot be read is left out of the returned results, its existing grants stay, and it logs `[sharing-rule] record reconcile failed for rule` with the rule and the error. The record's other rules still reconcile. This matches what `evaluateAllRulesForObject` already did.
23+
- **A missing membership object is still no holders.** A stack that never creates or never registers `sys_member` keeps answering `[]`. That is judged by the shared missing-table predicate, or by the engine's `OBJECT_NOT_FOUND` for `sys_member`, rather than by swallowing every error.
24+
25+
**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.

‎content/docs/automation/approvals.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,11 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
455455
decide it (the admin-override callout under
456456
[Acting on requests in the console](#acting-on-requests-in-the-console)).
457457
Beside entries that do resolve, a literal is a slot like any other, so a
458-
`behavior` that needs every slot (`unanimous`) waits on it too.
458+
`behavior` that needs every slot (`unanimous`) waits on it too. A `position` or
459+
`org_membership_level` entry whose `sys_member` read failed keeps its literal
460+
the same way. It logs `could not be expanded — a directory or read-filter
461+
lookup failed` with the `error`, instead of `expanded to nobody`, and never
462+
routes to the holders that happened to read.
459463
- **A slate left empty, or holding only literals,** goes to the node's
460464
`onEmptyApprovers` policy, whose four values are listed under
461465
[Approving across organizations](#cross-org-approvers). Under the default,

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

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formul
2121
// writer-local re-derivation here was rejected by name (Option B): it would be
2222
// a third answer to a question the codebase already answered two ways.
2323
import { createRecordOrganizationResolver, type RecordOrganizationResolver } from '@objectstack/metadata-core';
24-
import { keysetWalk, strandedDecisionFailure } from '@objectstack/types';
24+
import { isMissingTableError, keysetWalk, strandedDecisionFailure } from '@objectstack/types';
2525
// [#15981 / #16166] Every built-in identity NAME is deliberately absent here:
2626
// both arms of `isOverrideActor` read an ADR-0095 capability rung, never a name.
2727
// `BUILTIN_IDENTITY_PLATFORM_ADMIN` went with the platform arm's name read and
@@ -1118,6 +1118,31 @@ const OOO_MAX_CHAIN = 8;
11181118
*/
11191119
const MEMBER_SCREEN_READ_LIMIT = 50000;
11201120

1121+
/**
1122+
* [#29362] Rows per page of the membership-tier read
1123+
* ({@link ApprovalService.expandMembershipTierUsers}) — the bound the read
1124+
* always carried, so a tier held by fewer people issues the one read it always
1125+
* did, and a tier held by more is read in further pages rather than cut.
1126+
*/
1127+
const MEMBER_TIER_PAGE_ROWS = 10000;
1128+
1129+
/**
1130+
* [#29362] Did a `sys_member` read fail because this stack has no membership
1131+
* object at all — the one failure whose empty answer is the truth? Two
1132+
* spellings of the same absence, each attributed to `sys_member` so a failure
1133+
* about another object is not evidence about this one: the driver's
1134+
* missing-table refusal (the shared `isMissingTableError`, the table
1135+
* registered but never created), and the engine's `OBJECT_NOT_FOUND` (a
1136+
* composition that never registers the object, refused before any driver is
1137+
* asked — the reading `sys-metadata-repository.ts` gives its own history
1138+
* object). Anything else is an outage and stays loud.
1139+
*/
1140+
function membershipObjectAbsent(err: unknown): boolean {
1141+
if (isMissingTableError(err, 'sys_member')) return true;
1142+
const refused = err as { code?: unknown; object?: unknown } | null | undefined;
1143+
return refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_member';
1144+
}
1145+
11211146
/**
11221147
* Approver types resolved by QUERYING a graph rather than by taking `value`
11231148
* literally (#3807). Each can legitimately come back empty — an unstaffed
@@ -2551,6 +2576,9 @@ export class ApprovalService implements IApprovalService {
25512576
// The user a `manager` approver resolved FROM, kept for the warning below
25522577
// when no manager comes back.
25532578
let managerSubject: unknown;
2579+
// [#29362] The directory failure the catch below took, kept so the warning
2580+
// can say the read FAILED rather than that the slot expanded to nobody.
2581+
let directoryFailure: string | undefined;
25542582
try {
25552583
if (type === 'team') {
25562584
// #10230: the request's OWN organization, not `directoryOrg`. They are
@@ -2589,7 +2617,11 @@ export class ApprovalService implements IApprovalService {
25892617
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
25902618
}
25912619
}
2592-
} catch { /* a directory lookup failed → handled below as resolving to nobody */ }
2620+
} catch (err) {
2621+
// A directory lookup failed → handled below as resolving to nobody, and
2622+
// the graph-type warning names the failure (#29362).
2623+
directoryFailure = err instanceof Error ? err.message : String(err);
2624+
}
25932625
// #22558: a PERSON type that resolved to nobody contributes NO slot — see
25942626
// PERSON_APPROVER_TYPES for why its `type:value` literal could never be
25952627
// taken. With nothing else on the slate, the request reaches the node's
@@ -2656,12 +2688,25 @@ export class ApprovalService implements IApprovalService {
26562688
// follows. The old ending ("the request cannot advance until someone is
26572689
// added") restated a fate this resolver cannot know, and was false on
26582690
// the default path.
2659-
this.logger?.warn?.(
2660-
`[approvals] approver '${type}:${a.value}' expanded to nobody — the slot names no one, so no one is `
2661-
+ `asked through it. Staff it or re-point the approver; a slate left naming no one is decided by the `
2662-
+ `node's onEmptyApprovers.`,
2663-
{ type, value: a.value, organizationId: organizationId ?? null },
2664-
);
2691+
if (directoryFailure !== undefined) {
2692+
// [#29362] A failed read is not an unstaffed approver: say which one
2693+
// this is, and carry the error, so an operator does not go staffing a
2694+
// position whose holders simply could not be read. The try above also
2695+
// wraps the cross-org read filter (`bounded`), so the line names both.
2696+
this.logger?.warn?.(
2697+
`[approvals] approver '${type}:${a.value}' could not be expanded — a directory or read-filter lookup failed, so the `
2698+
+ `slot falls back to its literal rather than route to part of its people. Fix the read (see error); `
2699+
+ `a slate left naming no one is decided by the node's onEmptyApprovers.`,
2700+
{ type, value: a.value, organizationId: organizationId ?? null, error: directoryFailure },
2701+
);
2702+
} else {
2703+
this.logger?.warn?.(
2704+
`[approvals] approver '${type}:${a.value}' expanded to nobody — the slot names no one, so no one is `
2705+
+ `asked through it. Staff it or re-point the approver; a slate left naming no one is decided by the `
2706+
+ `node's onEmptyApprovers.`,
2707+
{ type, value: a.value, organizationId: organizationId ?? null },
2708+
);
2709+
}
26652710
}
26662711
// #27397: a `user` that resolved to no one adds no slot — `user:<value>`
26672712
// is no acting address (`approver-address.ts`), and the bare value is the
@@ -3355,16 +3400,54 @@ export class ApprovalService implements IApprovalService {
33553400
* {@link expandPositionUsers} carries the ruling both reads inherit
33563401
* (#8613 / commit 04d03c3a0) — this method is also the second limb of that union, so a
33573402
* change here changes position routing too.
3403+
*
3404+
* [#29362] Read to COMPLETION, or refused — never a shortened or an invented
3405+
* empty slate. Pages of {@link MEMBER_TIER_PAGE_ROWS} through the platform's
3406+
* one keyset walk (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a
3407+
* tier held past the bound routes to every holder rather than the first
3408+
* 10,000 rows. A read that fails, or a walk that cannot vouch for completion
3409+
* (a row without an `id`, a page that did not honour the cursor), THROWS:
3410+
* {@link resolveApproverSpec}'s directory catch then lets the slot fall back
3411+
* to its literal and says the read failed. Answering `[]` instead routed a
3412+
* `position` approver to its assignment holders alone — a shrunk slate that
3413+
* looks complete — and logged an `org_membership_level` one as "expanded to
3414+
* nobody", indistinguishable from a tier no one holds. A membership object
3415+
* that does not exist is the one failure that IS an answer (a stack that never
3416+
* materialises `sys_member` has no tier holders) and keeps answering `[]`;
3417+
* see {@link membershipObjectAbsent}.
33583418
*/
33593419
private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise<string[]> {
33603420
if (!tier) return [];
3361-
const filter: any = { role: tier };
3421+
const filter: Record<string, unknown> = { role: tier };
33623422
if (organizationId) filter.organization_id = organizationId;
3363-
let rows: any[] = [];
3423+
const scope = `the holders of membership tier '${tier}'${organizationId ? ` in organization '${organizationId}'` : ''}`;
3424+
const users = new Set<string>();
3425+
const walk = keysetWalk<Record<string, unknown>>(
3426+
(page) => this.engine.find('sys_member', { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }),
3427+
{ where: filter, pageSize: MEMBER_TIER_PAGE_ROWS },
3428+
);
33643429
try {
3365-
rows = await this.engine.find('sys_member', { filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX } as any);
3366-
} catch { rows = []; }
3367-
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
3430+
for await (const page of walk.pages()) {
3431+
for (const row of page) {
3432+
const uid = String(row?.user_id ?? '');
3433+
if (uid) users.add(uid);
3434+
}
3435+
}
3436+
} catch (err) {
3437+
if (membershipObjectAbsent(err)) return [];
3438+
throw Object.assign(new Error(
3439+
`[approvals] ${scope} could not be read: ${err instanceof Error ? err.message : String(err)}. `
3440+
+ 'Routing to a shortened slate would leave holders unasked, so the expansion refuses instead.',
3441+
), { cause: err });
3442+
}
3443+
if (walk.truncated) {
3444+
throw new Error(
3445+
`[approvals] ${scope} could not be read to completion: the read by id could not continue past a page `
3446+
+ '(a row without an id, or a page that repeated the cursor). Routing to a shortened slate would leave '
3447+
+ 'holders unasked, so the expansion refuses instead.',
3448+
);
3449+
}
3450+
return Array.from(users);
33683451
}
33693452

33703453
/**

0 commit comments

Comments
 (0)