Files
atomic-design-poc/libs/shared/src/application/feature-flags.store.ts
T
ehoandClaude Opus 5 4b94f8edb5 fix(flags): surface a failed admin toggle instead of swallowing it
FeatureFlagStore.set() was try/finally with no catch. A rejected
PUT /admin/flags/{key} escaped into the `void this.store.set(...)` call site
as an unhandled promise rejection; the finally-block reload then snapped the
control back to its old value. The admin saw a toggle that silently refused
to move, with no error rendered anywhere and nothing in the state.

set() now folds through the existing runSubmit helper and returns
Result<string, void>, reloading either way so the state still reflects the
server. The page awaits it and renders the failure in an app-alert.

Found by the CQRS-light pass (CQ-002/CQ-004) as one of three mutations that
reach the raw ApiClient without producing a Result — the baseline's BL-007
inventory had missed all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:29 +02:00

68 lines
2.6 KiB
TypeScript

import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { runSubmit } from '@shared/application/submit';
import { Result, ok, err } from '@shared/kernel/fp';
import { FeatureFlag } from '@shared/domain/feature-flag';
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
type Err = Error | undefined;
const SET_FAILED = $localize`:@@flags.set.failed:De functievlag kon niet worden opgeslagen.`;
/**
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
* server-owned; the FE only mirrors + renders it.
*/
@Injectable({ providedIn: 'root' })
export class FeatureFlagStore {
private adapter = inject(FeatureFlagsAdapter);
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
readonly flags = this.state.asReadonly();
/** The resolved list (empty until loaded) — for the admin toggle UI. */
readonly all = computed(() => {
const rd = this.state();
return rd.tag === 'Success' ? rd.value : [];
});
constructor() {
void this.load();
}
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseFlags(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
enabled(key: string): boolean {
const rd = this.state();
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
}
/**
* Admin toggle: persist, then reload so the state reflects the server either way.
*
* Returns the failure rather than throwing. The previous `try/finally` had no `catch`,
* so a rejected PUT escaped into the `void store.set(...)` call site as an unhandled
* rejection: the reload then snapped the toggle back to its old value and the admin saw
* a control that silently refused to move, with no error anywhere.
*/
async set(key: string, enabled: boolean): Promise<Result<string, void>> {
const r = await runSubmit(() => this.adapter.set(key, enabled), SET_FAILED);
await this.load();
return r.ok ? ok(undefined) : err(r.error);
}
}