Skip to content

Commit e0187d0

Browse files
fix(plugins)!: business-unit recipients and approvers reach every member past 10,000 rows, and a failed member read refuses instead of answering nobody (#29647)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8XHx1fy3fEnpoGAbZ7vvncC)_ Fixes #29606 domain:server#4 · os-warren · session_01JQwxbWdszDuXCYxfoR2ynB Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here. `content/docs/automation/approvals.mdx` now names `department` and the `sys_business_unit_member` read beside the team, position and tier reads for a failed holder read, and says that an `expression` value whose `resolveAs` read failed logs the same line. No skill teaches a bound on business-unit members or what a failed member read answers. ## 维护者速读 - 改了什么:共享规则与审批里「业务单元成员」(本单元 / 含下级单元) 现在分页读完全部成员;读失败不再当作「没人」,而是明确报错。开机回填 `sys_user.primary_business_unit_id` 也读完全部主成员关系,读失败或读不完整就不写并记录原因。审批表达式 `resolveAs` 展开失败时,现在和静态审批人一样打出「could not be expanded」告警并带上错误。 - 为什么改:这些读只读一次、上限 1 万行,读失败还会变空:共享规则会漏授权,业务单元结构一变,撤销流程会把整条规则的授权全部收回;审批会派给缩水的名单,或者把读失败记成「没人」;回填超过 1 万的用户永远不会被投影,而且什么都不说。 - 风险与回滚:唯一「从有答案变成报错」的情形是 `sys_business_unit_member` 的读本身失败(原来答空),属收窄,标题带 `!`;1 万人以下仍是一次读、结果不变;队列(ownership queue)的业务单元成员规格在读失败时仍贡献 0 人(文件头声明的姿态),只是现在会记一次日志,队列文件未改动。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-sharing/src/business-unit-graph.ts`(`readMembers`)、`packages/plugins/plugin-approvals/src/approval-service.ts`(`expandBusinessUnitUsers`、`warnExpansionFailed`)、`packages/plugins/plugin-sharing/src/primary-bu-projection.ts`(`readPrimaryMemberships`)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/business-unit-member-reads-complete.test.ts`,预期 14/14 通过(含 10,001 名成员全部在集合内、读失败抛错、缺表仍答无人、回填 10,001 行全部投影)。 - 需要你做的:无,席位落地。 ## What changes The `sys_business_unit_member` reads still read once at a 10,000-row bound and turned a failed read into nobody. This extends the family #29362 (PR #29497) and #29503 (PR #29602) closed for the team, role and position reads to the last membership object, through the same shared `keysetWalk` and `isObjectAbsentError` (`@objectstack/types`). No new walk spelling for a holder read: plugin-sharing's member reads go through `readMembershipToCompletion` (`team-graph.ts`), plugin-approvals' through `readHoldersToCompletion`. No new export from any package index. **Item 1, site 1: sharing `BusinessUnitGraphService`.** - `expandUnitMembers` (every `business_unit` recipient, and queue `business_unit` specs) and `expandUsers` (every `unit_and_subordinates` recipient, and queue `unit_and_subordinates` specs) read through one private `readMembers`, which calls `readMembershipToCompletion`. A failed or incomplete read throws, and nothing is cached for it. - What a caller sees: a whole-rule pass rejects, so the rule's existing per-rule warning fires and its grants stay. Before, `[]` made the retired-recipient revoke that a business-unit graph write triggers (`bu-tree-recompute.ts`) take the rule's grants from every member. - Queues: `OwnershipQueueService.expandSpec` already catches an expansion that throws, logs it once and contributes nobody (its declared posture). That file is untouched (#29604 holds it). A queue BU spec answers what it answered before on a failed read, and now logs it. **Item 1, site 2: sharing primary-business-unit backfill (`primary-bu-projection.ts`).** - The boot scan of `is_primary` rows pages to completion through `keysetWalk`. A scan that fails, or a walk that cannot vouch for completion, writes nothing and warns with the consequence and the error. Absence of the object answers nothing to project, silently. - Refusing here means writing nothing and saying so, not throwing. The scan's only caller is the plugin's boot step, whose catch would log "projection not started" though the hooks were bound. The failed-scan path already answered `{ updated: 0, refused: 0 }` with a warning, and the existing pin ("a refused SCAN answers both counters, not a half-built result") keeps that shape. It uses `keysetWalk` directly, not `readMembershipToCompletion`: this is a projection scan rather than a holder read, and the holder helper's refusal sentence speaks of grants. - The two one-row reads in the same file (`recompute`, the `beforeDelete` stash) are `limit: 1` existence probes that a bound cannot cut, so they are unchanged. **Item 1, site 3: approvals `expandBusinessUnitUsers`.** - The member read goes through `readHoldersToCompletion('sys_business_unit_member', …)`. A failed read throws, the approver-spec resolver's directory catch takes it, and the `department:ID` slot falls back to its literal with the `could not be expanded` warning and its `error`. Before, a failed read logged "expanded to nobody". **Item 3: `resolveAs` expansion failure warns.** - `resolveExpressionApprovers` caught a failed `department` / `position` / `team` expansion into `users = []` and pushed the `KIND:VALUE` literal with no log line. It now logs the same warning `resolveApproverSpec` logs, with the `error` and the `expression` source. - The sentence lives in one private method, `warnExpansionFailed`, which both resolvers call, so the two cannot drift. **Item 2: measured, reported, not changed.** See the report on the card and the Acceptance notes. ⛔ `ownership-queue-service.ts` is untouched. ## Tests - New `plugin-sharing/src/business-unit-member-reads-complete.test.ts` (14 cases, real ObjectQL + SqlDriver over in-memory SQLite, shipped `SysBusinessUnit` / `SysBusinessUnitMember`, row ids scrambled against user ids): - `expandUnitMembers`: 10,001 members all in the set and nobody from another unit or organization (2 reads); 9,999 members in one read; a failed read throws naming the unit and the driver error; a failed read is not cached; the object unregistered, or its table dropped, answers `[]`. - `expandUsers` (members split across the unit and its child): 10,001 (2 reads); 9,999 (1 read); a failed read throws; unregistered answers `[]`. - Backfill: 10,001 primary rows all projected (2 reads, no warning); 9,999 in one read; a failed scan writes nothing and warns with the error; unregistered projects nothing and says nothing. - `primary-bu-projection.test.ts`: fixture rows now carry their `id` (the scan seeks by it). The refused-scan case also asserts the warning and its error, and a new case pins that a scan that cannot vouch for completion writes nothing and warns. - `business-unit-graph.test.ts` (fixture triage, no assertion changed): its hand-rolled engine now stamps the `id` a stored row carries and models `$and`, `$gt`, `orderBy` and `limit` on the member side, so its 25 cases keep pinning the two tenant screens and the two widths. - New `plugin-approvals/src/business-unit-holders-complete.integration.test.ts` (11 cases, real engine): - `expandBusinessUnitUsers`: 10,001 (2 reads, nobody from another unit or organization); a `department` approver routes to all 10,001; 9,999 in one read; a failed read throws; what the caller sees is `department:bu_root` plus the `could not be expanded` warning with the `SQLITE_IOERR` error; unregistered answers the literal with no failure warning; dropped table answers `[]`. - Item 3: a `resolveAs: 'department'` control expands to the members with no failure warning; a failed `department` read keeps `{ id: 'department:bu_root', subGroup: 'bu_root' }` and logs one `could not be expanded` warning carrying `type`, `value`, `organizationId`, `expression` and the error; a failed `team` read through `resolveAs` warns the same way; an empty unit keeps the literal with no failure warning. <details><summary>Ablation (source paths: these suites import the files under test relatively, so no build is in the path)</summary> - Committed the fix first (`2324d29f`), then a trapped script checked out the three source files at the base (`4d8095ba`) and confirmed the mutation landed (`readMembers` 0 hits, `keysetWalk` in the projection 0, `warnExpansionFailed` 0, `limit: 10000` back in approvals 1). - Sharing: `Tests 9 failed | 11 passed (20)`, the red ones being every 10,001, failed-read, not-cached, truncated-scan and quiet-absence case. Approvals: `Tests 6 failed | 5 passed (11)`, the red ones being the 10,001 cases, both failed-read cases and both item-3 warning cases. - Restored with `git checkout HEAD -- PATH`: `git diff HEAD` 0 bytes, and each file's `git hash-object` equals its `HEAD` blob (`ddbfccfd…`, `8ccd37de…`, `6d3a5423…`). </details> ## Acceptance notes - **Clause-② and Metadata-protocol** are the claim's lines, confirmed from the diff: no export is added or removed from any package index, no schema key moves, and the only answer that changes kind is a failed member read (empty becomes a refusal), the `(narrowing)` arm. Review face: `node scripts/pm/record-recognisers.mjs --template --paths …` names no path face and defers to the changeset's `Clause-②:` line, so it is `--face narrowing` (`CONTRACT_REVIEW_TIER`). - **Item 2, measured on a real engine (scratch tests, not committed):** - `QUEUE_READ_LIMIT` can silently drop a holder. One authored queue with 10,001 `user` member rows: `expandMembers` answered 10,000 and missed `usr_010000`, `isMember` answered `false` for it, `queuesOf` answered `[]` for it, and no warning was logged. 10,001 authored queues: `queuesOf` missed a member of the 10,001st queue, no warning. 10,001 `team` member rows: `queuesNaming` missed the queue past the cap, so rules naming it are not re-evaluated, no warning. ⛔ Not changed: `ownership-queue-service.ts` belongs to #29604. - `APPROVER_INDEX_CAP` in `organizationAdministrators` can drop an administrator, loudly. 10,001 `sys_member` rows with the one administrator on the last row: `{"ids":[]}`; on the first row: `{"ids":["usr_010000"]}`. Both runs logged `organization-administrator probe hit its window — results may be truncated`. The read has no `orderBy`, so which members fall off is driver order. It is a declared bound with a loud warning, so per the card it is reported and left; the choice is on the card report. - **Same-family reads left as they are** (outside this card's sites, listed in the report for the seat): the `sys_business_unit` hierarchy reads in both plugins. Sharing `descendants` (`limit: 1000`, catch → `[]`) and `seedIsUsable` (catch → `false`, and the empty answer is cached for the pass), approvals' seed check (catch → `[]`) and descent (`limit: 1000`, catch → `[]`). A failed unit read still answers nobody there. The new not-cached pin had to key its fault to the member object for exactly this reason: a fault on the seed check is cached as `[]`. - `protocol.ts` (frozen), `packages/spec` and `ownership-queue-service.ts` are untouched. ## Verification Head: `241a137f` (merges `origin/main` `fa07650b`; the incoming commit touches none of these packages). Every reading below was taken on that head, except the approvals suite, typechecks and the first derived-gate run, which were taken on `0443a73e`. The only commit after it changes `business-unit-graph.test.ts`, and the sharing suite was re-run on `241a137f`. <details><summary>Package suites and typecheck</summary> - `@objectstack/plugin-sharing`: Test Files 63/63, Tests 1360/1360 (`241a137f`); typecheck exit 0. - `@objectstack/plugin-approvals`: Test Files 110/110, Tests 1434/1434; typecheck exit 0. </details> <details><summary>Full dogfood battery (the title carries <code>!</code> and the declaration a <code>(narrowing)</code> arm)</summary> - Closure: `turbo run build --concurrency=2 --filter='./packages/**'` under the lock, `VERDICT command-exit 0`, 78/78 tasks (covers the dogfood closure). - `pnpm --filter @objectstack/dogfood test` under the lock on `241a137f`: `VERDICT command-exit 0`, Test Files 398 passed and 3 skipped (401), Tests 3280 passed and 28 skipped (3308), 0 failed. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 94 families derived (the same list on `0443a73e` and `241a137f`). All 94 were run, each with its exit code recorded. - Four first answered exit 3 (PREREQUISITE NOT MET) and were re-run after their prerequisite: `check-plugin-teardown-shape --self-test` after `git fetch --unshallow origin main`; `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n` after `turbo run build --filter='./packages/**'` (78/78 tasks). All four then exited 0. - `--ran` reconciliation: `94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN` (a DERIVED zero). - `pnpm lint` (repo-wide eslint, `--no-inline-config`) on `241a137f`: exit 0. - CI families are not re-run locally; the PR check-runs are the record. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWdszDuXCYxfoR2ynB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7dae3b3 commit e0187d0

10 files changed

Lines changed: 753 additions & 80 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/plugin-approvals': patch
4+
---
5+
6+
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
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 and the primary-business-unit backfill read lists 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_member` read fails: the business-unit expansions used to answer nobody (an empty list) then, and now refuse.
14+
15+
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.
16+
17+
- **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.
18+
- **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).
19+
- **Smaller units are unchanged.** A unit or subtree with fewer than 10,000 members issues one read, as before, and answers the same set.
20+
- **Loud, never short or empty.** A member read that fails, or a page the driver returned without honouring the id cursor, now throws.
21+
- 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.
22+
- 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.
23+
- 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.
24+
- **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.
25+
- **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.
26+
27+
**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: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,11 +456,13 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
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
458458
`behavior` that needs every slot (`unanimous`) waits on it too. A `team`,
459-
`position` or `org_membership_level` entry whose holder read failed
460-
(`sys_team_member`, `sys_user_position` or `sys_member`) keeps its literal
461-
the same way. It logs `could not be expanded — a directory or read-filter
462-
lookup failed` with the `error`, instead of `expanded to nobody`, and never
463-
routes to the holders that happened to read.
459+
`position`, `department` or `org_membership_level` entry whose holder read
460+
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
462+
expanded — a directory or read-filter lookup failed` with the `error`,
463+
instead of `expanded to nobody`, and never routes to the holders that
464+
happened to read. An `expression` value whose `resolveAs` read failed keeps
465+
its literal and logs the same line, with the `expression` source.
464466
- **A slate left empty, or holding only literals,** goes to the node's
465467
`onEmptyApprovers` policy, whose four values are listed under
466468
[Approving across organizations](#cross-org-approvers). Under the default,

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

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,7 +1121,7 @@ const MEMBER_SCREEN_READ_LIMIT = 50000;
11211121
/**
11221122
* [#29362, #29503] Rows per page of the directory holder reads
11231123
* ({@link ApprovalService.readHoldersToCompletion}: the membership tier, the
1124-
* position assignments, the team members) — the bound each read always
1124+
* position assignments, the team members, the business-unit members) — the bound each read always
11251125
* carried, so a group held by fewer people issues the one read it always did,
11261126
* and one held by more is read in further pages rather than cut.
11271127
*/
@@ -2695,12 +2695,7 @@ export class ApprovalService implements IApprovalService {
26952695
// this is, and carry the error, so an operator does not go staffing a
26962696
// position whose holders simply could not be read. The try above also
26972697
// wraps the cross-org read filter (`bounded`), so the line names both.
2698-
this.logger?.warn?.(
2699-
`[approvals] approver '${type}:${a.value}' could not be expanded — a directory or read-filter lookup failed, so the `
2700-
+ `slot falls back to its literal rather than route to part of its people. Fix the read (see error); `
2701-
+ `a slate left naming no one is decided by the node's onEmptyApprovers.`,
2702-
{ type, value: a.value, organizationId: organizationId ?? null, error: directoryFailure },
2703-
);
2698+
this.warnExpansionFailed(type, a.value, organizationId, directoryFailure);
27042699
} else {
27052700
this.logger?.warn?.(
27062701
`[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 {
27172712
return [`${type}:${a.value}`];
27182713
}
27192714

2715+
/**
2716+
* [#29362, #29606] The one warning an approver slot logs when its expansion
2717+
* FAILED — a directory or read-filter lookup threw — rather than came back
2718+
* empty: the slot falls back to its literal, and the line carries the error.
2719+
* Shared by the static graph types ({@link resolveApproverSpec}) and an
2720+
* `expression` approver's `resolveAs` values ({@link resolveExpressionApprovers},
2721+
* which passes its source as `expression`), so the two cannot drift.
2722+
*/
2723+
private warnExpansionFailed(
2724+
type: string,
2725+
value: unknown,
2726+
organizationId: string | null | undefined,
2727+
error: string,
2728+
expression?: string,
2729+
): void {
2730+
this.logger?.warn?.(
2731+
`[approvals] approver '${type}:${value}' could not be expanded — a directory or read-filter lookup failed, so the `
2732+
+ `slot falls back to its literal rather than route to part of its people. Fix the read (see error); `
2733+
+ `a slate left naming no one is decided by the node's onEmptyApprovers.`,
2734+
{
2735+
type, value, organizationId: organizationId ?? null, error,
2736+
...(expression === undefined ? {} : { expression }),
2737+
},
2738+
);
2739+
}
2740+
27202741
/**
27212742
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
27222743
* node entry against the three explicit roots — `current` (live record),
@@ -2859,6 +2880,11 @@ export class ApprovalService implements IApprovalService {
28592880
}
28602881
} catch (err: any) {
28612882
if (String(err?.message ?? '').startsWith('VALIDATION_FAILED')) throw err;
2883+
// [#29606] The literal slot below is the same fallback the static graph
2884+
// types take, and it says why exactly as they do: a failed read is not
2885+
// an unstaffed value, so an operator is pointed at the read rather than
2886+
// at staffing it.
2887+
this.warnExpansionFailed(resolveAs, key, organizationId, err instanceof Error ? err.message : String(err), source);
28622888
users = [];
28632889
}
28642890
if (crossOrg && users.length) {
@@ -3210,20 +3236,22 @@ export class ApprovalService implements IApprovalService {
32103236
if (kid && !seen.has(kid)) { seen.add(kid); queue.push(kid); }
32113237
}
32123238
}
3213-
let rows: any[] = [];
3214-
try {
3215-
rows = await this.engine.find('sys_business_unit_member', {
3216-
// #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT
3217-
// on purpose and is not {@link businessUnitOrgScope}. The units above
3218-
// proved their tenancy; these rows have not, and the
3219-
// shared seeded unit id is exactly where other tenants' rows sit.
3220-
where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
3221-
fields: ['user_id'],
3222-
limit: 10000,
3223-
context: SYSTEM_CTX,
3224-
} as any);
3225-
} catch { rows = []; }
3226-
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
3239+
// [#29606] Every member of the subtree, read to completion — or a refusal
3240+
// that {@link resolveApproverSpec}'s directory catch reports as a failed
3241+
// read, never "expanded to nobody". It read once at a 10,000-row bound and
3242+
// answered `[]` on any failure: past the bound the slate silently lost the
3243+
// rest, and a failed read logged as an unstaffed unit. A stack with no
3244+
// `sys_business_unit_member` has no members.
3245+
return this.readHoldersToCompletion(
3246+
'sys_business_unit_member',
3247+
// #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT
3248+
// on purpose and is not {@link businessUnitOrgScope}. The units above
3249+
// proved their tenancy; these rows have not, and the
3250+
// shared seeded unit id is exactly where other tenants' rows sit.
3251+
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}'` : ''}`,
3254+
);
32273255
}
32283256

32293257
/**
@@ -3445,8 +3473,8 @@ export class ApprovalService implements IApprovalService {
34453473
/**
34463474
* [#29362, #29503] The `user_id`s of every `object` row matching `where`,
34473475
* read to COMPLETION — or a refusal, never a shortened or an invented empty
3448-
* slate. The one read behind the membership tier, the position assignments
3449-
* and the team members.
3476+
* slate. The one read behind the membership tier, the position assignments,
3477+
* the team members and the business-unit members (#29606).
34503478
*
34513479
* Pages of {@link HOLDER_PAGE_ROWS} through the platform's one keyset walk
34523480
* (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a group held past

0 commit comments

Comments
 (0)