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
5 changes: 5 additions & 0 deletions .changeset/localization-getmany-caller-10826.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/core': patch
---

`resolveLocalizationContext` prefers `settings.getMany` — one grouped namespace read instead of three per-key `get()`s (#10826); older services without `getMany` keep the three parallel gets, and a thrown `getMany` lands in the same direct `$in` fallback a thrown `get` did.
54 changes: 54 additions & 0 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,60 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
expect(loc.locale).toBe('en-US');
expect(loc.currency).toBeUndefined();
});

// [#10826] The settings-service path prefers ONE grouped getMany over three
// per-key get()s; an older service without getMany keeps the three gets;
// a thrown getMany lands in the same direct-$in fallback a thrown get did.
it('prefers settings.getMany (one grouped call) and never calls per-key get', async () => {
const getMany = { calls: 0 };
const settings = {
get: async () => { throw new Error('per-key get must not be called'); },
getMany: async (ns: string, keys: readonly string[]) => {
getMany.calls += 1;
expect(ns).toBe('localization');
expect([...keys].sort()).toEqual(['currency', 'locale', 'timezone']);
return {
timezone: { value: 'Asia/Tokyo' },
locale: { value: 'ja-JP' },
currency: { value: 'JPY' },
};
},
};
const ql = makeCountingQl({ sys_setting: [] });
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
expect(getMany.calls).toBe(1);
expect(ql.counts.sys_setting ?? 0).toBe(0); // service answered — no direct read
});

it('a service without getMany keeps the three per-key gets (older deployments)', async () => {
let gets = 0;
const settings = {
get: async (_ns: string, key: string) => {
gets += 1;
return { value: key === 'timezone' ? 'Asia/Tokyo' : key === 'locale' ? 'ja-JP' : 'JPY' };
},
};
const ql = makeCountingQl({ sys_setting: [] });
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
expect(gets).toBe(3);
});

it('a thrown getMany falls back to the direct $in read, same as a broken service', async () => {
const settings = {
get: async () => { throw new Error('unused'); },
getMany: async () => { throw new Error('store exploded'); },
};
const ql = makeCountingQl({
sys_setting: [
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' },
],
});
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
expect(loc.timezone).toBe('Europe/Paris');
expect(ql.counts.sys_setting).toBe(1); // the batched $in fallback ran once
});
});

// #10221: a fresh environment's `sys_setting` table doesn't exist yet, so
Expand Down
47 changes: 34 additions & 13 deletions packages/core/src/security/resolve-authz-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -748,20 +748,41 @@ async function resolveLocalizationContextUncached(
try {
if (settings && typeof settings.get === 'function') {
const sctx = { tenantId, userId } as any;
const [tzRes, localeRes, currencyRes] = await Promise.all([
settings.get('localization', 'timezone', sctx).catch(() => {
// [#10826] ONE grouped namespace read instead of three: `getMany`
// resolves all three keys over at most two `loadRows` calls (queries
// 16–18 of 24 on the measured rig collapse to one). Same per-key
// answers by the service's own equivalence contract. Feature-detected:
// an older service without `getMany` keeps the three parallel `get`s
// (still 1 leg — this is a query-count fix, per the card's calibration).
// A thrown `getMany` lands in the same place a thrown `get` did —
// `failed = true` and the direct `$in` fallback below, which reads the
// exact same three keys.
let tzRes: any; let localeRes: any; let currencyRes: any;
if (typeof settings.getMany === 'function') {
try {
const many = await settings.getMany('localization', ['timezone', 'locale', 'currency'], sctx);
tzRes = many.timezone;
localeRes = many.locale;
currencyRes = many.currency;
} catch {
failed = true;
return undefined;
}),
settings.get('localization', 'locale', sctx).catch(() => {
failed = true;
return undefined;
}),
settings.get('localization', 'currency', sctx).catch(() => {
failed = true;
return undefined;
}),
]);
}
} else {
[tzRes, localeRes, currencyRes] = await Promise.all([
settings.get('localization', 'timezone', sctx).catch(() => {
failed = true;
return undefined;
}),
settings.get('localization', 'locale', sctx).catch(() => {
failed = true;
return undefined;
}),
settings.get('localization', 'currency', sctx).catch(() => {
failed = true;
return undefined;
}),
]);
}
const tz = coerceTimeZone(tzRes?.value);
const locale = coerceLocale(localeRes?.value);
const currency = coerceCurrency(currencyRes?.value);
Expand Down
Loading