Skip to content

Commit 53ca973

Browse files
os-zhuangclaude
andcommitted
feat(spec,objectql): unify developer-facing org identifier in hooks — organizationId is blessed (#3280)
The caller's active organization was surfaced to hook authors as `ctx.session.tenantId`, while the column (`organization_id`), RLS (`current_user.organizationId`), and seed rows already said `organization`. A hook author had to internalize `tenantId === organizationId` to move between surfaces. Additive, non-breaking: - `ctx.session.organizationId` added as the blessed name; `session.tenantId` kept as a deprecated alias with the identical value (both from `ExecutionContext.tenantId`, resolved from `session.activeOrganizationId`). - `ctx.user.organizationId` added to the ergonomic user shortcut; the engine now populates `ctx.user` at every hook event that carries a session, and it stays undefined for system/unauthenticated writes. Generic driver-layer tenancy (`ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope`, `TenancyConfig.tenantField`) is deliberately untouched — that layer's isolation column is configurable and can carry an environment id in database-per-tenant kernels. Docs now teach `organizationId` and distinguish the two isolation axes (org row-scoping vs environment/database-per-tenant). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2271f3e commit 53ca973

7 files changed

Lines changed: 186 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
**Unify the developer-facing org identifier in JS hooks — `organizationId` is now the blessed name; `session.tenantId` becomes a deprecated alias (#3280).** The caller's active organization was surfaced to hook authors as `ctx.session.tenantId`, while everything else on the developer surface — the `organization_id` column, `current_user.organizationId` in RLS/sharing, and seed rows — already said `organization`. A hook author had to internalize the hidden equation `tenantId === organizationId` to move between surfaces. This is additive and non-breaking:
7+
8+
- **`ctx.session.organizationId`** is added as the blessed name; **`ctx.session.tenantId`** still carries the identical value but is marked `@deprecated` in its TSDoc. Both come from the same resolved `ExecutionContext.tenantId` (which the kernel derives from `session.activeOrganizationId`).
9+
- **`ctx.user.organizationId`** is added to the ergonomic `user` shortcut, so a hook that needs "the current org to filter by" writes `ctx.user.organizationId` with zero relearning — matching `current_user.organizationId` (RLS) and the `organization_id` column. The engine now populates `ctx.user` (`{ id, email?, organizationId? }`) at every hook event that already carries a `session`; it stays `undefined` for system / unauthenticated writes.
10+
11+
**No behavior change and no breaking rename.** The generic driver-layer tenancy abstraction (`ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope`, `TenancyConfig.tenantField`) is deliberately untouched — that layer's isolation column is configurable and legitimately carries an *environment* id in per-environment (database-per-tenant) kernels. Hook-authoring docs now teach `organizationId` and distinguish the two isolation axes: **org row-scoping** (`organization_id`, shared DB) vs **environment / database-per-tenant** (`service-tenant`, `driver-turso`). Community edition never populates an org, so `organizationId` is `undefined` there.

content/docs/references/data/hook.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ const result = HookContext.parse(data);
3737
| **input** | `Record<string, any>` || Mutable input parameters |
3838
| **result** | `any` | optional | Operation result (After hooks only) |
3939
| **previous** | `Record<string, any>` | optional | Record state before operation |
40-
| **session** | `{ userId?: string; tenantId?: string; roles?: string[]; accessToken?: string; … }` | optional | Current session context |
40+
| **session** | `{ userId?: string; organizationId?: string; tenantId?: string; roles?: string[]; … }` | optional | Current session context |
4141
| **transaction** | `any` | optional | Database transaction handle |
4242
| **ql** | `any` || ObjectQL Engine Reference |
4343
| **api** | `any` | optional | Cross-object data access (ScopedContext) |
44-
| **user** | `{ id?: string; name?: string; email?: string }` | optional | Current user info shortcut |
44+
| **user** | `{ id?: string; name?: string; email?: string; organizationId?: string }` | optional | Current user info shortcut |
4545

4646

4747
---

packages/objectql/src/engine.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,50 @@ describe('ObjectQL Engine', () => {
483483
});
484484
});
485485

486+
describe('organizationId exposed to hooks as the blessed org name (#3280)', () => {
487+
beforeEach(async () => {
488+
engine.registerDriver(mockDriver, true);
489+
await engine.init();
490+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any);
491+
});
492+
493+
it('populates session.organizationId + user.organizationId, both equal to the resolved org', async () => {
494+
let session: any, user: any;
495+
engine.registerHook('beforeInsert', async (ctx: any) => { session = ctx.session; user = ctx.user; }, { object: 'task' });
496+
497+
await engine.insert('task', { title: 'x' }, { context: { userId: 'u1', tenantId: 'org_1' } as any });
498+
499+
// Blessed name and the deprecated alias carry the identical value.
500+
expect(session.organizationId).toBe('org_1');
501+
expect(session.tenantId).toBe('org_1');
502+
expect(session.organizationId).toBe(session.tenantId);
503+
// `ctx.user` shortcut carries the same org for zero-relearning filtering.
504+
expect(user).toMatchObject({ id: 'u1', organizationId: 'org_1' });
505+
});
506+
507+
it('unscoped (no org) call: session present, org fields undefined, user has no organizationId', async () => {
508+
let session: any, user: any;
509+
engine.registerHook('beforeInsert', async (ctx: any) => { session = ctx.session; user = ctx.user; }, { object: 'task' });
510+
511+
await engine.insert('task', { title: 'y' }, { context: { userId: 'u1' } as any });
512+
513+
expect(session).toBeDefined();
514+
expect(session.organizationId).toBeUndefined();
515+
expect(session.tenantId).toBeUndefined();
516+
expect(user).toMatchObject({ id: 'u1' });
517+
expect(user.organizationId).toBeUndefined();
518+
});
519+
520+
it('system / no acting user: user shortcut is undefined (org read via session)', async () => {
521+
let userSeen: any = 'unset';
522+
engine.registerHook('beforeInsert', async (ctx: any) => { userSeen = ctx.user; }, { object: 'task' });
523+
524+
await engine.insert('task', { title: 'z' }, { context: { isSystem: true, tenantId: 'org_1' } as any });
525+
526+
expect(userSeen).toBeUndefined();
527+
});
528+
});
529+
486530
describe('execution context via the trailing options arg (read methods)', () => {
487531
// Regression: reads took context inside the query while writes took it in
488532
// a trailing options arg — so `find(obj, q, { context })` silently dropped

packages/objectql/src/engine.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,12 @@ export class ObjectQL implements IDataEngine {
737737
if (!execCtx) return undefined;
738738
return {
739739
userId: execCtx.userId,
740+
// `organizationId` is the blessed developer-facing name for the caller's
741+
// active org (matches the `organization_id` column, `current_user`
742+
// RLS shape, and seed rows). `tenantId` is kept as a deprecated alias
743+
// carrying the IDENTICAL value — both come from `execCtx.tenantId`, which
744+
// the kernel resolves from `session.activeOrganizationId` (#3280).
745+
organizationId: execCtx.tenantId,
740746
tenantId: execCtx.tenantId,
741747
positions: execCtx.positions,
742748
accessToken: execCtx.accessToken,
@@ -772,6 +778,28 @@ export class ObjectQL implements IDataEngine {
772778
};
773779
}
774780

781+
/**
782+
* Build the `HookContext.user` shortcut — the ergonomic "current user"
783+
* object surfaced to JS hooks. Carries `organizationId` (the blessed name
784+
* for the caller's active org, identical to `session.organizationId` and
785+
* `current_user.organizationId`) so a hook author who needs "the current org
786+
* to filter by" writes `ctx.user.organizationId` with zero relearning (#3280).
787+
*
788+
* Returns undefined for system / unauthenticated writes (no acting user) —
789+
* hooks that need an org regardless of a resolved user read
790+
* `ctx.session.organizationId`, which is populated whenever a session is.
791+
*/
792+
private buildUser(execCtx?: ExecutionContext): HookContext['user'] {
793+
if (!execCtx || execCtx.userId == null) return undefined;
794+
return {
795+
id: String(execCtx.userId),
796+
...(execCtx.email ? { email: execCtx.email } : {}),
797+
// Always equals `session.organizationId` (both from `execCtx.tenantId`).
798+
// Undefined on unscoped (platform/community) calls.
799+
...(execCtx.tenantId != null ? { organizationId: String(execCtx.tenantId) } : {}),
800+
};
801+
}
802+
775803
/**
776804
* Build the DriverOptions blob passed to every IDataDriver call.
777805
*
@@ -2162,6 +2190,7 @@ export class ObjectQL implements IDataEngine {
21622190
event: 'beforeFind',
21632191
input: { ast: opCtx.ast, options: opCtx.options },
21642192
session: this.buildSession(opCtx.context),
2193+
user: this.buildUser(opCtx.context),
21652194
api: this.buildHookApi(opCtx.context),
21662195
transaction: opCtx.context?.transaction,
21672196
ql: this
@@ -2244,6 +2273,7 @@ export class ObjectQL implements IDataEngine {
22442273
event: 'beforeFind',
22452274
input: { ast: opCtx.ast, options: opCtx.options },
22462275
session: this.buildSession(opCtx.context),
2276+
user: this.buildUser(opCtx.context),
22472277
api: this.buildHookApi(opCtx.context),
22482278
transaction: opCtx.context?.transaction,
22492279
ql: this
@@ -2330,6 +2360,7 @@ export class ObjectQL implements IDataEngine {
23302360
event: 'beforeInsert',
23312361
input: { data: row, options: opCtx.options },
23322362
session: this.buildSession(opCtx.context),
2363+
user: this.buildUser(opCtx.context),
23332364
api: this.buildHookApi(opCtx.context),
23342365
transaction: opCtx.context?.transaction,
23352366
ql: this,
@@ -2599,6 +2630,7 @@ export class ObjectQL implements IDataEngine {
25992630
event: 'beforeUpdate',
26002631
input: { id, data: opCtx.data, options: opCtx.options },
26012632
session: this.buildSession(opCtx.context),
2633+
user: this.buildUser(opCtx.context),
26022634
api: this.buildHookApi(opCtx.context),
26032635
transaction: opCtx.context?.transaction,
26042636
ql: this
@@ -2924,6 +2956,7 @@ export class ObjectQL implements IDataEngine {
29242956
event: 'beforeDelete',
29252957
input: { id, options: opCtx.options },
29262958
session: this.buildSession(opCtx.context),
2959+
user: this.buildUser(opCtx.context),
29272960
api: this.buildHookApi(opCtx.context),
29282961
transaction: opCtx.context?.transaction,
29292962
ql: this

packages/spec/src/data/hook.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,41 @@ describe('HookContextSchema', () => {
566566

567567
expect(context.session?.accessToken).toBe('token_abc123');
568568
});
569+
570+
// #3280 — `organizationId` is the blessed developer-facing name; `tenantId`
571+
// stays as a deprecated alias carrying the identical value.
572+
it('should accept session.organizationId (blessed) alongside tenantId (deprecated alias)', () => {
573+
const context = HookContextSchema.parse({
574+
object: 'account',
575+
event: 'beforeInsert',
576+
input: {},
577+
session: {
578+
userId: 'user_123',
579+
organizationId: 'org_456',
580+
tenantId: 'org_456',
581+
},
582+
ql: {},
583+
});
584+
585+
expect(context.session?.organizationId).toBe('org_456');
586+
expect(context.session?.tenantId).toBe('org_456');
587+
});
588+
589+
it('should accept user.organizationId shortcut', () => {
590+
const context = HookContextSchema.parse({
591+
object: 'account',
592+
event: 'beforeInsert',
593+
input: {},
594+
user: {
595+
id: 'user_123',
596+
email: 'dev@example.com',
597+
organizationId: 'org_456',
598+
},
599+
ql: {},
600+
});
601+
602+
expect(context.user?.organizationId).toBe('org_456');
603+
});
569604
});
570605

571606
describe('Transaction Support', () => {

packages/spec/src/data/hook.zod.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,26 @@ export const HookContextSchema = lazySchema(() => z.object({
208208

209209
/**
210210
* Execution Session
211-
* Contains authentication and tenancy information.
211+
* Contains authentication and organization/tenancy information.
212212
*/
213213
session: z.object({
214214
userId: z.string().optional(),
215-
tenantId: z.string().optional(),
215+
/**
216+
* Active organization ID — the blessed, developer-facing name for the
217+
* caller's current org. Same value everywhere a developer reads the org:
218+
* the `organization_id` column, `current_user.organizationId` (RLS/sharing),
219+
* and seed rows. Prefer this in hooks; it always equals `tenantId`.
220+
* `null`/`undefined` on unscoped (platform/community) calls.
221+
*/
222+
organizationId: z.string().optional().describe('Active organization ID (blessed name; equals tenantId)'),
223+
/**
224+
* @deprecated Prefer `organizationId` — the two carry the identical value
225+
* (the caller's active org). `tenantId` remains for back-compat; it names
226+
* the generic driver-layer isolation column, whereas everything else on the
227+
* developer surface (columns, RLS `current_user`, seed) says `organization`.
228+
* New docs/examples teach only `organizationId`.
229+
*/
230+
tenantId: z.string().optional().describe('Deprecated alias of organizationId'),
216231
roles: z.array(z.string()).optional(),
217232
accessToken: z.string().optional(),
218233
isSystem: z.boolean().optional().describe('True when the call was made with an elevated system context (engine self-writes)'),
@@ -253,6 +268,14 @@ export const HookContextSchema = lazySchema(() => z.object({
253268
id: z.string().optional(),
254269
name: z.string().optional(),
255270
email: z.string().optional(),
271+
/**
272+
* Active organization ID of the acting user — the blessed name for
273+
* "the current org to filter/scope by" inside a hook. Same value as
274+
* `session.organizationId`, `current_user.organizationId` (RLS), and the
275+
* `organization_id` column, so a hook author needs zero relearning to move
276+
* between surfaces. Undefined for unscoped (platform/community) calls.
277+
*/
278+
organizationId: z.string().optional().describe('Active organization ID of the acting user (equals session.organizationId)'),
256279
}).optional().describe('Current user info shortcut'),
257280
}));
258281

skills/objectstack-data/references/data-hooks.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ interface HookContext {
255255
// Execution context
256256
session?: {
257257
userId?: string;
258-
tenantId?: string;
258+
organizationId?: string; // Active org — blessed name. Matches the
259+
// `organization_id` column + `current_user.organizationId` (RLS)
260+
tenantId?: string; // @deprecated alias of organizationId (identical value)
259261
roles?: string[];
260262
accessToken?: string;
261263
};
@@ -266,15 +268,47 @@ interface HookContext {
266268
ql: IDataEngine; // ObjectQL engine instance
267269
api?: ScopedContext; // Cross-object CRUD API
268270

269-
// User info shortcut
271+
// User info shortcut (undefined for system / unauthenticated writes)
270272
user?: {
271273
id?: string;
272274
name?: string;
273275
email?: string;
276+
organizationId?: string; // Same value as session.organizationId
274277
};
275278
}
276279
```
277280

281+
### Reading the current organization
282+
283+
The value a hook usually wants when it needs "the current org to filter/scope
284+
by" is the caller's **active organization** — the same value that lives in the
285+
`organization_id` column, in `current_user.organizationId` inside RLS/sharing
286+
predicates, and in seed rows. Read it as **`organizationId`**:
287+
288+
```typescript
289+
// ✅ Blessed — matches columns, RLS `current_user`, and seed data
290+
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
291+
292+
// ⚠️ Deprecated alias — still works, carries the identical value
293+
const org = ctx.session?.tenantId;
294+
```
295+
296+
`ctx.user` is the ergonomic shortcut for an authenticated caller; it is
297+
`undefined` for system / unauthenticated writes, so read `ctx.session?.organizationId`
298+
when a hook must work regardless of whether a user resolved.
299+
300+
> **Two isolation axes — don't conflate them.** `organization_id` is
301+
> **org row-scoping**: many organizations share one database and every row
302+
> carries its owning org (`current_user.organizationId` filters reads/writes;
303+
> multi-org needs cloud + `@objectstack/organizations`). That is different from
304+
> **environment / database-per-tenant** isolation (`service-tenant`,
305+
> `driver-turso`), where "tenant" means an entire environment/database and the
306+
> generic driver-layer `tenantId` knob can carry that environment id. The
307+
> object-metadata `tenancy.*` knob configures the *mechanism* (isolation on/off
308+
> + which column); the *value* you read and write is your `organization_id`
309+
> column. Community edition never populates an org, so `organizationId` is
310+
> `undefined` there.
311+
278312
### `input` — Operation Parameters
279313

280314
The structure of `ctx.input` varies by event:

0 commit comments

Comments
 (0)