diff --git a/.changeset/27198-home-currency-lock.md b/.changeset/27198-home-currency-lock.md new file mode 100644 index 00000000000..bf86c04d280 --- /dev/null +++ b/.changeset/27198-home-currency-lock.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-settings': patch +--- + +feat(service-settings): the default currency (`localization.currency`) is locked once an exchange rate or a home-currency amount uses it, and the Localization page names what uses it (ADR-0144 D4) + +Clause-②: yes (widening) +Metadata-protocol: none + +⛔ Not released alone: this ships in the same release as the exchange-rate table and the home-currency amount it guards (ADR-0144 D9, release R2). + +The organization's default currency is its home currency: exchange rates are entered against it, and home-currency amounts are converted into it and stored. Changing it afterwards would relabel every amount already converted. So: + +- **The save is refused once the currency is used.** A change to a set default currency, to another code or to empty, is refused with `400 SETTINGS_VALIDATION` while the organization holds any exchange rate row, or any object declares `currencyConfig.homeAmount` on a field. The refusal sits on the `currency` field (`invalid_value`), names what uses it ("in use by 2 objects (Invoice, Order) and 14 exchange rates"), and carries the same facts as `constraint: { lockedBy: 'in_use', objects, exchangeRates }`. Re-saving the value in force is never refused, so the rest of the page still saves. +- **Choosing a first currency is never blocked.** While the currency is unset, nothing has been converted into it, so the first save passes; the page then asks for one. +- **The page says so.** Settings → Localization opens with a banner naming the lock and what uses the currency, or a warning while it is unset and already needed, in English, 简体中文, 日本語 or Español, following the request's language. +- **It lifts by itself** once the last exchange rate row is deleted and the last `homeAmount` declaration is removed. Nothing is recorded at boot; every save and every page read asks the engine. + +New for packages that own state a setting depends on: `SettingsService.registerWritePredicate(namespace, key, predicate)`, a rule over engine state asked when a write changes that key. A refusal refuses the whole batch before any row is written. Exported with its types `SettingsWritePredicate`, `SettingsWritePredicateInput`, `SettingsWriteRefusal` and `SettingsDisplayOptions` (the display language `setMany` and `liveStatusOf` now accept beside the caller's context). diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index b5e1ddce19d..92f254a4ef0 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -37,6 +37,11 @@ export { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + // ADR-0144 D4 — the write-side rule a package registers on one key. + type SettingsWritePredicate, + type SettingsWritePredicateInput, + type SettingsWriteRefusal, + type SettingsDisplayOptions, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, diff --git a/packages/services/service-settings/src/localization-currency-lock.test.ts b/packages/services/service-settings/src/localization-currency-lock.test.ts new file mode 100644 index 00000000000..2fef4ade1e3 --- /dev/null +++ b/packages/services/service-settings/src/localization-currency-lock.test.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The home-currency lock (ADR-0144 D4, multi-currency M1-3): + * `localization.currency` saves freely until an exchange rate row or a + * `homeAmount` declaration uses it, and is refused after either; the refusal + * and the Localization page name what uses it, in every locale the settings + * bundle ships. + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { mailSettingsManifest } from './manifests/mail.manifest.js'; +import { settingsBuiltinTranslations } from './translations/index.js'; +import { SETTINGS_LIVE_STATUS_BANNER_ID, SettingsValidationError } from './settings-service.types.js'; +import { + CURRENCY_LOCK_COPY, + EXCHANGE_RATE_OBJECT, + readCurrencyUsage, + registerLocalizationCurrencyLock, + type CurrencyUsageEngine, +} from './localization-currency-lock.js'; + +const ORG = 'org_alpha'; +const OTHER_ORG = 'org_beta'; +const ctx = { tenantId: ORG, userId: 'usr_admin' }; + +/** An invoice whose `amount` declares its home-currency projection (#27191's shape). */ +const INVOICE = { + name: 'invoice', + label: 'Invoice', + fields: { + amount: { + type: 'currency', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }, + amount_home: { type: 'currency', readonly: true }, + }, +}; +/** A record-mode amount with no home projection: it does not use the home currency. */ +const QUOTE = { + name: 'quote', + label: 'Quote', + fields: { total: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'code' } } }, +}; + +/** The engine the lock reads: `sys_exchange_rate` rows and the object registry. */ +function fakeEngine(opts: { rateObjectRegistered?: boolean } = {}) { + const rates: Array<{ organization_id: string | null; currency: string }> = []; + const objects: unknown[] = [QUOTE]; + const count = vi.fn(async (name: string, query?: { where?: Record }) => { + if (name !== EXCHANGE_RATE_OBJECT) throw new Error(`unexpected count of ${name}`); + const org = query?.where?.organization_id; + return rates.filter((r) => org === undefined || r.organization_id === org).length; + }); + const engine: CurrencyUsageEngine = { + count, + registry: { + getAllObjects: () => objects, + getObject: (name: string) => + name === EXCHANGE_RATE_OBJECT && opts.rateObjectRegistered !== false ? { name } : undefined, + }, + }; + return { engine, rates, objects, count }; +} + +function rig(opts: { rateObjectRegistered?: boolean } = {}) { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + const fake = fakeEngine(opts); + expect(registerLocalizationCurrencyLock(svc, fake.engine)).toBe(true); + return { svc, ...fake }; +} + +async function refusalOf(p: Promise): Promise { + const err = await p.then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(SettingsValidationError); + return err as SettingsValidationError; +} + +describe('localization.currency — saves freely until used', () => { + it('saves and changes before the first rate row and before any homeAmount declaration', async () => { + const { svc } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + await svc.set('localization', 'currency', null, ctx); + await svc.set('localization', 'currency', 'CNY', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('CNY'); + }); + + it('a record-mode amount without `homeAmount` does not lock it', async () => { + const { svc } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it("another organization's rate rows do not lock this one", async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: OTHER_ORG, currency: 'EUR' }); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); +}); + +describe('localization.currency — locked once used', () => { + it('is refused after the first rate row, naming it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ + field: 'currency', + code: 'invalid_value', + label: 'Default currency', + constraint: { lockedBy: 'in_use', objects: [], exchangeRates: 1 }, + }); + expect(err.fields[0].message).toMatch(/^The default currency is locked at USD because it is in use by 1 exchange rate\. /); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('is refused after a homeAmount declaration, naming the object', async () => { + const { svc, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields[0].constraint).toEqual({ lockedBy: 'in_use', objects: ['invoice'], exchangeRates: 0 }); + expect(err.fields[0].message).toContain('in use by 1 object (Invoice).'); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('names both users when both exist', async () => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE, { ...INVOICE, name: 'order', label: 'Order' }); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' }); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields[0].message).toContain('in use by 2 objects (Invoice, Order) and 2 exchange rates.'); + }); + + it('refuses a clear too, and the namespace reset with it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + await refusalOf(svc.set('localization', 'currency', null, ctx)); + await refusalOf(svc.setMany('localization', { currency: null, timezone: null }, ctx)); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('lets a re-save of the value in force through, and the batch beside it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + await svc.setMany('localization', { currency: 'USD', timezone: 'Asia/Tokyo' }, ctx); + expect((await svc.get('localization', 'timezone', ctx)).value).toBe('Asia/Tokyo'); + }); + + it('lets the first value through while the currency is unset (ADR-0144 Open item 4: never blocks choosing one)', async () => { + const { svc, rates, objects } = rig(); + rates.push({ organization_id: ORG, currency: 'EUR' }); + objects.push(INVOICE); + await svc.set('localization', 'currency', 'USD', ctx); + await refusalOf(svc.set('localization', 'currency', 'GBP', ctx)); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('lifts by itself once the last rate row and the last declaration are gone', async () => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + objects.push(INVOICE); + await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + rates.length = 0; + await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + objects.pop(); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it('counts no rate rows where the composition does not register the rate table', async () => { + const { svc, count } = rig({ rateObjectRegistered: false }); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect(count).not.toHaveBeenCalled(); + }); + + it('fails the write when the rate count fails, rather than guess', async () => { + const { svc, count } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + count.mockRejectedValueOnce(new Error('driver down')); + await expect(svc.set('localization', 'currency', 'EUR', ctx)).rejects.toThrow('driver down'); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); +}); + +describe('readCurrencyUsage', () => { + it("counts the organization's own rows, under a system read", async () => { + const { engine, rates, count } = fakeEngine(); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: OTHER_ORG, currency: 'EUR' }); + expect((await readCurrencyUsage(engine, ORG)).exchangeRates).toBe(1); + expect(count).toHaveBeenLastCalledWith(EXCHANGE_RATE_OBJECT, { + where: { organization_id: ORG }, + context: { isSystem: true }, + }); + }); + + it('counts every row in a composition with no organization object', async () => { + const { engine, rates } = fakeEngine(); + rates.push({ organization_id: null, currency: 'EUR' }, { organization_id: null, currency: 'JPY' }); + expect((await readCurrencyUsage(engine, null)).exchangeRates).toBe(2); + }); +}); + +describe('the copy — every locale the settings bundle ships', () => { + it('covers exactly the locales of settingsBuiltinTranslations', () => { + expect(Object.keys(CURRENCY_LOCK_COPY).sort()).toEqual(Object.keys(settingsBuiltinTranslations).sort()); + }); + + it.each([ + ['zh-CN', '默认币种已锁定为 USD:它已被 1 个对象(Invoice)和 2 条汇率使用。'], + ['ja', '既定の通貨は USD でロックされています。1 件のオブジェクト(Invoice)と 2 件の為替レートで使用されているためです。'], + ['es-MX', 'La moneda predeterminada está bloqueada en USD porque está en uso en 1 objeto (Invoice) y 2 tipos de cambio.'], + ['fr-FR', 'The default currency is locked at USD because it is in use by 1 object (Invoice) and 2 exchange rates.'], + ])('writes the refusal in %s', async (locale, opening) => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' }); + const err = await refusalOf(svc.setMany('localization', { currency: 'EUR' }, ctx, { locale })); + expect(err.fields[0].message.startsWith(opening)).toBe(true); + }); +}); + +// ── The page: GET /api/settings/localization ────────────────────────────── + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +function pageRig() { + const base = rig(); + const http = new MockHttp(); + registerSettingsRoutes(http, base.svc, { + contextFromRequest: () => ({ ...ctx, enforced: true, permissions: ['setup.access', 'setup.write'] }), + }); + const call = async (verb: 'GET' | 'PUT', headers: Record, body?: unknown) => { + const req: IHttpRequest = { + params: { namespace: 'localization' }, query: {}, body, headers, method: verb, path: '/', + }; + const state: { status: number; body?: any } = { status: 200 }; + const res: IHttpResponse = { + json: vi.fn((data) => { state.body = data; }) as any, + send: vi.fn() as any, + status: vi.fn((code: number) => { state.status = code; return res; }) as any, + header: vi.fn(() => res) as any, + }; + await http.routes.get(`${verb} /api/settings/:namespace`)!(req, res); + return state; + }; + return { ...base, call }; +} + +describe('the Localization page states what uses the currency', () => { + it('states nothing while nothing uses it', async () => { + const { svc, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + const { body } = await call('GET', {}); + expect(body.data.manifest.specifiers).toEqual(localizationSettingsManifest.specifiers); + }); + + it('heads the page with the lock and its users once used, in the request language', async () => { + const { svc, rates, objects, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const en = (await call('GET', {})).body.data.manifest.specifiers[0]; + expect(en).toMatchObject({ + type: 'info_banner', + id: SETTINGS_LIVE_STATUS_BANNER_ID, + label: 'Default currency locked: USD', + bannerText: 'In use by 1 object (Invoice) and 1 exchange rate. Converted amounts are stored in this currency, so it can no longer be changed.', + bannerSeverity: 'info', + }); + const zh = (await call('GET', { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' })).body.data.manifest.specifiers[0]; + expect(zh.label).toBe('默认币种已锁定:USD'); + expect(zh.bannerText).toBe('已被 1 个对象(Invoice)和 1 条汇率使用。换算后的金额都以该币种存储,因此不能再修改。'); + }); + + it('warns while the currency is unset and something already needs it', async () => { + const { rates, call } = pageRig(); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const banner = (await call('GET', { 'accept-language': 'ja-JP' })).body.data.manifest.specifiers[0]; + expect(banner).toMatchObject({ label: '既定の通貨が設定されていません', bannerSeverity: 'warning' }); + expect(banner.bannerText).toContain('使用状況: 1 件の為替レート。'); + }); + + it('answers a refused save 400 SETTINGS_VALIDATION, the refusal in the request language', async () => { + const { svc, rates, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const { status, body } = await call('PUT', { 'accept-language': 'es-ES' }, { currency: 'EUR' }); + expect(status).toBe(400); + expect(body.error.code).toBe('SETTINGS_VALIDATION'); + expect(body.error.details.fields[0]).toMatchObject({ field: 'currency', code: 'invalid_value' }); + expect(body.error.details.fields[0].message).toMatch(/^La moneda predeterminada está bloqueada en USD porque está en uso en 1 tipo de cambio\./); + }); +}); + +describe('registerLocalizationCurrencyLock', () => { + it('registers nothing on a service without the localization namespace', () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(mailSettingsManifest); + expect(registerLocalizationCurrencyLock(svc, fakeEngine().engine)).toBe(false); + }); +}); diff --git a/packages/services/service-settings/src/localization-currency-lock.ts b/packages/services/service-settings/src/localization-currency-lock.ts new file mode 100644 index 00000000000..b9a116e1486 --- /dev/null +++ b/packages/services/service-settings/src/localization-currency-lock.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The home-currency lock (ADR-0144 D4, multi-currency M1-3). + * + * The organization's home currency is `localization.currency`, and it is + * locked "once a rate row or a home-amount declaration exists". Changing it + * after that relabels every amount already converted into it: a stored home + * amount and its rate were computed against the old code, and nothing would + * recompute them. So once the currency is used: + * + * - **The write is refused.** {@link localizationCurrencyWritePredicate} is the + * key's settings write predicate. A change to a set value — another code, + * or a clear — is refused while any `sys_exchange_rate` row of the + * organization exists, or any registered object declares + * `currencyConfig.homeAmount` on a field. The refusal names both. + * - **The page says so.** {@link localizationCurrencyLiveStatus} heads the + * Localization page with what uses the currency, in the caller's language. + * + * Setting the currency while it is UNSET is not a change of home currency: + * nothing has been converted into a currency that does not exist, so that + * write passes, and the page asks for it (ADR-0144 Open item 4, this card's + * half: the lock never invents a default, and never blocks choosing one). + * + * "Used" reads the engine at each ask, never a flag recorded at boot: the lock + * lifts by itself once the last rate row is deleted and the last `homeAmount` + * declaration is removed. + */ + +import { resolveLocale } from '@objectstack/core'; +import type { + SettingsLiveStatus, + SettingsLiveStatusProvider, + SettingsWritePredicate, +} from './settings-service.types.js'; + +/** The rate table of multi-currency M1-1, owned by `@objectstack/platform-objects`. */ +export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate'; + +/** The engine surface the lock reads: a count, and the object registry. */ +export interface CurrencyUsageEngine { + count( + objectName: string, + query?: { where?: Record; context?: Record }, + ): Promise; + registry?: { + getAllObjects?(): ReadonlyArray; + getObject?(name: string): unknown; + }; +} + +/** What uses the home currency of one organization. */ +export interface CurrencyUsage { + /** Objects declaring `currencyConfig.homeAmount` on a field, in registry order. */ + objects: Array<{ name: string; label: string }>; + /** The organization's `sys_exchange_rate` rows. */ + exchangeRates: number; +} + +const SYSTEM_CONTEXT = Object.freeze({ isSystem: true as const }); + +type AnyRecord = Record; +const isRecord = (v: unknown): v is AnyRecord => typeof v === 'object' && v !== null && !Array.isArray(v); + +/** Does any field of this object declare a home amount? */ +function declaresHomeAmount(schema: AnyRecord): boolean { + const fields = isRecord(schema.fields) ? schema.fields : {}; + return Object.values(fields).some( + (def) => isRecord(def) && isRecord(def.currencyConfig) && isRecord(def.currencyConfig.homeAmount), + ); +} + +/** + * Read what uses the home currency. `organizationId` is the organization whose + * rate rows count: an id, or `null` in a composition with no organization + * object, whose rows carry none. Objects are deployment metadata, so every + * organization sees the same ones. + * + * A composition that does not register `sys_exchange_rate` holds no rate rows. + * Any other failure of the count propagates: the lock does not guess. + */ +export async function readCurrencyUsage( + engine: CurrencyUsageEngine, + organizationId: string | null, +): Promise { + const objects: CurrencyUsage['objects'] = []; + for (const schema of engine.registry?.getAllObjects?.() ?? []) { + if (!isRecord(schema) || typeof schema.name !== 'string' || !declaresHomeAmount(schema)) continue; + objects.push({ name: schema.name, label: typeof schema.label === 'string' ? schema.label : schema.name }); + } + const registered = typeof engine.registry?.getObject === 'function' + ? Boolean(engine.registry.getObject(EXCHANGE_RATE_OBJECT)) + : true; + const exchangeRates = registered + ? await engine.count(EXCHANGE_RATE_OBJECT, { + // The organization filter is explicit: a system read carries no + // organization, so nothing else scopes it. + ...(organizationId !== null ? { where: { organization_id: organizationId } } : {}), + context: SYSTEM_CONTEXT, + }) + : 0; + return { objects, exchangeRates }; +} + +const isUsed = (usage: CurrencyUsage): boolean => usage.objects.length > 0 || usage.exchangeRates > 0; + +const isUnset = (value: unknown): boolean => value === null || value === undefined || value === ''; + +// ── Copy ───────────────────────────────────────────────────────────────── + +/** How many object labels a sentence lists before it elides the rest. */ +const LISTED_OBJECTS = 5; + +interface CurrencyLockCopy { + objects(count: number, labels: string): string; + rates(count: number): string; + and: string; + listSeparator: string; + refusal(current: string, usage: string): string; + lockedLabel(current: string): string; + lockedText(usage: string): string; + unsetLabel: string; + unsetText(usage: string): string; +} + +/** + * The lock's copy, one entry per locale the settings bundle ships + * (`settingsBuiltinTranslations`). Kept here and not in that bundle: the + * sentences are computed per request with counts and names, and the console's + * banner lookup would replace a computed live-status text with a fixed one + * (`SETTINGS_LIVE_STATUS_BANNER_ID`). + */ +export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', CurrencyLockCopy> = { + en: { + objects: (n, labels) => `${n} ${n === 1 ? 'object' : 'objects'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'exchange rate' : 'exchange rates'}`, + and: ' and ', + listSeparator: ', ', + refusal: (current, usage) => + `The default currency is locked at ${current} because it is in use by ${usage}. ` + + 'Changing it would relabel every amount already converted into it. It can change again only once ' + + 'no exchange rate is recorded and no object declares a home-currency amount.', + lockedLabel: (current) => `Default currency locked: ${current}`, + lockedText: (usage) => + `In use by ${usage}. Converted amounts are stored in this currency, so it can no longer be changed.`, + unsetLabel: 'No default currency set', + unsetText: (usage) => + 'Exchange rates and home-currency amounts need a default currency, and none is set. ' + + `In use: ${usage}. Choose it below; once saved, it cannot be changed.`, + }, + 'zh-CN': { + objects: (n, labels) => `${n} 个对象(${labels})`, + rates: (n) => `${n} 条汇率`, + and: '和 ', + listSeparator: '、', + refusal: (current, usage) => + `默认币种已锁定为 ${current}:它已被 ${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` + + '只有在不再有任何汇率记录、也没有任何对象声明本位币金额之后,才能修改。', + lockedLabel: (current) => `默认币种已锁定:${current}`, + lockedText: (usage) => `已被 ${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`, + unsetLabel: '尚未设置默认币种', + unsetText: (usage) => + `汇率和本位币金额都需要默认币种,但目前尚未设置。使用情况:${usage}。请在下方选择;保存后将无法修改。`, + }, + 'ja-JP': { + objects: (n, labels) => `${n} 件のオブジェクト(${labels})`, + rates: (n) => `${n} 件の為替レート`, + and: 'と ', + listSeparator: '、', + refusal: (current, usage) => + `既定の通貨は ${current} でロックされています。${usage}で使用されているためです。` + + '変更すると、この通貨に換算済みのすべての金額に誤った通貨が付きます。' + + '為替レートが 1 件もなく、基準通貨の金額を宣言するオブジェクトもなくなった場合にのみ変更できます。', + lockedLabel: (current) => `既定の通貨はロックされています: ${current}`, + lockedText: (usage) => `${usage}で使用中です。換算後の金額はこの通貨で保存されるため、変更できません。`, + unsetLabel: '既定の通貨が設定されていません', + unsetText: (usage) => + '為替レートと基準通貨の金額には既定の通貨が必要ですが、まだ設定されていません。' + + `使用状況: ${usage}。下で選択してください。保存後は変更できません。`, + }, + 'es-ES': { + objects: (n, labels) => `${n} ${n === 1 ? 'objeto' : 'objetos'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'tipo de cambio' : 'tipos de cambio'}`, + and: ' y ', + listSeparator: ', ', + refusal: (current, usage) => + `La moneda predeterminada está bloqueada en ${current} porque está en uso en ${usage}. ` + + 'Cambiarla volvería a etiquetar todos los importes ya convertidos a ella. Solo podrá cambiarse ' + + 'cuando no quede ningún tipo de cambio y ningún objeto declare un importe en la moneda principal.', + lockedLabel: (current) => `Moneda predeterminada bloqueada: ${current}`, + lockedText: (usage) => + `En uso en ${usage}. Los importes convertidos se guardan en esta moneda, por lo que ya no se puede cambiar.`, + unsetLabel: 'No hay moneda predeterminada', + unsetText: (usage) => + 'Los tipos de cambio y los importes en la moneda principal necesitan una moneda predeterminada, ' + + `y no hay ninguna. En uso: ${usage}. Elíjala abajo; una vez guardada, no se podrá cambiar.`, + }, +}; + +const COPY_LOCALES = Object.keys(CURRENCY_LOCK_COPY); + +/** The copy for a caller's locale: exact, then by language, else English. */ +export function currencyLockCopy(locale: string | undefined): CurrencyLockCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return CURRENCY_LOCK_COPY[(picked ?? 'en') as keyof typeof CURRENCY_LOCK_COPY]; +} + +/** "2 objects (Invoice, Order) and 14 exchange rates", in the copy's language. */ +export function describeCurrencyUsage(copy: CurrencyLockCopy, usage: CurrencyUsage): string { + const parts: string[] = []; + if (usage.objects.length > 0) { + const listed = usage.objects.slice(0, LISTED_OBJECTS).map((o) => o.label); + const labels = listed.join(copy.listSeparator) + (usage.objects.length > LISTED_OBJECTS ? '…' : ''); + parts.push(copy.objects(usage.objects.length, labels)); + } + if (usage.exchangeRates > 0) parts.push(copy.rates(usage.exchangeRates)); + return parts.join(copy.and); +} + +/** The discrete values a refusal carries (`FieldError.constraint`). */ +function usageConstraint(usage: CurrencyUsage): Record { + return { + lockedBy: 'in_use', + objects: usage.objects.map((o) => o.name), + exchangeRates: usage.exchangeRates, + }; +} + +// ── The predicate and the statement ────────────────────────────────────── + +/** + * `localization.currency`'s write predicate: a change to a SET currency is + * refused while the currency is used. Setting it while unset passes. + */ +export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate { + return async ({ current, organizationId, locale }) => { + if (isUnset(current)) return undefined; + const usage = await readCurrencyUsage(engine, organizationId ?? null); + if (!isUsed(usage)) return undefined; + const copy = currencyLockCopy(locale); + return { + message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)), + constraint: usageConstraint(usage), + }; + }; +} + +/** + * The Localization page's statement: what uses the home currency, and that it + * is locked — or, while it is unset, that it is needed. Nothing to state while + * nothing uses it. + * + * Reads the currency the caller's page resolves through `currentCurrency`, so + * the statement and the form below it name the same value. + */ +export function localizationCurrencyLiveStatus( + engine: CurrencyUsageEngine, + currentCurrency: (ctx: Parameters[0]['ctx']) => Promise, +): SettingsLiveStatusProvider { + return async ({ ctx, organizationId, locale }): Promise => { + const organization = await organizationId(); + if (organization === undefined) return undefined; + const usage = await readCurrencyUsage(engine, organization); + if (!isUsed(usage)) return undefined; + const copy = currencyLockCopy(locale); + const described = describeCurrencyUsage(copy, usage); + const current = await currentCurrency(ctx); + if (isUnset(current)) { + return { label: copy.unsetLabel, bannerText: copy.unsetText(described), bannerSeverity: 'warning' }; + } + return { label: copy.lockedLabel(String(current)), bannerText: copy.lockedText(described), bannerSeverity: 'info' }; + }; +} + +/** The service surface the lock registers on. */ +export interface CurrencyLockHost { + getManifest(namespace: string): unknown; + registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void; + registerLiveStatus(namespace: string, provider: SettingsLiveStatusProvider): void; + get(namespace: string, key: string, ctx?: Parameters[0]['ctx']): Promise<{ value: unknown }>; +} + +/** + * Register the lock on a settings service bound to `engine`. Returns `false`, + * registering nothing, when the service carries no `localization` namespace + * (a host that replaced the built-in manifests). + */ +export function registerLocalizationCurrencyLock(host: CurrencyLockHost, engine: CurrencyUsageEngine): boolean { + try { + host.getManifest('localization'); + } catch { + return false; + } + host.registerWritePredicate('localization', 'currency', localizationCurrencyWritePredicate(engine)); + host.registerLiveStatus( + 'localization', + localizationCurrencyLiveStatus(engine, async (ctx) => (await host.get('localization', 'currency', ctx)).value), + ); + return true; +} diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 2461be95f03..19b90512ee2 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -44,6 +44,7 @@ import { shouldDenyAnonymous, } from '@objectstack/core'; import type { ErrorCode } from '@objectstack/spec/api'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; // The declared envelope is written in ONE place for the whole platform (#3973). // Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings // #4224 retired from this module cannot come back through it either. @@ -200,6 +201,21 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined) return true; } +/** + * The caller's display language, read the way REST's metadata door reads it: + * the top `Accept-Language` tag, else `?locale`. It only picks the language of + * prose a write predicate or a live-status provider composes + * (`SettingsDisplayOptions`); it authorizes nothing, and it never enters the + * caller's context. + */ +function requestLocaleOf(req: IHttpRequest): string | undefined { + const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language']; + const preferred = preferredLocaleFromHeader(typeof header === 'string' ? header : undefined); + if (preferred) return preferred; + const query = req.query?.locale; + return typeof query === 'string' && query.length > 0 ? query : undefined; +} + export function registerSettingsRoutes( http: IHttpServer, service: SettingsService, @@ -280,7 +296,7 @@ export function registerSettingsRoutes( // `getNamespace`, which in-process consumers call to read values: the // mail plugin reads `getNamespace('mail')` while rebuilding the very // transport its statement waits on. - const liveStatus = await service.liveStatusOf(ns, ctx); + const liveStatus = await service.liveStatusOf(ns, ctx, { locale: requestLocaleOf(req) }); const manifest = liveStatus ? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] } : payload.manifest; @@ -335,7 +351,13 @@ export function registerSettingsRoutes( // namespace — the same 404 `setMany` would have raised one line later, in // the same order relative to the 403 authz check. const secretKeys = service.secretKeysOf(ns); - const result = await service.setMany(ns, dropEchoedSecretMasks(body, secretKeys), ctx); + const result = await service.setMany( + ns, + dropEchoedSecretMasks(body, secretKeys), + ctx, + // The language a write predicate's refusal is written in. + { locale: requestLocaleOf(req) }, + ); // The write response carries resolved values too — including cascade // entries the caller never submitted (an upper-scope secret it may not // have set). Same boundary, same redaction. diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index eef084c1802..628b86b4d9a 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -34,6 +34,7 @@ import type { SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from '. import type { CryptoAdapter } from './crypto-adapter.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; +import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { registerSettingsReadDoor } from './settings-read-door.js'; @@ -345,6 +346,11 @@ export class SettingsServicePlugin implements Plugin { resolveGuestOrganization(engine, await this.resolveAdmissionTenancyPosture(ctx)), }, ); + // [ADR-0144 D4] `localization.currency` is the home currency, locked + // once an exchange rate or a `homeAmount` declaration uses it: the + // key's write predicate and the page's statement both read this + // engine (see `localization-currency-lock.ts`). + registerLocalizationCurrencyLock(this.service!, engine as unknown as CurrencyUsageEngine); // Each namespace's `readPermission`, applied at the generic data // API's read of the settings stores too (see `settings-read-door.ts`). if (!registerSettingsReadDoor(engine, this.service!)) { diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 148247002ea..627600abd3b 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -22,6 +22,8 @@ import { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + type SettingsWritePredicate, + type SettingsDisplayOptions, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, @@ -881,6 +883,8 @@ export class SettingsService { private organizationSource?: SettingsOrganizationSource; /** Live-status providers by namespace — see {@link registerLiveStatus}. */ private readonly liveStatusProviders = new Map(); + /** Write predicates by namespace, then key — see {@link registerWritePredicate}. */ + private readonly writePredicates = new Map>(); /** Change subscribers, optionally scoped to a namespace. */ private readonly subscribers = new Set<{ ns?: string; @@ -1587,6 +1591,21 @@ export class SettingsService { this.liveStatusProviders.set(namespace, provider); } + /** + * Register (or replace) the write predicate of one manifest key — a rule + * over engine state that may refuse a CHANGE to the key. See + * {@link SettingsWritePredicate}. The last registration wins, the same + * override rule as {@link registerAction}. + */ + registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void { + const reg = this.registry.get(namespace); + if (!reg) throw new UnknownNamespaceError(namespace); + if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); + let byKey = this.writePredicates.get(namespace); + if (!byKey) this.writePredicates.set(namespace, (byKey = new Map())); + byKey.set(key, predicate); + } + /** * The namespace's live-status statement as an `info_banner` specifier, ready * to head the page, or `undefined` when no provider is registered or the @@ -1601,12 +1620,18 @@ export class SettingsService { async liveStatusOf( namespace: string, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise { const provider = this.liveStatusProviders.get(namespace); if (!provider) return undefined; let status: SettingsLiveStatus | undefined; try { - status = await provider({ namespace, ctx }); + status = await provider({ + namespace, + ctx, + organizationId: async () => organizationReachRowOwner(await this.organizationReachOf(ctx)), + locale: display.locale, + }); } catch (err: any) { this.logger?.warn?.( `SettingsService: the live status of settings namespace '${namespace}' could not be read — ` @@ -2255,11 +2280,15 @@ export class SettingsService { * naming each such key (`invalid_value`, `constraint: { scope: 'user' }`): * the row it would store names no owner, and the user rung answers only its * owner, so no reader would ever see it while the write reported success. + * + * `display` picks the language of a write predicate's refusal, and nothing + * else (see {@link refuseByWritePredicates}). */ async setMany( namespace: string, patch: Record, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise> { // The pre-bind window: refuse rather than resolve against a store nothing // will ever read. See `assertEngineBound`. @@ -2339,6 +2368,12 @@ export class SettingsService { // validatePatch for the exact semantics. await this.validatePatch(namespace, patch, ctx); + // The keys' write predicates (ADR-0144 D4): rules over engine state that + // may refuse a change the declarations above accept. After them, so a + // malformed value is told about its own fault first; before the crypto + // pass and every write, so a refused batch writes nothing. + await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach, display); + // #8026 — a declared-encrypted key with nothing able to encrypt it fails // the WHOLE batch, here, rather than part-way down the write loop below. // The persist site enforces the same rule (that is the load-bearing half); @@ -2536,6 +2571,52 @@ export class SettingsService { return out; } + /** + * Ask each patched key's {@link SettingsWritePredicate}, when the patch + * changes the key, and refuse the whole batch with every refusal at once. + * + * "Changes" compares the submitted value (`null` / `undefined` read as a + * clear) with the value the caller's cascade resolves today, so re-saving + * the value in force — the console posts a form's dirty keys, and a form + * can post back what it read — is never refused. + */ + private async refuseByWritePredicates( + reg: RegisteredManifest, + namespace: string, + patch: Record, + ctx: SettingsContext, + reach: OrganizationReach | undefined, + display: SettingsDisplayOptions, + ): Promise { + const byKey = this.writePredicates.get(namespace); + if (!byKey) return; + const refusals: FieldError[] = []; + for (const [key, submitted] of Object.entries(patch)) { + const predicate = byKey.get(key); + if (!predicate) continue; + const next = submitted ?? null; + const current = (await this.get(namespace, key, ctx)).value ?? null; + if (stableStringify(next) === stableStringify(current)) continue; + const scope = reg.scopes.get(key)!; + const organizationId = scope === 'global' || !reach ? undefined : organizationReachRowOwner(reach) ?? null; + const refusal = await predicate({ namespace, key, next, current, organizationId, ctx, locale: display.locale }); + if (!refusal) continue; + const spec = ((reg.manifest.specifiers ?? []) as Array>) + .find((s) => s.key === key); + const label = typeof spec?.label === 'string' ? spec.label : key; + refusals.push({ + field: key, + // ADR-0114's slot for "rejected for a reason no other member names": + // the value is well-formed; the system's state refuses the change. + code: 'invalid_value', + message: refusal.message, + label, + ...(refusal.constraint ? { constraint: refusal.constraint } : {}), + }); + } + if (refusals.length > 0) throw new SettingsValidationError(namespace, refusals); + } + /** * Save-time validation for `setMany`, fulfilling the spec promise that * `required` is enforced server-side and hidden specifiers are not diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index dba6d0e0e7f..1d5231f8518 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -84,6 +84,21 @@ export interface SettingsContext { authGate?: AuthGate; } +/** + * How a settings call's computed prose is written — never who calls or what + * they may do, which is {@link SettingsContext}'s alone. + */ +export interface SettingsDisplayOptions { + /** + * The caller's display language (BCP-47), for prose a + * {@link SettingsWritePredicate} refusal or a {@link SettingsLiveStatusProvider} + * statement composes. The settings routes pass it from the request + * (`Accept-Language`, else `?locale`). Absent, such prose is written in + * English. + */ + locale?: string; +} + /** Storage row shape used by both the engine and the in-memory store. */ export interface SettingsRow { namespace: string; @@ -321,6 +336,58 @@ export type SettingsActionHandler = (input: { ctx: SettingsContext; }) => Promise | SettingsActionResult; +/** + * What a {@link SettingsWritePredicate} is asked about one key of a write. + * + * Asked only when the write CHANGES the key: `next` differs from `current`. + * A write that stores the value already in force is never refused by one. + */ +export interface SettingsWritePredicateInput { + namespace: string; + key: string; + /** The value the write would store; `null` clears the key. */ + next: unknown; + /** The value the caller's cascade resolves for the key before the write. */ + current: unknown; + /** + * The organization the row lands in: its id, `null` in a composition with no + * organization object, `undefined` for a `global` key. + */ + organizationId: string | null | undefined; + ctx: SettingsContext; + /** The language the refusal is written in — {@link SettingsDisplayOptions.locale}. */ + locale?: string; +} + +/** + * A predicate's refusal. `message` is the sentence the caller reads under the + * field, written in the input's `locale`; `constraint` carries its discrete values + * (`FieldError.constraint`, ADR-0114), so a client can compose its own text. + */ +export interface SettingsWriteRefusal { + message: string; + constraint?: Record; +} + +/** + * The settings write predicate: a rule over ENGINE STATE that may refuse a + * change to one manifest key. The key's declaration (`options`, `pattern`, + * `valueDomain`, …) judges the value by itself; a predicate judges whether + * the value may change at all, given what the rest of the system already holds + * (ADR-0144 D4: `localization.currency` is locked once used). + * + * Registered by the package that owns the state it reads, with + * `SettingsService.registerWritePredicate`. `setMany` asks it after the + * batch's declared constraints have passed and before any row is written; a + * refusal refuses the WHOLE batch with `SettingsValidationError` + * (`invalid_value`, at the key). `undefined` lets the change through. A + * predicate that throws fails the write: the state it guards could not be + * read, so the change is not made. + */ +export type SettingsWritePredicate = ( + input: SettingsWritePredicateInput, +) => Promise | SettingsWriteRefusal | undefined; + /** * What a namespace's page states about the RUNNING system, as opposed to what * is stored. The stored values say what an operator chose; they do not say what @@ -348,6 +415,14 @@ export interface SettingsLiveStatus { export type SettingsLiveStatusProvider = (input: { namespace: string; ctx: SettingsContext; + /** + * The organization the caller's page reads: its id, `null` in a composition + * with no organization object, `undefined` when the caller has none. + * Resolved only when called. + */ + organizationId: () => Promise; + /** The language the statement is written in — {@link SettingsDisplayOptions.locale}. */ + locale?: string; }) => Promise | SettingsLiveStatus | undefined; /** diff --git a/packages/services/service-settings/src/settings-write-predicate.test.ts b/packages/services/service-settings/src/settings-write-predicate.test.ts new file mode 100644 index 00000000000..fbb96bb354c --- /dev/null +++ b/packages/services/service-settings/src/settings-write-predicate.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The settings write predicate (ADR-0144 D4): a rule over engine state that a + * package registers on one manifest key, asked by `setMany` when a write + * CHANGES that key. The home-currency lock is its first user + * (`localization-currency-lock.test.ts`); this suite pins the mechanism. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { SettingsService } from './settings-service.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { + SettingsValidationError, + UnknownKeyError, + UnknownNamespaceError, + type SettingsWritePredicate, +} from './settings-service.types.js'; + +const ORG = 'org_alpha'; +const ctx = { tenantId: ORG, userId: 'usr_admin' }; + +function rig(predicate: SettingsWritePredicate) { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + const spy = vi.fn(predicate); + svc.registerWritePredicate('localization', 'currency', spy); + return { svc, spy }; +} + +const refuseAll: SettingsWritePredicate = () => ({ message: 'No.', constraint: { why: 'test' } }); + +describe('SettingsService write predicate', () => { + it('is asked with the value in force, the value to store and the organization the row lands in', async () => { + const { svc, spy } = rig(() => undefined); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0][0]).toMatchObject({ + namespace: 'localization', key: 'currency', current: null, next: 'USD', organizationId: ORG, + }); + expect(spy.mock.calls[1][0]).toMatchObject({ current: 'USD', next: 'EUR' }); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it('is not asked when the write stores the value already in force', async () => { + const { svc, spy } = rig(() => undefined); + await svc.set('localization', 'currency', 'USD', ctx); + spy.mockClear(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.setMany('localization', { timezone: 'Europe/Paris' }, ctx); + expect(spy).not.toHaveBeenCalled(); + }); + + it('refuses the whole batch, before any write, with the refusal at the key', async () => { + const { svc } = rig(refuseAll); + const err = await svc + .setMany('localization', { currency: 'EUR', timezone: 'Europe/Paris' }, ctx) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(SettingsValidationError); + expect((err as SettingsValidationError).fields).toEqual([ + { field: 'currency', code: 'invalid_value', message: 'No.', label: 'Default currency', constraint: { why: 'test' } }, + ]); + expect((await svc.get('localization', 'currency', ctx)).value ?? null).toBeNull(); + expect((await svc.get('localization', 'timezone', ctx)).value).toBe('UTC'); + }); + + it('asks about a clear too: clearing a set value is a change', async () => { + const { svc, spy } = rig(({ current }) => (current ? { message: 'Locked.' } : undefined)); + await svc.set('localization', 'currency', 'USD', ctx); + await expect(svc.set('localization', 'currency', null, ctx)).rejects.toBeInstanceOf(SettingsValidationError); + expect(spy.mock.calls[spy.mock.calls.length - 1][0]).toMatchObject({ current: 'USD', next: null }); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('judges the declared constraints first: a malformed value is told about its own fault', async () => { + const { svc, spy } = rig(refuseAll); + const err = (await svc.set('localization', 'currency', 'ZZZ', ctx).catch((e: unknown) => e)) as SettingsValidationError; + expect(err.fields[0].code).toBe('value_domain'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('fails the write when the predicate throws: the state it guards could not be read', async () => { + const { svc } = rig(() => { + throw new Error('rate table unreachable'); + }); + await expect(svc.set('localization', 'currency', 'USD', ctx)).rejects.toThrow('rate table unreachable'); + expect((await svc.get('localization', 'currency', ctx)).value ?? null).toBeNull(); + }); + + it('refuses a predicate for an unregistered namespace or an undeclared key', () => { + const { svc } = rig(() => undefined); + expect(() => svc.registerWritePredicate('nope', 'currency', () => undefined)).toThrow(UnknownNamespaceError); + expect(() => svc.registerWritePredicate('localization', 'nope', () => undefined)).toThrow(UnknownKeyError); + }); +});