From 09124e77c225aae5f7424fbd72f70b4817a4d3e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:17:47 +0000 Subject: [PATCH 1/3] test(plugin-hono-server): reproduce the always-null /auth/me/localization answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens the #14788 fixture so the tenant can configure `localization.timezone` and `localization.currency` rows (the endpoint reads all three keys in one `$in` query, so the double now answers whichever the fixture sets), and adds five cases for the resolved regional defaults. Measured against unchanged source: 4 failed | 9 passed. An authenticated caller configured with `Asia/Shanghai` / `CNY` is answered `currency: null, timezone: null` — the defect, reproduced here rather than inherited from the card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...urrent-user-endpoints-localization.test.ts | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts index 80c5957719..8a6ec3ec3b 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -61,6 +61,10 @@ interface MountOptions { rules?: Row[] | null; /** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */ settingLocale?: string; + /** Tenant-scoped `localization.timezone` `sys_setting` row value. */ + settingTimezone?: string; + /** Tenant-scoped `localization.currency` `sys_setting` row value. */ + settingCurrency?: string; /** Whether a session resolves at all. */ authenticated?: boolean; /** @@ -73,7 +77,7 @@ interface MountOptions { failUserRead?: boolean; } -function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authenticated = true, failUserRead = false }: MountOptions = {}) { +function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, settingTimezone, settingCurrency, authenticated = true, failUserRead = false }: MountOptions = {}) { const reads: Array<{ object: string; opts: any }> = []; let sysUserReads = 0; const ql = { @@ -83,8 +87,18 @@ function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authe if (failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable'); return opts?.where?.id === USER ? [{ id: USER, email: 'lang@example.com', locale: storedLocale }] : []; } - if (object === 'sys_setting' && settingLocale !== undefined) { - return [{ namespace: 'localization', key: 'locale', value: settingLocale, scope: 'tenant' }]; + if (object === 'sys_setting') { + // The endpoint reads all three `localization` keys in ONE `$in` + // query, so the double answers whichever of them the fixture + // configured — and nothing for the rest. + const configured: Array<[string, string | undefined]> = [ + ['locale', settingLocale], + ['timezone', settingTimezone], + ['currency', settingCurrency], + ]; + return configured + .filter(([, value]) => value !== undefined) + .map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant' })); } return []; }, @@ -215,3 +229,53 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs expect(reads.filter((r) => r.object === 'sys_user')).toEqual([]); }); }); + +describe('/auth/me/localization — the regional defaults are RESOLVED, not always null (#15387)', () => { + it('answers the tenant\'s configured currency and time zone', async () => { + const { get } = mount({ settingTimezone: 'Asia/Shanghai', settingCurrency: 'CNY', settingLocale: 'zh-CN' }); + const { status, body } = await get(); + expect(status).toBe(200); + // The WHOLE published shape, so a fourth key cannot appear unnoticed. + expect(body).toEqual({ authenticated: true, currency: 'CNY', locale: 'zh-CN', timezone: 'Asia/Shanghai' }); + }); + + it('resolves them independently of which locale rung won — they are not a by-product of the language cascade', async () => { + // Rung 1 answers the language, so the deployment cascade does NOT decide + // `locale` here. Before this card that short-circuit was the only reason + // the cascade was consulted at all, and `currency` / `timezone` came off + // an ExecutionContext that never carried them. + const { get } = mount({ storedLocale: 'ja-JP', settingLocale: 'zh-CN', settingTimezone: 'Europe/Paris', settingCurrency: 'eur' }); + const { body } = await get('de-DE'); + // `eur` lower-case: the cascade's own coercion upper-cases a 3-letter code. + expect(body).toEqual({ authenticated: true, currency: 'EUR', locale: 'ja-JP', timezone: 'Europe/Paris' }); + }); + + it('a value the cascade refuses falls to the cascade\'s own answer — this surface adds no second parser', async () => { + // `coerceCurrency` takes exactly three letters; `coerceTimeZone` takes an + // `iana_time_zone` domain member. Neither refusal is re-implemented here: + // the endpoint answers whatever the shared resolver answers. + const { get } = mount({ settingTimezone: 'Middle/Earth', settingCurrency: 'euro' }); + const { body } = await get(); + expect(body.timezone).toBe('UTC'); + expect(body.currency).toBeNull(); + }); + + it('with nothing configured: the time-zone floor answers, and currency is the one value that stays null', async () => { + // The asymmetry is the cascade's, and it is deliberate to pin: `timezone` + // has a floor (`UTC`) so an authenticated caller can never see null for + // it again, while `currency` has none — a deployment that configures no + // currency has no reference currency, and inventing one would be a wrong + // answer rather than a missing one. + const { get } = mount({}); + const { body } = await get(); + expect(body).toEqual({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' }); + }); + + it('the unauthenticated answer stays localization-free', async () => { + const { get, reads } = mount({ authenticated: false, settingTimezone: 'Asia/Shanghai', settingCurrency: 'CNY' }); + const { body } = await get(); + expect(body).toEqual({ authenticated: false }); + // Anti-vacuity: no settings read is issued for a caller with no session. + expect(reads.filter((r) => r.object === 'sys_setting')).toEqual([]); + }); +}); From 18588cd9dcca7c98df9bb32a027c6b2ae9628f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 05:21:31 +0000 Subject: [PATCH 2/3] fix(plugin-hono-server): resolve the /auth/me/localization regional defaults instead of answering null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler read `currency` / `timezone` off the request ExecutionContext, citing ADR-0053 — but `makeExecutionContextResolver`, the resolver that serves this surface, is a hand-rolled envelope that assigns neither. Both were therefore `undefined` on every request and the `?? null` answered `null` to every authenticated caller, whatever the `localization` settings said. All three values now come from ONE reading of `resolveLocalizationContext`, the same cascade the dispatcher's shared assembler fills `execCtx` from, so the two faces agree by construction rather than by comment. `locale` keeps its three #14788 rungs and its answers are unchanged; what changed underneath is that the cascade is read even when rung 1 or 2 wins, because the other two values need it whichever rung answers the language. The identity read and the settings read are independent and now run concurrently — the console races this endpoint against a 500 ms budget on a first visit. The #14788 pin asserted `timezone: null` as the contract; it was pinning the defect, and it now asserts the corrected one. `currency: null` in that fixture is UNCHANGED and still correct: the cascade gives `timezone` a floor (`UTC`) and `currency` none. The platform checklist's "nulls legal" clause is rewritten to that asymmetry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/great-clouds-repair.md | 7 ++ .../areas/access-security.json | 4 +- ...urrent-user-endpoints-localization.test.ts | 35 ++++-- .../src/current-user-endpoints.ts | 105 ++++++++++++++---- 4 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 .changeset/great-clouds-repair.md diff --git a/.changeset/great-clouds-repair.md b/.changeset/great-clouds-repair.md new file mode 100644 index 0000000000..082e4063dd --- /dev/null +++ b/.changeset/great-clouds-repair.md @@ -0,0 +1,7 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +`GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null` + +The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index dd2275739e..563c265a7d 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2572,9 +2572,9 @@ "evidence": "the probe trace" }, { - "clause": "localization rides the ExecutionContext without a setup gate: an ordinary member's /auth/me/localization answers 200 with currency/locale/timezone keys (nulls legal) — the SETTINGS surface is setup-gated, the resolved defaults deliberately are not", + "clause": "localization is RESOLVED without a setup gate: an ordinary member's /auth/me/localization answers 200 with currency/locale/timezone keys carrying the deployment cascade's own answers — the SETTINGS surface is setup-gated, the resolved defaults deliberately are not. \"Nulls legal\" no longer holds for all three (#15387, which repaired a resolver that carried none of them and made the endpoint answer null for currency AND timezone to every authenticated caller): locale and timezone ALWAYS answer, on cascade floors en-US / UTC, so a null for either is a FAIL and a regression of that repair. currency is the one key with no floor — null there is legal, and only when the deployment configures no localization.currency", "oracle": "api", - "verify": "member trace carries authenticated:true plus the three keys", + "verify": "member trace carries authenticated:true plus the three keys, with timezone and locale non-null; configure localization.currency and localization.timezone and re-trace — both must move to the configured values (an unmoved trace is the #15387 defect, not a pass)", "evidence": "the trace" } ], diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts index 8a6ec3ec3b..5e4af5ac99 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -20,13 +20,30 @@ // served" clause; the narrowed-rule case is the "no second parser" clause // (the answer moves with the registry's rule, not with a built-in regex). // -// Before this card the resolver behind these endpoints assembled NO -// localization at all — `execCtx.locale` here was always `undefined` and the -// endpoint answered `locale: null` for every authenticated caller — so the -// rung-3 cases are also the first pins that this surface answers a language -// at all. `currency` / `timezone` are deliberately untouched by the ruling and -// still come off that resolver (i.e. `null` in these fixtures); the response -// SHAPE objectui reads is pinned unchanged. +// Before #14788 the resolver behind these endpoints assembled NO localization +// at all — `execCtx.locale` here was always `undefined` and the endpoint +// answered `locale: null` for every authenticated caller — so the rung-3 cases +// are also the first pins that this surface answers a language at all. +// +// #15387 — WHAT THIS FILE PINS CHANGED, deliberately and not silently. +// `currency` / `timezone` were out of #14788's scope and kept coming off that +// same resolver, so the rung-1 case below asserted `timezone: null` as the +// contract. It was pinning the DEFECT: the endpoint answered null for both to +// every authenticated caller whatever the deployment configured. The +// assertion is now the corrected contract, and the `#15387` block at the foot +// of this file is what makes the two values falsifiable rather than merely +// present: +// +// * `timezone` — the deployment cascade's answer, floor `UTC`. An +// authenticated caller can no longer be answered `null` for it, which is +// why the rung-1 fixture (which configures no time zone) now reads `UTC`. +// * `currency` — the deployment cascade's answer, and the one value with NO +// floor. `null` there is still legal and still correct for a deployment +// that configures no currency, so the rung-1 expectation for it is +// UNCHANGED. Keeping that asymmetry visible is the point: the two keys do +// not have the same nullability contract. +// +// The response SHAPE objectui reads is unchanged — same four keys. import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; @@ -144,7 +161,9 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8'); expect(status).toBe(200); // The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged. - expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null }); + // `timezone: 'UTC'` is the cascade floor for a fixture that configures + // no time zone (#15387); `currency: null` is the no-floor value. + expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: 'UTC' }); // The identity row is read under a SYSTEM context by the caller's own // id (the `tryFind` shape core uses for the same row) — never routed // through the caller's own RLS wall. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 262960af69..1e6d311a66 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -716,7 +716,7 @@ async function storedSignedInUserLocale( return rules.every((re) => re.test(value)) ? value : undefined; } -/** Input of {@link resolveSignedInUserLocale}. */ +/** Input of {@link resolveCurrentUserLocalization} and {@link resolveSignedInUserLocale}. */ export interface ResolveSignedInUserLocaleInput { /** The locator of the kernel that OWNS the request (see `withRequestContext`). */ ctx: CurrentUserEndpointsContext; @@ -728,9 +728,28 @@ export interface ResolveSignedInUserLocaleInput { acceptLanguage?: string | null; } +/** Everything `/auth/me/localization` answers a signed-in caller. */ +interface CurrentUserLocalization { + /** + * The reference currency, or `undefined` when the deployment configures + * none. The ONE value here with no floor, deliberately: a deployment that + * has stated no currency has none, and inventing one would be a WRONG + * answer where `undefined` is merely a missing one (the renderer's + * documented degradation is a plain number). + */ + currency?: string; + /** The signed-in user's language. Always a string — rung 3 has a floor. */ + locale: string; + /** The reference time zone. Always a string — the cascade's floor is `UTC`. */ + timezone: string; +} + /** - * The signed-in user's language — the ONE read face (#14788, maintainer - * ruling 2026-09-03, option D): + * The endpoint's whole answer, resolved from ONE reading of the deployment + * cascade (#15387). + * + * `locale` keeps the three rungs of #14788 (maintainer ruling 2026-09-03, + * option D): * * 1. `sys_user.locale` when set and shaped like the column's own rule says * ({@link storedSignedInUserLocale}); @@ -742,24 +761,62 @@ export interface ResolveSignedInUserLocaleInput { * `execCtx.locale` already derives from on the dispatcher * (`localization.locale` settings → tenant `sys_setting` rows → `en-US`). * - * Always answers a string for an authenticated caller: rung 3 has a floor. - * Exported for the serverless host path that composes the resolver directly - * (cloud#924) and for the pin that asserts the precedence. + * `currency` and `timezone` come off that same cascade reading, which is the + * repair #15387 names: they used to be read off the request + * `ExecutionContext`, and the resolver serving THIS surface + * ({@link makeExecutionContextResolver}) is a hand-rolled envelope that never + * carried them — so the endpoint answered `null` for both to every + * authenticated caller, whatever the `localization` settings said. The + * dispatcher's shared assembler fills them from this very function + * (`core/security/assemble-execution-context.ts` ⇒ `resolveLocalizationContext`), + * so reading the cascade here makes the two faces agree by construction + * instead of by comment. + * + * ONE reading, not two: rungs 1–2 no longer short-circuit it, because + * `currency` / `timezone` are needed whichever rung answers the language. That + * is one `sys_setting` read added to the requests where the caller's own + * column or header already decided the locale, and it replaces the second + * reading the obvious alternative (resolve again for the other two values) + * would have cost on every request. The two reads it does issue are + * INDEPENDENT — the caller's identity row and the deployment's settings — and + * run concurrently: the console races this endpoint against a 500 ms budget on + * a device's first visit (objectui `seedTenantLanguage`), so a needless serial + * round-trip here is a language flash there. Neither read throws by its own + * documented contract, which is what makes the concurrent form safe. */ -export async function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise { +async function resolveCurrentUserLocalization( + input: ResolveSignedInUserLocaleInput, +): Promise { const { ctx, userId, tenantId } = input; const ql = (() => { try { return ctx.getService('objectql'); } catch { return undefined; } })(); - const stored = await storedSignedInUserLocale(ql, userId, ctx.logger); - if (stored) return stored; - const requested = preferredLocaleFromHeader(input.acceptLanguage); - if (requested) return requested; const settings = (() => { try { return ctx.getService('settings'); } catch { return undefined; } })(); - const localization = await resolveLocalizationContext({ ql, settings, tenantId, userId }); - return localization.locale; + const [stored, deployment] = await Promise.all([ + storedSignedInUserLocale(ql, userId, ctx.logger), + resolveLocalizationContext({ ql, settings, tenantId, userId }), + ]); + return { + currency: deployment.currency, + locale: stored ?? preferredLocaleFromHeader(input.acceptLanguage) ?? deployment.locale, + timezone: deployment.timezone, + }; +} + +/** + * The signed-in user's language — the ONE read face (#14788), the `locale` + * rung of {@link resolveCurrentUserLocalization} on its own. + * + * Always answers a string for an authenticated caller: rung 3 has a floor. + * Exported for the serverless host path that composes the resolver directly + * (cloud#924) and for the pin that asserts the precedence. Its ANSWER is + * unchanged by #15387; what changed underneath is that the deployment cascade + * is now read even when rung 1 or 2 wins (see above). + */ +export async function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise { + return (await resolveCurrentUserLocalization(input)).locale; } /** @@ -1002,8 +1059,7 @@ export function registerCurrentUserEndpoints( // language (`locale`), exposed to EVERY authenticated user. The // `localization` SETTINGS are gated to `setup.access`, but the resolved // defaults are needed by every renderer to format currency/dates/numbers — - // so they ride on the request ExecutionContext (ADR-0053) and are surfaced - // here without that gate. + // so they are surfaced here without that gate. // // [#14788] `locale` is the ONE read face for "this user's language" // (maintainer ruling 2026-09-03, option D, which also retired the dead @@ -1014,14 +1070,21 @@ export function registerCurrentUserEndpoints( // 2. the request's `Accept-Language` preference; // 3. the deployment default (`localization.locale`, the same cascade // that feeds `execCtx.locale` on the dispatcher). - // See {@link resolveSignedInUserLocale}. `currency` / `timezone` and the - // unauthenticated answer are unchanged by that ruling. + // + // [#15387] All three come from {@link resolveCurrentUserLocalization} — + // ONE reading of that same cascade. `currency` / `timezone` used to be read + // off `execCtx` instead, citing ADR-0053; the resolver this surface uses + // ({@link makeExecutionContextResolver}) never carried them, so the + // endpoint answered `null` for both to every authenticated caller no matter + // what the deployment had configured. The `?? null` on `currency` is not + // that fallback returning: it is the cascade's own shape, which gives + // `timezone` a floor and `currency` none. rawApp.get(`${prefix}/auth/me/localization`, withRequestContext(async (c, ctx, resolveCtx) => { const execCtx = await resolveCtx(c); if (!execCtx?.userId) { return c.json({ authenticated: false }); } - const locale = await resolveSignedInUserLocale({ + const localization = await resolveCurrentUserLocalization({ ctx, userId: execCtx.userId, tenantId: execCtx.tenantId ?? undefined, @@ -1029,9 +1092,9 @@ export function registerCurrentUserEndpoints( }); return c.json({ authenticated: true, - currency: execCtx.currency ?? null, - locale, - timezone: execCtx.timezone ?? null, + currency: localization.currency ?? null, + locale: localization.locale, + timezone: localization.timezone, }); })); From c2cf2da59ac095d4dfcb35ad27a3870fbe319d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:14:10 +0000 Subject: [PATCH 3/3] test(plugin-hono-server): make the localization pin's ObjectQL double honour `where` and the caller's bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double answered by object name and dropped `opts.limit`, so neither read it serves could tell a bounded read from an unbounded one — and both carry a bound (`sys_user` at 1, the grouped `sys_setting` `$in` read at 10). It now draws from a row table, filters with a `where` matcher that refuses any operator it does not implement, and applies the bound BY PRESENCE (`typeof opts?.limit === 'number'`) AFTER the filter. `check:objectql-double-limit` could not grade the old double at all: its deepest binding strategy stubs every non-function declaration, the counter `let sysUserReads = 0` became the gate's row-stub Proxy, and `++sysUserReads` raised `TypeError: Cannot convert object to primitive value` — reported as UNJUDGED, which the gate treats as debt rather than a skip. The double now seats on the gate's control probe at the earlier binding strategy, so it is graded CONFORMING (limit 3 -> 3 rows, 5 -> 5, 0 -> 0 of 7 matches) instead of throwing. No baseline entry was added; the ledger never grows. What the file ASSERTS is unchanged — all 13 cases pass, including the corrected `timezone`/`currency` contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...urrent-user-endpoints-localization.test.ts | 71 ++++++++++++++----- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts index 5e4af5ac99..d9ffb865d8 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -94,30 +94,65 @@ interface MountOptions { failUserRead?: boolean; } +/** + * The `where` shapes these endpoints actually issue: scalar equality + * (`{ id }`, `{ namespace }`, `{ scope }`) and the one `$in` list on `key` the + * grouped settings read carries. Anything else — a combinator, another + * operator — is REFUSED loudly rather than answered silently wrong, which is + * the cheap correct answer for a double that never sees one (the defect class + * `check:where-matcher` exists for is silence, not incompleteness). + */ +function matchesWhere(row: Row, where: Row | undefined): boolean { + for (const [key, condition] of Object.entries(where ?? {})) { + if (key.startsWith('$')) { + throw new Error(`this double does not implement the where operator ${key}`); + } + if (condition !== null && typeof condition === 'object') { + const operators = Object.keys(condition); + if (operators.length !== 1 || operators[0] !== '$in') { + throw new Error(`this double does not implement the where operator(s) ${operators.join(', ')} on ${key}`); + } + if (!condition.$in.includes(row[key])) return false; + continue; + } + if (row[key] !== condition) return false; + } + return true; +} + function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, settingTimezone, settingCurrency, authenticated = true, failUserRead = false }: MountOptions = {}) { const reads: Array<{ object: string; opts: any }> = []; let sysUserReads = 0; + /** + * The rows each read draws from, held as a table rather than assembled per + * branch, so the double answers `where` and the caller's `limit` the way + * the engine does instead of by object name. + */ + const tables: Record = { + sys_user: [{ id: USER, email: 'lang@example.com', locale: storedLocale }], + // The endpoint reads all three `localization` keys in ONE `$in` query, + // so the table carries whichever of them the fixture configured — and + // nothing for the rest. + sys_setting: ([ + ['locale', settingLocale], + ['timezone', settingTimezone], + ['currency', settingCurrency], + ] as Array<[string, string | undefined]>) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant' })), + }; const ql = { find: async (object: string, opts: any) => { reads.push({ object, opts }); - if (object === 'sys_user') { - if (failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable'); - return opts?.where?.id === USER ? [{ id: USER, email: 'lang@example.com', locale: storedLocale }] : []; - } - if (object === 'sys_setting') { - // The endpoint reads all three `localization` keys in ONE `$in` - // query, so the double answers whichever of them the fixture - // configured — and nothing for the rest. - const configured: Array<[string, string | undefined]> = [ - ['locale', settingLocale], - ['timezone', settingTimezone], - ['currency', settingCurrency], - ]; - return configured - .filter(([, value]) => value !== undefined) - .map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant' })); - } - return []; + if (object === 'sys_user' && failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable'); + // The caller's bound is applied BY PRESENCE and AFTER the filter. + // Both reads this double serves carry one — `sys_user` at 1, the + // grouped `sys_setting` read at 10 — so a double that dropped it + // could not tell a bounded read from an unbounded one, and every + // bound change on those reads would be green by construction + // (`check:objectql-double-limit`). + const matched = (tables[object] ?? []).filter((row) => matchesWhere(row, opts?.where)); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, // The registry view the endpoint reads the column's rule off. getSchema: (name: string) => (name === 'sys_user' && rules !== null ? { name: 'sys_user', validations: rules } : undefined),