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>
68 lines
2.6 KiB
TypeScript
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);
|
|
}
|
|
}
|