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:
@@ -8,15 +8,24 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
|
||||
import { AppError, errorEnvelope } from '../shared/errors.js';
|
||||
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
flags: FeatureFlagProvider;
|
||||
}
|
||||
}
|
||||
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
||||
|
||||
export interface BuildAppDeps {
|
||||
/** Injectable logger so tests can capture structured output. */
|
||||
logger?: Logger;
|
||||
/** Feature flags. Default: empty store, every flag OFF (fail-safe). */
|
||||
flags?: FeatureFlagProvider;
|
||||
}
|
||||
|
||||
function generateRequestId(raw: IncomingMessage): string {
|
||||
@@ -33,9 +42,11 @@ function generateRequestId(raw: IncomingMessage): string {
|
||||
*/
|
||||
export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance> {
|
||||
const logger = deps.logger ?? createLogger();
|
||||
const flags = deps.flags ?? createFlagStore();
|
||||
const startTimes = new WeakMap<FastifyRequest, number>();
|
||||
|
||||
const app = Fastify({ logger: false, genReqId: generateRequestId });
|
||||
app.decorate('flags', flags);
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
startTimes.set(request, performance.now());
|
||||
|
||||
47
project/src/app/tests/config-flags.test.ts
Normal file
47
project/src/app/tests/config-flags.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createFlagStore } from '../../modules/flags/index.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import type { DestinationStream } from 'pino';
|
||||
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
|
||||
describe('feature flags wiring (composition)', () => {
|
||||
it('default flags are all OFF when no store is injected', async () => {
|
||||
const app = await buildApp({ logger: silentLogger() });
|
||||
app.get('/__test/guarded', async (request) => {
|
||||
return { enabled: request.server.flags.isEnabled('risky_path') };
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/__test/guarded' });
|
||||
expect(response.json()).toEqual({ enabled: false });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('guarded path is skipped when the flag is off and runs once enabled at runtime', async () => {
|
||||
const store = createFlagStore({ risky_path: false });
|
||||
const app = await buildApp({ logger: silentLogger(), flags: store });
|
||||
app.get('/__test/guarded', async (request) => {
|
||||
if (!request.server.flags.isEnabled('risky_path')) {
|
||||
return { path: 'skipped' };
|
||||
}
|
||||
return { path: 'executed' };
|
||||
});
|
||||
|
||||
const off = await app.inject({ method: 'GET', url: '/__test/guarded' });
|
||||
expect(off.json()).toEqual({ path: 'skipped' });
|
||||
|
||||
// Activation without redeploy: same running app instance.
|
||||
store.setEnabled('risky_path', true);
|
||||
const on = await app.inject({ method: 'GET', url: '/__test/guarded' });
|
||||
expect(on.json()).toEqual({ path: 'executed' });
|
||||
|
||||
store.setEnabled('risky_path', false);
|
||||
const offAgain = await app.inject({ method: 'GET', url: '/__test/guarded' });
|
||||
expect(offAgain.json()).toEqual({ path: 'skipped' });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user