diff --git a/.changeset/29503-recipient-reads-complete.md b/.changeset/29503-recipient-reads-complete.md new file mode 100644 index 00000000000..c42271d60b0 --- /dev/null +++ b/.changeset/29503-recipient-reads-complete.md @@ -0,0 +1,27 @@ +--- +'@objectstack/types': patch +'@objectstack/plugin-sharing': patch +'@objectstack/plugin-approvals': patch +--- + +fix(plugins)!: team and position recipients reach every holder past 10,000 rows in sharing and approvals, and a team, position or role holder read that fails is refused loudly instead of answered with nobody + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** only where a `sys_team_member`, `sys_user_position` or `sys_member` read fails: the team and position expansions and the user-scoped holder checks used to answer nobody (an empty list, or `false`) then, and now refuse. + +This completes the family the role and membership-tier fix started. The team and position reads still read once with a 10,000-row bound and turned any read error into nobody. + +- **Every holder, nobody else.** In plugin-sharing, `TeamGraphService.expandUsers` (`sys_team_member`, every `team` sharing recipient) and the assignment limb of `PositionGraphService.expandPositionUsers` (`sys_user_position`, every `position` sharing recipient) now read in pages of 10,000 ordered by id until the holders are exhausted. In plugin-approvals, the `team` approver and the assignment limb of the `position` approver (and every caller of the position expansion: escalation targets, reassign checks, `resolveAs` expressions) do the same. Past 10,000 holders, the holders past the 10,000th row used to get no grant and no approval slot. They now do. +- **Smaller groups are unchanged.** A team or position held by fewer than 10,000 people issues one read, as before, and answers the same set. +- **Loud, never short or empty.** A 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 team read answered `[]`, and a failed assignment read answered the `sys_member.role` holders alone, so the retired-recipient revoke took grants away from people who still held the recipient. + - Sharing, per-user recompute (a write to `sys_team_member` or `sys_user_position`): the holder checks `teamHasMember`, `holdsRole` and `PositionGraphService.holdsPosition` now refuse a failed read instead of answering `false`. The recompute logs `recipient membership re-evaluation failed` for that rule and user, and the user's grants stay. Before, an outage revoked that user's grants. + - Approvals: the approver slot falls back to its literal (`team:ID` or `position:NAME`) and logs `could not be expanded — a directory or read-filter lookup failed` with its `error`. Before, a failed team read logged "expanded to nobody", and a failed assignment read routed a `position` approver to its membership-tier holders alone, which looked like a complete slate. +- **Ownership queues: a position specification now contributes nobody on a `sys_user_position` outage.** A queue team specification whose `sys_team_member` read fails still contributes nobody, as before. A queue position specification whose `sys_user_position` read fails now contributes nobody too, on the expansion and on the queue's holder check alike, where before it contributed that position's `sys_member.role` transition-source holders. That is the posture the queue declares for a failed member-table read, reached here for the first time. Both now log once. Whether a queue should refuse instead is left to a ruling. +- **A missing object is still no holders.** A stack that never registers, or never creates, `sys_team_member`, `sys_user_position` or `sys_member` keeps answering no holders through it. That is judged by the new `isObjectAbsentError(error, readObject)` in `@objectstack/types`: the shared missing-table predicate for that object, or the engine's `OBJECT_NOT_FOUND` naming that object. Any other error, including an absence of a different object, 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 4ef71390e72..c5e6a64b29f 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -455,8 +455,9 @@ What an entry that resolves to nobody leaves on the slate depends on its kind: decide it (the admin-override callout under [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 `position` or - `org_membership_level` entry whose `sys_member` read failed keeps its literal + `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. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 857865f158e..8dc96249615 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -21,7 +21,7 @@ import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formul // writer-local re-derivation here was rejected by name (Option B): it would be // a third answer to a question the codebase already answered two ways. import { createRecordOrganizationResolver, type RecordOrganizationResolver } from '@objectstack/metadata-core'; -import { isMissingTableError, keysetWalk, strandedDecisionFailure } from '@objectstack/types'; +import { isObjectAbsentError, keysetWalk, strandedDecisionFailure } from '@objectstack/types'; // [#15981 / #16166] Every built-in identity NAME is deliberately absent here: // both arms of `isOverrideActor` read an ADR-0095 capability rung, never a name. // `BUILTIN_IDENTITY_PLATFORM_ADMIN` went with the platform arm's name read and @@ -1119,29 +1119,13 @@ const OOO_MAX_CHAIN = 8; const MEMBER_SCREEN_READ_LIMIT = 50000; /** - * [#29362] Rows per page of the membership-tier read - * ({@link ApprovalService.expandMembershipTierUsers}) — the bound the read - * always carried, so a tier held by fewer people issues the one read it always - * did, and a tier held by more is read in further pages rather than cut. + * [#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 + * 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. */ -const MEMBER_TIER_PAGE_ROWS = 10000; - -/** - * [#29362] Did a `sys_member` read fail because this stack has no membership - * object at all — the one failure whose empty answer is the truth? Two - * spellings of the same absence, each attributed to `sys_member` so a failure - * about another object is not evidence about this one: the driver's - * missing-table refusal (the shared `isMissingTableError`, the table - * registered but never created), and the engine's `OBJECT_NOT_FOUND` (a - * composition that never registers the object, refused before any driver is - * asked — the reading `sys-metadata-repository.ts` gives its own history - * object). Anything else is an outage and stays loud. - */ -function membershipObjectAbsent(err: unknown): boolean { - if (isMissingTableError(err, 'sys_member')) return true; - const refused = err as { code?: unknown; object?: unknown } | null | undefined; - return refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_member'; -} +const HOLDER_PAGE_ROWS = 10000; /** * Approver types resolved by QUERYING a graph rather than by taking `value` @@ -2917,16 +2901,12 @@ export class ApprovalService implements IApprovalService { private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise { if (!teamId) return []; if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return []; - let rows: any[] = []; - try { - rows = await this.engine.find('sys_team_member', { - where: { team_id: teamId }, - fields: ['user_id'], - limit: 10000, - context: SYSTEM_CTX, - } as any); - } catch { rows = []; } - const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); + // [#29503] Every member, read to completion — or a refusal that + // {@link resolveApproverSpec}'s directory catch reports as a failed read, + // never "expanded to nobody". A stack with no `sys_team_member` has none. + const users = await this.readHoldersToCompletion( + 'sys_team_member', { team_id: teamId }, `the members of team '${teamId}'`, + ); // #10547: the TEAM proved its tenancy above; its members have not proved // theirs, and `sys_team_member` holds no fact that could. One read. return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId); @@ -3303,18 +3283,16 @@ export class ApprovalService implements IApprovalService { */ private async expandPositionUsers(positionName: string, organizationId?: string | null): Promise { if (!positionName) return []; - const users = new Set(); - const filter: any = { position: positionName }; + const filter: Record = { position: positionName }; if (organizationId) filter.organization_id = organizationId; - try { - const rows = await this.engine.find('sys_user_position', { - filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX, - } as any); - for (const r of (rows ?? []) as any[]) { - const uid = String(r.user_id ?? ''); - if (uid) users.add(uid); - } - } catch { /* table may not exist on minimal stacks — union source below still applies */ } + // [#29503] Every assignment, read to completion — or a refusal, never the + // tier limb below alone (a shrunk slate that looks complete). A stack with + // no `sys_user_position` has no assignment holders; the union source below + // still applies. + const users = new Set(await this.readHoldersToCompletion( + 'sys_user_position', filter, + `the assignments of position '${positionName}'${organizationId ? ` in organization '${organizationId}'` : ''}`, + )); // ADR-0057 D4 transition source: pre-migration stacks still carry the // position name in better-auth's `sys_member.role` column, so the same // lookup serves a position name here and a membership tier for @@ -3402,7 +3380,7 @@ export class ApprovalService implements IApprovalService { * change here changes position routing too. * * [#29362] Read to COMPLETION, or refused — never a shortened or an invented - * empty slate. Pages of {@link MEMBER_TIER_PAGE_ROWS} through the platform's + * empty slate. Pages of {@link HOLDER_PAGE_ROWS} through the platform's * one keyset walk (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a * tier held past the bound routes to every holder rather than the first * 10,000 rows. A read that fails, or a walk that cannot vouch for completion @@ -3414,17 +3392,40 @@ export class ApprovalService implements IApprovalService { * nobody", indistinguishable from a tier no one holds. A membership object * that does not exist is the one failure that IS an answer (a stack that never * materialises `sys_member` has no tier holders) and keeps answering `[]`; - * see {@link membershipObjectAbsent}. + * see {@link readHoldersToCompletion}. */ private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise { if (!tier) return []; const filter: Record = { role: tier }; if (organizationId) filter.organization_id = organizationId; - const scope = `the holders of membership tier '${tier}'${organizationId ? ` in organization '${organizationId}'` : ''}`; + return this.readHoldersToCompletion( + 'sys_member', filter, + `the holders of membership tier '${tier}'${organizationId ? ` in organization '${organizationId}'` : ''}`, + ); + } + + /** + * [#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. + * + * Pages of {@link HOLDER_PAGE_ROWS} through the platform's one keyset walk + * (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a group held past + * the bound routes to every holder rather than the first 10,000 rows. A read + * that fails, or a walk that cannot vouch for completion (a row without an + * `id`, a page that did not honour the cursor), THROWS: + * {@link resolveApproverSpec}'s directory catch then lets the slot fall back + * to its literal and says the read failed. An object that does not exist on + * this stack is the one failure that IS an answer (no holders through it) and + * answers `[]` — `isObjectAbsentError` (`@objectstack/types`) decides that, + * attributed to `object`, so an absence of another object is still loud. + */ + private async readHoldersToCompletion(object: string, where: Record, scope: string): Promise { const users = new Set(); const walk = keysetWalk>( - (page) => this.engine.find('sys_member', { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }), - { where: filter, pageSize: MEMBER_TIER_PAGE_ROWS }, + (page) => this.engine.find(object, { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }), + { where, pageSize: HOLDER_PAGE_ROWS }, ); try { for await (const page of walk.pages()) { @@ -3434,7 +3435,7 @@ export class ApprovalService implements IApprovalService { } } } catch (err) { - if (membershipObjectAbsent(err)) return []; + if (isObjectAbsentError(err, object)) return []; throw Object.assign(new Error( `[approvals] ${scope} could not be read: ${err instanceof Error ? err.message : String(err)}. ` + 'Routing to a shortened slate would leave holders unasked, so the expansion refuses instead.', diff --git a/packages/plugins/plugin-approvals/src/team-position-holders-complete.integration.test.ts b/packages/plugins/plugin-approvals/src/team-position-holders-complete.integration.test.ts new file mode 100644 index 00000000000..a1374a3cc7b --- /dev/null +++ b/packages/plugins/plugin-approvals/src/team-position-holders-complete.integration.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29503] The approver `team` expansion (`sys_team_member`) and the + * assignment limb of the `position` expansion (`sys_user_position`) answer + * EVERY holder, or refuse — never a shortened or an invented empty slate. The + * close-out of the family #29362 opened with the `sys_member` tier read. + * + * Both read once at a 10,000-row bound and took that read as the holder list, + * and both turned a read that threw into nothing: + * + * - past 10,000 holders the slate was missing the holders past the 10,000th + * row, with no signal; + * - a failed `sys_team_member` read logged "expanded to nobody", + * indistinguishable from an empty team; + * - a failed `sys_user_position` read routed a `position` approver to its + * `sys_member.role` holders ALONE — a shrunk slate that looks complete. + * + * 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 holders. + * + * Measured on a REAL engine (ObjectQL + SqlDriver over in-memory SQLite) + * carrying the shipped `SysTeamMember` and `SysMember`; `sys_user_position` is + * plugin-security's object and is declared here with the columns it ships. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { SysMember, SysOrganization, SysTeamMember } from '@objectstack/platform-objects/identity'; +import { ApprovalService } from './approval-service.js'; + +const ORG = 'org_large'; +const ELSEWHERE = 'org_elsewhere'; +const TEAM = 'team_large'; +const POSITION = 'cfo_office'; +/** The bound each read is paged at. */ +const PAGE = 10000; + +const pad = (n: number) => String(n).padStart(6, '0'); + +const SysUserPosition = ObjectSchema.create({ + name: 'sys_user_position', + label: 'User Position', + isSystem: true, + fields: { + id: Field.text({ label: 'Assignment ID', required: true }), + user_id: Field.text({ label: 'User', required: true }), + position: Field.text({ label: 'Position', required: true }), + organization_id: Field.text({ label: 'Organization' }), + valid_from: Field.datetime({ label: 'Valid From' }), + valid_until: Field.datetime({ label: 'Valid Until' }), + }, +}); + +interface Rig { + engine: ObjectQL; + driver: SqlDriver; + svc: ApprovalService; + warn: ReturnType; + holders: string[]; + reads: (table: 'sys_team_member' | 'sys_user_position') => number; + failReadsOf: (object: string) => void; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(seeded: 'team' | 'position', 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.team-position-holders-complete', + name: 'team position holders complete', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysOrganization, SysMember, SysTeamMember, SysUserPosition].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 holders = Array.from({ length: n }, (_, i) => `usr_${pad(i)}`); + const id = (prefix: string, i: number) => `${prefix}_${pad((i * 7919) % 1000003)}`; + const seed = async (object: string, rows: Record[]) => { + const d = e.getDriverForObject(object); + for (let i = 0; i < rows.length; i += 250) await d.bulkCreate(object, rows.slice(i, i + 250)); + }; + if (seeded === 'team' && !omit.has('sys_team_member')) { + const rows: Record[] = holders.map((user_id, i) => ({ id: id('tm', i), team_id: TEAM, user_id })); + rows.push({ id: 'tm_other_team', team_id: 'team_other', user_id: 'usr_other_team' }); + await seed('sys_team_member', rows); + } + if (seeded === 'position' && !omit.has('sys_user_position')) { + const rows: Record[] = holders.map((user_id, i) => ({ + id: id('up', i), user_id, position: POSITION, organization_id: ORG, + })); + rows.push({ id: 'up_elsewhere', user_id: 'usr_elsewhere', position: POSITION, organization_id: ELSEWHERE }); + await seed('sys_user_position', rows); + } + + const counts = { sys_team_member: 0, sys_user_position: 0 }; + (driver as any).knex.on('query', (q: { sql: string }) => { + const m = /^select .* from [`"](sys_team_member|sys_user_position)[`"]/i.exec(q.sql); + if (m) counts[m[1] as keyof typeof counts] += 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: (t) => counts[t], failReadsOf }; +} + +/** The private expansions and the resolver, as the request opener calls them. */ +const team = (rig: Rig, value = TEAM) => (rig.svc as any).expandTeamUsers(value, ORG) as Promise; +const position = (rig: Rig, value = POSITION) => (rig.svc as any).expandPositionUsers(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 failureWarnings = (rig: Rig) => rig.warn.mock.calls.filter(([, meta]) => typeof meta?.error === 'string'); + +describe('[#29503] approvals team expansion reads every member (driver-sql, shipped SysTeamMember)', () => { + it('⭐ past the 10,000-row bound: every member is on the slate, and no one else', async () => { + const rig = await boot('team', PAGE + 1); + const users = await team(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_other_team'); + expect(rig.reads('sys_team_member')).toBe(2); + }, 30_000); + + it('⭐ a team approver routes to every member past the bound', async () => { + const rig = await boot('team', PAGE + 1); + const slate = await resolveSpec(rig, { type: 'team', value: TEAM }); + + 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('team', PAGE - 1); + const users = await team(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads('sys_team_member')).toBe(1); + }, 30_000); + + it('⛔ a team read that fails refuses — it never answers an invented empty set', async () => { + const rig = await boot('team', 3); + rig.failReadsOf('sys_team_member'); + + await expect(team(rig)).rejects.toThrow(/members of team 'team_large'.*could not be read/); + }); + + it('⛔ what the caller sees: the team slot falls back to its literal, and the warning names the failed read', async () => { + const rig = await boot('team', 3); + rig.failReadsOf('sys_team_member'); + + expect(await resolveSpec(rig, { type: 'team', value: TEAM })).toEqual([`team:${TEAM}`]); + const failed = failureWarnings(rig); + expect(failed.map(([, meta]) => meta.type)).toEqual(['team']); + 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_team_member has no members: the literal, with no failure warning', async () => { + const rig = await boot('team', 0, { omit: ['sys_team_member'] }); + + expect(await team(rig)).toEqual([]); + expect(await resolveSpec(rig, { type: 'team', value: TEAM })).toEqual([`team:${TEAM}`]); + expect(failureWarnings(rig)).toEqual([]); + }); +}); + +describe('[#29503] approvals position expansion reads every assignment (driver-sql)', () => { + it('⭐ past the 10,000-row bound: every assignment holder is on the slate, and no one else', async () => { + const rig = await boot('position', PAGE + 1); + const users = await position(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_elsewhere'); + expect(rig.reads('sys_user_position')).toBe(2); + }, 30_000); + + it('control — under the bound: one read, the same slate as before', async () => { + const rig = await boot('position', PAGE - 1); + const users = await position(rig); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads('sys_user_position')).toBe(1); + }, 30_000); + + it('⛔ an assignment read that fails refuses rather than route to the membership-tier limb alone', async () => { + const rig = await boot('position', 3); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_tier', user_id: 'usr_tier', organization_id: ORG, role: POSITION }, + ]); + rig.failReadsOf('sys_user_position'); + + await expect(position(rig)).rejects.toThrow(/assignments of position 'cfo_office'.*could not be read/); + }); + + it('⛔ what the caller sees: the position slot falls back to its literal, never the tier holders alone', async () => { + const rig = await boot('position', 3); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_tier', user_id: 'usr_tier', organization_id: ORG, role: POSITION }, + ]); + rig.failReadsOf('sys_user_position'); + + expect(await resolveSpec(rig, { type: 'position', value: POSITION })).toEqual([`position:${POSITION}`]); + const failed = failureWarnings(rig); + expect(failed.map(([, meta]) => meta.type)).toEqual(['position']); + expect(failed[0][1].error).toMatch(/SQLITE_IOERR/); + }); + + it('a composition that never registers sys_user_position routes to the membership-tier limb, with no failure warning', async () => { + const rig = await boot('position', 0, { omit: ['sys_user_position'] }); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_tier', user_id: 'usr_tier', organization_id: ORG, role: POSITION }, + ]); + + expect(await resolveSpec(rig, { type: 'position', value: POSITION })).toEqual(['usr_tier']); + expect(failureWarnings(rig)).toEqual([]); + }); + + it('a stack whose sys_user_position table was never created routes to the membership-tier limb', async () => { + const rig = await boot('position', 0); + await (rig.driver as any).knex.schema.dropTable('sys_user_position'); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_tier', user_id: 'usr_tier', organization_id: ORG, role: POSITION }, + ]); + + expect(await position(rig)).toEqual(['usr_tier']); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/ownership-queue-service.ts b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts index 0cd711c0ef9..2fad5375487 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue-service.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue-service.ts @@ -365,12 +365,12 @@ export class OwnershipQueueService implements IOwnershipQueueService { if (spec.value === userId) return true; break; case 'team': - if (await teamHasMember(engine, spec.value, userId)) return true; + if (await this.holderCheck(spec, () => teamHasMember(engine, spec.value, userId))) return true; break; case 'position': { if (!(await this.positionActive(spec.value, pass))) break; const positions = new PositionGraphService({ engine, organizationId }); - if (await positions.holdsPosition(userId, spec.value, organizationId ?? undefined)) return true; + if (await this.holderCheck(spec, () => positions.holdsPosition(userId, spec.value, organizationId ?? undefined))) return true; break; } default: @@ -380,6 +380,28 @@ export class OwnershipQueueService implements IOwnershipQueueService { return false; } + /** + * [#29503] A team or position holder check, answered in the posture this + * service declares for a failed member-table read (module header): that + * specification contributes nobody, logged once — the same answer + * {@link expandSpec} gives the expansion, so the two twins cannot disagree. + * + * The checks themselves (`teamHasMember`, `PositionGraphService.holdsPosition`) + * now refuse a failed read instead of answering `false`; this keeps the + * queue's consumers where they were. Whether a queue should refuse too is not + * decided by ADR-0142 and is left to a ruling rather than narrowed here. + */ + private async holderCheck(spec: QueueMemberSpec, check: () => Promise): Promise { + try { + return await check(); + } catch (err: any) { + this.warnOnce(`holds::${spec.type}::${spec.value}`, '[queue] a member specification could not be read for a holder check — it contributes nobody until it can', { + type: spec.type, value: spec.value, error: err?.message, + }); + return false; + } + } + // ── Reads ────────────────────────────────────────────────────────────────── private declaredItems(): DeclaredQueueItem[] { diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index 5d231272f36..5370315e3f6 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -104,13 +104,15 @@ function makeEngine(opts: { const SUPPORT_DECLARED = [{ name: 'support_tier1', label: 'Support Tier 1', members: [{ type: 'position', value: 'support_agent' }], _packageId: 'com.acme.support' }]; const BASE_TABLES = (): Record => ({ + // Every membership row carries its `id`, as a stored one does: the team and + // position reads page by it (#29503) and refuse a row they cannot resume after. sys_user_position: [ - { position: 'support_agent', user_id: 'u_b', organization_id: 'org_a' }, - { position: 'support_agent', user_id: 'u_a', organization_id: 'org_a' }, - { position: 'support_agent', user_id: 'u_other_org', organization_id: 'org_b' }, - { position: 'billing_clerk', user_id: 'u_c', organization_id: 'org_a' }, + { id: 'up_1', position: 'support_agent', user_id: 'u_b', organization_id: 'org_a' }, + { id: 'up_2', position: 'support_agent', user_id: 'u_a', organization_id: 'org_a' }, + { id: 'up_3', position: 'support_agent', user_id: 'u_other_org', organization_id: 'org_b' }, + { id: 'up_4', position: 'billing_clerk', user_id: 'u_c', organization_id: 'org_a' }, ], - sys_team_member: [{ team_id: 't_night', user_id: 'u_d' }, { team_id: 't_night', user_id: 'u_a' }], + sys_team_member: [{ id: 'tm_1', team_id: 't_night', user_id: 'u_d' }, { id: 'tm_2', team_id: 't_night', user_id: 'u_a' }], sys_queue: [ { id: 'q1', name: 'billing', label: 'Billing', organization_id: 'org_a' }, { id: 'q2', name: 'billing', label: 'Billing B', organization_id: 'org_b' }, @@ -196,6 +198,23 @@ describe('[ADR-0142 D2] OwnershipQueueService — the one expander', () => { expect(warn.mock.calls[0][0]).toMatch(/sys_queue_member read failed/); }); + it('[#29503] a failed team or position holder read keeps the queue\'s declared posture: that specification contributes nobody, logged once', async () => { + // The team and position reads now refuse a failed read instead of answering + // "nobody"; the queue service keeps answering for its own consumers exactly + // as its header declares (a failed member-table read contributes nobody), + // on the expansion and on the holder check alike. Whether the queue should + // refuse too is left to a ruling (ADR-0142 does not declare this posture). + for (const failed of ['sys_team_member', 'sys_user_position'] as const) { + const warn = vi.fn(); + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES(), failOn: [failed] }), { warn }); + const expected = failed === 'sys_team_member' ? ['u_a', 'u_b'] : ['u_a', 'u_d']; + expect(await svc.expandMembers('support_tier1', 'org_a')).toEqual(expected); + expect(await svc.isMember(failed === 'sys_team_member' ? 'u_d' : 'u_b', 'support_tier1', 'org_a')).toBe(false); + expect(await svc.isMember(failed === 'sys_team_member' ? 'u_b' : 'u_d', 'support_tier1', 'org_a')).toBe(true); + expect(warn.mock.calls.some(([line]) => /could not be (expanded|read)/.test(String(line)))).toBe(true); + } + }); + it('a failed ledger read leaves the declared default — on — and says so', async () => { const warn = vi.fn(); const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES(), failOn: [METADATA_ACTIVATION_TABLE] }), { warn }); diff --git a/packages/plugins/plugin-sharing/src/position-graph.test.ts b/packages/plugins/plugin-sharing/src/position-graph.test.ts index a1b60745066..0415a485c46 100644 --- a/packages/plugins/plugin-sharing/src/position-graph.test.ts +++ b/packages/plugins/plugin-sharing/src/position-graph.test.ts @@ -8,7 +8,7 @@ import { PositionGraphService } from './position-graph.js'; // Minimal engine: resolves find('sys_user_position') and find('sys_member'). function makeEngine( - assignments: Array<{ position: string; user_id: string; organization_id?: string | null }>, + assignments: Array<{ id: string; position: string; user_id: string; organization_id?: string | null }>, members: Array<{ role: string; user_id: string }>, ) { return { @@ -27,10 +27,12 @@ function makeEngine( } as any; } +// Every `sys_user_position` row carries its `id`, as a stored one does: the +// assignment read pages by it (#29503) and refuses a row it cannot resume after. const ASSIGNMENTS = [ - { position: 'sales_manager', user_id: 'u_mgr' }, - { position: 'sales_rep', user_id: 'u_rep1' }, - { position: 'sales_rep', user_id: 'u_rep2' }, + { id: 'up_1', position: 'sales_manager', user_id: 'u_mgr' }, + { id: 'up_2', position: 'sales_rep', user_id: 'u_rep1' }, + { id: 'up_3', position: 'sales_rep', user_id: 'u_rep2' }, ]; // Every `sys_member` row carries its `id`, as a stored one does: the role read // pages by it (#29362) and refuses a row it cannot resume after. @@ -48,7 +50,7 @@ describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => { it('unions the better-auth membership transition source (no duplicates)', async () => { const g = new PositionGraphService({ - engine: makeEngine([...ASSIGNMENTS, { position: 'sales_rep', user_id: 'u_legacy_rep' }], MEMBERS), + engine: makeEngine([...ASSIGNMENTS, { id: 'up_4', position: 'sales_rep', user_id: 'u_legacy_rep' }], MEMBERS), }); const users = (await g.expandPositionUsers('sales_rep')).sort(); expect(users).toEqual(['u_legacy_rep', 'u_rep1', 'u_rep2']); @@ -71,10 +73,10 @@ describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => { const g = new PositionGraphService({ engine: makeEngine( [ - { position: 'sales_rep', user_id: 'u_active' }, - { position: 'sales_rep', user_id: 'u_expired', valid_until: past } as any, - { position: 'sales_rep', user_id: 'u_pending', valid_from: future } as any, - { position: 'sales_rep', user_id: 'u_windowed', valid_from: past, valid_until: future } as any, + { id: 'up_5', position: 'sales_rep', user_id: 'u_active' }, + { id: 'up_6', position: 'sales_rep', user_id: 'u_expired', valid_until: past } as any, + { id: 'up_7', position: 'sales_rep', user_id: 'u_pending', valid_from: future } as any, + { id: 'up_8', position: 'sales_rep', user_id: 'u_windowed', valid_from: past, valid_until: future } as any, ], [], ), @@ -87,7 +89,7 @@ describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => { const engine = { async find(object: string, options: any) { calls++; - if (object === 'sys_user_position') return [{ position: 'p', user_id: 'u1' }]; + if (object === 'sys_user_position') return [{ id: 'up_9', position: 'p', user_id: 'u1' }]; return []; }, } as any; @@ -103,14 +105,14 @@ describe('PositionGraphService.holdsPosition — the user-scoped twin of the exp const past = '2000-01-01T00:00:00Z'; const future = '2999-01-01T00:00:00Z'; const ASSIGN = [ - { position: 'rep', user_id: 'u_a', organization_id: 'org_a' }, - { position: 'rep', user_id: 'u_b', organization_id: 'org_b' }, - { position: 'rep', user_id: 'u_global', organization_id: null }, - { position: 'rep', user_id: 'u_expired', organization_id: 'org_a', valid_until: past }, - { position: 'rep', user_id: 'u_pending', organization_id: 'org_a', valid_from: future }, - { position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_until: past }, - { position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_from: past, valid_until: future }, - { position: 'mgr', user_id: 'u_a', organization_id: 'org_a' }, + { id: 'up_10', position: 'rep', user_id: 'u_a', organization_id: 'org_a' }, + { id: 'up_11', position: 'rep', user_id: 'u_b', organization_id: 'org_b' }, + { id: 'up_12', position: 'rep', user_id: 'u_global', organization_id: null }, + { id: 'up_13', position: 'rep', user_id: 'u_expired', organization_id: 'org_a', valid_until: past }, + { id: 'up_14', position: 'rep', user_id: 'u_pending', organization_id: 'org_a', valid_from: future }, + { id: 'up_15', position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_until: past }, + { id: 'up_16', position: 'rep', user_id: 'u_mixed', organization_id: 'org_a', valid_from: past, valid_until: future }, + { id: 'up_17', position: 'mgr', user_id: 'u_a', organization_id: 'org_a' }, ]; const MEMBER = [ { id: 'm_role_a', role: 'rep', user_id: 'u_role_a', organization_id: 'org_a' }, diff --git a/packages/plugins/plugin-sharing/src/position-graph.ts b/packages/plugins/plugin-sharing/src/position-graph.ts index 015510e8dba..487a61ccf6b 100644 --- a/packages/plugins/plugin-sharing/src/position-graph.ts +++ b/packages/plugins/plugin-sharing/src/position-graph.ts @@ -3,9 +3,7 @@ import { isGrantActive } from '@objectstack/core'; import type { SharingEngine } from './sharing-service.js'; -import { TeamGraphService, holdsRole } from './team-graph.js'; - -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +import { TeamGraphService, holdsRole, readMembershipToCompletion } from './team-graph.js'; /** * The `sys_user_position` predicate "assignments of `positionName`", scoped to @@ -23,6 +21,13 @@ function positionAssignmentPredicate(positionName: string, scopeOrg: string | nu /** The columns both reads project: the holder and the ADR-0091 D2 validity window. */ const ASSIGNMENT_FIELDS = ['user_id', 'valid_from', 'valid_until']; +/** + * [#29503] Page size of the user-scoped assignment read — the bound it always + * carried. One user's rows for one position fit in one page in practice; the + * walk only makes a longer history of lapsed rows read whole instead of cut. + */ +const HOLDER_CHECK_PAGE_ROWS = 1000; + type PositionCache = { expand?: Map; }; @@ -90,24 +95,24 @@ export class PositionGraphService { // 1) Platform assignment table (source of truth). const scopeOrg = organizationId ?? this.organizationId; - try { - // valid_from / valid_until ride the projection so the ADR-0091 D2 - // validity filter below sees them — expired holders stop receiving - // position-recipient shares at resolution time, fail-closed. - const rows = await this.engine.find('sys_user_position', { - filter: positionAssignmentPredicate(positionName, scopeOrg), - fields: ASSIGNMENT_FIELDS, - limit: 10000, - context: SYSTEM_CTX, - }); - const nowMs = Date.now(); - for (const r of (rows ?? []) as any[]) { - if (!isGrantActive(r, nowMs)) continue; - const uid = String(r.user_id ?? ''); - if (uid) users.add(uid); - } - } catch { - /* table may not exist on minimal stacks — union source below still applies */ + // valid_from / valid_until ride the projection so the ADR-0091 D2 + // validity filter below sees them — expired holders stop receiving + // position-recipient shares at resolution time, fail-closed. + // [#29503] Read to completion, and a failed read throws (nothing is cached + // for it): answering the membership-role limb alone was a shrunk holder set + // that looked complete. A stack with no `sys_user_position` has no + // assignment holders, and the union source below still applies. + const rows = await readMembershipToCompletion(this.engine, { + object: 'sys_user_position', + where: positionAssignmentPredicate(positionName, scopeOrg), + fields: ASSIGNMENT_FIELDS, + subject: `the assignments of position '${positionName}'${scopeOrg ? ` in organization '${scopeOrg}'` : ''}`, + }); + const nowMs = Date.now(); + for (const r of (rows ?? []) as any[]) { + if (!isGrantActive(r, nowMs)) continue; + const uid = String(r.user_id ?? ''); + if (uid) users.add(uid); } // 2) better-auth membership string (transition window, ADR-0057 D4). @@ -135,25 +140,29 @@ export class PositionGraphService { * assignment import quadratic in the position's holder count. * * Uncached on purpose: the answer is wanted right after a write that may - * have changed it. A failed assignment read falls through to the transition - * source, as the expansion's does. + * have changed it. [#29503] A failed assignment read refuses, as the + * expansion's does; only a stack with no `sys_user_position` falls through + * to the transition source. */ async holdsPosition(userId: string, positionName: string, organizationId?: string): Promise { if (!userId || !positionName) return false; const scopeOrg = organizationId ?? this.organizationId; - try { - const rows = await this.engine.find('sys_user_position', { - filter: { ...positionAssignmentPredicate(positionName, scopeOrg), user_id: userId }, - fields: ASSIGNMENT_FIELDS, - limit: 1000, - context: SYSTEM_CTX, - }); - const nowMs = Date.now(); - for (const r of (rows ?? []) as any[]) { - if (String(r.user_id ?? '') === userId && isGrantActive(r, nowMs)) return true; - } - } catch { - /* table may not exist on minimal stacks — the transition source below still applies */ + // [#29503] Every one of the user's assignment rows for the position, read to + // completion, so an active row past an arbitrary bound of lapsed ones still + // counts; a failed read refuses (a `false` here made the per-user recompute + // revoke the holder's grants on an outage). A stack with no + // `sys_user_position` falls through to the transition source below. + const rows = await readMembershipToCompletion(this.engine, { + object: 'sys_user_position', + where: { ...positionAssignmentPredicate(positionName, scopeOrg), user_id: userId }, + fields: ASSIGNMENT_FIELDS, + subject: `whether user '${userId}' holds position '${positionName}'${scopeOrg ? ` in organization '${scopeOrg}'` : ''}`, + pageRows: HOLDER_CHECK_PAGE_ROWS, + kind: 'check', + }); + const nowMs = Date.now(); + for (const r of (rows ?? []) as any[]) { + if (String(r.user_id ?? '') === userId && isGrantActive(r, nowMs)) return true; } return holdsRole(this.engine, positionName, userId, scopeOrg); } diff --git a/packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts b/packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts index 7f89cf05dc9..cdb692ddc8b 100644 --- a/packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts +++ b/packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts @@ -384,6 +384,62 @@ describe('team membership writes re-evaluate team-recipient rules, and the trans }); }); +/** + * [#29503] The holder check the per-user pass asks reads the membership table + * again, and a failed read used to answer "not a holder": the pass then revoked + * the user's grants on an outage. It now refuses, the pass's catch logs, and + * the grants stay until the read succeeds. Only the holder check's reads fail + * here (a read filtered on the recipient column), so the write itself and its + * pre-image read go through as they would in an outage of that index. + */ +describe('[#29503] a failed holder read keeps the user\'s grants instead of revoking them', () => { + const failHolderReads = (t: Awaited>, table: string, column: string) => { + const real = t.driver.find.bind(t.driver); + return vi.spyOn(t.driver, 'find').mockImplementation(((obj: string, query: unknown, ...rest: unknown[]) => + obj === table && JSON.stringify(query ?? {}).includes(`"${column}"`) + ? Promise.reject(Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' })) + : real(obj as never, query as never, ...(rest as []))) as never); + }; + + it('⛔ team: a member still on the team through a second row keeps the grants when the holder read fails', async () => { + const t = await boot(); + await t.ql.insert('sys_team_member', { id: 'tm_1', organization_id: ORG_A, team_id: 'team_eu', user_id: 'u_team' }, { context: SYSTEM } as never); + await t.ql.insert('sys_team_member', { id: 'tm_2', organization_id: ORG_A, team_id: 'team_eu', user_id: 'u_team' }, { context: SYSTEM } as never); + expect(await t.grantsOf('u_team', 'srule_team')).toEqual(['acct_eu_1:read', 'acct_eu_2:read']); + + failHolderReads(t, 'sys_team_member', 'team_id'); + await t.ql.delete('sys_team_member', { where: { id: 'tm_2' }, context: SYSTEM } as never); + vi.restoreAllMocks(); + + expect(await t.grantsOf('u_team', 'srule_team')).toEqual(['acct_eu_1:read', 'acct_eu_2:read']); + expect(t.warn.mock.calls.some(([line]) => /recipient membership re-evaluation failed/.test(String(line)))).toBe(true); + }); + + it('⛔ position: a holder still assigned through a second row keeps the grants when the assignment read fails', async () => { + const t = await boot(); + await t.assign('up_1', 'u_pos', 'eu_sales'); + await t.assign('up_2', 'u_pos', 'eu_sales'); + expect(await t.grantsOf('u_pos')).toEqual(EU_GRANTS); + + failHolderReads(t, 'sys_user_position', 'position'); + await t.ql.delete('sys_user_position', { where: { id: 'up_2' }, context: SYSTEM } as never); + vi.restoreAllMocks(); + + expect(await t.grantsOf('u_pos')).toEqual(EU_GRANTS); + expect(t.warn.mock.calls.some(([line]) => /recipient membership re-evaluation failed/.test(String(line)))).toBe(true); + }); + + it('control — the same delete with the reads healthy keeps the grants too (the user still holds the recipient)', async () => { + const t = await boot(); + await t.ql.insert('sys_team_member', { id: 'tm_1', organization_id: ORG_A, team_id: 'team_eu', user_id: 'u_team' }, { context: SYSTEM } as never); + await t.ql.insert('sys_team_member', { id: 'tm_2', organization_id: ORG_A, team_id: 'team_eu', user_id: 'u_team' }, { context: SYSTEM } as never); + + await t.ql.delete('sys_team_member', { where: { id: 'tm_2' }, context: SYSTEM } as never); + + expect(await t.grantsOf('u_team', 'srule_team')).toEqual(['acct_eu_1:read', 'acct_eu_2:read']); + }); +}); + describe('binding and write classification', () => { const fakeEngine = () => { const hooks: Array<{ event: string; options: Record }> = []; diff --git a/packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts b/packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts new file mode 100644 index 00000000000..ff7fe7134ed --- /dev/null +++ b/packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29503] The team and position recipient reads answer EVERY holder, or + * refuse — never a shortened or an invented empty set. The close-out of the + * family #29362 opened with the `sys_member` role read. + * + * 1. `TeamGraphService.expandUsers` read `sys_team_member` once at a + * 10,000-row bound and answered `[]` on any failure. + * 2. `PositionGraphService.expandPositionUsers` read `sys_user_position` the + * same way and swallowed any failure, answering the `sys_member.role` + * limb alone — a shrunk holder set that looks complete. + * 3. The user-scoped twins the per-user recompute asks + * (`teamHasMember`, `holdsRole`, `PositionGraphService.holdsPosition`) + * answered `false` on a failed read, so the recompute revoked that + * user's grants on an outage. + * + * A grant set is diffed against these answers: a missing holder is a revoked + * grant. So a failed read throws, and a walk that cannot vouch for + * completion throws. The one failure that IS an answer is absence — 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 `SysTeamMember` and `SysMember`, because completion is a + * keyset read (`id > cursor`, ordered by `id`) that a double ignoring the + * cursor cannot vouch for. `sys_user_position` is plugin-security's object, + * which this package does not depend on; its columns are declared here as the + * shipped object declares them. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { SysMember, SysOrganization, SysTeamMember } from '@objectstack/platform-objects/identity'; +import { TeamGraphService, holdsRole, teamHasMember } from './team-graph.js'; +import { PositionGraphService } from './position-graph.js'; + +const ORG = 'org_large'; +const ELSEWHERE = 'org_elsewhere'; +const TEAM = 'team_large'; +const POSITION = 'field_rep'; +/** The bound each membership read is paged at. */ +const PAGE = 10000; + +const pad = (n: number) => String(n).padStart(6, '0'); + +/** `sys_user_position` as plugin-security ships it: the columns the reads touch. */ +const SysUserPosition = ObjectSchema.create({ + name: 'sys_user_position', + label: 'User Position', + isSystem: true, + fields: { + id: Field.text({ label: 'Assignment ID', required: true }), + user_id: Field.text({ label: 'User', required: true }), + position: Field.text({ label: 'Position', required: true }), + organization_id: Field.text({ label: 'Organization' }), + valid_from: Field.datetime({ label: 'Valid From' }), + valid_until: Field.datetime({ label: 'Valid Until' }), + }, +}); + +type Seeded = 'team' | 'position'; + +interface Rig { + engine: ObjectQL; + driver: SqlDriver; + /** Every seeded holder, sorted. */ + holders: string[]; + /** The membership reads the driver ran, by table. */ + reads: (table: 'sys_team_member' | 'sys_user_position') => number; + /** Make every driver read of `object` throw `fault`; other objects read as before. */ + failReadsOf: (object: string, fault?: Error) => void; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +const IOERR = () => Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' }); + +/** + * `n` holders of {@link TEAM} (seeded `team`) or of {@link POSITION} in + * {@link ORG} (seeded `position`), plus the rows the predicate must keep out: + * a member of another team, a holder of the position in another organization, + * and an assignment whose window closed. Row ids are scrambled against user + * ids, so the 10,001st row by id is not the 10,001st user. + */ +async function boot(seeded: Seeded, 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.recipient-reads-complete', + name: 'recipient reads complete', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysOrganization, SysMember, SysTeamMember, SysUserPosition].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 holders = Array.from({ length: n }, (_, i) => `usr_${pad(i)}`); + const id = (prefix: string, i: number) => `${prefix}_${pad((i * 7919) % 1000003)}`; + const seed = async (object: string, rows: Record[]) => { + const d = e.getDriverForObject(object); + for (let i = 0; i < rows.length; i += 250) await d.bulkCreate(object, rows.slice(i, i + 250)); + }; + if (seeded === 'team' && !omit.has('sys_team_member')) { + const rows: Record[] = holders.map((user_id, i) => ({ id: id('tm', i), team_id: TEAM, user_id })); + rows.push({ id: 'tm_other_team', team_id: 'team_other', user_id: 'usr_other_team' }); + await seed('sys_team_member', rows); + } + if (seeded === 'position' && !omit.has('sys_user_position')) { + const rows: Record[] = holders.map((user_id, i) => ({ + id: id('up', i), user_id, position: POSITION, organization_id: ORG, + })); + rows.push({ id: 'up_elsewhere', user_id: 'usr_elsewhere', position: POSITION, organization_id: ELSEWHERE }); + rows.push({ + id: 'up_lapsed', user_id: 'usr_lapsed', position: POSITION, organization_id: ORG, + valid_until: '2020-01-01T00:00:00.000Z', + }); + await seed('sys_user_position', rows); + } + + const counts = { sys_team_member: 0, sys_user_position: 0 }; + (driver as any).knex.on('query', (q: { sql: string }) => { + const m = /^select .* from [`"](sys_team_member|sys_user_position)[`"]/i.exec(q.sql); + if (m) counts[m[1] as keyof typeof counts] += 1; + }); + const failReadsOf = (object: string, fault: Error = IOERR()) => { + const d = e.getDriverForObject(object); + const real = d.find.bind(d); + vi.spyOn(d, 'find').mockImplementation(((obj: string, ...rest: unknown[]) => + obj === object ? Promise.reject(fault) : real(obj, ...rest)) as never); + }; + return { engine, driver, holders: [...holders].sort(), reads: (t) => counts[t], failReadsOf }; +} + +const teams = (rig: Rig) => new TeamGraphService({ engine: rig.engine as never, organizationId: ORG }); +const positions = (rig: Rig) => new PositionGraphService({ engine: rig.engine as never, organizationId: ORG }); + +describe('[#29503] site 1 — TeamGraphService.expandUsers reads every team member', () => { + it('⭐ past the 10,000-row bound: every member is in the set, and no one else', async () => { + const rig = await boot('team', PAGE + 1); + const users = await teams(rig).expandUsers(TEAM); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_other_team'); + expect(rig.reads('sys_team_member')).toBe(2); + }, 30_000); + + it('control — under the bound: one read, the same set as before', async () => { + const rig = await boot('team', PAGE - 1); + const users = await teams(rig).expandUsers(TEAM); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads('sys_team_member')).toBe(1); + }, 30_000); + + it('⛔ a read that fails refuses — it never answers an invented empty set', async () => { + const rig = await boot('team', 3); + rig.failReadsOf('sys_team_member'); + + await expect(teams(rig).expandUsers(TEAM)).rejects.toThrow(/members of team 'team_large'.*could not be read/); + }); + + it('⛔ a failed read is not cached: the next read answers the real members', async () => { + const rig = await boot('team', 3); + const d = (rig.engine as any).getDriverForObject('sys_team_member'); + vi.spyOn(d, 'find').mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')); + const graph = teams(rig); + + await expect(graph.expandUsers(TEAM)).rejects.toThrow(/could not be read/); + expect([...(await graph.expandUsers(TEAM))].sort()).toEqual(rig.holders); + }); + + it('a composition that never registers sys_team_member has no members: [] as before', async () => { + const rig = await boot('team', 0, { omit: ['sys_team_member'] }); + + expect(await teams(rig).expandUsers(TEAM)).toEqual([]); + }); + + it('a stack whose sys_team_member table was never created has no members: [] as before', async () => { + const rig = await boot('team', 0); + await (rig.driver as any).knex.schema.dropTable('sys_team_member'); + + expect(await teams(rig).expandUsers(TEAM)).toEqual([]); + }); +}); + +describe('[#29503] site 2 — PositionGraphService.expandPositionUsers reads every assignment', () => { + it('⭐ past the 10,000-row bound: every active holder is in the set, and no one else', async () => { + const rig = await boot('position', PAGE + 1); + const users = await positions(rig).expandPositionUsers(POSITION, ORG); + + expect([...users].sort()).toEqual(rig.holders); + expect(users).not.toContain('usr_elsewhere'); + expect(users).not.toContain('usr_lapsed'); + expect(rig.reads('sys_user_position')).toBe(2); + }, 30_000); + + it('control — under the bound: one read, the same set as before', async () => { + // 9,998 holders + the lapsed row = 9,999 rows the predicate matches. + const rig = await boot('position', PAGE - 2); + const users = await positions(rig).expandPositionUsers(POSITION, ORG); + + expect([...users].sort()).toEqual(rig.holders); + expect(rig.reads('sys_user_position')).toBe(1); + }, 30_000); + + it('⛔ an assignment read that fails refuses rather than answer the membership-role limb alone', async () => { + const rig = await boot('position', 3); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_role', user_id: 'usr_role', organization_id: ORG, role: POSITION }, + ]); + rig.failReadsOf('sys_user_position'); + + await expect(positions(rig).expandPositionUsers(POSITION, ORG)).rejects.toThrow( + /assignments of position 'field_rep'.*could not be read/, + ); + }); + + it('a composition that never registers sys_user_position answers the membership-role limb, with no refusal', async () => { + const rig = await boot('position', 0, { omit: ['sys_user_position'] }); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_role', user_id: 'usr_role', organization_id: ORG, role: POSITION }, + ]); + + expect(await positions(rig).expandPositionUsers(POSITION, ORG)).toEqual(['usr_role']); + }); + + it('a stack whose sys_user_position table was never created answers the membership-role limb', async () => { + const rig = await boot('position', 0); + await (rig.driver as any).knex.schema.dropTable('sys_user_position'); + + expect(await positions(rig).expandPositionUsers(POSITION, ORG)).toEqual([]); + }); +}); + +describe('[#29503] site 3 — the user-scoped holder checks refuse on a failed read', () => { + it('control — teamHasMember, holdsRole and holdsPosition answer from the rows', async () => { + const rig = await boot('team', 2); + await (rig.engine as any).getDriverForObject('sys_member').bulkCreate('sys_member', [ + { id: 'mem_role', user_id: 'usr_role', organization_id: ORG, role: POSITION }, + ]); + + expect(await teamHasMember(rig.engine as never, TEAM, 'usr_000001')).toBe(true); + expect(await teamHasMember(rig.engine as never, TEAM, 'usr_other_team')).toBe(false); + expect(await holdsRole(rig.engine as never, POSITION, 'usr_role', ORG)).toBe(true); + expect(await holdsRole(rig.engine as never, POSITION, 'usr_000001', ORG)).toBe(false); + expect(await positions(rig).holdsPosition('usr_role', POSITION, ORG)).toBe(true); + }); + + it('⛔ teamHasMember refuses on a failed read — never "not a member"', async () => { + const rig = await boot('team', 2); + rig.failReadsOf('sys_team_member'); + + await expect(teamHasMember(rig.engine as never, TEAM, 'usr_000001')).rejects.toThrow( + /whether user 'usr_000001' is a member of team 'team_large' could not be read/, + ); + }); + + it('⛔ holdsRole refuses on a failed read — never "does not hold it"', async () => { + const rig = await boot('team', 0); + rig.failReadsOf('sys_member'); + + await expect(holdsRole(rig.engine as never, POSITION, 'usr_role', ORG)).rejects.toThrow( + /whether user 'usr_role' holds role 'field_rep'.*could not be read/, + ); + }); + + it('⛔ holdsPosition refuses on a failed assignment read — never falls through to the role limb alone', async () => { + const rig = await boot('position', 2); + rig.failReadsOf('sys_user_position'); + + await expect(positions(rig).holdsPosition('usr_000001', POSITION, ORG)).rejects.toThrow( + /whether user 'usr_000001' holds position 'field_rep'.*could not be read/, + ); + }); + + it('absence still answers: no sys_team_member / sys_member / sys_user_position means not a holder', async () => { + const rig = await boot('team', 0, { omit: ['sys_team_member', 'sys_member', 'sys_user_position'] }); + + expect(await teamHasMember(rig.engine as never, TEAM, 'usr_000001')).toBe(false); + expect(await holdsRole(rig.engine as never, POSITION, 'usr_role', ORG)).toBe(false); + expect(await positions(rig).holdsPosition('usr_role', POSITION, ORG)).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/team-graph.ts b/packages/plugins/plugin-sharing/src/team-graph.ts index 8e170007a92..8d3256d9967 100644 --- a/packages/plugins/plugin-sharing/src/team-graph.ts +++ b/packages/plugins/plugin-sharing/src/team-graph.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { ITeamGraphService } from '@objectstack/spec/contracts'; -import { isMissingTableError, keysetWalk } from '@objectstack/types'; +import { isObjectAbsentError, keysetWalk } from '@objectstack/types'; import type { SharingEngine } from './sharing-service.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -130,79 +130,136 @@ function roleHolderPredicate(roleName: string, org: string | null | undefined): } /** - * [#29362] Rows per page of the role-holder read — the bound the read always - * carried, so a role held by fewer people issues the one read it always did. + * [#29362, #29503] Rows per page of a membership read — the bound each read + * always carried, so a recipient held by fewer people issues the one read it + * always did, and one held by more is read in further pages rather than cut. */ -const ROLE_HOLDER_PAGE_ROWS = 10000; +const MEMBERSHIP_PAGE_ROWS = 10000; /** - * [#29362] Every holder of `roleName` (in `org`, when given), read to - * completion — or a refusal, never a shortened or an invented empty set. + * [#29503] Why a holder read refused, as one sentence family: what could not + * be read, the reason, and why the answer is a refusal rather than "nobody". + * The driver's error rides along as `cause`. + */ +function holderReadRefusal(subject: string, reason: string, cause: unknown, kind: 'expansion' | 'check'): Error { + const consequence = kind === 'expansion' + ? 'A shortened or empty holder set would withhold or revoke the grants it confers, so the expansion refuses instead.' + : 'Answering "not a holder" would revoke the grants the holding confers, so the check refuses instead.'; + return Object.assign( + new Error(`[sharing] ${subject} could not be read: ${reason}. ${consequence}`), + cause === undefined ? {} : { cause }, + ); +} + +/** The reason text for a keyset walk that could not vouch for completion. */ +const WALK_INCOMPLETE = 'the read by id could not continue past a page (a row without an id, or a page that repeated the cursor)'; + +/** + * [#29362, #29503] Every row of `object` matching `where`, read to completion — + * or a refusal, never a shortened or an invented empty set; `null` when the + * object does not exist on this stack. * - * Pages of {@link ROLE_HOLDER_PAGE_ROWS} through the platform's one keyset - * walk (`keysetWalk`, `@objectstack/types`): ordered by `id`, each page - * resuming after the last id the one before it returned, so a role held past - * the bound is read whole rather than cut at an arbitrary 10,000 rows. + * Pages of {@link MEMBERSHIP_PAGE_ROWS} (or `pageRows`) through the platform's + * one keyset walk (`keysetWalk`, `@objectstack/types`): ordered by `id`, each + * page resuming after the last id the one before it returned, so a recipient + * held past the bound is read whole rather than cut at an arbitrary row. * - * Loud, never short. The set feeds sharing GRANTS, and the reconcilers diff the - * grants a rule already holds against it — an answer missing a holder revokes - * that holder's access, and an invented `[]` revokes everyone's. So: + * Loud, never short. These sets feed sharing GRANTS, and the reconcilers diff + * the grants a rule already holds against them — an answer missing a holder + * revokes that holder's access, and an invented `[]` revokes everyone's. So: * * - a read that fails throws, carrying the driver's error as `cause`; * - a walk that cannot vouch for completion (a row without an `id` to resume * after, a page the driver returned without honouring the cursor) throws; * - a membership object that does not exist is the one failure that IS an - * answer — a stack that never materialises `sys_member` has no role - * holders — and answers `[]` as it always did; see - * {@link membershipObjectAbsent}. + * answer — a stack that never materialises it has no holders through it — + * 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. */ +export async function readMembershipToCompletion( + engine: SharingEngine, + read: { + object: string; + where: Record; + fields: readonly string[]; + subject: string; + pageRows?: number; + /** A user-scoped holder check words its refusal as a check, not an expansion. */ + kind?: 'expansion' | 'check'; + }, +): Promise[] | null> { + const rows: Record[] = []; + const walk = keysetWalk>( + (page) => engine.find(read.object, { ...page, fields: ['id', ...read.fields], context: SYSTEM_CTX }), + { where: read.where, pageSize: read.pageRows ?? MEMBERSHIP_PAGE_ROWS }, + ); + try { + for await (const page of walk.pages()) rows.push(...page); + } catch (err) { + if (isObjectAbsentError(err, read.object)) return null; + throw holderReadRefusal(read.subject, err instanceof Error ? err.message : String(err), err, read.kind ?? 'expansion'); + } + if (walk.truncated) throw holderReadRefusal(read.subject, WALK_INCOMPLETE, undefined, read.kind ?? 'expansion'); + return rows; +} + /** - * [#29362] Did the role read fail because this stack has no `sys_member` at - * all — the one failure whose empty answer is the truth? Two spellings of the - * same absence, each attributed to `sys_member` so a failure about another - * object is not evidence about this one: the driver's missing-table refusal - * (the shared `isMissingTableError`, the table registered but never created), - * and the engine's `OBJECT_NOT_FOUND` (a composition that never registers the - * object, refused before any driver is asked — the reading - * `sys-metadata-repository.ts` gives its own history object). Anything else - * is an outage and stays loud. + * [#29503] One user-scoped holder probe (`limit: 1`): does any row of `object` + * match `where`? A one-row existence probe is not cut by its bound, so only its + * failure answer changed: absence of the object answers `false` (no holders + * through it), and every other failure refuses — "not a holder" on an outage is + * what made the per-user recompute revoke a holder's grants. + * + * ⛔ Not exported from the package index. */ -function membershipObjectAbsent(err: unknown): boolean { - if (isMissingTableError(err, 'sys_member')) return true; - const refused = err as { code?: unknown; object?: unknown } | null | undefined; - return refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_member'; +export async function probeMembership( + engine: SharingEngine, + probe: { object: string; filter: Record; fields: readonly string[]; subject: string; limit?: number }, +): Promise[]> { + try { + const rows = await engine.find(probe.object, { + filter: probe.filter, + fields: [...probe.fields], + limit: probe.limit ?? 1, + context: SYSTEM_CTX, + }); + return Array.isArray(rows) ? rows : []; + } catch (err) { + if (isObjectAbsentError(err, probe.object)) return []; + throw holderReadRefusal(probe.subject, err instanceof Error ? err.message : String(err), err, 'check'); + } } +/** The user ids `rows` carry, de-duplicated, in first-seen order. */ +function userIdsOf(rows: readonly Record[]): string[] { + const users = new Set(); + for (const row of rows) { + const uid = String(row?.user_id ?? ''); + if (uid) users.add(uid); + } + return Array.from(users); +} + +/** + * [#29362] Every holder of `roleName` (in `org`, when given), read to + * completion through {@link readMembershipToCompletion}. A stack with no + * `sys_member` has no role holders and answers `[]` as it always did. + */ async function readRoleHoldersToCompletion( engine: SharingEngine, roleName: string, org: string | null | undefined, ): Promise { - const refusal = (reason: string, cause?: unknown) => Object.assign(new Error( - `[sharing] the holders of role '${roleName}'${org ? ` in organization '${org}'` : ''} could not be read: ` - + `${reason}. A shortened or empty holder set would withhold or revoke the grants it confers, so the ` - + 'expansion refuses instead.', - ), cause === undefined ? {} : { cause }); - const users = new Set(); - const walk = keysetWalk>( - (page) => engine.find('sys_member', { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }), - { where: roleHolderPredicate(roleName, org), pageSize: ROLE_HOLDER_PAGE_ROWS }, - ); - try { - for await (const page of walk.pages()) { - for (const row of page) { - const uid = String(row?.user_id ?? ''); - if (uid) users.add(uid); - } - } - } catch (err) { - if (membershipObjectAbsent(err)) return []; - throw refusal(err instanceof Error ? err.message : String(err), err); - } - if (walk.truncated) { - throw refusal('the read by id could not continue past a page (a row without an id, or a page that repeated the cursor)'); - } - return Array.from(users); + const rows = await readMembershipToCompletion(engine, { + object: 'sys_member', + where: roleHolderPredicate(roleName, org), + fields: ['user_id'], + subject: `the holders of role '${roleName}'${org ? ` in organization '${org}'` : ''}`, + }); + return rows ? userIdsOf(rows) : []; } /** @@ -214,25 +271,23 @@ async function readRoleHoldersToCompletion( * Exists for the recipient-membership recompute (`recipient-membership-recompute.ts`), * which re-evaluates a team rule for the ONE user whose membership row was * written — a write path, where reading every member of the team per written - * row would make a bulk membership import quadratic. A failed read answers - * `false`, exactly as the expansion answers `[]`. + * row would make a bulk membership import quadratic. [#29503] A failed read + * refuses, exactly as the expansion does: the recompute's per-user catch then + * logs and the user's grants stay, where a `false` revoked them on an outage. + * A stack with no `sys_team_member` answers `false`. * * ⛔ Not exported from the package index — an internal seam, not a public * affordance of {@link TeamGraphService}. */ export async function teamHasMember(engine: SharingEngine, teamId: string, userId: string): Promise { if (!teamId || !userId) return false; - try { - const rows = await engine.find('sys_team_member', { - where: { ...teamMemberPredicate(teamId), user_id: userId }, - fields: ['user_id'], - limit: 1, - context: SYSTEM_CTX, - }); - return (rows ?? []).some((r: any) => String(r?.user_id ?? '') === userId); - } catch { - return false; - } + const rows = await probeMembership(engine, { + object: 'sys_team_member', + filter: { ...teamMemberPredicate(teamId), user_id: userId }, + fields: ['user_id'], + subject: `whether user '${userId}' is a member of team '${teamId}'`, + }); + return rows.some((r: any) => String(r?.user_id ?? '') === userId); } /** @@ -251,17 +306,13 @@ export async function holdsRole( organizationId?: string | null, ): Promise { if (!roleName || !userId) return false; - try { - const rows = await engine.find('sys_member', { - filter: { ...roleHolderPredicate(roleName, organizationId), user_id: userId }, - fields: ['user_id'], - limit: 1, - context: SYSTEM_CTX, - }); - return (rows ?? []).some((r: any) => String(r?.user_id ?? '') === userId); - } catch { - return false; - } + const rows = await probeMembership(engine, { + object: 'sys_member', + filter: { ...roleHolderPredicate(roleName, organizationId), user_id: userId }, + fields: ['user_id'], + subject: `whether user '${userId}' holds role '${roleName}'${organizationId ? ` in organization '${organizationId}'` : ''}`, + }); + return rows.some((r: any) => String(r?.user_id ?? '') === userId); } export class TeamGraphService implements ITeamGraphService { @@ -283,18 +334,16 @@ export class TeamGraphService implements ITeamGraphService { const cached = this.cache.expandUsers!.get(teamId); if (cached) return cached; - let rows: any[] = []; - try { - rows = await this.engine.find('sys_team_member', { - where: teamMemberPredicate(teamId), - 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))); + // [#29503] Read to completion, and a failed read throws — so nothing is + // cached for it, and the next caller reads again. A stack with no + // `sys_team_member` has no team members. + const rows = await readMembershipToCompletion(this.engine, { + object: 'sys_team_member', + where: teamMemberPredicate(teamId), + fields: ['user_id'], + subject: `the members of team '${teamId}'`, + }); + const users = rows ? userIdsOf(rows) : []; this.cache.expandUsers!.set(teamId, users); return users; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 20bacd5d0e4..d62aa4c8c67 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -66,6 +66,11 @@ export * from './unique-violation.js'; // so adopting the predicate adds no edge. `@objectstack/metadata/errors` still // re-exports `isMissingTableError` for its published consumers. export * from './driver-error-classification.js'; +// [#29503] The one "did this read fail because the object does not exist?" +// predicate: the missing-table refusal above or the engine's `OBJECT_NOT_FOUND`, +// each attributed to the object read. The sharing and approvals recipient reads +// answer "no holders" for that and refuse every other failure. +export * from './object-absence.js'; // [#21385, maintainer ruling 2026-10-02 letter A] THE driver-fault redaction: // the one cut that keeps a statement's bound values and inlined literals out of // every log face and out of the error that leaves the engine. Moved here from diff --git a/packages/types/src/object-absence.test.ts b/packages/types/src/object-absence.test.ts new file mode 100644 index 00000000000..7db1aa1683b --- /dev/null +++ b/packages/types/src/object-absence.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { isObjectAbsentError } from './object-absence.js'; + +/** + * [#29503] `isObjectAbsentError` answers `true` only for an absence of the + * object that was read. Everything else is an outage the caller must refuse on. + */ +describe('isObjectAbsentError', () => { + it('a missing-table refusal naming the object read is absence', () => { + expect(isObjectAbsentError(new Error('SQLITE_ERROR: no such table: sys_team_member'), 'sys_team_member')).toBe(true); + expect(isObjectAbsentError(new Error('relation "sys_user_position" does not exist'), 'sys_user_position')).toBe(true); + }); + + it('the engine\'s OBJECT_NOT_FOUND for the object read is absence', () => { + const refused = Object.assign(new Error("Object 'sys_member' is not registered"), { code: 'OBJECT_NOT_FOUND', object: 'sys_member' }); + expect(isObjectAbsentError(refused, 'sys_member')).toBe(true); + }); + + it('⛔ an absence attributed to another object is not evidence about this one', () => { + expect(isObjectAbsentError(new Error('SQLITE_ERROR: no such table: sys_team'), 'sys_team_member')).toBe(false); + const refused = Object.assign(new Error("Object 'sys_team' is not registered"), { code: 'OBJECT_NOT_FOUND', object: 'sys_team' }); + expect(isObjectAbsentError(refused, 'sys_team_member')).toBe(false); + }); + + it('⛔ an OBJECT_NOT_FOUND that names no object is not attributable', () => { + expect(isObjectAbsentError(Object.assign(new Error('not found'), { code: 'OBJECT_NOT_FOUND' }), 'sys_member')).toBe(false); + }); + + it('⛔ an outage is not absence', () => { + expect(isObjectAbsentError(Object.assign(new Error('SQLITE_IOERR: disk I/O error'), { code: 'SQLITE_IOERR' }), 'sys_member')).toBe(false); + expect(isObjectAbsentError(new Error('connection terminated unexpectedly'), 'sys_member')).toBe(false); + expect(isObjectAbsentError(undefined, 'sys_member')).toBe(false); + expect(isObjectAbsentError(null, 'sys_member')).toBe(false); + }); +}); diff --git a/packages/types/src/object-absence.ts b/packages/types/src/object-absence.ts new file mode 100644 index 00000000000..31450b3c85b --- /dev/null +++ b/packages/types/src/object-absence.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { isMissingTableError } from './driver-error-classification.js'; + +/** + * Did a read of `readObject` fail because this stack has no such object at all — + * the one read failure whose empty answer is the truth? + * + * A reader that turns a failed read into "no rows" is safe only for this case. + * Two spellings of the same absence answer `true`, and each must be attributed + * to `readObject`, so a failure about another object is never taken as + * evidence about this one: + * + * - the driver's missing-table refusal ({@link isMissingTableError}, asked + * with `readObject`): the object is registered but its table was never + * created; + * - the engine's `OBJECT_NOT_FOUND` whose `object` is `readObject`: the + * composition never registered the object, so the engine refused the name + * before asking any driver. + * + * Every other failure — a connection drop, a lock timeout, a permission + * refusal, a missing-table refusal naming a different relation — is an outage, + * and answers `false`. A caller that would otherwise answer "nobody" for it + * must refuse instead, because an invented empty membership withholds or + * revokes whatever that membership confers. + * + * One home for the predicate. The sharing and approvals recipient reads each + * carried a copy keyed to `sys_member`, and the next membership object + * (`sys_team_member`, `sys_user_position`) would have made the copies diverge. + * `readObject` is required: an absence that names no object cannot be + * attributed, and the wide verdict is the outage-swallowing shape this exists + * to prevent. + * + * @param error - The value a read of `readObject` threw. + * @param readObject - The object the caller read, by its API name. + * @returns `true` only when the error says `readObject` itself does not exist. + */ +export function isObjectAbsentError(error: unknown, readObject: string): boolean { + if (isMissingTableError(error, readObject)) return true; + const refused = error as { code?: unknown; object?: unknown } | null | undefined; + return refused?.code === 'OBJECT_NOT_FOUND' && refused.object === readObject; +}