diff --git a/.changeset/30664-reset-clears-env-pinned.md b/.changeset/30664-reset-clears-env-pinned.md new file mode 100644 index 00000000000..f22d6ef9626 --- /dev/null +++ b/.changeset/30664-reset-clears-env-pinned.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-settings': patch +--- + +fix(service-settings): a namespace reset clears the value stored beneath an env pin, so the old value does not come back when the variable is removed + +Clause-②: no +Metadata-protocol: none + +- **The reset clears beneath a pin.** `SettingsService.resetNamespace` and the built-in `reset` settings action skipped every key an `OS_*` override in force pins, so a value stored beneath the pin survived the reset and took over again the day the variable was removed. The reset now clears that stored value too, with the same clear a single `null` write to a pinned key makes: it changes nothing in force (the key still answers the env value, `source: 'env'`, `locked: true`), it records the same `reset` audit row and change event, and once the variable is removed the key reads its default. A pinned key with nothing stored beneath it is left alone, and a key no variable pins is reset as before. +- **The count includes those keys.** `resetNamespace` returns the number of keys it cleared, pinned ones included. +- **The reset's answer says what is still pinned.** When an environment variable still pins some of the namespace's keys, the `reset` action's message adds one sentence with their number ("2 setting(s) remain pinned by environment variables."), in all eleven languages the answer is written in. A namespace no variable pins gets the same message as before. diff --git a/packages/services/service-settings/src/settings-action-copy.ts b/packages/services/service-settings/src/settings-action-copy.ts index c3994aa557e..57bb013d6e6 100644 --- a/packages/services/service-settings/src/settings-action-copy.ts +++ b/packages/services/service-settings/src/settings-action-copy.ts @@ -20,6 +20,13 @@ export interface SettingsActionCopy { resetCleared(count: number): string; /** The built-in reset found no saved value to clear. */ resetNothing: string; + /** + * Appended to either reset answer when `count` (> 0) of the namespace's keys + * are still pinned by an environment variable, which the reset leaves in + * force. Written to follow the answer directly: it carries the space its + * language puts between two sentences, and none where it puts none. + */ + resetStillPinned(count: number): string; /** No handler is registered for `actionId` in `namespace`. */ noHandler(actionId: string, namespace: string): string; } @@ -29,26 +36,31 @@ export const SETTINGS_ACTION_COPY: Record `Cleared ${n} saved value(s); environment/default configuration is back in effect.`, resetNothing: 'No saved values to clear — already using environment/default configuration.', + resetStillPinned: (n) => ` ${n} setting(s) remain pinned by environment variables.`, noHandler: (actionId, namespace) => `No handler registered for action '${actionId}' in '${namespace}'.`, }, 'zh-CN': { resetCleared: (n) => `已清除 ${n} 个已保存的值;环境变量/默认配置已重新生效。`, resetNothing: '没有可清除的已保存值:当前已在使用环境变量/默认配置。', + resetStillPinned: (n) => `仍有 ${n} 个设置由环境变量固定。`, noHandler: (actionId, namespace) => `“${namespace}”中的操作“${actionId}”没有注册处理程序。`, }, 'zh-Hant': { resetCleared: (n) => `已清除 ${n} 個已儲存的值;環境變數/預設設定已重新生效。`, resetNothing: '沒有可清除的已儲存值:目前已在使用環境變數/預設設定。', + resetStillPinned: (n) => `仍有 ${n} 個設定由環境變數固定。`, noHandler: (actionId, namespace) => `「${namespace}」中的操作「${actionId}」沒有註冊處理常式。`, }, 'ja-JP': { resetCleared: (n) => `保存済みの値を ${n} 件消去しました。環境変数/既定の設定が再び有効になっています。`, resetNothing: '消去する保存済みの値はありません。すでに環境変数/既定の設定を使用しています。', + resetStillPinned: (n) => `${n} 件の設定は引き続き環境変数で固定されています。`, noHandler: (actionId, namespace) => `「${namespace}」のアクション「${actionId}」にはハンドラーが登録されていません。`, }, ko: { resetCleared: (n) => `저장된 값 ${n}개를 지웠습니다. 환경 변수/기본 설정이 다시 적용됩니다.`, resetNothing: '지울 저장된 값이 없습니다. 이미 환경 변수/기본 설정을 사용하고 있습니다.', + resetStillPinned: (n) => ` 설정 ${n}개는 여전히 환경 변수로 고정되어 있습니다.`, noHandler: (actionId, namespace) => `'${namespace}'의 '${actionId}' 작업에 등록된 핸들러가 없습니다.`, }, 'es-ES': { @@ -56,6 +68,10 @@ export const SETTINGS_ACTION_COPY: Record + n === 1 + ? ' 1 ajuste sigue fijado por una variable de entorno.' + : ` ${n} ajustes siguen fijados por variables de entorno.`, noHandler: (actionId, namespace) => `No hay ningún controlador registrado para la acción «${actionId}» en «${namespace}».`, }, @@ -65,6 +81,7 @@ export const SETTINGS_ACTION_COPY: Record ` عدد الإعدادات التي لا تزال مثبّتة بمتغيرات البيئة: ${n}.`, noHandler: (actionId, namespace) => `لا يوجد معالج مسجّل للإجراء «${actionId}» في «${namespace}».`, }, fr: { @@ -72,6 +89,10 @@ export const SETTINGS_ACTION_COPY: Record + n === 1 + ? ' 1 paramètre reste fixé par une variable d’environnement.' + : ` ${n} paramètres restent fixés par des variables d’environnement.`, noHandler: (actionId, namespace) => `Aucun gestionnaire n’est enregistré pour l’action « ${actionId} » dans « ${namespace} ».`, }, @@ -80,12 +101,17 @@ export const SETTINGS_ACTION_COPY: Record + n === 1 + ? ' 1 configuração continua fixada por uma variável de ambiente.' + : ` ${n} configurações continuam fixadas por variáveis de ambiente.`, noHandler: (actionId, namespace) => `Nenhum manipulador registrado para a ação "${actionId}" em "${namespace}".`, }, ru: { resetCleared: (n) => `Очищено сохранённых значений: ${n}; снова действуют настройки из окружения или по умолчанию.`, resetNothing: 'Нет сохранённых значений для очистки: уже используются настройки из окружения или по умолчанию.', + resetStillPinned: (n) => ` Настроек, по-прежнему закреплённых переменными окружения: ${n}.`, noHandler: (actionId, namespace) => `Для действия «${actionId}» в «${namespace}» не зарегистрирован обработчик.`, }, de: { @@ -95,6 +121,10 @@ export const SETTINGS_ACTION_COPY: Record + n === 1 + ? ' 1 Einstellung bleibt durch eine Umgebungsvariable festgelegt.' + : ` ${n} Einstellungen bleiben durch Umgebungsvariablen festgelegt.`, noHandler: (actionId, namespace) => `Für die Aktion „${actionId}“ in „${namespace}“ ist kein Handler registriert.`, }, }; diff --git a/packages/services/service-settings/src/settings-copy-locale.test.ts b/packages/services/service-settings/src/settings-copy-locale.test.ts index 5444e86ef7f..dbb47ad8a76 100644 --- a/packages/services/service-settings/src/settings-copy-locale.test.ts +++ b/packages/services/service-settings/src/settings-copy-locale.test.ts @@ -28,6 +28,7 @@ const SAMPLE_ARGS: Record = { lockedText: ['2 objects'], unsetText: ['2 objects'], resetCleared: [3], + resetStillPinned: [2], noHandler: ['probe', 'branding'], }; diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 3a3ab08fc5f..6058880afdb 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -378,7 +378,7 @@ describe('SettingsService — resetNamespace / built-in reset action', () => { expect(r.value).toBeNull(); }); - it('leaves env-locked keys untouched', async () => { + it('leaves an env-locked key with nothing stored beneath it untouched', async () => { const svc = new SettingsService({ env: { OS_BRANDING_WORKSPACE_NAME: 'EnvCorp' } }); svc.registerManifest(brandingSettingsManifest); await expect(svc.resetNamespace('branding')).resolves.toBe(0); @@ -2880,3 +2880,92 @@ describe('SettingsService — a clear goes through an env pin, every other write expect(answer.banner).toMatchObject({ value: 'Pinned banner', source: 'env', locked: true }); }); }); + +describe('SettingsService — a reset clears the value stored beneath an env pin', () => { + const RESETTABLE = { + namespace: 'resettable', + version: 1, + label: 'Resettable', + scope: 'tenant', + specifiers: [ + { type: 'text', key: 'motto', label: 'Motto', scope: 'tenant', default: 'none' }, + { type: 'text', key: 'banner', label: 'Banner', scope: 'tenant', default: 'none' }, + { type: 'text', key: 'footer', label: 'Footer', scope: 'tenant', default: 'none' }, + ], + } as any; + + /** `motto` pinned over a stored value, `banner` stored and not pinned, `footer` pinned with nothing stored. */ + async function namespaceWithPins() { + const env: Record = {}; + const audit: Array> = []; + const svc = new SettingsService({ + env, + auditWriter: { write: async (row: unknown) => { audit.push(row as Record); } }, + } as any); + svc.registerManifest(RESETTABLE); + await svc.setMany('resettable', { motto: 'Stored motto', banner: 'Stored banner' }); + env.OS_RESETTABLE_MOTTO = 'Pinned motto'; + env.OS_RESETTABLE_FOOTER = 'Pinned footer'; + audit.length = 0; + return { env, svc, audit }; + } + + it('clears the pinned key\'s stored value, counts it, and changes nothing in force while the pin holds', async () => { + const { env, svc } = await namespaceWithPins(); + const changes: Array<{ key: string; action: string }> = []; + svc.subscribe('resettable', (e) => { changes.push(e as { key: string; action: string }); }); + + await expect(svc.resetNamespace('resettable')).resolves.toBe(2); + expect(changes.map((c) => [c.key, c.action]).sort()).toEqual([['banner', 'reset'], ['motto', 'reset']]); + expect(await svc.get('resettable', 'motto')).toMatchObject({ value: 'Pinned motto', source: 'env', locked: true }); + expect(await svc.get('resettable', 'footer')).toMatchObject({ value: 'Pinned footer', source: 'env', locked: true }); + expect(await svc.get('resettable', 'banner')).toMatchObject({ value: 'none', source: 'default' }); + + // Lift the pins: each key reads its default, not the value once stored beneath it. + delete env.OS_RESETTABLE_MOTTO; + delete env.OS_RESETTABLE_FOOTER; + expect(await svc.get('resettable', 'motto')).toMatchObject({ value: 'none', source: 'default', locked: false }); + expect(await svc.get('resettable', 'footer')).toMatchObject({ value: 'none', source: 'default', locked: false }); + }); + + it('records the pinned key\'s reset as a single clear of it records it', async () => { + const reset = await namespaceWithPins(); + await reset.svc.resetNamespace('resettable'); + const single = await namespaceWithPins(); + await single.svc.set('resettable', 'motto', null); + + const motto = (rows: Array>) => rows.filter((r) => r.key === 'motto'); + expect(motto(reset.audit)).toEqual(motto(single.audit)); + expect(motto(reset.audit)).toEqual([expect.objectContaining({ action: 'reset', newHash: null })]); + expect(reset.audit.map((r) => r.key).sort()).toEqual(['banner', 'motto']); + }); + + it('a pin with nothing stored beneath it is not cleared, and a second reset clears nothing', async () => { + const { svc, audit } = await namespaceWithPins(); + await svc.resetNamespace('resettable'); + audit.length = 0; + await expect(svc.resetNamespace('resettable')).resolves.toBe(0); + expect(audit).toEqual([]); + }); + + it('the reset action counts the pinned key it cleared and says how many keys the environment still pins', async () => { + const { svc } = await namespaceWithPins(); + const first = await svc.runAction('resettable', 'reset', null); + expect(first).toMatchObject({ ok: true, severity: 'info' }); + expect(first.message).toBe( + 'Cleared 2 saved value(s); environment/default configuration is back in effect. ' + + '2 setting(s) remain pinned by environment variables.', + ); + + const second = await svc.runAction('resettable', 'reset', null, {}, { locale: 'zh-CN' }); + expect(second.message).toBe('没有可清除的已保存值:当前已在使用环境变量/默认配置。仍有 2 个设置由环境变量固定。'); + }); + + it('a namespace no environment variable pins answers without the pinned sentence', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(RESETTABLE); + await svc.set('resettable', 'motto', 'Stored motto'); + const r = await svc.runAction('resettable', 'reset', null); + expect(r.message).toBe('Cleared 1 saved value(s); environment/default configuration is back in effect.'); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 1c84d5028b6..c66f6dbbdc6 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1963,6 +1963,21 @@ export class SettingsService { }; } + return this.resolveStored(reg, namespace, key, ctx); + } + + /** + * The stored cascade of one key, below any env override: what {@link get} + * answers when no override is in force, and what a key an override pins + * falls back to once the override is removed ({@link resetNamespace} reads + * it to find a value stored beneath a pin). + */ + private async resolveStored( + reg: RegisteredManifest, + namespace: string, + key: string, + ctx: SettingsContext, + ): Promise> { const scope = reg.scopes.get(key)!; // For 'user' scope we pre-filter by user_id; for 'tenant' and 'global' // we load everything for the namespace and pick the right row below. @@ -3355,23 +3370,46 @@ export class SettingsService { /** * Clear every persisted row in a namespace so values fall back to - * env/defaults. Env-locked keys are untouched (env wins over rows - * anyway and refuses writes). Persisted rows are nulled rather than - * deleted so the audit trail records the reset per key. + * env/defaults. Persisted rows are nulled rather than deleted so the audit + * trail records the reset per key. + * + * A key an env override in force pins is cleared too when a value is stored + * beneath the pin, by the clear {@link setMany} lets through a pin (ADR-0007 + * D6): it changes nothing in force — the env value still answers — and the + * key falls back to its default once the override is removed, instead of to + * the old stored value. * - * Returns the number of cleared keys. + * Returns the number of cleared keys, pinned ones included. */ async resetNamespace(namespace: string, ctx: SettingsContext = {}): Promise { + return (await this.resetNamespaceRows(namespace, ctx)).cleared; + } + + /** + * {@link resetNamespace}, also answering how many of the namespace's keys an + * env override still pins after the reset: those keep their env value, and + * the built-in `reset` action says so. + */ + private async resetNamespaceRows( + namespace: string, + ctx: SettingsContext, + ): Promise<{ cleared: number; pinned: number }> { const payload = await this.getNamespace(namespace, ctx); + const reg = this.registry.get(namespace)!; const patch: Record = {}; + let pinned = 0; for (const [key, v] of Object.entries(payload.values)) { - if (v.source === 'global' || v.source === 'tenant' || v.source === 'user') { + // A pinned key answers `env` and hides its stored rows, so whether a + // value is stored beneath the pin is read from the cascade below it. + if (v.source === 'env') pinned++; + const stored = v.source === 'env' ? (await this.resolveStored(reg, namespace, key, ctx)).source : v.source; + if (stored === 'global' || stored === 'tenant' || stored === 'user') { patch[key] = null; } } const keys = Object.keys(patch); if (keys.length > 0) await this.setMany(namespace, patch, ctx); - return keys.length; + return { cleared: keys.length, pinned }; } /** @@ -3403,11 +3441,14 @@ export class SettingsService { // `settingsActionCopy`), as a handler's own answer is. const copy = settingsActionCopy(display.locale); if (actionId === 'reset') { - const cleared = await this.resetNamespace(namespace, ctx); + const { cleared, pinned } = await this.resetNamespaceRows(namespace, ctx); + const answer = cleared > 0 ? copy.resetCleared(cleared) : copy.resetNothing; return { ok: true, severity: 'info', - message: cleared > 0 ? copy.resetCleared(cleared) : copy.resetNothing, + // A key an env override pins keeps its env value through the reset, + // so the answer says how many still do. + message: pinned > 0 ? answer + copy.resetStillPinned(pinned) : answer, }; } return {