Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/perf-execctx-request-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/core': patch
'@objectstack/rest': patch
---

perf(core): authenticated requests issued ~16 sequential queries — duplicate authz + repeated localization — now request-scoped memoized

An authenticated REST request resolves its execution context (identity +
RBAC/RLS + localization) many times in a single handler — the data operation
itself, app-nav RBAC filtering, dashboard widget gating, the ADR-0069 auth gate.
Each `resolveExecCtx` pass is the full `resolveAuthzContext` aggregation plus the
localization read (~16 sequential queries), and nothing memoized it, so a request
that resolves twice paid for duplicate authz and repeated localization.

- **`@objectstack/rest`** — `resolveExecCtx` is now memoized per request, keyed by
the request object (a `WeakMap`, so the entry is collected with the request — no
TTL, no cross-request leak) and the input `environmentId`. The in-flight Promise
is cached so concurrent callers share one resolution. The heavy path moved to
`computeExecCtx`. Anonymous (`undefined`) resolutions are cached too.
- **`@objectstack/core`** — within a single `resolveAuthzContext` pass, `sys_user`
is now read at most once (the email fallback and the `ai_seat` synthesis shared a
duplicate query on the API-key path); `resolveLocalizationContext`'s direct-read
fallback batches `timezone`/`locale`/`currency` into one `sys_setting` query
(`$in` on `key`) instead of three sequential reads.

No authorization-behavior change — the same roles/permissions/RLS context is
resolved, just without the redundant reads. The `sys_member` reads (per-user roles
vs. all-org-members) are intentionally left distinct (different filters/limits).

Tests: query-counting regressions assert `sys_user` reads once and localization
reads once; new rest-server tests pin the per-request/per-environment memo contract.
62 changes: 61 additions & 1 deletion packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { resolveAuthzContext } from './resolve-authz-context.js';
import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js';

/**
* Contract test for the SINGLE authorization resolver. Every authorization
Expand Down Expand Up @@ -109,3 +109,63 @@ describe('resolveAuthzContext — single source of truth', () => {
expect(ctx.permissions).toEqual([]);
});
});

// A counting ObjectQL: records how many find() calls hit each object so we can
// assert the de-duplication of redundant authz/localization reads (#2409).
function makeCountingQl(tables: Record<string, any[]>) {
const counts: Record<string, number> = {};
return {
counts,
async find(object: string, opts: any) {
counts[object] = (counts[object] ?? 0) + 1;
const rows = tables[object] ?? [];
const where = opts?.where ?? {};
return rows.filter((r) =>
Object.entries(where).every(([k, v]) => {
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
return r[k] === v;
}),
);
},
};
}

describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => {
it('reads sys_user at most once even when both email fallback and ai_seat need it', async () => {
// No email in the session → email fallback reads sys_user; ai_seat synthesis
// also needs sys_user. Previously these were two separate queries.
const ql = makeCountingQl({
sys_user: [{ id: 'u1', email: 'ada@x.com', ai_access: 1 }],
sys_member: [],
sys_user_role: [],
sys_user_permission_set: [],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
expect(ctx.email).toBe('ada@x.com');
expect(ctx.permissions).toContain('ai_seat');
expect(ql.counts.sys_user).toBe(1);
});
});

describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
it('reads sys_setting once (all three keys) when no settings service is wired', async () => {
const ql = makeCountingQl({
sys_setting: [
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
{ namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' },
],
});
const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' });
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
expect(ql.counts.sys_setting).toBe(1);
});

it('falls back to UTC / en-US when no rows exist', async () => {
const ql = makeCountingQl({ sys_setting: [] });
const loc = await resolveLocalizationContext({ ql });
expect(loc.timezone).toBe('UTC');
expect(loc.locale).toBe('en-US');
expect(loc.currency).toBeUndefined();
});
});
39 changes: 29 additions & 10 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,26 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
if (tenantId) ctx.tenantId = tenantId;
if (!ql || typeof ql.find !== 'function') return ctx;

// sys_user is needed for both the `current_user.email` fallback (API-key auth,
// where the session didn't supply an email) and the ai_seat synthesis below.
// Read the row at most once per resolution — the two reads were a duplicate
// query on the API-key path.
let userRowLoaded = false;
let userRow: any;
const getUserRow = async (): Promise<any> => {
if (!userRowLoaded) {
userRowLoaded = true;
const rows = await tryFind(ql, 'sys_user', { id: userId }, 1);
userRow = rows[0];
}
return userRow;
};

// Resolve the caller's unique email for `current_user.email` RLS owner
// policies when the session path didn't supply it (e.g. API-key auth).
if (!ctx.email) {
const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
if (userRows[0]?.email) ctx.email = String(userRows[0].email);
const u = await getUserRow();
if (u?.email) ctx.email = String(u.email);
}

// 3. Organization-administration roles via sys_member (better-auth), normalized
Expand Down Expand Up @@ -244,8 +259,7 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
// 7. [ADR-0024] Env-side AI seat: synthesize the `ai_seat` capability from the
// boolean sys_user.ai_access (sqlite returns 1/0; memory returns boolean).
if (!ctx.permissions.includes('ai_seat')) {
const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
const aiAccess = (seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access;
const aiAccess = ((await getUserRow()) as { ai_access?: unknown } | undefined)?.ai_access;
if (aiAccess === true || aiAccess === 1 || aiAccess === '1') ctx.permissions.push('ai_seat');
}

Expand Down Expand Up @@ -304,12 +318,17 @@ export async function resolveLocalizationContext(
} catch {
// settings service unavailable → direct read
}
const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1);
const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1);
const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1);
// One read for all three keys instead of a query per key (`$in` on `key`).
const rows = await tryFind(
ql,
'sys_setting',
{ namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' },
10,
);
const valueOf = (k: string) => rows.find((r) => r.key === k)?.value;
return {
timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC',
locale: coerceLocale(localeRows[0]?.value) ?? 'en-US',
currency: coerceCurrency(currencyRows[0]?.value),
timezone: coerceTimeZone(valueOf('timezone')) ?? 'UTC',
locale: coerceLocale(valueOf('locale')) ?? 'en-US',
currency: coerceCurrency(valueOf('currency')),
};
}
67 changes: 67 additions & 0 deletions packages/rest/src/rest-exec-ctx-memo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server';

/**
* Request-scoped memoization of `resolveExecCtx` (#2409). A single HTTP request
* resolves the same execution context many times (data op, app-nav RBAC,
* dashboard gating, auth gate). Each resolution is ~16 sequential queries, so we
* memoize on the per-request `req` object + input environmentId. These tests pin
* that contract by stubbing the heavy `computeExecCtx` and counting invocations.
*/
const httpServer: any = {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
};
const protocol: any = {};

describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () => {
it('resolves once per request object and returns the cached instance', async () => {
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };

const req = { method: 'GET', path: '/x', headers: {} };
const a = await rest.resolveExecCtx(undefined, req);
const b = await rest.resolveExecCtx(undefined, req);

expect(calls).toBe(1);
expect(a).toBe(b);
});

it('re-resolves for a different request object', async () => {
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; };

await rest.resolveExecCtx(undefined, { headers: {} });
await rest.resolveExecCtx(undefined, { headers: {} });

expect(calls).toBe(2);
});

it('keys the memo by environmentId within the same request', async () => {
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
let calls = 0;
rest.computeExecCtx = async (env: any) => { calls++; return { env }; };

const req = { headers: {} };
await rest.resolveExecCtx('envA', req);
await rest.resolveExecCtx('envA', req);
await rest.resolveExecCtx('envB', req);

expect(calls).toBe(2);
});

it('caches an anonymous (undefined) resolution so repeat callers do not re-resolve', async () => {
const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any);
let calls = 0;
rest.computeExecCtx = async () => { calls++; return undefined; };

const req = { headers: {} };
expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined();
expect(await rest.resolveExecCtx(undefined, req)).toBeUndefined();

expect(calls).toBe(1);
});
});
32 changes: 32 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,19 @@ export class RestServer {
*/
private readonly hostnameCache = new Map<string, { value: { environmentId: string } | null; expiresAt: number }>();
private readonly hostnameCacheTtlMs = 30_000;
/**
* Request-scoped memoization for `resolveExecCtx`. A single HTTP request
* resolves the SAME execution context (identity + RBAC/RLS + localization)
* many times — the data operation itself, app-nav RBAC filtering, dashboard
* widget gating, the auth gate, etc. Each resolution is ~16 sequential
* queries (the `resolveAuthzContext` aggregation plus localization), so a
* request that resolves twice pays for duplicate authz and repeated
* localization. Keyed by the per-request `req` object (a `WeakMap`, so the
* entry is collected with the request — naturally request-scoped, no TTL,
* no cross-request leak) and the input `environmentId`. We cache the
* in-flight Promise so concurrent callers share one resolution.
*/
private readonly execCtxMemo = new WeakMap<object, Map<string, Promise<any | undefined>>>();
private defaultEnvironmentIdProvider?: () => string | undefined;
private authServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private objectQLProvider?: (environmentId?: string) => Promise<any | undefined>;
Expand Down Expand Up @@ -847,6 +860,25 @@ export class RestServer {
* to the protocol layer (the SecurityPlugin treats undefined as anon).
*/
private async resolveExecCtx(environmentId: string | undefined, req: any): Promise<any | undefined> {
// Request-scoped memoization — see `execCtxMemo`. The same `req` flows
// unchanged through every handler call, so its identity keys the memo;
// the input `environmentId` is part of the key because one host can route
// multiple environments. Anonymous (`undefined`) resolutions are cached
// too so repeat callers don't re-run getSession. Fall back to a direct
// resolve when there is no object to key on.
if (!req || typeof req !== 'object') return this.computeExecCtx(environmentId, req);
const key = environmentId ?? '\u0000default';
let perReq = this.execCtxMemo.get(req);
if (!perReq) { perReq = new Map(); this.execCtxMemo.set(req, perReq); }
const cached = perReq.get(key);
if (cached) return cached;
const pending = this.computeExecCtx(environmentId, req);
perReq.set(key, pending);
return pending;
}

/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
private async computeExecCtx(environmentId: string | undefined, req: any): Promise<any | undefined> {
try {
// For multi-tenant hosts (objectos), incoming requests on unscoped
// URLs like `/api/v1/data/:object` arrive with `environmentId === undefined`.
Expand Down