Skip to content

Commit a255a62

Browse files
fix(service-settings): a namespace reset clears the value stored beneath an env pin (#30793)
Fixes #30664 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:设置命名空间的「重置」现在也会清掉被环境变量钉住的设置项下面存着的旧值;当前生效的仍是环境变量。重置的提示会多一句,说明还有几项被环境变量钉住(十一种语言)。 - 为什么改:以前重置跳过被钉住的项,提示却说已回到环境/默认配置;哪天去掉环境变量,存着的旧值又会生效,正是 #30385 刚修过的那个坑。 - 风险与回滚:只多清了「钉住项下面的存值」,走的是 #30385 已放行的同一条清除路径,审计与变更事件和单项清除完全一样;没有钉住的项照旧。回滚即撤本 PR。 - 先看:`packages/services/service-settings/src/settings-service.ts` 的 `resetNamespaceRows`(读钉住项下面的存值)与 `runAction` 的 reset 分支;`settings-action-copy.ts` 新增的 `resetStillPinned`;`settings-service.test.ts` 末尾的新 describe。 - 两分钟验证:`pnpm --filter @objectstack/service-settings exec vitest run src/settings-service.test.ts -t 'beneath an env pin'`,预期 5 条全绿。 - 需要你做的:无,席位落地。 ## What changes **The rule: a namespace reset clears every stored value it can reach, the one beneath an env pin included, and says how many keys the environment still pins.** - **`resetNamespace` (`@objectstack/service-settings`).** It built its patch from keys whose resolved source is a stored rung (`global` / `tenant` / `user`). A key an `OS_*` override in force pins resolves `env`, so a value stored beneath the pin was never cleared and took over again once the variable was removed, while the built-in reset said "environment/default configuration is back in effect". For a key that resolves `env`, the reset now reads the stored cascade beneath the pin (`resolveStored`, the half of `get()` below the env branch, extracted rather than copied, so `get()` is unchanged) and, when that names a stored rung, puts the key in the same all-`null` patch. The patch goes through `setMany`, whose env-lock branch already lets a clear through a pin since PR #30448 (ADR-0007 D6 as amended), so there is no second bypass of the env lock: the clear meets the same row locks, platform-rung rule and write predicates a single clear meets. Nothing in force changes: the key still answers `{ source: 'env', locked: true }`. A pinned key with nothing stored beneath it is not in the patch, so it writes no row and no audit entry. The count it returns includes the pinned keys it cleared; its signature is unchanged. - **The reset action's answer (`settings-action-copy.ts`).** `runAction`'s built-in `reset` keeps `resetCleared` / `resetNothing` as they were (their wording is true now that nothing stored survives the reset) and, when some keys are still pinned, appends one new sentence, `resetStillPinned(count)`, in all eleven languages: "2 setting(s) remain pinned by environment variables." Each sentence carries its own leading space where its language separates sentences with one (none in zh-CN, zh-Hant, ja-JP). A namespace no variable pins gets exactly the message it got before. - **Audit / config-change rows.** The reset of a pinned key goes through `setMany`'s write loop like any clear, so it records exactly what a single clear of that key records: one `reset` row on each ledger (`newHash: null`) and one `reset` change event. Pinned in a test that compares the two writer rows field for field. ## Tests `packages/services/service-settings/src/settings-service.test.ts`, new top-level describe (5 cases), on the real `SettingsService`, with one key pinned over a stored value, one stored and not pinned, and one pinned with nothing stored: - the reset returns 2 (the pinned key counted), fires `reset` for those two keys only, both pinned keys still answer env and locked, and once the variables are removed both read their default; - the pinned key's audit row from the reset equals the one from a single `set(…, null)`; - a pin with nothing stored beneath it is not cleared, and a second reset clears nothing and writes no audit row; - the reset action's message in English and zh-CN carries the pinned sentence (with the count) after the cleared or nothing-to-clear answer; - a namespace with no pins answers the old message byte for byte. One existing case was renamed to what it pins now ("leaves an env-locked key with nothing stored beneath it untouched"); its assertions are unchanged. `settings-copy-locale.test.ts` gets a sample argument for `resetStillPinned`, so its existing every-language checks cover the new sentence. <details><summary>Suites and typecheck (head 9b806cb)</summary> - `pnpm --filter @objectstack/service-settings test`: Test Files 70 passed (70), Tests 1206 passed (1206). `typecheck` (`tsc --noEmit`, includes `src/**/*.test.ts`): exit 0. - `pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts src/auth-settings-clear.pin.test.ts` (the two callers of `resetNamespace` outside the package, after building their closure): Test Files 2 passed, Tests 39 passed. </details> <details><summary>Ablations (each through `scripts/ablation-replace.mjs` on `settings-service.ts`: anchor 1 → 0, blob changed, restore blob == HEAD blob, `git diff HEAD` empty; the tests import the service from source, so no build sits between)</summary> - A. the reset reads the resolved source only, as before (`const stored = v.source; /* ABLATION-30664-A */`): 3 failed | 2 passed (the count and default-after-unpin case, the audit comparison, the action message); the two that pass pin what must not change (nothing stored beneath a pin; no pins at all). - B. the pinned sentence dropped (`message: answer, /* ABLATION-30664-B */`): 1 failed | 4 passed (the action message). </details> ## Acceptance notes - **A reset now meets the row locks for a pinned key too.** A pinned key whose stored value sits beneath a locked upper-rung row is now in the reset's patch, so `setMany` refuses the whole reset `SETTINGS_LOCKED` (`locked-by-global` / `locked-by-tenant`) for a caller below that rung, exactly as it already did for an unpinned key in that position. That is the clear's own rule from PR #30448, not a new one. - **The reset clears at the key's declared rung.** As before for unpinned keys, the clear writes `null` at the key's own rung for this caller; a value stored only at a higher rung the caller cannot write stays, and the key falls back to it once the pin goes. Unchanged behaviour, now applied to pinned keys as well. - **Published surface.** The action's `message` is the toast text the console prints and the HTTP action route returns; it gains one sentence only when keys are still pinned. No export, type, error code or route changes; `resetNamespace` keeps its signature. - **Console.** `SettingsField` still hides "Reset to default" on a pinned field; the namespace-level reset now reaches the value beneath it. Nothing in `ui/` changes here. ## Verification All readings on head `9b806cbe` (branched from `origin/main` `1371b9d8`). <details><summary>Derived gates: 60 derived, 59 exit 0, 1 NOT MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands for the 5 changed paths; each ran with its exit code captured before any pipe. `--ran` reconciliation (recorded as `command :: exit N`): "60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET (it needs a whole-workspace build; only the closures of `service-settings` and `plugin-auth` were built here). CI's `Lint & Repo Gates` carries it. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cytLmJxfAmgPVjUbbW)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 177f0ee commit a255a62

5 files changed

Lines changed: 182 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **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.
11+
- **The count includes those keys.** `resetNamespace` returns the number of keys it cleared, pinned ones included.
12+
- **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.

‎packages/services/service-settings/src/settings-action-copy.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export interface SettingsActionCopy {
2020
resetCleared(count: number): string;
2121
/** The built-in reset found no saved value to clear. */
2222
resetNothing: string;
23+
/**
24+
* Appended to either reset answer when `count` (> 0) of the namespace's keys
25+
* are still pinned by an environment variable, which the reset leaves in
26+
* force. Written to follow the answer directly: it carries the space its
27+
* language puts between two sentences, and none where it puts none.
28+
*/
29+
resetStillPinned(count: number): string;
2330
/** No handler is registered for `actionId` in `namespace`. */
2431
noHandler(actionId: string, namespace: string): string;
2532
}
@@ -29,33 +36,42 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
2936
en: {
3037
resetCleared: (n) => `Cleared ${n} saved value(s); environment/default configuration is back in effect.`,
3138
resetNothing: 'No saved values to clear — already using environment/default configuration.',
39+
resetStillPinned: (n) => ` ${n} setting(s) remain pinned by environment variables.`,
3240
noHandler: (actionId, namespace) => `No handler registered for action '${actionId}' in '${namespace}'.`,
3341
},
3442
'zh-CN': {
3543
resetCleared: (n) => `已清除 ${n} 个已保存的值;环境变量/默认配置已重新生效。`,
3644
resetNothing: '没有可清除的已保存值:当前已在使用环境变量/默认配置。',
45+
resetStillPinned: (n) => `仍有 ${n} 个设置由环境变量固定。`,
3746
noHandler: (actionId, namespace) => `“${namespace}”中的操作“${actionId}”没有注册处理程序。`,
3847
},
3948
'zh-Hant': {
4049
resetCleared: (n) => `已清除 ${n} 個已儲存的值;環境變數/預設設定已重新生效。`,
4150
resetNothing: '沒有可清除的已儲存值:目前已在使用環境變數/預設設定。',
51+
resetStillPinned: (n) => `仍有 ${n} 個設定由環境變數固定。`,
4252
noHandler: (actionId, namespace) => `「${namespace}」中的操作「${actionId}」沒有註冊處理常式。`,
4353
},
4454
'ja-JP': {
4555
resetCleared: (n) => `保存済みの値を ${n} 件消去しました。環境変数/既定の設定が再び有効になっています。`,
4656
resetNothing: '消去する保存済みの値はありません。すでに環境変数/既定の設定を使用しています。',
57+
resetStillPinned: (n) => `${n} 件の設定は引き続き環境変数で固定されています。`,
4758
noHandler: (actionId, namespace) => `「${namespace}」のアクション「${actionId}」にはハンドラーが登録されていません。`,
4859
},
4960
ko: {
5061
resetCleared: (n) => `저장된 값 ${n}개를 지웠습니다. 환경 변수/기본 설정이 다시 적용됩니다.`,
5162
resetNothing: '지울 저장된 값이 없습니다. 이미 환경 변수/기본 설정을 사용하고 있습니다.',
63+
resetStillPinned: (n) => ` 설정 ${n}개는 여전히 환경 변수로 고정되어 있습니다.`,
5264
noHandler: (actionId, namespace) => `'${namespace}'의 '${actionId}' 작업에 등록된 핸들러가 없습니다.`,
5365
},
5466
'es-ES': {
5567
resetCleared: (n) =>
5668
`Se ${n === 1 ? 'ha borrado 1 valor guardado' : `han borrado ${n} valores guardados`}; vuelve a aplicarse `
5769
+ 'la configuración del entorno o la predeterminada.',
5870
resetNothing: 'No hay valores guardados que borrar: ya se usa la configuración del entorno o la predeterminada.',
71+
resetStillPinned: (n) =>
72+
n === 1
73+
? ' 1 ajuste sigue fijado por una variable de entorno.'
74+
: ` ${n} ajustes siguen fijados por variables de entorno.`,
5975
noHandler: (actionId, namespace) =>
6076
`No hay ningún controlador registrado para la acción «${actionId}» en «${namespace}».`,
6177
},
@@ -65,13 +81,18 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
6581
one: 'قيمة محفوظة واحدة', two: 'قيمتين محفوظتين', few: 'قيم محفوظة', many: 'قيمة محفوظة', other: 'قيمة محفوظة',
6682
})}؛ وعاد إعداد البيئة أو الإعداد الافتراضي إلى العمل.`,
6783
resetNothing: 'لا توجد قيم محفوظة لمسحها، فإعداد البيئة أو الإعداد الافتراضي مستخدم بالفعل.',
84+
resetStillPinned: (n) => ` عدد الإعدادات التي لا تزال مثبّتة بمتغيرات البيئة: ${n}.`,
6885
noHandler: (actionId, namespace) => `لا يوجد معالج مسجّل للإجراء «${actionId}» في «${namespace}».`,
6986
},
7087
fr: {
7188
resetCleared: (n) =>
7289
`${n === 1 ? '1 valeur enregistrée effacée' : `${n} valeurs enregistrées effacées`} ; la configuration de `
7390
+ 'l’environnement ou par défaut s’applique de nouveau.',
7491
resetNothing: 'Aucune valeur enregistrée à effacer : la configuration de l’environnement ou par défaut est déjà utilisée.',
92+
resetStillPinned: (n) =>
93+
n === 1
94+
? ' 1 paramètre reste fixé par une variable d’environnement.'
95+
: ` ${n} paramètres restent fixés par des variables d’environnement.`,
7596
noHandler: (actionId, namespace) =>
7697
`Aucun gestionnaire n’est enregistré pour l’action « ${actionId} » dans « ${namespace} ».`,
7798
},
@@ -80,12 +101,17 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
80101
`${n === 1 ? '1 valor salvo foi apagado' : `${n} valores salvos foram apagados`}; a configuração do ambiente `
81102
+ 'ou padrão voltou a valer.',
82103
resetNothing: 'Não há valores salvos para apagar: a configuração do ambiente ou padrão já está em uso.',
104+
resetStillPinned: (n) =>
105+
n === 1
106+
? ' 1 configuração continua fixada por uma variável de ambiente.'
107+
: ` ${n} configurações continuam fixadas por variáveis de ambiente.`,
83108
noHandler: (actionId, namespace) => `Nenhum manipulador registrado para a ação "${actionId}" em "${namespace}".`,
84109
},
85110
ru: {
86111
resetCleared: (n) =>
87112
`Очищено сохранённых значений: ${n}; снова действуют настройки из окружения или по умолчанию.`,
88113
resetNothing: 'Нет сохранённых значений для очистки: уже используются настройки из окружения или по умолчанию.',
114+
resetStillPinned: (n) => ` Настроек, по-прежнему закреплённых переменными окружения: ${n}.`,
89115
noHandler: (actionId, namespace) => `Для действия «${actionId}» в «${namespace}» не зарегистрирован обработчик.`,
90116
},
91117
de: {
@@ -95,6 +121,10 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
95121
resetNothing:
96122
'Keine gespeicherten Werte zu löschen: Es gilt bereits die Konfiguration aus der Umgebung bzw. die '
97123
+ 'Standardkonfiguration.',
124+
resetStillPinned: (n) =>
125+
n === 1
126+
? ' 1 Einstellung bleibt durch eine Umgebungsvariable festgelegt.'
127+
: ` ${n} Einstellungen bleiben durch Umgebungsvariablen festgelegt.`,
98128
noHandler: (actionId, namespace) => `Für die Aktion „${actionId}“ in „${namespace}“ ist kein Handler registriert.`,
99129
},
100130
};

‎packages/services/service-settings/src/settings-copy-locale.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const SAMPLE_ARGS: Record<string, unknown[]> = {
2828
lockedText: ['2 objects'],
2929
unsetText: ['2 objects'],
3030
resetCleared: [3],
31+
resetStillPinned: [2],
3132
noHandler: ['probe', 'branding'],
3233
};
3334

‎packages/services/service-settings/src/settings-service.test.ts‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ describe('SettingsService — resetNamespace / built-in reset action', () => {
378378
expect(r.value).toBeNull();
379379
});
380380

381-
it('leaves env-locked keys untouched', async () => {
381+
it('leaves an env-locked key with nothing stored beneath it untouched', async () => {
382382
const svc = new SettingsService({ env: { OS_BRANDING_WORKSPACE_NAME: 'EnvCorp' } });
383383
svc.registerManifest(brandingSettingsManifest);
384384
await expect(svc.resetNamespace('branding')).resolves.toBe(0);
@@ -2880,3 +2880,92 @@ describe('SettingsService — a clear goes through an env pin, every other write
28802880
expect(answer.banner).toMatchObject({ value: 'Pinned banner', source: 'env', locked: true });
28812881
});
28822882
});
2883+
2884+
describe('SettingsService — a reset clears the value stored beneath an env pin', () => {
2885+
const RESETTABLE = {
2886+
namespace: 'resettable',
2887+
version: 1,
2888+
label: 'Resettable',
2889+
scope: 'tenant',
2890+
specifiers: [
2891+
{ type: 'text', key: 'motto', label: 'Motto', scope: 'tenant', default: 'none' },
2892+
{ type: 'text', key: 'banner', label: 'Banner', scope: 'tenant', default: 'none' },
2893+
{ type: 'text', key: 'footer', label: 'Footer', scope: 'tenant', default: 'none' },
2894+
],
2895+
} as any;
2896+
2897+
/** `motto` pinned over a stored value, `banner` stored and not pinned, `footer` pinned with nothing stored. */
2898+
async function namespaceWithPins() {
2899+
const env: Record<string, string | undefined> = {};
2900+
const audit: Array<Record<string, unknown>> = [];
2901+
const svc = new SettingsService({
2902+
env,
2903+
auditWriter: { write: async (row: unknown) => { audit.push(row as Record<string, unknown>); } },
2904+
} as any);
2905+
svc.registerManifest(RESETTABLE);
2906+
await svc.setMany('resettable', { motto: 'Stored motto', banner: 'Stored banner' });
2907+
env.OS_RESETTABLE_MOTTO = 'Pinned motto';
2908+
env.OS_RESETTABLE_FOOTER = 'Pinned footer';
2909+
audit.length = 0;
2910+
return { env, svc, audit };
2911+
}
2912+
2913+
it('clears the pinned key\'s stored value, counts it, and changes nothing in force while the pin holds', async () => {
2914+
const { env, svc } = await namespaceWithPins();
2915+
const changes: Array<{ key: string; action: string }> = [];
2916+
svc.subscribe('resettable', (e) => { changes.push(e as { key: string; action: string }); });
2917+
2918+
await expect(svc.resetNamespace('resettable')).resolves.toBe(2);
2919+
expect(changes.map((c) => [c.key, c.action]).sort()).toEqual([['banner', 'reset'], ['motto', 'reset']]);
2920+
expect(await svc.get('resettable', 'motto')).toMatchObject({ value: 'Pinned motto', source: 'env', locked: true });
2921+
expect(await svc.get('resettable', 'footer')).toMatchObject({ value: 'Pinned footer', source: 'env', locked: true });
2922+
expect(await svc.get('resettable', 'banner')).toMatchObject({ value: 'none', source: 'default' });
2923+
2924+
// Lift the pins: each key reads its default, not the value once stored beneath it.
2925+
delete env.OS_RESETTABLE_MOTTO;
2926+
delete env.OS_RESETTABLE_FOOTER;
2927+
expect(await svc.get('resettable', 'motto')).toMatchObject({ value: 'none', source: 'default', locked: false });
2928+
expect(await svc.get('resettable', 'footer')).toMatchObject({ value: 'none', source: 'default', locked: false });
2929+
});
2930+
2931+
it('records the pinned key\'s reset as a single clear of it records it', async () => {
2932+
const reset = await namespaceWithPins();
2933+
await reset.svc.resetNamespace('resettable');
2934+
const single = await namespaceWithPins();
2935+
await single.svc.set('resettable', 'motto', null);
2936+
2937+
const motto = (rows: Array<Record<string, unknown>>) => rows.filter((r) => r.key === 'motto');
2938+
expect(motto(reset.audit)).toEqual(motto(single.audit));
2939+
expect(motto(reset.audit)).toEqual([expect.objectContaining({ action: 'reset', newHash: null })]);
2940+
expect(reset.audit.map((r) => r.key).sort()).toEqual(['banner', 'motto']);
2941+
});
2942+
2943+
it('a pin with nothing stored beneath it is not cleared, and a second reset clears nothing', async () => {
2944+
const { svc, audit } = await namespaceWithPins();
2945+
await svc.resetNamespace('resettable');
2946+
audit.length = 0;
2947+
await expect(svc.resetNamespace('resettable')).resolves.toBe(0);
2948+
expect(audit).toEqual([]);
2949+
});
2950+
2951+
it('the reset action counts the pinned key it cleared and says how many keys the environment still pins', async () => {
2952+
const { svc } = await namespaceWithPins();
2953+
const first = await svc.runAction('resettable', 'reset', null);
2954+
expect(first).toMatchObject({ ok: true, severity: 'info' });
2955+
expect(first.message).toBe(
2956+
'Cleared 2 saved value(s); environment/default configuration is back in effect. '
2957+
+ '2 setting(s) remain pinned by environment variables.',
2958+
);
2959+
2960+
const second = await svc.runAction('resettable', 'reset', null, {}, { locale: 'zh-CN' });
2961+
expect(second.message).toBe('没有可清除的已保存值:当前已在使用环境变量/默认配置。仍有 2 个设置由环境变量固定。');
2962+
});
2963+
2964+
it('a namespace no environment variable pins answers without the pinned sentence', async () => {
2965+
const svc = new SettingsService({ env: {} });
2966+
svc.registerManifest(RESETTABLE);
2967+
await svc.set('resettable', 'motto', 'Stored motto');
2968+
const r = await svc.runAction('resettable', 'reset', null);
2969+
expect(r.message).toBe('Cleared 1 saved value(s); environment/default configuration is back in effect.');
2970+
});
2971+
});

‎packages/services/service-settings/src/settings-service.ts‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,21 @@ export class SettingsService {
19631963
};
19641964
}
19651965

1966+
return this.resolveStored<T>(reg, namespace, key, ctx);
1967+
}
1968+
1969+
/**
1970+
* The stored cascade of one key, below any env override: what {@link get}
1971+
* answers when no override is in force, and what a key an override pins
1972+
* falls back to once the override is removed ({@link resetNamespace} reads
1973+
* it to find a value stored beneath a pin).
1974+
*/
1975+
private async resolveStored<T = unknown>(
1976+
reg: RegisteredManifest,
1977+
namespace: string,
1978+
key: string,
1979+
ctx: SettingsContext,
1980+
): Promise<ResolvedSettingValue<T>> {
19661981
const scope = reg.scopes.get(key)!;
19671982
// For 'user' scope we pre-filter by user_id; for 'tenant' and 'global'
19681983
// we load everything for the namespace and pick the right row below.
@@ -3355,23 +3370,46 @@ export class SettingsService {
33553370

33563371
/**
33573372
* Clear every persisted row in a namespace so values fall back to
3358-
* env/defaults. Env-locked keys are untouched (env wins over rows
3359-
* anyway and refuses writes). Persisted rows are nulled rather than
3360-
* deleted so the audit trail records the reset per key.
3373+
* env/defaults. Persisted rows are nulled rather than deleted so the audit
3374+
* trail records the reset per key.
3375+
*
3376+
* A key an env override in force pins is cleared too when a value is stored
3377+
* beneath the pin, by the clear {@link setMany} lets through a pin (ADR-0007
3378+
* D6): it changes nothing in force — the env value still answers — and the
3379+
* key falls back to its default once the override is removed, instead of to
3380+
* the old stored value.
33613381
*
3362-
* Returns the number of cleared keys.
3382+
* Returns the number of cleared keys, pinned ones included.
33633383
*/
33643384
async resetNamespace(namespace: string, ctx: SettingsContext = {}): Promise<number> {
3385+
return (await this.resetNamespaceRows(namespace, ctx)).cleared;
3386+
}
3387+
3388+
/**
3389+
* {@link resetNamespace}, also answering how many of the namespace's keys an
3390+
* env override still pins after the reset: those keep their env value, and
3391+
* the built-in `reset` action says so.
3392+
*/
3393+
private async resetNamespaceRows(
3394+
namespace: string,
3395+
ctx: SettingsContext,
3396+
): Promise<{ cleared: number; pinned: number }> {
33653397
const payload = await this.getNamespace(namespace, ctx);
3398+
const reg = this.registry.get(namespace)!;
33663399
const patch: Record<string, null> = {};
3400+
let pinned = 0;
33673401
for (const [key, v] of Object.entries(payload.values)) {
3368-
if (v.source === 'global' || v.source === 'tenant' || v.source === 'user') {
3402+
// A pinned key answers `env` and hides its stored rows, so whether a
3403+
// value is stored beneath the pin is read from the cascade below it.
3404+
if (v.source === 'env') pinned++;
3405+
const stored = v.source === 'env' ? (await this.resolveStored(reg, namespace, key, ctx)).source : v.source;
3406+
if (stored === 'global' || stored === 'tenant' || stored === 'user') {
33693407
patch[key] = null;
33703408
}
33713409
}
33723410
const keys = Object.keys(patch);
33733411
if (keys.length > 0) await this.setMany(namespace, patch, ctx);
3374-
return keys.length;
3412+
return { cleared: keys.length, pinned };
33753413
}
33763414

33773415
/**
@@ -3403,11 +3441,14 @@ export class SettingsService {
34033441
// `settingsActionCopy`), as a handler's own answer is.
34043442
const copy = settingsActionCopy(display.locale);
34053443
if (actionId === 'reset') {
3406-
const cleared = await this.resetNamespace(namespace, ctx);
3444+
const { cleared, pinned } = await this.resetNamespaceRows(namespace, ctx);
3445+
const answer = cleared > 0 ? copy.resetCleared(cleared) : copy.resetNothing;
34073446
return {
34083447
ok: true,
34093448
severity: 'info',
3410-
message: cleared > 0 ? copy.resetCleared(cleared) : copy.resetNothing,
3449+
// A key an env override pins keeps its env value through the reset,
3450+
// so the answer says how many still do.
3451+
message: pinned > 0 ? answer + copy.resetStillPinned(pinned) : answer,
34113452
};
34123453
}
34133454
return {

0 commit comments

Comments
 (0)