Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30664-reset-clears-env-pinned.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions packages/services/service-settings/src/settings-action-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -29,33 +36,42 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
en: {
resetCleared: (n) => `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': {
resetCleared: (n) =>
`Se ${n === 1 ? 'ha borrado 1 valor guardado' : `han borrado ${n} valores guardados`}; vuelve a aplicarse `
+ 'la configuración del entorno o la predeterminada.',
resetNothing: 'No hay valores guardados que borrar: ya se usa la configuración del entorno o la predeterminada.',
resetStillPinned: (n) =>
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}».`,
},
Expand All @@ -65,13 +81,18 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
one: 'قيمة محفوظة واحدة', two: 'قيمتين محفوظتين', few: 'قيم محفوظة', many: 'قيمة محفوظة', other: 'قيمة محفوظة',
})}؛ وعاد إعداد البيئة أو الإعداد الافتراضي إلى العمل.`,
resetNothing: 'لا توجد قيم محفوظة لمسحها، فإعداد البيئة أو الإعداد الافتراضي مستخدم بالفعل.',
resetStillPinned: (n) => ` عدد الإعدادات التي لا تزال مثبّتة بمتغيرات البيئة: ${n}.`,
noHandler: (actionId, namespace) => `لا يوجد معالج مسجّل للإجراء «${actionId}» في «${namespace}».`,
},
fr: {
resetCleared: (n) =>
`${n === 1 ? '1 valeur enregistrée effacée' : `${n} valeurs enregistrées effacées`} ; la configuration de `
+ 'l’environnement ou par défaut s’applique de nouveau.',
resetNothing: 'Aucune valeur enregistrée à effacer : la configuration de l’environnement ou par défaut est déjà utilisée.',
resetStillPinned: (n) =>
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} ».`,
},
Expand All @@ -80,12 +101,17 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
`${n === 1 ? '1 valor salvo foi apagado' : `${n} valores salvos foram apagados`}; a configuração do ambiente `
+ 'ou padrão voltou a valer.',
resetNothing: 'Não há valores salvos para apagar: a configuração do ambiente ou padrão já está em uso.',
resetStillPinned: (n) =>
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: {
Expand All @@ -95,6 +121,10 @@ export const SETTINGS_ACTION_COPY: Record<SettingsCopyLocale, SettingsActionCopy
resetNothing:
'Keine gespeicherten Werte zu löschen: Es gilt bereits die Konfiguration aus der Umgebung bzw. die '
+ 'Standardkonfiguration.',
resetStillPinned: (n) =>
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.`,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const SAMPLE_ARGS: Record<string, unknown[]> = {
lockedText: ['2 objects'],
unsetText: ['2 objects'],
resetCleared: [3],
resetStillPinned: [2],
noHandler: ['probe', 'branding'],
};

Expand Down
91 changes: 90 additions & 1 deletion packages/services/service-settings/src/settings-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, string | undefined> = {};
const audit: Array<Record<string, unknown>> = [];
const svc = new SettingsService({
env,
auditWriter: { write: async (row: unknown) => { audit.push(row as Record<string, unknown>); } },
} 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<Record<string, unknown>>) => 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.');
});
});
57 changes: 49 additions & 8 deletions packages/services/service-settings/src/settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,21 @@ export class SettingsService {
};
}

return this.resolveStored<T>(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<T = unknown>(
reg: RegisteredManifest,
namespace: string,
key: string,
ctx: SettingsContext,
): Promise<ResolvedSettingValue<T>> {
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.
Expand Down Expand Up @@ -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<number> {
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<string, null> = {};
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 };
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
Loading