@arikajs/config 0.0.5

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.
@@ -0,0 +1,562 @@
1
+
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { createRequire } from 'module';
5
+
6
+ const localRequire = createRequire(import.meta.url);
7
+
8
+ // ─── Schema Types ───────────────────────────────────────────────────────────
9
+
10
+ export type SchemaRule = {
11
+ type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
12
+ required?: boolean;
13
+ default?: any;
14
+ enum?: any[];
15
+ min?: number;
16
+ max?: number;
17
+ pattern?: RegExp;
18
+ message?: string;
19
+ children?: Record<string, SchemaRule>;
20
+ };
21
+
22
+ export type ConfigSchema = Record<string, SchemaRule | Record<string, SchemaRule>>;
23
+
24
+ export interface ValidationError {
25
+ key: string;
26
+ message: string;
27
+ }
28
+
29
+ // ─── Change Listener Types ──────────────────────────────────────────────────
30
+
31
+ export type ConfigChangeListener = (key: string, newValue: any, oldValue: any) => void;
32
+
33
+ // ─── Repository ─────────────────────────────────────────────────────────────
34
+
35
+ export class Repository {
36
+ private config: Record<string, any> = {};
37
+ private booted = false;
38
+
39
+ // Feature 1: Config Caching
40
+ private flatCache: Map<string, any> = new Map();
41
+ private cacheBuilt = false;
42
+
43
+ // Feature 2: Schema Validation
44
+ private schema: ConfigSchema | null = null;
45
+
46
+ // Feature 4: Change Listeners
47
+ private listeners: Map<string, ConfigChangeListener[]> = new Map();
48
+ private globalListeners: ConfigChangeListener[] = [];
49
+
50
+ // Feature 6: Encrypted Config
51
+ private decrypter: ((value: string) => string) | null = null;
52
+
53
+ constructor(initialConfig: Record<string, any> = {}) {
54
+ this.config = { ...initialConfig };
55
+ }
56
+
57
+ // ─── Loading ────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Load configuration files from the config directory.
61
+ */
62
+ public loadConfigDirectory(configPath: string, environment?: string): void {
63
+ if (this.booted) {
64
+ throw new Error('Configuration cannot be modified after boot.');
65
+ }
66
+
67
+ if (!fs.existsSync(configPath)) {
68
+ return;
69
+ }
70
+
71
+ const files = fs.readdirSync(configPath);
72
+
73
+ // Feature 3: Environment-based Merging
74
+ // First load base configs (e.g., database.js)
75
+ // Then overlay env-specific (e.g., database.production.js)
76
+ const env = environment || process.env.NODE_ENV || 'development';
77
+
78
+ const baseFiles = files.filter(
79
+ (file: string) =>
80
+ this.isConfigFile(file) && !this.isEnvSpecificFile(file),
81
+ );
82
+
83
+ const envFiles = files.filter(
84
+ (file: string) =>
85
+ this.isConfigFile(file) && file.includes(`.${env}.`),
86
+ );
87
+
88
+ // Load base configs first
89
+ for (const file of baseFiles) {
90
+ this.loadFile(configPath, file);
91
+ }
92
+
93
+ // Then overlay environment-specific configs
94
+ for (const file of envFiles) {
95
+ this.loadFile(configPath, file, true);
96
+ }
97
+ }
98
+
99
+ private isConfigFile(file: string): boolean {
100
+ return (
101
+ file.endsWith('.js') ||
102
+ file.endsWith('.ts') ||
103
+ file.endsWith('.cjs') ||
104
+ file.endsWith('.mjs')
105
+ );
106
+ }
107
+
108
+ private isEnvSpecificFile(file: string): boolean {
109
+ const envPatterns = [
110
+ '.development.',
111
+ '.production.',
112
+ '.staging.',
113
+ '.testing.',
114
+ '.local.',
115
+ ];
116
+ return envPatterns.some((p) => file.includes(p));
117
+ }
118
+
119
+ private loadFile(
120
+ configPath: string,
121
+ file: string,
122
+ isOverlay: boolean = false,
123
+ ): void {
124
+ const filePath = path.join(configPath, file);
125
+
126
+ // Extract base config name (e.g., "database" from "database.production.js")
127
+ const parts = path.basename(file, path.extname(file)).split('.');
128
+ const configName = parts[0];
129
+
130
+ try {
131
+ let configValue;
132
+ try {
133
+ const module = localRequire(filePath);
134
+ configValue = module.default || module;
135
+ } catch {
136
+ // ESM fallback — silently skip
137
+ }
138
+
139
+ if (typeof configValue === 'object' && configValue !== null) {
140
+ if (isOverlay && this.config[configName]) {
141
+ // Deep merge for env overlays
142
+ this.config[configName] = this.deepMerge(
143
+ this.config[configName],
144
+ configValue,
145
+ );
146
+ } else {
147
+ this.config[configName] = {
148
+ ...this.config[configName],
149
+ ...configValue,
150
+ };
151
+ }
152
+ }
153
+ } catch {
154
+ // Silently skip files that can't be loaded
155
+ }
156
+ }
157
+
158
+ // ─── Get / Set / Has ────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Get a configuration value using dot notation.
162
+ * Uses flat cache for O(1) lookup when booted.
163
+ */
164
+ public get<T = any>(key: string, defaultValue?: T): T {
165
+ // Feature 1: Use flat cache after boot for O(1)
166
+ if (this.cacheBuilt) {
167
+ const cached = this.flatCache.get(key);
168
+ if (cached !== undefined) {
169
+ // Feature 6: Auto-decrypt encrypted values
170
+ if (typeof cached === 'string' && cached.startsWith('enc:')) {
171
+ return this.decrypt(cached) as T;
172
+ }
173
+ return cached as T;
174
+ }
175
+ return defaultValue as T;
176
+ }
177
+
178
+ // Pre-boot: walk the tree
179
+ const keys = key.split('.');
180
+ let value: any = this.config;
181
+
182
+ for (const k of keys) {
183
+ if (value === undefined || value === null) {
184
+ return defaultValue as T;
185
+ }
186
+ value = value[k];
187
+ }
188
+
189
+ const result = value !== undefined ? value : defaultValue;
190
+
191
+ // Feature 6: Auto-decrypt encrypted values
192
+ if (typeof result === 'string' && result.startsWith('enc:')) {
193
+ return this.decrypt(result) as T;
194
+ }
195
+
196
+ return result as T;
197
+ }
198
+
199
+ /**
200
+ * Check if a configuration key exists.
201
+ */
202
+ public has(key: string): boolean {
203
+ if (this.cacheBuilt) {
204
+ return this.flatCache.has(key);
205
+ }
206
+ return this.get(key) !== undefined;
207
+ }
208
+
209
+ /**
210
+ * Set a configuration value using dot notation.
211
+ */
212
+ public set(key: string, value: any): void {
213
+ if (this.booted) {
214
+ throw new Error('Configuration cannot be modified after boot.');
215
+ }
216
+
217
+ const oldValue = this.get(key);
218
+
219
+ const keys = key.split('.');
220
+ let current = this.config;
221
+
222
+ for (let i = 0; i < keys.length - 1; i++) {
223
+ const k = keys[i];
224
+ if (
225
+ !(k in current) ||
226
+ typeof current[k] !== 'object' ||
227
+ current[k] === null
228
+ ) {
229
+ current[k] = {};
230
+ }
231
+ current = current[k];
232
+ }
233
+
234
+ current[keys[keys.length - 1]] = value;
235
+
236
+ // Feature 4: Notify change listeners
237
+ this.notifyListeners(key, value, oldValue);
238
+ }
239
+
240
+ /**
241
+ * Get all configuration.
242
+ */
243
+ public all(): Record<string, any> {
244
+ return { ...this.config };
245
+ }
246
+
247
+ // ─── Boot Lifecycle ─────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Mark the repository as booted (read-only).
251
+ * Builds the flat cache and deep-freezes the config for immutability.
252
+ */
253
+ public markAsBooted(): void {
254
+ // Feature 2: Validate schema before boot
255
+ if (this.schema) {
256
+ const errors = this.validateSchema();
257
+ if (errors.length > 0) {
258
+ const messages = errors.map((e) => ` - ${e.key}: ${e.message}`).join('\n');
259
+ throw new Error(
260
+ `Configuration validation failed:\n${messages}`,
261
+ );
262
+ }
263
+ }
264
+
265
+ // Feature 1: Build flat cache
266
+ this.buildFlatCache();
267
+
268
+ // Feature 5: Deep freeze
269
+ this.deepFreeze(this.config);
270
+
271
+ this.booted = true;
272
+ }
273
+
274
+ /**
275
+ * Check if the repository has been booted.
276
+ */
277
+ public isBooted(): boolean {
278
+ return this.booted;
279
+ }
280
+
281
+ // ─── Feature 1: Config Caching ──────────────────────────────────────────
282
+
283
+ /**
284
+ * Build a flat Map of all dot-notation keys for O(1) lookups.
285
+ */
286
+ private buildFlatCache(obj?: Record<string, any>, prefix?: string): void {
287
+ const target = obj || this.config;
288
+ const pre = prefix ? `${prefix}.` : '';
289
+
290
+ for (const key of Object.keys(target)) {
291
+ const fullKey = `${pre}${key}`;
292
+ const value = target[key];
293
+
294
+ this.flatCache.set(fullKey, value);
295
+
296
+ if (
297
+ typeof value === 'object' &&
298
+ value !== null &&
299
+ !Array.isArray(value)
300
+ ) {
301
+ this.buildFlatCache(value, fullKey);
302
+ }
303
+ }
304
+
305
+ if (!prefix) {
306
+ this.cacheBuilt = true;
307
+ }
308
+ }
309
+
310
+ // ─── Feature 2: Schema Validation ───────────────────────────────────────
311
+
312
+ /**
313
+ * Define a validation schema for the configuration.
314
+ */
315
+ public defineSchema(schema: ConfigSchema): this {
316
+ this.schema = schema;
317
+ return this;
318
+ }
319
+
320
+ /**
321
+ * Validate the current config against the defined schema.
322
+ */
323
+ public validate(): ValidationError[] {
324
+ if (!this.schema) return [];
325
+ return this.validateSchema();
326
+ }
327
+
328
+ private validateSchema(
329
+ schema?: Record<string, SchemaRule>,
330
+ prefix?: string,
331
+ ): ValidationError[] {
332
+ const errors: ValidationError[] = [];
333
+ const rules = schema || this.schema;
334
+
335
+ if (!rules) return errors;
336
+
337
+ for (const [key, rule] of Object.entries(rules)) {
338
+ const fullKey = prefix ? `${prefix}.${key}` : key;
339
+ const value = this.get(fullKey);
340
+
341
+ // Handle nested schema via 'children'
342
+ if ((rule as SchemaRule).children) {
343
+ errors.push(
344
+ ...this.validateSchema(
345
+ (rule as SchemaRule).children,
346
+ fullKey,
347
+ ),
348
+ );
349
+ continue;
350
+ }
351
+
352
+ const r = rule as SchemaRule;
353
+
354
+ // Required check
355
+ if (r.required && (value === undefined || value === null)) {
356
+ errors.push({
357
+ key: fullKey,
358
+ message:
359
+ r.message || `'${fullKey}' is required but not set.`,
360
+ });
361
+ continue;
362
+ }
363
+
364
+ // Skip further checks if value is undefined and not required
365
+ if (value === undefined || value === null) continue;
366
+
367
+ // Type check
368
+ if (r.type) {
369
+ const actualType = Array.isArray(value) ? 'array' : typeof value;
370
+ if (actualType !== r.type) {
371
+ errors.push({
372
+ key: fullKey,
373
+ message:
374
+ r.message ||
375
+ `'${fullKey}' must be of type '${r.type}', got '${actualType}'.`,
376
+ });
377
+ }
378
+ }
379
+
380
+ // Enum check
381
+ if (r.enum && !r.enum.includes(value)) {
382
+ errors.push({
383
+ key: fullKey,
384
+ message:
385
+ r.message ||
386
+ `'${fullKey}' must be one of [${r.enum.join(', ')}], got '${value}'.`,
387
+ });
388
+ }
389
+
390
+ // Min/Max for numbers
391
+ if (r.type === 'number' && typeof value === 'number') {
392
+ if (r.min !== undefined && value < r.min) {
393
+ errors.push({
394
+ key: fullKey,
395
+ message:
396
+ r.message ||
397
+ `'${fullKey}' must be >= ${r.min}, got ${value}.`,
398
+ });
399
+ }
400
+ if (r.max !== undefined && value > r.max) {
401
+ errors.push({
402
+ key: fullKey,
403
+ message:
404
+ r.message ||
405
+ `'${fullKey}' must be <= ${r.max}, got ${value}.`,
406
+ });
407
+ }
408
+ }
409
+
410
+ // Min/Max for string length
411
+ if (r.type === 'string' && typeof value === 'string') {
412
+ if (r.min !== undefined && value.length < r.min) {
413
+ errors.push({
414
+ key: fullKey,
415
+ message:
416
+ r.message ||
417
+ `'${fullKey}' must have at least ${r.min} characters.`,
418
+ });
419
+ }
420
+ if (r.max !== undefined && value.length > r.max) {
421
+ errors.push({
422
+ key: fullKey,
423
+ message:
424
+ r.message ||
425
+ `'${fullKey}' must have at most ${r.max} characters.`,
426
+ });
427
+ }
428
+ }
429
+
430
+ // Pattern check
431
+ if (r.pattern && typeof value === 'string' && !r.pattern.test(value)) {
432
+ errors.push({
433
+ key: fullKey,
434
+ message:
435
+ r.message ||
436
+ `'${fullKey}' does not match the required pattern.`,
437
+ });
438
+ }
439
+ }
440
+
441
+ return errors;
442
+ }
443
+
444
+ // ─── Feature 3: Environment Merging (handled in loadConfigDirectory) ────
445
+
446
+ // ─── Feature 4: Config Change Listeners ─────────────────────────────────
447
+
448
+ /**
449
+ * Register a listener for changes to a specific config key.
450
+ */
451
+ public onChange(key: string, listener: ConfigChangeListener): this {
452
+ if (!this.listeners.has(key)) {
453
+ this.listeners.set(key, []);
454
+ }
455
+ this.listeners.get(key)!.push(listener);
456
+ return this;
457
+ }
458
+
459
+ /**
460
+ * Register a global listener for ALL config changes.
461
+ */
462
+ public onAnyChange(listener: ConfigChangeListener): this {
463
+ this.globalListeners.push(listener);
464
+ return this;
465
+ }
466
+
467
+ private notifyListeners(key: string, newValue: any, oldValue: any): void {
468
+ // Key-specific listeners
469
+ const keyListeners = this.listeners.get(key);
470
+ if (keyListeners) {
471
+ for (const listener of keyListeners) {
472
+ listener(key, newValue, oldValue);
473
+ }
474
+ }
475
+
476
+ // Wildcard: notify listeners for parent keys too
477
+ // e.g. changing 'database.host' should notify 'database' listeners
478
+ const parts = key.split('.');
479
+ for (let i = parts.length - 1; i > 0; i--) {
480
+ const parentKey = parts.slice(0, i).join('.');
481
+ const parentListeners = this.listeners.get(parentKey);
482
+ if (parentListeners) {
483
+ for (const listener of parentListeners) {
484
+ listener(key, newValue, oldValue);
485
+ }
486
+ }
487
+ }
488
+
489
+ // Global listeners
490
+ for (const listener of this.globalListeners) {
491
+ listener(key, newValue, oldValue);
492
+ }
493
+ }
494
+
495
+ // ─── Feature 5: Deep Freeze ─────────────────────────────────────────────
496
+
497
+ /**
498
+ * Recursively freeze an object to prevent any mutations.
499
+ */
500
+ private deepFreeze(obj: any): any {
501
+ if (obj === null || typeof obj !== 'object') return obj;
502
+
503
+ Object.freeze(obj);
504
+
505
+ for (const key of Object.keys(obj)) {
506
+ const value = obj[key];
507
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
508
+ this.deepFreeze(value);
509
+ }
510
+ }
511
+
512
+ return obj;
513
+ }
514
+
515
+ // ─── Feature 6: Encrypted Config Values ─────────────────────────────────
516
+
517
+ /**
518
+ * Set a decrypter function for auto-decrypting `enc:` prefixed values.
519
+ */
520
+ public setDecrypter(fn: (encryptedValue: string) => string): this {
521
+ this.decrypter = fn;
522
+ return this;
523
+ }
524
+
525
+ private decrypt(value: string): string {
526
+ const encrypted = value.substring(4); // Remove 'enc:' prefix
527
+ if (this.decrypter) {
528
+ return this.decrypter(encrypted);
529
+ }
530
+ // If no decrypter is set, return the raw encrypted string
531
+ return encrypted;
532
+ }
533
+
534
+ // ─── Utilities ──────────────────────────────────────────────────────────
535
+
536
+ /**
537
+ * Deep merge two objects. Source values override target values.
538
+ */
539
+ private deepMerge(
540
+ target: Record<string, any>,
541
+ source: Record<string, any>,
542
+ ): Record<string, any> {
543
+ const result = { ...target };
544
+
545
+ for (const key of Object.keys(source)) {
546
+ if (
547
+ source[key] &&
548
+ typeof source[key] === 'object' &&
549
+ !Array.isArray(source[key]) &&
550
+ result[key] &&
551
+ typeof result[key] === 'object' &&
552
+ !Array.isArray(result[key])
553
+ ) {
554
+ result[key] = this.deepMerge(result[key], source[key]);
555
+ } else {
556
+ result[key] = source[key];
557
+ }
558
+ }
559
+
560
+ return result;
561
+ }
562
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,34 @@
1
+
2
+ import { Repository } from './Repository.js';
3
+ import { EnvLoader } from './EnvLoader.js';
4
+
5
+ let repository: Repository | null = null;
6
+
7
+ /**
8
+ * Set the global configuration repository.
9
+ */
10
+ export function setConfigRepository(repo: Repository): void {
11
+ repository = repo;
12
+ }
13
+
14
+ /**
15
+ * Get/Set configuration values.
16
+ */
17
+ export function config<T = any>(key?: string, defaultValue?: T): T | Repository | any {
18
+ if (key === undefined) {
19
+ return repository;
20
+ }
21
+
22
+ if (!repository) {
23
+ return defaultValue;
24
+ }
25
+
26
+ return repository.get(key, defaultValue);
27
+ }
28
+
29
+ /**
30
+ * Get an environment variable.
31
+ */
32
+ export function env<T = any>(key: string, defaultValue?: T): T {
33
+ return EnvLoader.get(key, defaultValue);
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+
2
+ export * from './Repository.js';
3
+ export * from './EnvLoader.js';
4
+ export * from './helpers.js';
5
+
6
+ export type {
7
+ SchemaRule,
8
+ ConfigSchema,
9
+ ValidationError,
10
+ ConfigChangeListener,
11
+ } from './Repository.js';