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:
34
project/src/modules/flags/domain/feature-flag-store.ts
Normal file
34
project/src/modules/flags/domain/feature-flag-store.ts
Normal 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);
|
||||
}
|
||||
9
project/src/modules/flags/index.ts
Normal file
9
project/src/modules/flags/index.ts
Normal 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';
|
||||
47
project/src/modules/flags/tests/feature-flag-store.test.ts
Normal file
47
project/src/modules/flags/tests/feature-flag-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user