elsabro 2.3.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +668 -20
- package/bin/install.js +0 -0
- package/flows/development-flow.json +452 -0
- package/flows/quick-flow.json +118 -0
- package/package.json +3 -2
- package/references/SYSTEM_INDEX.md +379 -5
- package/references/agent-marketplace.md +2274 -0
- package/references/agent-protocol.md +1126 -0
- package/references/ai-code-suggestions.md +2413 -0
- package/references/checkpointing.md +595 -0
- package/references/collaboration-patterns.md +851 -0
- package/references/collaborative-sessions.md +1081 -0
- package/references/configuration-management.md +1810 -0
- package/references/cost-tracking.md +1095 -0
- package/references/enterprise-sso.md +2001 -0
- package/references/error-contracts-v2.md +968 -0
- package/references/event-driven.md +1031 -0
- package/references/flow-orchestration.md +940 -0
- package/references/flow-visualization.md +1557 -0
- package/references/ide-integrations.md +3513 -0
- package/references/interrupt-system.md +681 -0
- package/references/kubernetes-deployment.md +3099 -0
- package/references/memory-system.md +683 -0
- package/references/mobile-companion.md +3236 -0
- package/references/multi-llm-providers.md +2494 -0
- package/references/multi-project-memory.md +1182 -0
- package/references/observability.md +793 -0
- package/references/output-schemas.md +858 -0
- package/references/performance-profiler.md +955 -0
- package/references/plugin-system.md +1526 -0
- package/references/prompt-management.md +292 -0
- package/references/sandbox-execution.md +303 -0
- package/references/security-system.md +1253 -0
- package/references/streaming.md +696 -0
- package/references/testing-framework.md +1151 -0
- package/references/time-travel.md +802 -0
- package/references/tool-registry.md +886 -0
- package/references/voice-commands.md +3296 -0
- package/templates/agent-marketplace-config.json +220 -0
- package/templates/agent-protocol-config.json +136 -0
- package/templates/ai-suggestions-config.json +100 -0
- package/templates/checkpoint-state.json +61 -0
- package/templates/collaboration-config.json +157 -0
- package/templates/collaborative-sessions-config.json +153 -0
- package/templates/configuration-config.json +245 -0
- package/templates/cost-tracking-config.json +148 -0
- package/templates/enterprise-sso-config.json +438 -0
- package/templates/events-config.json +148 -0
- package/templates/flow-visualization-config.json +196 -0
- package/templates/ide-integrations-config.json +442 -0
- package/templates/kubernetes-config.json +764 -0
- package/templates/memory-state.json +84 -0
- package/templates/mobile-companion-config.json +600 -0
- package/templates/multi-llm-config.json +544 -0
- package/templates/multi-project-memory-config.json +145 -0
- package/templates/observability-config.json +109 -0
- package/templates/performance-profiler-config.json +125 -0
- package/templates/plugin-config.json +170 -0
- package/templates/prompt-management-config.json +86 -0
- package/templates/sandbox-config.json +185 -0
- package/templates/schemas-config.json +65 -0
- package/templates/security-config.json +120 -0
- package/templates/streaming-config.json +72 -0
- package/templates/testing-config.json +81 -0
- package/templates/timetravel-config.json +62 -0
- package/templates/tool-registry-config.json +109 -0
- package/templates/voice-commands-config.json +658 -0
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
# ELSABRO Configuration Management System
|
|
2
|
+
|
|
3
|
+
> Sistema avanzado de configuración con environments, feature flags, validación y remote config.
|
|
4
|
+
|
|
5
|
+
## Arquitectura General
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Configuration Management │
|
|
10
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
11
|
+
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
|
12
|
+
│ │ ConfigManager │ │ FeatureFlags │ │ EnvResolver │ │
|
|
13
|
+
│ │ ───────── │ │ ───────── │ │ ───────── │ │
|
|
14
|
+
│ │ • Load/Save │ │ • Targeting │ │ • Variables │ │
|
|
15
|
+
│ │ • Merge │ │ • Percentage │ │ • Inheritance │ │
|
|
16
|
+
│ │ • Watch │ │ • Schedules │ │ • Secrets │ │
|
|
17
|
+
│ └───────────────┘ └───────────────┘ └───────────────┘ │
|
|
18
|
+
│ │ │
|
|
19
|
+
│ ┌───────────────────────────┴───────────────────────────┐ │
|
|
20
|
+
│ │ ConfigValidator │ │
|
|
21
|
+
│ │ • JSON Schema • Type Coercion • Required Fields │ │
|
|
22
|
+
│ └────────────────────────────────────────────────────────┘ │
|
|
23
|
+
│ │ │
|
|
24
|
+
│ ┌───────────────────────────┴───────────────────────────┐ │
|
|
25
|
+
│ │ RemoteConfigSync │ │
|
|
26
|
+
│ │ • Polling • Webhooks • Caching • Fallback │ │
|
|
27
|
+
│ └────────────────────────────────────────────────────────┘ │
|
|
28
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. ConfigManager
|
|
34
|
+
|
|
35
|
+
### Propósito
|
|
36
|
+
Gestiona la carga, fusión y persistencia de configuraciones con soporte para múltiples fuentes.
|
|
37
|
+
|
|
38
|
+
### Interfaz
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface ConfigSource {
|
|
42
|
+
type: 'file' | 'env' | 'remote' | 'memory';
|
|
43
|
+
path?: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
priority: number; // Higher = override lower
|
|
46
|
+
required?: boolean;
|
|
47
|
+
format?: 'json' | 'yaml' | 'toml' | 'env';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ConfigOptions {
|
|
51
|
+
sources: ConfigSource[];
|
|
52
|
+
environment: string;
|
|
53
|
+
watchForChanges: boolean;
|
|
54
|
+
reloadOnChange: boolean;
|
|
55
|
+
mergeStrategy: 'deep' | 'shallow' | 'replace';
|
|
56
|
+
encryptionKey?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ConfigManager {
|
|
60
|
+
// Core operations
|
|
61
|
+
load(): Promise<void>;
|
|
62
|
+
get<T>(path: string, defaultValue?: T): T;
|
|
63
|
+
set(path: string, value: unknown): void;
|
|
64
|
+
has(path: string): boolean;
|
|
65
|
+
delete(path: string): void;
|
|
66
|
+
|
|
67
|
+
// Bulk operations
|
|
68
|
+
getAll(): Record<string, unknown>;
|
|
69
|
+
merge(config: Record<string, unknown>): void;
|
|
70
|
+
reset(): void;
|
|
71
|
+
|
|
72
|
+
// Environment
|
|
73
|
+
getEnvironment(): string;
|
|
74
|
+
setEnvironment(env: string): Promise<void>;
|
|
75
|
+
|
|
76
|
+
// Persistence
|
|
77
|
+
save(source?: string): Promise<void>;
|
|
78
|
+
export(format: 'json' | 'yaml' | 'env'): string;
|
|
79
|
+
|
|
80
|
+
// Events
|
|
81
|
+
onChange(path: string, callback: (newValue: unknown, oldValue: unknown) => void): () => void;
|
|
82
|
+
onReload(callback: (config: Record<string, unknown>) => void): () => void;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Implementación
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
class ConfigManagerImpl implements ConfigManager {
|
|
90
|
+
private config: Map<string, unknown> = new Map();
|
|
91
|
+
private sources: ConfigSource[] = [];
|
|
92
|
+
private watchers: Map<string, Set<Function>> = new Map();
|
|
93
|
+
private environment: string;
|
|
94
|
+
private options: ConfigOptions;
|
|
95
|
+
|
|
96
|
+
constructor(options: ConfigOptions) {
|
|
97
|
+
this.options = options;
|
|
98
|
+
this.environment = options.environment;
|
|
99
|
+
this.sources = options.sources.sort((a, b) => a.priority - b.priority);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async load(): Promise<void> {
|
|
103
|
+
const configs: Record<string, unknown>[] = [];
|
|
104
|
+
|
|
105
|
+
for (const source of this.sources) {
|
|
106
|
+
try {
|
|
107
|
+
const config = await this.loadSource(source);
|
|
108
|
+
configs.push(config);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (source.required) {
|
|
111
|
+
throw new ConfigLoadError(`Required config source failed: ${source.path || source.url}`, error);
|
|
112
|
+
}
|
|
113
|
+
console.warn(`Optional config source unavailable: ${source.path || source.url}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Merge all configs in priority order
|
|
118
|
+
const merged = this.mergeConfigs(configs);
|
|
119
|
+
|
|
120
|
+
// Apply environment-specific overrides
|
|
121
|
+
const envConfig = merged.environments?.[this.environment];
|
|
122
|
+
if (envConfig) {
|
|
123
|
+
this.mergeConfigs([merged, envConfig as Record<string, unknown>]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Store flattened config
|
|
127
|
+
this.flattenAndStore(merged);
|
|
128
|
+
|
|
129
|
+
// Setup watchers if enabled
|
|
130
|
+
if (this.options.watchForChanges) {
|
|
131
|
+
this.setupWatchers();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async loadSource(source: ConfigSource): Promise<Record<string, unknown>> {
|
|
136
|
+
switch (source.type) {
|
|
137
|
+
case 'file':
|
|
138
|
+
return this.loadFileConfig(source);
|
|
139
|
+
case 'env':
|
|
140
|
+
return this.loadEnvConfig(source);
|
|
141
|
+
case 'remote':
|
|
142
|
+
return this.loadRemoteConfig(source);
|
|
143
|
+
case 'memory':
|
|
144
|
+
return source.data || {};
|
|
145
|
+
default:
|
|
146
|
+
throw new Error(`Unknown config source type: ${source.type}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async loadFileConfig(source: ConfigSource): Promise<Record<string, unknown>> {
|
|
151
|
+
const content = await fs.readFile(source.path!, 'utf-8');
|
|
152
|
+
|
|
153
|
+
switch (source.format || this.detectFormat(source.path!)) {
|
|
154
|
+
case 'json':
|
|
155
|
+
return JSON.parse(content);
|
|
156
|
+
case 'yaml':
|
|
157
|
+
return yaml.parse(content);
|
|
158
|
+
case 'toml':
|
|
159
|
+
return toml.parse(content);
|
|
160
|
+
case 'env':
|
|
161
|
+
return this.parseEnvFile(content);
|
|
162
|
+
default:
|
|
163
|
+
return JSON.parse(content);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private loadEnvConfig(source: ConfigSource): Record<string, unknown> {
|
|
168
|
+
const prefix = source.path || '';
|
|
169
|
+
const config: Record<string, unknown> = {};
|
|
170
|
+
|
|
171
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
172
|
+
if (prefix && !key.startsWith(prefix)) continue;
|
|
173
|
+
|
|
174
|
+
const configKey = prefix ? key.slice(prefix.length + 1) : key;
|
|
175
|
+
this.setNestedValue(config, configKey.toLowerCase().replace(/_/g, '.'), value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return config;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async loadRemoteConfig(source: ConfigSource): Promise<Record<string, unknown>> {
|
|
182
|
+
const response = await fetch(source.url!, {
|
|
183
|
+
headers: source.headers || {},
|
|
184
|
+
timeout: source.timeout || 5000
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`Remote config fetch failed: ${response.status}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return response.json();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get<T>(path: string, defaultValue?: T): T {
|
|
195
|
+
const value = this.config.get(path);
|
|
196
|
+
return (value !== undefined ? value : defaultValue) as T;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
set(path: string, value: unknown): void {
|
|
200
|
+
const oldValue = this.config.get(path);
|
|
201
|
+
this.config.set(path, value);
|
|
202
|
+
this.notifyWatchers(path, value, oldValue);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
has(path: string): boolean {
|
|
206
|
+
return this.config.has(path);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
onChange(path: string, callback: Function): () => void {
|
|
210
|
+
if (!this.watchers.has(path)) {
|
|
211
|
+
this.watchers.set(path, new Set());
|
|
212
|
+
}
|
|
213
|
+
this.watchers.get(path)!.add(callback);
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
this.watchers.get(path)?.delete(callback);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private notifyWatchers(path: string, newValue: unknown, oldValue: unknown): void {
|
|
221
|
+
// Exact path watchers
|
|
222
|
+
this.watchers.get(path)?.forEach(cb => cb(newValue, oldValue));
|
|
223
|
+
|
|
224
|
+
// Wildcard watchers
|
|
225
|
+
this.watchers.forEach((callbacks, pattern) => {
|
|
226
|
+
if (pattern.includes('*') && this.matchesPattern(path, pattern)) {
|
|
227
|
+
callbacks.forEach(cb => cb(newValue, oldValue));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private mergeConfigs(configs: Record<string, unknown>[]): Record<string, unknown> {
|
|
233
|
+
switch (this.options.mergeStrategy) {
|
|
234
|
+
case 'shallow':
|
|
235
|
+
return Object.assign({}, ...configs);
|
|
236
|
+
case 'replace':
|
|
237
|
+
return configs[configs.length - 1] || {};
|
|
238
|
+
case 'deep':
|
|
239
|
+
default:
|
|
240
|
+
return this.deepMerge({}, ...configs);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private deepMerge(...objects: Record<string, unknown>[]): Record<string, unknown> {
|
|
245
|
+
const result: Record<string, unknown> = {};
|
|
246
|
+
|
|
247
|
+
for (const obj of objects) {
|
|
248
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
249
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
250
|
+
result[key] = this.deepMerge(
|
|
251
|
+
(result[key] || {}) as Record<string, unknown>,
|
|
252
|
+
value as Record<string, unknown>
|
|
253
|
+
);
|
|
254
|
+
} else {
|
|
255
|
+
result[key] = value;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Uso
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
const config = new ConfigManager({
|
|
269
|
+
environment: process.env.NODE_ENV || 'development',
|
|
270
|
+
sources: [
|
|
271
|
+
{ type: 'file', path: '.elsabro/config.json', priority: 1 },
|
|
272
|
+
{ type: 'file', path: '.elsabro/config.local.json', priority: 2, required: false },
|
|
273
|
+
{ type: 'env', path: 'ELSABRO', priority: 3 },
|
|
274
|
+
{ type: 'remote', url: 'https://config.example.com/elsabro', priority: 4, required: false }
|
|
275
|
+
],
|
|
276
|
+
watchForChanges: true,
|
|
277
|
+
reloadOnChange: true,
|
|
278
|
+
mergeStrategy: 'deep'
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await config.load();
|
|
282
|
+
|
|
283
|
+
// Get values
|
|
284
|
+
const llmModel = config.get('llm.model', 'claude-sonnet-4-20250514');
|
|
285
|
+
const maxTokens = config.get<number>('llm.maxTokens', 4096);
|
|
286
|
+
|
|
287
|
+
// Watch for changes
|
|
288
|
+
config.onChange('llm.*', (newValue, oldValue) => {
|
|
289
|
+
console.log('LLM config changed:', { newValue, oldValue });
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 2. FeatureFlags
|
|
296
|
+
|
|
297
|
+
### Propósito
|
|
298
|
+
Sistema de feature flags con targeting, rollout gradual y programación temporal.
|
|
299
|
+
|
|
300
|
+
### Interfaz
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
interface FeatureFlag {
|
|
304
|
+
key: string;
|
|
305
|
+
name: string;
|
|
306
|
+
description?: string;
|
|
307
|
+
enabled: boolean;
|
|
308
|
+
|
|
309
|
+
// Targeting
|
|
310
|
+
targeting?: {
|
|
311
|
+
rules: TargetingRule[];
|
|
312
|
+
defaultVariant: string;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Variants for A/B testing
|
|
316
|
+
variants?: {
|
|
317
|
+
[key: string]: {
|
|
318
|
+
value: unknown;
|
|
319
|
+
weight?: number; // For percentage rollout
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Schedule
|
|
324
|
+
schedule?: {
|
|
325
|
+
enableAt?: string; // ISO date
|
|
326
|
+
disableAt?: string; // ISO date
|
|
327
|
+
timezone?: string;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Metadata
|
|
331
|
+
tags?: string[];
|
|
332
|
+
owner?: string;
|
|
333
|
+
createdAt: string;
|
|
334
|
+
updatedAt: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
interface TargetingRule {
|
|
338
|
+
attribute: string;
|
|
339
|
+
operator: 'eq' | 'neq' | 'in' | 'nin' | 'gt' | 'lt' | 'contains' | 'regex';
|
|
340
|
+
value: unknown;
|
|
341
|
+
variant: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
interface EvaluationContext {
|
|
345
|
+
userId?: string;
|
|
346
|
+
sessionId?: string;
|
|
347
|
+
environment?: string;
|
|
348
|
+
attributes?: Record<string, unknown>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface FeatureFlagService {
|
|
352
|
+
// Evaluation
|
|
353
|
+
isEnabled(key: string, context?: EvaluationContext): boolean;
|
|
354
|
+
getVariant(key: string, context?: EvaluationContext): string;
|
|
355
|
+
getValue<T>(key: string, context?: EvaluationContext): T | undefined;
|
|
356
|
+
|
|
357
|
+
// Management
|
|
358
|
+
getFlag(key: string): FeatureFlag | undefined;
|
|
359
|
+
getAllFlags(): FeatureFlag[];
|
|
360
|
+
createFlag(flag: Omit<FeatureFlag, 'createdAt' | 'updatedAt'>): FeatureFlag;
|
|
361
|
+
updateFlag(key: string, updates: Partial<FeatureFlag>): FeatureFlag;
|
|
362
|
+
deleteFlag(key: string): void;
|
|
363
|
+
|
|
364
|
+
// Bulk operations
|
|
365
|
+
enableAll(tags?: string[]): void;
|
|
366
|
+
disableAll(tags?: string[]): void;
|
|
367
|
+
|
|
368
|
+
// Events
|
|
369
|
+
onFlagChange(key: string, callback: (flag: FeatureFlag) => void): () => void;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Implementación
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
class FeatureFlagServiceImpl implements FeatureFlagService {
|
|
377
|
+
private flags: Map<string, FeatureFlag> = new Map();
|
|
378
|
+
private listeners: Map<string, Set<Function>> = new Map();
|
|
379
|
+
private hashSeed: string;
|
|
380
|
+
|
|
381
|
+
constructor(private config: ConfigManager, seed?: string) {
|
|
382
|
+
this.hashSeed = seed || 'elsabro-feature-flags';
|
|
383
|
+
this.loadFlags();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private loadFlags(): void {
|
|
387
|
+
const flagsConfig = this.config.get<Record<string, FeatureFlag>>('featureFlags', {});
|
|
388
|
+
for (const [key, flag] of Object.entries(flagsConfig)) {
|
|
389
|
+
this.flags.set(key, { ...flag, key });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
isEnabled(key: string, context: EvaluationContext = {}): boolean {
|
|
394
|
+
const flag = this.flags.get(key);
|
|
395
|
+
if (!flag) return false;
|
|
396
|
+
|
|
397
|
+
// Check schedule
|
|
398
|
+
if (!this.isWithinSchedule(flag)) return false;
|
|
399
|
+
|
|
400
|
+
// Check base enabled state
|
|
401
|
+
if (!flag.enabled) return false;
|
|
402
|
+
|
|
403
|
+
// Evaluate targeting rules
|
|
404
|
+
if (flag.targeting?.rules) {
|
|
405
|
+
for (const rule of flag.targeting.rules) {
|
|
406
|
+
if (this.evaluateRule(rule, context)) {
|
|
407
|
+
return rule.variant === 'enabled' || rule.variant === 'true';
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Percentage rollout
|
|
413
|
+
if (flag.variants) {
|
|
414
|
+
const variant = this.getVariantByPercentage(flag, context);
|
|
415
|
+
return variant === 'enabled' || variant === 'true';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
getVariant(key: string, context: EvaluationContext = {}): string {
|
|
422
|
+
const flag = this.flags.get(key);
|
|
423
|
+
if (!flag) return 'control';
|
|
424
|
+
|
|
425
|
+
if (!this.isWithinSchedule(flag)) return 'control';
|
|
426
|
+
if (!flag.enabled) return 'control';
|
|
427
|
+
|
|
428
|
+
// Evaluate targeting rules
|
|
429
|
+
if (flag.targeting?.rules) {
|
|
430
|
+
for (const rule of flag.targeting.rules) {
|
|
431
|
+
if (this.evaluateRule(rule, context)) {
|
|
432
|
+
return rule.variant;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Percentage-based variant selection
|
|
438
|
+
if (flag.variants) {
|
|
439
|
+
return this.getVariantByPercentage(flag, context);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return flag.targeting?.defaultVariant || 'control';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
getValue<T>(key: string, context: EvaluationContext = {}): T | undefined {
|
|
446
|
+
const flag = this.flags.get(key);
|
|
447
|
+
if (!flag || !flag.variants) return undefined;
|
|
448
|
+
|
|
449
|
+
const variant = this.getVariant(key, context);
|
|
450
|
+
return flag.variants[variant]?.value as T;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private isWithinSchedule(flag: FeatureFlag): boolean {
|
|
454
|
+
if (!flag.schedule) return true;
|
|
455
|
+
|
|
456
|
+
const now = new Date();
|
|
457
|
+
const { enableAt, disableAt } = flag.schedule;
|
|
458
|
+
|
|
459
|
+
if (enableAt && new Date(enableAt) > now) return false;
|
|
460
|
+
if (disableAt && new Date(disableAt) < now) return false;
|
|
461
|
+
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private evaluateRule(rule: TargetingRule, context: EvaluationContext): boolean {
|
|
466
|
+
const attributeValue = this.getAttributeValue(rule.attribute, context);
|
|
467
|
+
|
|
468
|
+
switch (rule.operator) {
|
|
469
|
+
case 'eq':
|
|
470
|
+
return attributeValue === rule.value;
|
|
471
|
+
case 'neq':
|
|
472
|
+
return attributeValue !== rule.value;
|
|
473
|
+
case 'in':
|
|
474
|
+
return Array.isArray(rule.value) && rule.value.includes(attributeValue);
|
|
475
|
+
case 'nin':
|
|
476
|
+
return Array.isArray(rule.value) && !rule.value.includes(attributeValue);
|
|
477
|
+
case 'gt':
|
|
478
|
+
return Number(attributeValue) > Number(rule.value);
|
|
479
|
+
case 'lt':
|
|
480
|
+
return Number(attributeValue) < Number(rule.value);
|
|
481
|
+
case 'contains':
|
|
482
|
+
return String(attributeValue).includes(String(rule.value));
|
|
483
|
+
case 'regex':
|
|
484
|
+
return new RegExp(String(rule.value)).test(String(attributeValue));
|
|
485
|
+
default:
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private getAttributeValue(attribute: string, context: EvaluationContext): unknown {
|
|
491
|
+
// Built-in attributes
|
|
492
|
+
switch (attribute) {
|
|
493
|
+
case 'userId':
|
|
494
|
+
return context.userId;
|
|
495
|
+
case 'sessionId':
|
|
496
|
+
return context.sessionId;
|
|
497
|
+
case 'environment':
|
|
498
|
+
return context.environment;
|
|
499
|
+
default:
|
|
500
|
+
return context.attributes?.[attribute];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private getVariantByPercentage(flag: FeatureFlag, context: EvaluationContext): string {
|
|
505
|
+
if (!flag.variants) return 'control';
|
|
506
|
+
|
|
507
|
+
// Generate deterministic hash for consistent bucketing
|
|
508
|
+
const identifier = context.userId || context.sessionId || 'anonymous';
|
|
509
|
+
const hash = this.hashString(`${flag.key}:${identifier}:${this.hashSeed}`);
|
|
510
|
+
const bucket = hash % 100;
|
|
511
|
+
|
|
512
|
+
// Calculate cumulative weights
|
|
513
|
+
let cumulative = 0;
|
|
514
|
+
for (const [variant, config] of Object.entries(flag.variants)) {
|
|
515
|
+
cumulative += config.weight || 0;
|
|
516
|
+
if (bucket < cumulative) {
|
|
517
|
+
return variant;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return flag.targeting?.defaultVariant || Object.keys(flag.variants)[0] || 'control';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private hashString(str: string): number {
|
|
525
|
+
let hash = 0;
|
|
526
|
+
for (let i = 0; i < str.length; i++) {
|
|
527
|
+
const char = str.charCodeAt(i);
|
|
528
|
+
hash = ((hash << 5) - hash) + char;
|
|
529
|
+
hash = hash & hash;
|
|
530
|
+
}
|
|
531
|
+
return Math.abs(hash);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
createFlag(flag: Omit<FeatureFlag, 'createdAt' | 'updatedAt'>): FeatureFlag {
|
|
535
|
+
const now = new Date().toISOString();
|
|
536
|
+
const newFlag: FeatureFlag = {
|
|
537
|
+
...flag,
|
|
538
|
+
createdAt: now,
|
|
539
|
+
updatedAt: now
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
this.flags.set(flag.key, newFlag);
|
|
543
|
+
this.notifyListeners(flag.key, newFlag);
|
|
544
|
+
return newFlag;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
updateFlag(key: string, updates: Partial<FeatureFlag>): FeatureFlag {
|
|
548
|
+
const existing = this.flags.get(key);
|
|
549
|
+
if (!existing) {
|
|
550
|
+
throw new Error(`Flag not found: ${key}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const updated: FeatureFlag = {
|
|
554
|
+
...existing,
|
|
555
|
+
...updates,
|
|
556
|
+
key, // Prevent key change
|
|
557
|
+
createdAt: existing.createdAt,
|
|
558
|
+
updatedAt: new Date().toISOString()
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
this.flags.set(key, updated);
|
|
562
|
+
this.notifyListeners(key, updated);
|
|
563
|
+
return updated;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
onFlagChange(key: string, callback: Function): () => void {
|
|
567
|
+
if (!this.listeners.has(key)) {
|
|
568
|
+
this.listeners.set(key, new Set());
|
|
569
|
+
}
|
|
570
|
+
this.listeners.get(key)!.add(callback);
|
|
571
|
+
|
|
572
|
+
return () => {
|
|
573
|
+
this.listeners.get(key)?.delete(callback);
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private notifyListeners(key: string, flag: FeatureFlag): void {
|
|
578
|
+
this.listeners.get(key)?.forEach(cb => cb(flag));
|
|
579
|
+
this.listeners.get('*')?.forEach(cb => cb(flag));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Uso
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
const flags = new FeatureFlagService(config);
|
|
588
|
+
|
|
589
|
+
// Simple check
|
|
590
|
+
if (flags.isEnabled('new-agent-ui')) {
|
|
591
|
+
renderNewUI();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// With targeting context
|
|
595
|
+
const context = {
|
|
596
|
+
userId: 'user-123',
|
|
597
|
+
environment: 'production',
|
|
598
|
+
attributes: {
|
|
599
|
+
plan: 'enterprise',
|
|
600
|
+
region: 'us-west'
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
if (flags.isEnabled('beta-features', context)) {
|
|
605
|
+
enableBetaFeatures();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// A/B testing
|
|
609
|
+
const variant = flags.getVariant('checkout-flow', context);
|
|
610
|
+
switch (variant) {
|
|
611
|
+
case 'single-page':
|
|
612
|
+
renderSinglePageCheckout();
|
|
613
|
+
break;
|
|
614
|
+
case 'multi-step':
|
|
615
|
+
renderMultiStepCheckout();
|
|
616
|
+
break;
|
|
617
|
+
default:
|
|
618
|
+
renderDefaultCheckout();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Get variant value
|
|
622
|
+
const buttonColor = flags.getValue<string>('button-color', context);
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## 3. EnvResolver
|
|
628
|
+
|
|
629
|
+
### Propósito
|
|
630
|
+
Resuelve variables de entorno con herencia, interpolación y soporte para secretos.
|
|
631
|
+
|
|
632
|
+
### Interfaz
|
|
633
|
+
|
|
634
|
+
```typescript
|
|
635
|
+
interface EnvDefinition {
|
|
636
|
+
name: string;
|
|
637
|
+
inherits?: string; // Parent environment
|
|
638
|
+
variables: Record<string, EnvVariable>;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
interface EnvVariable {
|
|
642
|
+
value?: string;
|
|
643
|
+
secret?: boolean;
|
|
644
|
+
required?: boolean;
|
|
645
|
+
default?: string;
|
|
646
|
+
description?: string;
|
|
647
|
+
validation?: {
|
|
648
|
+
type?: 'string' | 'number' | 'boolean' | 'url' | 'email';
|
|
649
|
+
pattern?: string;
|
|
650
|
+
min?: number;
|
|
651
|
+
max?: number;
|
|
652
|
+
enum?: string[];
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
interface ResolvedEnv {
|
|
657
|
+
[key: string]: string;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
interface EnvResolver {
|
|
661
|
+
// Resolution
|
|
662
|
+
resolve(envName: string): Promise<ResolvedEnv>;
|
|
663
|
+
get(envName: string, key: string): Promise<string | undefined>;
|
|
664
|
+
|
|
665
|
+
// Interpolation
|
|
666
|
+
interpolate(template: string, env?: ResolvedEnv): Promise<string>;
|
|
667
|
+
|
|
668
|
+
// Validation
|
|
669
|
+
validate(envName: string): Promise<ValidationResult>;
|
|
670
|
+
|
|
671
|
+
// Environment management
|
|
672
|
+
getEnvironments(): string[];
|
|
673
|
+
getCurrentEnvironment(): string;
|
|
674
|
+
setCurrentEnvironment(name: string): void;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
interface ValidationResult {
|
|
678
|
+
valid: boolean;
|
|
679
|
+
errors: ValidationError[];
|
|
680
|
+
warnings: ValidationWarning[];
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Implementación
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
class EnvResolverImpl implements EnvResolver {
|
|
688
|
+
private definitions: Map<string, EnvDefinition> = new Map();
|
|
689
|
+
private cache: Map<string, ResolvedEnv> = new Map();
|
|
690
|
+
private currentEnv: string = 'development';
|
|
691
|
+
|
|
692
|
+
constructor(
|
|
693
|
+
private config: ConfigManager,
|
|
694
|
+
private secretsVault?: SecretsVault
|
|
695
|
+
) {
|
|
696
|
+
this.loadDefinitions();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private loadDefinitions(): void {
|
|
700
|
+
const envs = this.config.get<Record<string, EnvDefinition>>('environments', {});
|
|
701
|
+
for (const [name, def] of Object.entries(envs)) {
|
|
702
|
+
this.definitions.set(name, { ...def, name });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async resolve(envName: string): Promise<ResolvedEnv> {
|
|
707
|
+
// Check cache
|
|
708
|
+
if (this.cache.has(envName)) {
|
|
709
|
+
return this.cache.get(envName)!;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const definition = this.definitions.get(envName);
|
|
713
|
+
if (!definition) {
|
|
714
|
+
throw new Error(`Environment not found: ${envName}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Build inheritance chain
|
|
718
|
+
const chain = this.buildInheritanceChain(envName);
|
|
719
|
+
|
|
720
|
+
// Merge variables from all environments in chain
|
|
721
|
+
let resolved: ResolvedEnv = {};
|
|
722
|
+
for (const env of chain) {
|
|
723
|
+
const envDef = this.definitions.get(env)!;
|
|
724
|
+
resolved = { ...resolved, ...await this.resolveVariables(envDef.variables) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Apply process.env overrides (highest priority)
|
|
728
|
+
for (const key of Object.keys(resolved)) {
|
|
729
|
+
if (process.env[key] !== undefined) {
|
|
730
|
+
resolved[key] = process.env[key]!;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Interpolate variable references
|
|
735
|
+
resolved = await this.interpolateAll(resolved);
|
|
736
|
+
|
|
737
|
+
this.cache.set(envName, resolved);
|
|
738
|
+
return resolved;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private buildInheritanceChain(envName: string): string[] {
|
|
742
|
+
const chain: string[] = [];
|
|
743
|
+
let current = envName;
|
|
744
|
+
const visited = new Set<string>();
|
|
745
|
+
|
|
746
|
+
while (current) {
|
|
747
|
+
if (visited.has(current)) {
|
|
748
|
+
throw new Error(`Circular inheritance detected: ${current}`);
|
|
749
|
+
}
|
|
750
|
+
visited.add(current);
|
|
751
|
+
chain.unshift(current);
|
|
752
|
+
|
|
753
|
+
const def = this.definitions.get(current);
|
|
754
|
+
current = def?.inherits || '';
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return chain;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async resolveVariables(
|
|
761
|
+
variables: Record<string, EnvVariable>
|
|
762
|
+
): Promise<ResolvedEnv> {
|
|
763
|
+
const resolved: ResolvedEnv = {};
|
|
764
|
+
|
|
765
|
+
for (const [key, variable] of Object.entries(variables)) {
|
|
766
|
+
let value: string | undefined;
|
|
767
|
+
|
|
768
|
+
if (variable.secret && this.secretsVault) {
|
|
769
|
+
// Retrieve from secrets vault
|
|
770
|
+
value = await this.secretsVault.get(key);
|
|
771
|
+
} else if (variable.value !== undefined) {
|
|
772
|
+
value = variable.value;
|
|
773
|
+
} else if (variable.default !== undefined) {
|
|
774
|
+
value = variable.default;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (value === undefined && variable.required) {
|
|
778
|
+
throw new Error(`Required environment variable not set: ${key}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (value !== undefined) {
|
|
782
|
+
resolved[key] = value;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return resolved;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private async interpolateAll(env: ResolvedEnv): Promise<ResolvedEnv> {
|
|
790
|
+
const result: ResolvedEnv = {};
|
|
791
|
+
let changed = true;
|
|
792
|
+
let iterations = 0;
|
|
793
|
+
const maxIterations = 10;
|
|
794
|
+
|
|
795
|
+
// Copy initial values
|
|
796
|
+
for (const [key, value] of Object.entries(env)) {
|
|
797
|
+
result[key] = value;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Iteratively resolve references until stable
|
|
801
|
+
while (changed && iterations < maxIterations) {
|
|
802
|
+
changed = false;
|
|
803
|
+
iterations++;
|
|
804
|
+
|
|
805
|
+
for (const [key, value] of Object.entries(result)) {
|
|
806
|
+
const interpolated = await this.interpolate(value, result);
|
|
807
|
+
if (interpolated !== result[key]) {
|
|
808
|
+
result[key] = interpolated;
|
|
809
|
+
changed = true;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async interpolate(template: string, env?: ResolvedEnv): Promise<string> {
|
|
818
|
+
const resolvedEnv = env || await this.resolve(this.currentEnv);
|
|
819
|
+
|
|
820
|
+
// Pattern: ${VAR_NAME} or ${VAR_NAME:-default}
|
|
821
|
+
return template.replace(/\$\{([^}]+)\}/g, (match, expr) => {
|
|
822
|
+
const [key, defaultValue] = expr.split(':-');
|
|
823
|
+
|
|
824
|
+
if (key in resolvedEnv) {
|
|
825
|
+
return resolvedEnv[key];
|
|
826
|
+
}
|
|
827
|
+
if (process.env[key]) {
|
|
828
|
+
return process.env[key]!;
|
|
829
|
+
}
|
|
830
|
+
if (defaultValue !== undefined) {
|
|
831
|
+
return defaultValue;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return match; // Keep unresolved
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async validate(envName: string): Promise<ValidationResult> {
|
|
839
|
+
const errors: ValidationError[] = [];
|
|
840
|
+
const warnings: ValidationWarning[] = [];
|
|
841
|
+
|
|
842
|
+
const definition = this.definitions.get(envName);
|
|
843
|
+
if (!definition) {
|
|
844
|
+
return {
|
|
845
|
+
valid: false,
|
|
846
|
+
errors: [{ key: '', message: `Environment not found: ${envName}` }],
|
|
847
|
+
warnings: []
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const resolved = await this.resolve(envName);
|
|
852
|
+
|
|
853
|
+
for (const [key, variable] of Object.entries(definition.variables)) {
|
|
854
|
+
const value = resolved[key];
|
|
855
|
+
|
|
856
|
+
// Required check
|
|
857
|
+
if (variable.required && !value) {
|
|
858
|
+
errors.push({ key, message: 'Required variable is missing' });
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (!value) continue;
|
|
863
|
+
|
|
864
|
+
// Type validation
|
|
865
|
+
if (variable.validation) {
|
|
866
|
+
const v = variable.validation;
|
|
867
|
+
|
|
868
|
+
switch (v.type) {
|
|
869
|
+
case 'number':
|
|
870
|
+
if (isNaN(Number(value))) {
|
|
871
|
+
errors.push({ key, message: 'Must be a number' });
|
|
872
|
+
} else {
|
|
873
|
+
const num = Number(value);
|
|
874
|
+
if (v.min !== undefined && num < v.min) {
|
|
875
|
+
errors.push({ key, message: `Must be at least ${v.min}` });
|
|
876
|
+
}
|
|
877
|
+
if (v.max !== undefined && num > v.max) {
|
|
878
|
+
errors.push({ key, message: `Must be at most ${v.max}` });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
break;
|
|
882
|
+
|
|
883
|
+
case 'boolean':
|
|
884
|
+
if (!['true', 'false', '1', '0'].includes(value.toLowerCase())) {
|
|
885
|
+
errors.push({ key, message: 'Must be a boolean' });
|
|
886
|
+
}
|
|
887
|
+
break;
|
|
888
|
+
|
|
889
|
+
case 'url':
|
|
890
|
+
try {
|
|
891
|
+
new URL(value);
|
|
892
|
+
} catch {
|
|
893
|
+
errors.push({ key, message: 'Must be a valid URL' });
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
|
|
897
|
+
case 'email':
|
|
898
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
899
|
+
errors.push({ key, message: 'Must be a valid email' });
|
|
900
|
+
}
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Pattern validation
|
|
905
|
+
if (v.pattern && !new RegExp(v.pattern).test(value)) {
|
|
906
|
+
errors.push({ key, message: `Must match pattern: ${v.pattern}` });
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Enum validation
|
|
910
|
+
if (v.enum && !v.enum.includes(value)) {
|
|
911
|
+
errors.push({ key, message: `Must be one of: ${v.enum.join(', ')}` });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Warnings for potential issues
|
|
916
|
+
if (variable.secret && !this.secretsVault) {
|
|
917
|
+
warnings.push({ key, message: 'Secret variable without vault configured' });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
valid: errors.length === 0,
|
|
923
|
+
errors,
|
|
924
|
+
warnings
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
getEnvironments(): string[] {
|
|
929
|
+
return Array.from(this.definitions.keys());
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
getCurrentEnvironment(): string {
|
|
933
|
+
return this.currentEnv;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
setCurrentEnvironment(name: string): void {
|
|
937
|
+
if (!this.definitions.has(name)) {
|
|
938
|
+
throw new Error(`Environment not found: ${name}`);
|
|
939
|
+
}
|
|
940
|
+
this.currentEnv = name;
|
|
941
|
+
this.cache.clear();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
### Uso
|
|
947
|
+
|
|
948
|
+
```typescript
|
|
949
|
+
const envResolver = new EnvResolver(config, secretsVault);
|
|
950
|
+
|
|
951
|
+
// Resolve all variables for an environment
|
|
952
|
+
const prodEnv = await envResolver.resolve('production');
|
|
953
|
+
console.log(prodEnv.DATABASE_URL);
|
|
954
|
+
|
|
955
|
+
// Interpolate templates
|
|
956
|
+
const connectionString = await envResolver.interpolate(
|
|
957
|
+
'postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME}'
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
// Validate environment
|
|
961
|
+
const validation = await envResolver.validate('production');
|
|
962
|
+
if (!validation.valid) {
|
|
963
|
+
console.error('Environment validation failed:', validation.errors);
|
|
964
|
+
}
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## 4. ConfigValidator
|
|
970
|
+
|
|
971
|
+
### Propósito
|
|
972
|
+
Valida configuraciones contra JSON Schema con coerción de tipos y mensajes de error amigables.
|
|
973
|
+
|
|
974
|
+
### Interfaz
|
|
975
|
+
|
|
976
|
+
```typescript
|
|
977
|
+
interface SchemaDefinition {
|
|
978
|
+
$schema?: string;
|
|
979
|
+
type: string;
|
|
980
|
+
properties?: Record<string, SchemaDefinition>;
|
|
981
|
+
required?: string[];
|
|
982
|
+
items?: SchemaDefinition;
|
|
983
|
+
enum?: unknown[];
|
|
984
|
+
minimum?: number;
|
|
985
|
+
maximum?: number;
|
|
986
|
+
minLength?: number;
|
|
987
|
+
maxLength?: number;
|
|
988
|
+
pattern?: string;
|
|
989
|
+
format?: string;
|
|
990
|
+
default?: unknown;
|
|
991
|
+
description?: string;
|
|
992
|
+
additionalProperties?: boolean | SchemaDefinition;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
interface ValidationOptions {
|
|
996
|
+
coerceTypes?: boolean;
|
|
997
|
+
removeAdditional?: boolean;
|
|
998
|
+
useDefaults?: boolean;
|
|
999
|
+
allErrors?: boolean;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
interface ConfigValidatorResult {
|
|
1003
|
+
valid: boolean;
|
|
1004
|
+
errors: ConfigValidationError[];
|
|
1005
|
+
coerced?: Record<string, unknown>;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
interface ConfigValidationError {
|
|
1009
|
+
path: string;
|
|
1010
|
+
message: string;
|
|
1011
|
+
keyword: string;
|
|
1012
|
+
params?: Record<string, unknown>;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
interface ConfigValidator {
|
|
1016
|
+
// Schema management
|
|
1017
|
+
addSchema(name: string, schema: SchemaDefinition): void;
|
|
1018
|
+
getSchema(name: string): SchemaDefinition | undefined;
|
|
1019
|
+
|
|
1020
|
+
// Validation
|
|
1021
|
+
validate(data: unknown, schemaName: string, options?: ValidationOptions): ConfigValidatorResult;
|
|
1022
|
+
validatePartial(data: unknown, schemaName: string, paths: string[]): ConfigValidatorResult;
|
|
1023
|
+
|
|
1024
|
+
// Utilities
|
|
1025
|
+
coerce(data: unknown, schemaName: string): unknown;
|
|
1026
|
+
getDefaults(schemaName: string): Record<string, unknown>;
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
### Implementación
|
|
1031
|
+
|
|
1032
|
+
```typescript
|
|
1033
|
+
class ConfigValidatorImpl implements ConfigValidator {
|
|
1034
|
+
private schemas: Map<string, SchemaDefinition> = new Map();
|
|
1035
|
+
|
|
1036
|
+
constructor() {
|
|
1037
|
+
// Register built-in formats
|
|
1038
|
+
this.registerFormats();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private formatValidators: Record<string, (value: string) => boolean> = {
|
|
1042
|
+
'date-time': (v) => !isNaN(Date.parse(v)),
|
|
1043
|
+
'date': (v) => /^\d{4}-\d{2}-\d{2}$/.test(v),
|
|
1044
|
+
'time': (v) => /^\d{2}:\d{2}:\d{2}$/.test(v),
|
|
1045
|
+
'email': (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
1046
|
+
'uri': (v) => { try { new URL(v); return true; } catch { return false; } },
|
|
1047
|
+
'uuid': (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
|
|
1048
|
+
'hostname': (v) => /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(v),
|
|
1049
|
+
'ipv4': (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) && v.split('.').every(n => parseInt(n) <= 255),
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
addSchema(name: string, schema: SchemaDefinition): void {
|
|
1053
|
+
this.schemas.set(name, schema);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
getSchema(name: string): SchemaDefinition | undefined {
|
|
1057
|
+
return this.schemas.get(name);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
validate(
|
|
1061
|
+
data: unknown,
|
|
1062
|
+
schemaName: string,
|
|
1063
|
+
options: ValidationOptions = {}
|
|
1064
|
+
): ConfigValidatorResult {
|
|
1065
|
+
const schema = this.schemas.get(schemaName);
|
|
1066
|
+
if (!schema) {
|
|
1067
|
+
return {
|
|
1068
|
+
valid: false,
|
|
1069
|
+
errors: [{ path: '', message: `Schema not found: ${schemaName}`, keyword: 'schema' }]
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const errors: ConfigValidationError[] = [];
|
|
1074
|
+
let coerced = options.coerceTypes ? this.deepClone(data) : data;
|
|
1075
|
+
|
|
1076
|
+
if (options.useDefaults) {
|
|
1077
|
+
coerced = this.applyDefaults(coerced as Record<string, unknown>, schema);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (options.coerceTypes) {
|
|
1081
|
+
coerced = this.coerceTypes(coerced, schema);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
this.validateValue(coerced, schema, '', errors, options);
|
|
1085
|
+
|
|
1086
|
+
if (options.removeAdditional && schema.type === 'object') {
|
|
1087
|
+
coerced = this.removeAdditionalProps(coerced as Record<string, unknown>, schema);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
valid: errors.length === 0,
|
|
1092
|
+
errors,
|
|
1093
|
+
coerced: options.coerceTypes ? coerced as Record<string, unknown> : undefined
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
private validateValue(
|
|
1098
|
+
value: unknown,
|
|
1099
|
+
schema: SchemaDefinition,
|
|
1100
|
+
path: string,
|
|
1101
|
+
errors: ConfigValidationError[],
|
|
1102
|
+
options: ValidationOptions
|
|
1103
|
+
): void {
|
|
1104
|
+
// Type validation
|
|
1105
|
+
if (!this.checkType(value, schema.type)) {
|
|
1106
|
+
errors.push({
|
|
1107
|
+
path,
|
|
1108
|
+
message: `Expected ${schema.type}, got ${typeof value}`,
|
|
1109
|
+
keyword: 'type',
|
|
1110
|
+
params: { expected: schema.type, actual: typeof value }
|
|
1111
|
+
});
|
|
1112
|
+
if (!options.allErrors) return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
switch (schema.type) {
|
|
1116
|
+
case 'object':
|
|
1117
|
+
this.validateObject(value as Record<string, unknown>, schema, path, errors, options);
|
|
1118
|
+
break;
|
|
1119
|
+
case 'array':
|
|
1120
|
+
this.validateArray(value as unknown[], schema, path, errors, options);
|
|
1121
|
+
break;
|
|
1122
|
+
case 'string':
|
|
1123
|
+
this.validateString(value as string, schema, path, errors);
|
|
1124
|
+
break;
|
|
1125
|
+
case 'number':
|
|
1126
|
+
case 'integer':
|
|
1127
|
+
this.validateNumber(value as number, schema, path, errors);
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Enum validation
|
|
1132
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
1133
|
+
errors.push({
|
|
1134
|
+
path,
|
|
1135
|
+
message: `Must be one of: ${schema.enum.join(', ')}`,
|
|
1136
|
+
keyword: 'enum',
|
|
1137
|
+
params: { allowed: schema.enum }
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private validateObject(
|
|
1143
|
+
obj: Record<string, unknown>,
|
|
1144
|
+
schema: SchemaDefinition,
|
|
1145
|
+
path: string,
|
|
1146
|
+
errors: ConfigValidationError[],
|
|
1147
|
+
options: ValidationOptions
|
|
1148
|
+
): void {
|
|
1149
|
+
// Required fields
|
|
1150
|
+
if (schema.required) {
|
|
1151
|
+
for (const field of schema.required) {
|
|
1152
|
+
if (!(field in obj)) {
|
|
1153
|
+
errors.push({
|
|
1154
|
+
path: this.joinPath(path, field),
|
|
1155
|
+
message: 'Required field is missing',
|
|
1156
|
+
keyword: 'required'
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Property validation
|
|
1163
|
+
if (schema.properties) {
|
|
1164
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1165
|
+
if (key in obj) {
|
|
1166
|
+
this.validateValue(obj[key], propSchema, this.joinPath(path, key), errors, options);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Additional properties
|
|
1172
|
+
if (schema.additionalProperties === false) {
|
|
1173
|
+
const allowed = new Set(Object.keys(schema.properties || {}));
|
|
1174
|
+
for (const key of Object.keys(obj)) {
|
|
1175
|
+
if (!allowed.has(key)) {
|
|
1176
|
+
errors.push({
|
|
1177
|
+
path: this.joinPath(path, key),
|
|
1178
|
+
message: 'Additional property not allowed',
|
|
1179
|
+
keyword: 'additionalProperties'
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private validateArray(
|
|
1187
|
+
arr: unknown[],
|
|
1188
|
+
schema: SchemaDefinition,
|
|
1189
|
+
path: string,
|
|
1190
|
+
errors: ConfigValidationError[],
|
|
1191
|
+
options: ValidationOptions
|
|
1192
|
+
): void {
|
|
1193
|
+
if (schema.items) {
|
|
1194
|
+
arr.forEach((item, index) => {
|
|
1195
|
+
this.validateValue(item, schema.items!, `${path}[${index}]`, errors, options);
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private validateString(
|
|
1201
|
+
value: string,
|
|
1202
|
+
schema: SchemaDefinition,
|
|
1203
|
+
path: string,
|
|
1204
|
+
errors: ConfigValidationError[]
|
|
1205
|
+
): void {
|
|
1206
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
1207
|
+
errors.push({
|
|
1208
|
+
path,
|
|
1209
|
+
message: `Must be at least ${schema.minLength} characters`,
|
|
1210
|
+
keyword: 'minLength'
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
1215
|
+
errors.push({
|
|
1216
|
+
path,
|
|
1217
|
+
message: `Must be at most ${schema.maxLength} characters`,
|
|
1218
|
+
keyword: 'maxLength'
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
1223
|
+
errors.push({
|
|
1224
|
+
path,
|
|
1225
|
+
message: `Must match pattern: ${schema.pattern}`,
|
|
1226
|
+
keyword: 'pattern'
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (schema.format && this.formatValidators[schema.format]) {
|
|
1231
|
+
if (!this.formatValidators[schema.format](value)) {
|
|
1232
|
+
errors.push({
|
|
1233
|
+
path,
|
|
1234
|
+
message: `Invalid ${schema.format} format`,
|
|
1235
|
+
keyword: 'format'
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private validateNumber(
|
|
1242
|
+
value: number,
|
|
1243
|
+
schema: SchemaDefinition,
|
|
1244
|
+
path: string,
|
|
1245
|
+
errors: ConfigValidationError[]
|
|
1246
|
+
): void {
|
|
1247
|
+
if (schema.type === 'integer' && !Number.isInteger(value)) {
|
|
1248
|
+
errors.push({
|
|
1249
|
+
path,
|
|
1250
|
+
message: 'Must be an integer',
|
|
1251
|
+
keyword: 'type'
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
1256
|
+
errors.push({
|
|
1257
|
+
path,
|
|
1258
|
+
message: `Must be at least ${schema.minimum}`,
|
|
1259
|
+
keyword: 'minimum'
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
1264
|
+
errors.push({
|
|
1265
|
+
path,
|
|
1266
|
+
message: `Must be at most ${schema.maximum}`,
|
|
1267
|
+
keyword: 'maximum'
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private checkType(value: unknown, type: string): boolean {
|
|
1273
|
+
switch (type) {
|
|
1274
|
+
case 'object':
|
|
1275
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1276
|
+
case 'array':
|
|
1277
|
+
return Array.isArray(value);
|
|
1278
|
+
case 'string':
|
|
1279
|
+
return typeof value === 'string';
|
|
1280
|
+
case 'number':
|
|
1281
|
+
return typeof value === 'number' && !isNaN(value);
|
|
1282
|
+
case 'integer':
|
|
1283
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
1284
|
+
case 'boolean':
|
|
1285
|
+
return typeof value === 'boolean';
|
|
1286
|
+
case 'null':
|
|
1287
|
+
return value === null;
|
|
1288
|
+
default:
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
private coerceTypes(value: unknown, schema: SchemaDefinition): unknown {
|
|
1294
|
+
if (value === undefined || value === null) return value;
|
|
1295
|
+
|
|
1296
|
+
switch (schema.type) {
|
|
1297
|
+
case 'string':
|
|
1298
|
+
return String(value);
|
|
1299
|
+
case 'number':
|
|
1300
|
+
return Number(value);
|
|
1301
|
+
case 'integer':
|
|
1302
|
+
return Math.floor(Number(value));
|
|
1303
|
+
case 'boolean':
|
|
1304
|
+
if (typeof value === 'string') {
|
|
1305
|
+
return value.toLowerCase() === 'true' || value === '1';
|
|
1306
|
+
}
|
|
1307
|
+
return Boolean(value);
|
|
1308
|
+
case 'object':
|
|
1309
|
+
if (typeof value === 'object' && schema.properties) {
|
|
1310
|
+
const obj = value as Record<string, unknown>;
|
|
1311
|
+
const result: Record<string, unknown> = {};
|
|
1312
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1313
|
+
if (key in obj) {
|
|
1314
|
+
result[key] = this.coerceTypes(obj[key], propSchema);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return result;
|
|
1318
|
+
}
|
|
1319
|
+
return value;
|
|
1320
|
+
case 'array':
|
|
1321
|
+
if (Array.isArray(value) && schema.items) {
|
|
1322
|
+
return value.map(item => this.coerceTypes(item, schema.items!));
|
|
1323
|
+
}
|
|
1324
|
+
return value;
|
|
1325
|
+
default:
|
|
1326
|
+
return value;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
private applyDefaults(
|
|
1331
|
+
obj: Record<string, unknown>,
|
|
1332
|
+
schema: SchemaDefinition
|
|
1333
|
+
): Record<string, unknown> {
|
|
1334
|
+
const result = { ...obj };
|
|
1335
|
+
|
|
1336
|
+
if (schema.properties) {
|
|
1337
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1338
|
+
if (!(key in result) && propSchema.default !== undefined) {
|
|
1339
|
+
result[key] = this.deepClone(propSchema.default);
|
|
1340
|
+
} else if (key in result && propSchema.type === 'object') {
|
|
1341
|
+
result[key] = this.applyDefaults(
|
|
1342
|
+
result[key] as Record<string, unknown>,
|
|
1343
|
+
propSchema
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
return result;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
getDefaults(schemaName: string): Record<string, unknown> {
|
|
1353
|
+
const schema = this.schemas.get(schemaName);
|
|
1354
|
+
if (!schema) return {};
|
|
1355
|
+
return this.extractDefaults(schema);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
private extractDefaults(schema: SchemaDefinition): Record<string, unknown> {
|
|
1359
|
+
const defaults: Record<string, unknown> = {};
|
|
1360
|
+
|
|
1361
|
+
if (schema.properties) {
|
|
1362
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1363
|
+
if (propSchema.default !== undefined) {
|
|
1364
|
+
defaults[key] = propSchema.default;
|
|
1365
|
+
} else if (propSchema.type === 'object') {
|
|
1366
|
+
const nested = this.extractDefaults(propSchema);
|
|
1367
|
+
if (Object.keys(nested).length > 0) {
|
|
1368
|
+
defaults[key] = nested;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
return defaults;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
private joinPath(base: string, key: string): string {
|
|
1378
|
+
return base ? `${base}.${key}` : key;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
private deepClone<T>(obj: T): T {
|
|
1382
|
+
return JSON.parse(JSON.stringify(obj));
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
### Uso
|
|
1388
|
+
|
|
1389
|
+
```typescript
|
|
1390
|
+
const validator = new ConfigValidator();
|
|
1391
|
+
|
|
1392
|
+
// Register schema
|
|
1393
|
+
validator.addSchema('elsabro-config', {
|
|
1394
|
+
type: 'object',
|
|
1395
|
+
required: ['llm', 'agents'],
|
|
1396
|
+
properties: {
|
|
1397
|
+
llm: {
|
|
1398
|
+
type: 'object',
|
|
1399
|
+
required: ['model'],
|
|
1400
|
+
properties: {
|
|
1401
|
+
model: {
|
|
1402
|
+
type: 'string',
|
|
1403
|
+
enum: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-3-5-haiku-20241022'],
|
|
1404
|
+
default: 'claude-sonnet-4-20250514'
|
|
1405
|
+
},
|
|
1406
|
+
maxTokens: {
|
|
1407
|
+
type: 'integer',
|
|
1408
|
+
minimum: 1,
|
|
1409
|
+
maximum: 200000,
|
|
1410
|
+
default: 4096
|
|
1411
|
+
},
|
|
1412
|
+
temperature: {
|
|
1413
|
+
type: 'number',
|
|
1414
|
+
minimum: 0,
|
|
1415
|
+
maximum: 1,
|
|
1416
|
+
default: 0.7
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
},
|
|
1420
|
+
agents: {
|
|
1421
|
+
type: 'object',
|
|
1422
|
+
additionalProperties: {
|
|
1423
|
+
type: 'object',
|
|
1424
|
+
properties: {
|
|
1425
|
+
enabled: { type: 'boolean', default: true },
|
|
1426
|
+
model: { type: 'string' },
|
|
1427
|
+
maxIterations: { type: 'integer', minimum: 1 }
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// Validate config
|
|
1435
|
+
const result = validator.validate(userConfig, 'elsabro-config', {
|
|
1436
|
+
coerceTypes: true,
|
|
1437
|
+
useDefaults: true,
|
|
1438
|
+
allErrors: true
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
if (!result.valid) {
|
|
1442
|
+
console.error('Config validation failed:');
|
|
1443
|
+
result.errors.forEach(err => {
|
|
1444
|
+
console.error(` ${err.path}: ${err.message}`);
|
|
1445
|
+
});
|
|
1446
|
+
} else {
|
|
1447
|
+
// Use coerced config with defaults applied
|
|
1448
|
+
const validConfig = result.coerced;
|
|
1449
|
+
}
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
---
|
|
1453
|
+
|
|
1454
|
+
## 5. RemoteConfigSync
|
|
1455
|
+
|
|
1456
|
+
### Propósito
|
|
1457
|
+
Sincroniza configuración desde fuentes remotas con caching, fallback y actualización automática.
|
|
1458
|
+
|
|
1459
|
+
### Interfaz
|
|
1460
|
+
|
|
1461
|
+
```typescript
|
|
1462
|
+
interface RemoteConfigOptions {
|
|
1463
|
+
url: string;
|
|
1464
|
+
pollingInterval?: number; // ms, 0 = disabled
|
|
1465
|
+
cacheEnabled?: boolean;
|
|
1466
|
+
cacheTTL?: number; // ms
|
|
1467
|
+
fallbackToCache?: boolean;
|
|
1468
|
+
headers?: Record<string, string>;
|
|
1469
|
+
timeout?: number;
|
|
1470
|
+
retryAttempts?: number;
|
|
1471
|
+
retryDelay?: number;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
interface RemoteConfigSync {
|
|
1475
|
+
// Fetch operations
|
|
1476
|
+
fetch(): Promise<Record<string, unknown>>;
|
|
1477
|
+
fetchWithFallback(): Promise<Record<string, unknown>>;
|
|
1478
|
+
|
|
1479
|
+
// Polling
|
|
1480
|
+
startPolling(): void;
|
|
1481
|
+
stopPolling(): void;
|
|
1482
|
+
|
|
1483
|
+
// Cache
|
|
1484
|
+
getFromCache(): Record<string, unknown> | null;
|
|
1485
|
+
clearCache(): void;
|
|
1486
|
+
|
|
1487
|
+
// Events
|
|
1488
|
+
onUpdate(callback: (config: Record<string, unknown>) => void): () => void;
|
|
1489
|
+
onError(callback: (error: Error) => void): () => void;
|
|
1490
|
+
|
|
1491
|
+
// Status
|
|
1492
|
+
getStatus(): RemoteConfigStatus;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
interface RemoteConfigStatus {
|
|
1496
|
+
lastFetch: Date | null;
|
|
1497
|
+
lastSuccess: Date | null;
|
|
1498
|
+
lastError: Error | null;
|
|
1499
|
+
isPolling: boolean;
|
|
1500
|
+
cacheAge: number | null;
|
|
1501
|
+
}
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
### Implementación
|
|
1505
|
+
|
|
1506
|
+
```typescript
|
|
1507
|
+
class RemoteConfigSyncImpl implements RemoteConfigSync {
|
|
1508
|
+
private options: Required<RemoteConfigOptions>;
|
|
1509
|
+
private cache: { data: Record<string, unknown>; timestamp: number } | null = null;
|
|
1510
|
+
private pollingTimer: NodeJS.Timer | null = null;
|
|
1511
|
+
private updateCallbacks: Set<Function> = new Set();
|
|
1512
|
+
private errorCallbacks: Set<Function> = new Set();
|
|
1513
|
+
private status: RemoteConfigStatus = {
|
|
1514
|
+
lastFetch: null,
|
|
1515
|
+
lastSuccess: null,
|
|
1516
|
+
lastError: null,
|
|
1517
|
+
isPolling: false,
|
|
1518
|
+
cacheAge: null
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
constructor(options: RemoteConfigOptions) {
|
|
1522
|
+
this.options = {
|
|
1523
|
+
pollingInterval: 0,
|
|
1524
|
+
cacheEnabled: true,
|
|
1525
|
+
cacheTTL: 300000, // 5 minutes
|
|
1526
|
+
fallbackToCache: true,
|
|
1527
|
+
headers: {},
|
|
1528
|
+
timeout: 10000,
|
|
1529
|
+
retryAttempts: 3,
|
|
1530
|
+
retryDelay: 1000,
|
|
1531
|
+
...options
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async fetch(): Promise<Record<string, unknown>> {
|
|
1536
|
+
this.status.lastFetch = new Date();
|
|
1537
|
+
|
|
1538
|
+
let lastError: Error | null = null;
|
|
1539
|
+
|
|
1540
|
+
for (let attempt = 1; attempt <= this.options.retryAttempts; attempt++) {
|
|
1541
|
+
try {
|
|
1542
|
+
const controller = new AbortController();
|
|
1543
|
+
const timeoutId = setTimeout(() => controller.abort(), this.options.timeout);
|
|
1544
|
+
|
|
1545
|
+
const response = await fetch(this.options.url, {
|
|
1546
|
+
headers: this.options.headers,
|
|
1547
|
+
signal: controller.signal
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
clearTimeout(timeoutId);
|
|
1551
|
+
|
|
1552
|
+
if (!response.ok) {
|
|
1553
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const data = await response.json();
|
|
1557
|
+
|
|
1558
|
+
// Update cache
|
|
1559
|
+
if (this.options.cacheEnabled) {
|
|
1560
|
+
this.cache = {
|
|
1561
|
+
data,
|
|
1562
|
+
timestamp: Date.now()
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
this.status.lastSuccess = new Date();
|
|
1567
|
+
this.status.lastError = null;
|
|
1568
|
+
|
|
1569
|
+
// Notify listeners
|
|
1570
|
+
this.updateCallbacks.forEach(cb => cb(data));
|
|
1571
|
+
|
|
1572
|
+
return data;
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
lastError = error as Error;
|
|
1575
|
+
|
|
1576
|
+
if (attempt < this.options.retryAttempts) {
|
|
1577
|
+
await this.delay(this.options.retryDelay * attempt);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
this.status.lastError = lastError;
|
|
1583
|
+
this.errorCallbacks.forEach(cb => cb(lastError));
|
|
1584
|
+
throw lastError;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async fetchWithFallback(): Promise<Record<string, unknown>> {
|
|
1588
|
+
try {
|
|
1589
|
+
return await this.fetch();
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
if (this.options.fallbackToCache && this.cache) {
|
|
1592
|
+
console.warn('Remote config fetch failed, using cached config');
|
|
1593
|
+
return this.cache.data;
|
|
1594
|
+
}
|
|
1595
|
+
throw error;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
startPolling(): void {
|
|
1600
|
+
if (this.pollingTimer || this.options.pollingInterval <= 0) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
this.status.isPolling = true;
|
|
1605
|
+
|
|
1606
|
+
this.pollingTimer = setInterval(async () => {
|
|
1607
|
+
try {
|
|
1608
|
+
await this.fetch();
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
// Error already handled in fetch()
|
|
1611
|
+
}
|
|
1612
|
+
}, this.options.pollingInterval);
|
|
1613
|
+
|
|
1614
|
+
// Initial fetch
|
|
1615
|
+
this.fetch().catch(() => {});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
stopPolling(): void {
|
|
1619
|
+
if (this.pollingTimer) {
|
|
1620
|
+
clearInterval(this.pollingTimer);
|
|
1621
|
+
this.pollingTimer = null;
|
|
1622
|
+
}
|
|
1623
|
+
this.status.isPolling = false;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
getFromCache(): Record<string, unknown> | null {
|
|
1627
|
+
if (!this.cache) return null;
|
|
1628
|
+
|
|
1629
|
+
const age = Date.now() - this.cache.timestamp;
|
|
1630
|
+
if (age > this.options.cacheTTL) {
|
|
1631
|
+
this.cache = null;
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
return this.cache.data;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
clearCache(): void {
|
|
1639
|
+
this.cache = null;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
onUpdate(callback: Function): () => void {
|
|
1643
|
+
this.updateCallbacks.add(callback);
|
|
1644
|
+
return () => this.updateCallbacks.delete(callback);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
onError(callback: Function): () => void {
|
|
1648
|
+
this.errorCallbacks.add(callback);
|
|
1649
|
+
return () => this.errorCallbacks.delete(callback);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
getStatus(): RemoteConfigStatus {
|
|
1653
|
+
return {
|
|
1654
|
+
...this.status,
|
|
1655
|
+
cacheAge: this.cache ? Date.now() - this.cache.timestamp : null
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
private delay(ms: number): Promise<void> {
|
|
1660
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
---
|
|
1666
|
+
|
|
1667
|
+
## 6. Integración con ELSABRO
|
|
1668
|
+
|
|
1669
|
+
### Inicialización
|
|
1670
|
+
|
|
1671
|
+
```typescript
|
|
1672
|
+
// config/index.ts
|
|
1673
|
+
import { ConfigManager } from './ConfigManager';
|
|
1674
|
+
import { FeatureFlagService } from './FeatureFlags';
|
|
1675
|
+
import { EnvResolver } from './EnvResolver';
|
|
1676
|
+
import { ConfigValidator } from './ConfigValidator';
|
|
1677
|
+
import { RemoteConfigSync } from './RemoteConfigSync';
|
|
1678
|
+
|
|
1679
|
+
export async function initializeConfig(): Promise<ConfigurationSystem> {
|
|
1680
|
+
// 1. Create config manager
|
|
1681
|
+
const configManager = new ConfigManager({
|
|
1682
|
+
environment: process.env.NODE_ENV || 'development',
|
|
1683
|
+
sources: [
|
|
1684
|
+
{ type: 'file', path: '.elsabro/config.json', priority: 1 },
|
|
1685
|
+
{ type: 'file', path: '.elsabro/config.local.json', priority: 2, required: false },
|
|
1686
|
+
{ type: 'env', path: 'ELSABRO', priority: 3 }
|
|
1687
|
+
],
|
|
1688
|
+
watchForChanges: true,
|
|
1689
|
+
mergeStrategy: 'deep'
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
await configManager.load();
|
|
1693
|
+
|
|
1694
|
+
// 2. Create validator and validate config
|
|
1695
|
+
const validator = new ConfigValidator();
|
|
1696
|
+
validator.addSchema('elsabro', elsabroConfigSchema);
|
|
1697
|
+
|
|
1698
|
+
const validation = validator.validate(configManager.getAll(), 'elsabro', {
|
|
1699
|
+
coerceTypes: true,
|
|
1700
|
+
useDefaults: true
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
if (!validation.valid) {
|
|
1704
|
+
throw new ConfigValidationError(validation.errors);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// 3. Create env resolver
|
|
1708
|
+
const envResolver = new EnvResolver(configManager);
|
|
1709
|
+
const envValidation = await envResolver.validate(configManager.getEnvironment());
|
|
1710
|
+
|
|
1711
|
+
if (!envValidation.valid) {
|
|
1712
|
+
console.warn('Environment validation warnings:', envValidation.warnings);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// 4. Create feature flags
|
|
1716
|
+
const featureFlags = new FeatureFlagService(configManager);
|
|
1717
|
+
|
|
1718
|
+
// 5. Setup remote config if enabled
|
|
1719
|
+
let remoteConfig: RemoteConfigSync | null = null;
|
|
1720
|
+
if (configManager.get('remoteConfig.enabled')) {
|
|
1721
|
+
remoteConfig = new RemoteConfigSync({
|
|
1722
|
+
url: configManager.get('remoteConfig.url'),
|
|
1723
|
+
pollingInterval: configManager.get('remoteConfig.pollingInterval', 60000)
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
remoteConfig.onUpdate((config) => {
|
|
1727
|
+
configManager.merge(config);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
remoteConfig.startPolling();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
return {
|
|
1734
|
+
config: configManager,
|
|
1735
|
+
flags: featureFlags,
|
|
1736
|
+
env: envResolver,
|
|
1737
|
+
validator,
|
|
1738
|
+
remoteConfig
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
### Dashboard de Configuración
|
|
1744
|
+
|
|
1745
|
+
```
|
|
1746
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
1747
|
+
│ ELSABRO Configuration Dashboard │
|
|
1748
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
1749
|
+
│ Environment: production │
|
|
1750
|
+
│ Config Sources: 4 loaded, 0 failed │
|
|
1751
|
+
│ Last Validation: ✓ Valid (2024-01-15 10:30:45) │
|
|
1752
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
1753
|
+
│ Feature Flags │
|
|
1754
|
+
├─────────────────┬──────────┬─────────────┬──────────────────────────────────┤
|
|
1755
|
+
│ Flag │ Status │ Targeting │ Rollout │
|
|
1756
|
+
├─────────────────┼──────────┼─────────────┼──────────────────────────────────┤
|
|
1757
|
+
│ new-agent-ui │ ✓ ON │ All Users │ ████████████████████ 100% │
|
|
1758
|
+
│ beta-tools │ ✓ ON │ Enterprise │ ████████░░░░░░░░░░░░ 40% │
|
|
1759
|
+
│ experimental │ ✗ OFF │ Internal │ ░░░░░░░░░░░░░░░░░░░░ 0% │
|
|
1760
|
+
│ cost-optimizer │ ⏰ SCHED │ All Users │ Starts: 2024-02-01 │
|
|
1761
|
+
└─────────────────┴──────────┴─────────────┴──────────────────────────────────┘
|
|
1762
|
+
│ Remote Config │
|
|
1763
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
1764
|
+
│ Status: ● Connected │
|
|
1765
|
+
│ Last Sync: 2024-01-15 10:30:00 (45 seconds ago) │
|
|
1766
|
+
│ Polling: Every 60s │
|
|
1767
|
+
│ Cache: Valid (TTL: 4:15 remaining) │
|
|
1768
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
---
|
|
1772
|
+
|
|
1773
|
+
## 7. Best Practices
|
|
1774
|
+
|
|
1775
|
+
### Diseño de Configuración
|
|
1776
|
+
|
|
1777
|
+
1. **Estructura jerárquica clara**: Agrupa configs relacionadas
|
|
1778
|
+
2. **Valores por defecto sensibles**: Funcionamiento out-of-the-box
|
|
1779
|
+
3. **Documentación inline**: Descripciones en schema
|
|
1780
|
+
4. **Validación estricta**: Falla temprano con errores claros
|
|
1781
|
+
|
|
1782
|
+
### Feature Flags
|
|
1783
|
+
|
|
1784
|
+
1. **Nombres descriptivos**: `enable-new-checkout` no `flag1`
|
|
1785
|
+
2. **Kill switches**: Siempre poder desactivar features
|
|
1786
|
+
3. **Cleanup regular**: Eliminar flags obsoletos
|
|
1787
|
+
4. **Auditoría**: Log de cambios en flags
|
|
1788
|
+
|
|
1789
|
+
### Environments
|
|
1790
|
+
|
|
1791
|
+
1. **Herencia clara**: `prod > staging > development`
|
|
1792
|
+
2. **Secretos separados**: Nunca en archivos de config
|
|
1793
|
+
3. **Validación por environment**: Diferentes requerimientos
|
|
1794
|
+
4. **Documentación de variables**: Qué hace cada una
|
|
1795
|
+
|
|
1796
|
+
### Remote Config
|
|
1797
|
+
|
|
1798
|
+
1. **Fallback robusto**: Cache local siempre disponible
|
|
1799
|
+
2. **Timeouts cortos**: No bloquear startup
|
|
1800
|
+
3. **Versionamiento**: Rollback fácil
|
|
1801
|
+
4. **Monitoring**: Alertas de sync failures
|
|
1802
|
+
|
|
1803
|
+
---
|
|
1804
|
+
|
|
1805
|
+
## Referencias
|
|
1806
|
+
|
|
1807
|
+
- **REF-001**: Architecture Guide
|
|
1808
|
+
- **REF-006**: Observability & Metrics
|
|
1809
|
+
- **REF-022**: Security System (para integración con SecretsVault)
|
|
1810
|
+
- **REF-023**: Esta referencia (Configuration Management)
|