feat(F-004): typed fail-fast config and feature flag module

- loadConfig: pure over env object, accumulates all problems, names var names only
- DATABASE_URL now required at startup; PORT/HOST/LOG_LEVEL/NODE_ENV/REDIS_URL defaulted
- flags module behind FeatureFlagProvider; unknown flags OFF; runtime setEnabled (no redeploy)
- buildApp decorates app.flags; server.ts fail-fast before app boot
- tests caught and fixed flag-store case-normalization bug before gates
- zero new dependencies; all gates approved; verify.sh green
This commit is contained in:
rikrdo
2026-08-14 22:29:18 +02:00
parent 41f144d7bd
commit 4851692031
25 changed files with 756 additions and 18 deletions

View File

@@ -0,0 +1,34 @@
/**
* Feature flags domain. Deployment and activation are separate operations:
* flags can change at runtime without redeploy. Unknown flags are OFF
* (fail-safe default for risky paths).
*/
export interface FeatureFlagProvider {
isEnabled(name: string): boolean;
}
export class InMemoryFeatureFlagStore implements FeatureFlagProvider {
private readonly state: Map<string, boolean>;
constructor(initial: Readonly<Record<string, boolean>> = {}) {
this.state = new Map(
Object.entries(initial).map(([name, enabled]) => [name.toLowerCase(), enabled]),
);
}
isEnabled(name: string): boolean {
return this.state.get(name.toLowerCase()) === true;
}
/** Runtime mutation: activation does not require a redeploy. */
setEnabled(name: string, enabled: boolean): void {
this.state.set(name.toLowerCase(), enabled);
}
}
export function createFlagStore(
initial: Readonly<Record<string, boolean>> = {},
): InMemoryFeatureFlagStore {
return new InMemoryFeatureFlagStore(initial);
}

View File

@@ -0,0 +1,9 @@
/**
* Public API of the flags module. Everything the module exposes to the
* outside world goes through this file.
*/
export {
type FeatureFlagProvider,
InMemoryFeatureFlagStore,
createFlagStore,
} from './domain/feature-flag-store.js';

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { createFlagStore } from '../index.js';
describe('InMemoryFeatureFlagStore', () => {
it('reflects seeded state', () => {
const store = createFlagStore({ new_checkout: true, beta_search: false });
expect(store.isEnabled('new_checkout')).toBe(true);
expect(store.isEnabled('beta_search')).toBe(false);
});
it('defaults unknown flags to OFF (fail-safe)', () => {
const store = createFlagStore();
expect(store.isEnabled('never_heard_of_it')).toBe(false);
});
it('matches flag names case-insensitively', () => {
const store = createFlagStore({ New_Checkout: true });
expect(store.isEnabled('new_checkout')).toBe(true);
expect(store.isEnabled('NEW_CHECKOUT')).toBe(true);
});
it('flips state at runtime without a new store (no redeploy)', () => {
const store = createFlagStore({ risky_path: false });
expect(store.isEnabled('risky_path')).toBe(false);
store.setEnabled('risky_path', true);
expect(store.isEnabled('risky_path')).toBe(true);
store.setEnabled('risky_path', false);
expect(store.isEnabled('risky_path')).toBe(false);
});
it('skips a guarded path when the flag is off', () => {
const store = createFlagStore({ risky_path: false });
let ran = false;
if (store.isEnabled('risky_path')) {
ran = true;
}
expect(ran).toBe(false);
store.setEnabled('risky_path', true);
if (store.isEnabled('risky_path')) {
ran = true;
}
expect(ran).toBe(true);
});
});