Skip to content

Commit 30c0313

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(platform-objects,plugin-sharing): sys_user.primary_business_unit_id projection (ADR-0057 D12) (#2146)
Denormalise the user's primary business unit onto sys_user, maintained by plugin-sharing from sys_business_unit_member.is_primary (insert/update/delete hooks + a boot-time backfill), so "pick people by BU" (Dataverse filtered lookup / ServiceNow reference qualifier) is expressible as a plain where:{primary_business_unit_id:X} — and thus a lookupFilters picker filter — with zero query-engine change, no junction traversal. Homed in plugin-sharing (always loaded, owns the BU graph) rather than plugin-org-scoping, so it works single-tenant. afterDelete loses the row, so user_id is captured in beforeDelete via the shared hookContext. Adds en/zh/ja/es labels + a dogfood proof (insert sets it, query filters by it, primary-flag move follows, delete clears). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ee8e49 commit 30c0313

9 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-sharing": minor
4+
---
5+
6+
Add `sys_user.primary_business_unit_id` projection (ADR-0057 addendum D12).
7+
8+
Adds a denormalised `primary_business_unit_id` lookup to `sys_user`, maintained
9+
by plugin-sharing as a projection of `sys_business_unit_member.is_primary`
10+
(insert/update/delete hooks + a boot-time backfill). This makes "pick people by
11+
business unit" — the Dataverse *filtered lookup* / ServiceNow *reference
12+
qualifier* interaction — expressible as a plain `where: { primary_business_unit_id: X }`
13+
(and thus as a `lookupFilters` picker filter) with **zero** query-engine change,
14+
without traversing the membership junction. `sys_business_unit_member` remains
15+
the effective-dated, matrix-friendly source of truth; the new column is a
16+
maintained projection, not a second source. Home is plugin-sharing (always
17+
loaded, owns the BU graph) rather than plugin-org-scoping, so the projection
18+
works in single-tenant deployments too. Picker filtering by BU is therefore an
19+
**open** (non-enterprise) capability — only hierarchy *rollup* stays paid.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0057 addendum D12 — sys_user.primary_business_unit_id projection.
5+
*
6+
* Proves the server-side capability D12 actually delivers: the denormalised
7+
* `primary_business_unit_id` column is maintained by plugin-sharing's hooks as
8+
* `sys_business_unit_member.is_primary` changes, which is what makes
9+
* "pick people by business unit" expressible as a plain
10+
* `where: { primary_business_unit_id: X }` query (and therefore as a
11+
* `lookupFilters` picker filter — that part is a client-side hint, so it is the
12+
* column + its maintenance, not lookupFilters enforcement, that we assert here).
13+
*/
14+
15+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
16+
import showcaseStack from '@objectstack/example-showcase';
17+
import { bootStack, type VerifyStack } from '@objectstack/verify';
18+
19+
const SYS = { context: { isSystem: true } } as const;
20+
const EMAIL = 'd12-primary-bu@verify.test';
21+
22+
describe('ADR-0057 D12: sys_user.primary_business_unit_id projection', () => {
23+
let stack: VerifyStack;
24+
let ql: any;
25+
let orgId: string;
26+
let uid: string;
27+
28+
const sys = (o: string, d: any) => ql.insert(o, d, SYS);
29+
const findOne = async (o: string, where: any, fields: string[]) =>
30+
(await ql.find(o, { where, fields, limit: 1, context: { isSystem: true } }))?.[0];
31+
const primaryBuOf = async (id: string) =>
32+
(await findOne('sys_user', { id }, ['id', 'primary_business_unit_id']))?.primary_business_unit_id ?? null;
33+
34+
beforeAll(async () => {
35+
stack = await bootStack(showcaseStack, {});
36+
await stack.signIn();
37+
await stack.signUp(EMAIL);
38+
ql = await stack.kernel.getServiceAsync('objectql');
39+
40+
const org = await findOne('sys_organization', {}, ['id']);
41+
orgId = org?.id ?? 'org_d12';
42+
if (!org) await sys('sys_organization', { id: orgId, name: 'D12 Org', slug: 'd12' });
43+
44+
await sys('sys_business_unit', { id: 'bu_d12_a', name: 'Unit A', kind: 'department', organization_id: orgId, active: true });
45+
await sys('sys_business_unit', { id: 'bu_d12_b', name: 'Unit B', kind: 'department', organization_id: orgId, active: true });
46+
47+
uid = (await findOne('sys_user', { email: EMAIL }, ['id']))?.id;
48+
expect(uid, 'signed-up user resolves').toBeTruthy();
49+
}, 120_000);
50+
51+
afterAll(async () => { await stack?.stop?.(); });
52+
53+
it('sets primary_business_unit_id when a primary member is inserted', async () => {
54+
await sys('sys_business_unit_member', { id: 'mem_d12_a', user_id: uid, business_unit_id: 'bu_d12_a', is_primary: true });
55+
expect(await primaryBuOf(uid)).toBe('bu_d12_a');
56+
});
57+
58+
it('makes "pick people by BU" expressible — sys_user filters by primary_business_unit_id', async () => {
59+
const inA = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_a' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id);
60+
const inB = (await ql.find('sys_user', { where: { primary_business_unit_id: 'bu_d12_b' }, fields: ['id'], limit: 100, context: { isSystem: true } })).map((u: any) => u.id);
61+
expect(inA).toContain(uid);
62+
expect(inB).not.toContain(uid);
63+
});
64+
65+
it('follows the primary flag when it moves to another business unit', async () => {
66+
await ql.update('sys_business_unit_member', { id: 'mem_d12_a', is_primary: false }, SYS);
67+
await sys('sys_business_unit_member', { id: 'mem_d12_b', user_id: uid, business_unit_id: 'bu_d12_b', is_primary: true });
68+
expect(await primaryBuOf(uid)).toBe('bu_d12_b');
69+
});
70+
71+
it('clears the projection when the primary member is deleted', async () => {
72+
await ql.delete('sys_business_unit_member', { where: { id: 'mem_d12_b' }, context: { isSystem: true } });
73+
expect(await primaryBuOf(uid)).toBeNull();
74+
});
75+
});

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
4848
manager_id: {
4949
label: "Manager"
5050
},
51+
primary_business_unit_id: {
52+
label: "Primary Business Unit"
53+
},
5154
id: {
5255
label: "User ID"
5356
},

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
4848
manager_id: {
4949
label: "Gerente"
5050
},
51+
primary_business_unit_id: {
52+
label: "Unidad de negocio principal"
53+
},
5154
id: {
5255
label: "ID de usuario"
5356
},

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
4848
manager_id: {
4949
label: "マネージャー"
5050
},
51+
primary_business_unit_id: {
52+
label: "主所属ビジネスユニット"
53+
},
5154
id: {
5255
label: "ユーザー ID"
5356
},

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
4848
manager_id: {
4949
label: "经理"
5050
},
51+
primary_business_unit_id: {
52+
label: "主属业务单元"
53+
},
5154
id: {
5255
label: "用户 ID"
5356
},

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,13 @@ export const SysUser = ObjectSchema.create({
410410
description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).",
411411
}),
412412

413+
primary_business_unit_id: Field.lookup('sys_business_unit', {
414+
label: 'Primary Business Unit',
415+
required: false,
416+
group: 'Organization',
417+
description: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership.",
418+
}),
419+
413420
// ── System (auto-managed, hidden from create/edit forms) ─────
414421
id: Field.text({
415422
label: 'User ID',
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* sys_user.primary_business_unit_id projection (ADR-0057 addendum D12).
5+
*
6+
* `sys_business_unit_member` is the effective-dated, matrix-friendly source of
7+
* truth for "which business units a user belongs to". But a lookup field can
8+
* only filter on the *target object's own columns* (`lookupFilters` /
9+
* `dependsOn`), and ObjectQL cannot traverse the membership junction inside a
10+
* single filter. So "pick people by business unit" — the Dataverse *filtered
11+
* lookup* / ServiceNow *reference qualifier* interaction — is not expressible
12+
* against `sys_user` unless the user row carries its BU directly.
13+
*
14+
* This module maintains a denormalised `sys_user.primary_business_unit_id`
15+
* (the member row flagged `is_primary`) so a plain `where:
16+
* { primary_business_unit_id: X }` works with **zero** query-engine change.
17+
* It is a *projection*, not a second source of truth: `sys_business_unit_member`
18+
* still owns matrix / effective-dated membership.
19+
*
20+
* Home: plugin-sharing — always loaded, owns the BU graph domain
21+
* (`BusinessUnitGraphService`), and already binds engine hooks on
22+
* `kernel:ready`. NOT plugin-org-scoping (that is multi-tenant-only; BU
23+
* membership is usable single-tenant too).
24+
*/
25+
26+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
27+
28+
export const PRIMARY_BU_HOOK_PACKAGE = 'plugin-sharing:primary-bu';
29+
30+
/** Shared-hookContext key: beforeDelete stashes the doomed row's user_id here
31+
* because afterDelete exposes neither `previous` nor the (now-gone) row. */
32+
const STASH_KEY = '__primaryBuUserId';
33+
34+
interface MinimalEngine {
35+
registerHook(
36+
event: string,
37+
handler: (ctx: any) => any | Promise<any>,
38+
options?: { object?: string | string[]; priority?: number; packageId?: string },
39+
): void;
40+
unregisterHooksByPackage(packageId: string): number;
41+
find(object: string, query?: any, options?: any): Promise<any[]>;
42+
update(object: string, data: any, options?: any): Promise<any>;
43+
}
44+
45+
interface MinimalLogger {
46+
info?: (msg: any, ...rest: any[]) => void;
47+
warn?: (msg: any, ...rest: any[]) => void;
48+
}
49+
50+
/** Recompute one user's primary_business_unit_id from their `is_primary` member
51+
* row (null when they have none). Idempotent. */
52+
async function recompute(engine: MinimalEngine, userId: string, logger?: MinimalLogger): Promise<void> {
53+
if (!userId) return;
54+
let buId: string | null = null;
55+
try {
56+
const rows = await engine.find('sys_business_unit_member', {
57+
where: { user_id: userId, is_primary: true },
58+
fields: ['business_unit_id'],
59+
limit: 1,
60+
context: SYSTEM_CTX,
61+
});
62+
buId = rows?.[0]?.business_unit_id ?? null;
63+
} catch (err: any) {
64+
logger?.warn?.('[primary-bu] member lookup failed', { userId, error: err?.message });
65+
return;
66+
}
67+
try {
68+
await engine.update('sys_user', { id: userId, primary_business_unit_id: buId }, { context: SYSTEM_CTX });
69+
} catch (err: any) {
70+
logger?.warn?.('[primary-bu] sys_user update failed', { userId, error: err?.message });
71+
}
72+
}
73+
74+
/** Affected user_ids reachable from a member-write hook context. */
75+
function collectUserIds(ctx: any): string[] {
76+
const ids = new Set<string>();
77+
const add = (v: unknown) => { if (v != null && v !== '') ids.add(String(v)); };
78+
add(ctx?.result?.user_id);
79+
add(ctx?.previous?.user_id);
80+
add((ctx?.input?.data ?? ctx?.input?.doc)?.user_id);
81+
add(ctx?.[STASH_KEY]);
82+
return [...ids];
83+
}
84+
85+
/**
86+
* Bind insert/update/delete hooks on `sys_business_unit_member` that keep the
87+
* `sys_user.primary_business_unit_id` projection in step. Unlike the
88+
* sharing-rule hooks, these run for **system-context writes too** — the
89+
* projection must stay correct regardless of who mutates membership (seeds,
90+
* HRIS sync, admin UI).
91+
*/
92+
export function bindPrimaryBuHooks(engine: MinimalEngine, logger?: MinimalLogger): void {
93+
if (typeof engine.registerHook !== 'function') return;
94+
if (typeof engine.unregisterHooksByPackage === 'function') {
95+
engine.unregisterHooksByPackage(PRIMARY_BU_HOOK_PACKAGE);
96+
}
97+
const opts = { object: 'sys_business_unit_member', packageId: PRIMARY_BU_HOOK_PACKAGE, priority: 150 };
98+
99+
// afterDelete loses the row; capture user_id while it still exists. Same
100+
// hookContext instance is reused for before/afterDelete (engine.ts), so the
101+
// stash survives into the afterDelete handler below.
102+
engine.registerHook('beforeDelete', async (ctx: any) => {
103+
const id = ctx?.input?.id;
104+
if (!id) return;
105+
try {
106+
const rows = await engine.find('sys_business_unit_member', {
107+
where: { id }, fields: ['user_id'], limit: 1, context: SYSTEM_CTX,
108+
});
109+
const uid = rows?.[0]?.user_id;
110+
if (uid) ctx[STASH_KEY] = String(uid);
111+
} catch { /* best-effort — projection self-heals on next member write or boot backfill */ }
112+
}, opts);
113+
114+
const sync = async (ctx: any) => {
115+
for (const uid of collectUserIds(ctx)) await recompute(engine, uid, logger);
116+
};
117+
engine.registerHook('afterInsert', sync, opts);
118+
engine.registerHook('afterUpdate', sync, opts);
119+
engine.registerHook('afterDelete', sync, opts);
120+
121+
logger?.info?.('[primary-bu] projection hooks bound on sys_business_unit_member');
122+
}
123+
124+
/**
125+
* One-time boot reconcile: set every user's primary_business_unit_id from their
126+
* `is_primary` member row, so pre-existing memberships (seeds, prior data)
127+
* project even though their inserts pre-dated the hooks. Idempotent.
128+
*/
129+
export async function backfillPrimaryBu(engine: MinimalEngine, logger?: MinimalLogger): Promise<{ updated: number }> {
130+
let rows: any[] = [];
131+
try {
132+
rows = await engine.find('sys_business_unit_member', {
133+
where: { is_primary: true },
134+
fields: ['user_id', 'business_unit_id'],
135+
limit: 10000,
136+
context: SYSTEM_CTX,
137+
});
138+
} catch (err: any) {
139+
logger?.warn?.('[primary-bu] backfill scan failed', { error: err?.message });
140+
return { updated: 0 };
141+
}
142+
let updated = 0;
143+
for (const m of rows ?? []) {
144+
if (!m?.user_id) continue;
145+
try {
146+
await engine.update('sys_user', { id: m.user_id, primary_business_unit_id: m.business_unit_id }, { context: SYSTEM_CTX });
147+
updated++;
148+
} catch { /* skip one bad row, keep going */ }
149+
}
150+
if (updated > 0) logger?.info?.('[primary-bu] backfilled projection', { updated });
151+
return { updated };
152+
}

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SharingRuleService } from './sharing-rule-service.js';
1010
import { ShareLinkService } from './share-link-service.js';
1111
import { registerShareLinkRoutes } from './share-link-routes.js';
1212
import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js';
13+
import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js';
1314
import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js';
1415

1516
export interface SharingPluginOptions {
@@ -146,6 +147,19 @@ export class SharingServicePlugin implements Plugin {
146147
});
147148
ctx.registerService('sharing', this.service);
148149

150+
// [ADR-0057 D12] Maintain sys_user.primary_business_unit_id as a
151+
// denormalised projection of sys_business_unit_member.is_primary so a
152+
// user-lookup can filter candidates by business unit. Bound regardless of
153+
// `enforce` — it is a data projection, not an access-control surface.
154+
try {
155+
if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') {
156+
bindPrimaryBuHooks(engine, ctx.logger as any);
157+
await backfillPrimaryBu(engine, ctx.logger as any);
158+
}
159+
} catch (err: any) {
160+
ctx.logger.warn('SharingServicePlugin: primary-bu projection not started', { error: err?.message });
161+
}
162+
149163
// Enforcement (read-filter middleware + sharing-rule hooks) is opt-out
150164
// via `enforce: false`. The share-link service below is registered
151165
// REGARDLESS — capability-token sharing does not depend on principal-

0 commit comments

Comments
 (0)