@plyaz/core 1.0.2 → 1.0.4
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 +69 -16
- package/dist/base/cache/index.d.ts.map +1 -1
- package/dist/base/cache/strategies/memory.d.ts.map +1 -1
- package/dist/base/cache/strategies/redis.d.ts.map +1 -1
- package/dist/domain/featureFlags/provider.d.ts +8 -0
- package/dist/domain/featureFlags/provider.d.ts.map +1 -1
- package/dist/domain/featureFlags/providers/factory.d.ts +30 -5
- package/dist/domain/featureFlags/providers/factory.d.ts.map +1 -1
- package/dist/domain/featureFlags/providers/file.d.ts +97 -1
- package/dist/domain/featureFlags/providers/file.d.ts.map +1 -1
- package/dist/domain/featureFlags/providers/memory.d.ts +12 -5
- package/dist/domain/featureFlags/providers/memory.d.ts.map +1 -1
- package/dist/engine/featureFlags/engine.d.ts +7 -0
- package/dist/engine/featureFlags/engine.d.ts.map +1 -1
- package/dist/index.cjs +507 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +482 -75
- package/dist/index.mjs.map +1 -1
- package/dist/utils/common/hash.d.ts.map +1 -1
- package/dist/utils/common/values.d.ts.map +1 -1
- package/dist/utils/featureFlags/context.d.ts.map +1 -1
- package/package.json +6 -5
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { FEATURES } from '@plyaz/config';
|
|
1
|
+
import { CACHE_MAX_SIZE_DEFAULT, CACHE_CLEANUP_INTERVAL_DEFAULT, TIME_CONSTANTS, FORMAT_CONSTANTS, FILE_CHECK_INTERVAL_DEFAULT, FEATURE_FLAG_FILE_PATHS, FEATURE_FLAG_CACHE_TTL_DEFAULT, MATH_CONSTANTS, ISO_STANDARDS, FEATURES } from '@plyaz/config';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import * as yaml from 'yaml';
|
|
2
7
|
import { Post, Param, Body, Put, Delete, Get, Query, Controller, Injectable, Global, Module, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
|
3
8
|
import React, { createContext, useState, useRef, useCallback, useEffect, useContext, useMemo } from 'react';
|
|
4
9
|
import { jsx } from 'react/jsx-runtime';
|
|
@@ -18,8 +23,6 @@ var __decorateClass = (decorators, target, key, kind) => {
|
|
|
18
23
|
};
|
|
19
24
|
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
20
25
|
var __publicField = (obj, key, value) => __defNormalProp(obj, key + "" , value);
|
|
21
|
-
|
|
22
|
-
// src/utils/common/hash.ts
|
|
23
26
|
function hashString(str) {
|
|
24
27
|
const HASH_SHIFT = 5;
|
|
25
28
|
let hash = 0;
|
|
@@ -32,10 +35,10 @@ function hashString(str) {
|
|
|
32
35
|
}
|
|
33
36
|
__name(hashString, "hashString");
|
|
34
37
|
function isInRollout(identifier, percentage) {
|
|
35
|
-
if (percentage >=
|
|
38
|
+
if (percentage >= MATH_CONSTANTS.PERCENTAGE_MAX) return true;
|
|
36
39
|
if (percentage <= 0) return false;
|
|
37
40
|
const hash = hashString(identifier);
|
|
38
|
-
return hash %
|
|
41
|
+
return hash % MATH_CONSTANTS.PERCENTAGE_MAX < percentage;
|
|
39
42
|
}
|
|
40
43
|
__name(isInRollout, "isInRollout");
|
|
41
44
|
function createRolloutIdentifier(featureKey, userId) {
|
|
@@ -64,7 +67,7 @@ var HashUtils = {
|
|
|
64
67
|
* @param totalBuckets - Total number of buckets (default: 100)
|
|
65
68
|
* @returns true if identifier is in the bucket range
|
|
66
69
|
*/
|
|
67
|
-
isInBucketRange: /* @__PURE__ */ __name((identifier, startBucket, endBucket, totalBuckets =
|
|
70
|
+
isInBucketRange: /* @__PURE__ */ __name((identifier, startBucket, endBucket, totalBuckets = MATH_CONSTANTS.PERCENTAGE_MAX) => {
|
|
68
71
|
const bucket = hashString(identifier) % totalBuckets;
|
|
69
72
|
return bucket >= startBucket && bucket <= endBucket;
|
|
70
73
|
}, "isInBucketRange"),
|
|
@@ -79,8 +82,6 @@ var HashUtils = {
|
|
|
79
82
|
return hashString(str) % SAFE_INT;
|
|
80
83
|
}, "createSeed")
|
|
81
84
|
};
|
|
82
|
-
|
|
83
|
-
// src/utils/common/values.ts
|
|
84
85
|
function isStringFalsy(value) {
|
|
85
86
|
if (value === "") return true;
|
|
86
87
|
const lower = value.toLowerCase().trim();
|
|
@@ -146,7 +147,7 @@ var ValueUtils = {
|
|
|
146
147
|
*/
|
|
147
148
|
isValidPercentage: /* @__PURE__ */ __name((value) => {
|
|
148
149
|
if (typeof value !== "number") return false;
|
|
149
|
-
return !isNaN(value) && isFinite(value) && value >= 0 && value <=
|
|
150
|
+
return !isNaN(value) && isFinite(value) && value >= 0 && value <= MATH_CONSTANTS.PERCENTAGE_MAX;
|
|
150
151
|
}, "isValidPercentage"),
|
|
151
152
|
/**
|
|
152
153
|
* Clamps a number to a specific range.
|
|
@@ -180,9 +181,9 @@ var ValueUtils = {
|
|
|
180
181
|
* @param defaultValue - Default if path doesn't exist
|
|
181
182
|
* @returns Property value or default
|
|
182
183
|
*/
|
|
183
|
-
getNestedProperty: /* @__PURE__ */ __name((obj,
|
|
184
|
+
getNestedProperty: /* @__PURE__ */ __name((obj, path2, defaultValue) => {
|
|
184
185
|
if (!obj || typeof obj !== "object") return defaultValue;
|
|
185
|
-
const keys =
|
|
186
|
+
const keys = path2.split(".");
|
|
186
187
|
let current = obj;
|
|
187
188
|
for (const key of keys) {
|
|
188
189
|
if (current == null || typeof current !== "object") return defaultValue;
|
|
@@ -192,8 +193,6 @@ var ValueUtils = {
|
|
|
192
193
|
return current;
|
|
193
194
|
}, "getNestedProperty")
|
|
194
195
|
};
|
|
195
|
-
|
|
196
|
-
// src/utils/featureFlags/context.ts
|
|
197
196
|
var FeatureFlagContextBuilder = class _FeatureFlagContextBuilder {
|
|
198
197
|
static {
|
|
199
198
|
__name(this, "FeatureFlagContextBuilder");
|
|
@@ -389,7 +388,7 @@ var ContextUtils = {
|
|
|
389
388
|
if (context.platform && !["web", "mobile", "desktop"].includes(context.platform)) {
|
|
390
389
|
errors.push("Platform must be web, mobile, or desktop");
|
|
391
390
|
}
|
|
392
|
-
if (context.country && context.country.length !==
|
|
391
|
+
if (context.country && context.country.length !== ISO_STANDARDS.ISO_COUNTRY_CODE_LENGTH) {
|
|
393
392
|
errors.push("Country must be a 2-letter ISO country code");
|
|
394
393
|
}
|
|
395
394
|
return {
|
|
@@ -750,6 +749,16 @@ var FeatureFlagEngine = class {
|
|
|
750
749
|
this.overrides.delete(key);
|
|
751
750
|
this.log("Override removed:", key);
|
|
752
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Updates the default values for feature flags.
|
|
754
|
+
* This is useful when the FEATURES constant is updated at runtime.
|
|
755
|
+
*
|
|
756
|
+
* @param newDefaults - New default values
|
|
757
|
+
*/
|
|
758
|
+
updateDefaults(newDefaults) {
|
|
759
|
+
this.defaults = newDefaults;
|
|
760
|
+
this.log("Updated default feature values");
|
|
761
|
+
}
|
|
753
762
|
/**
|
|
754
763
|
* Clears all manual overrides.
|
|
755
764
|
*/
|
|
@@ -927,7 +936,7 @@ var FeatureFlagEngine = class {
|
|
|
927
936
|
value: rule.value,
|
|
928
937
|
isEnabled: isTruthy(rule.value),
|
|
929
938
|
reason: "rule_match",
|
|
930
|
-
|
|
939
|
+
matchedRuleId: rule.id,
|
|
931
940
|
evaluatedAt: evaluatedAt ?? /* @__PURE__ */ new Date()
|
|
932
941
|
};
|
|
933
942
|
}
|
|
@@ -970,8 +979,6 @@ var FeatureFlagEngine = class {
|
|
|
970
979
|
}
|
|
971
980
|
}
|
|
972
981
|
};
|
|
973
|
-
|
|
974
|
-
// src/base/cache/strategies/memory.ts
|
|
975
982
|
var MemoryCacheStrategy = class {
|
|
976
983
|
static {
|
|
977
984
|
__name(this, "MemoryCacheStrategy");
|
|
@@ -994,11 +1001,9 @@ var MemoryCacheStrategy = class {
|
|
|
994
1001
|
* @param config - Memory cache configuration
|
|
995
1002
|
*/
|
|
996
1003
|
constructor(config = {}) {
|
|
997
|
-
const DEFAULT_MAX_ENTRIES = 1e3;
|
|
998
|
-
const DEFAULT_CLEANUP_INTERVAL = 6e4;
|
|
999
1004
|
const defaultConfig = {
|
|
1000
|
-
maxEntries:
|
|
1001
|
-
cleanupInterval:
|
|
1005
|
+
maxEntries: CACHE_MAX_SIZE_DEFAULT,
|
|
1006
|
+
cleanupInterval: CACHE_CLEANUP_INTERVAL_DEFAULT
|
|
1002
1007
|
};
|
|
1003
1008
|
this.maxSize = config.maxSize ?? config.maxEntries ?? defaultConfig.maxEntries;
|
|
1004
1009
|
this.cleanupInterval = config.cleanupInterval ?? defaultConfig.cleanupInterval;
|
|
@@ -1150,8 +1155,6 @@ var MemoryCacheStrategy = class {
|
|
|
1150
1155
|
}
|
|
1151
1156
|
}
|
|
1152
1157
|
};
|
|
1153
|
-
|
|
1154
|
-
// src/base/cache/strategies/redis.ts
|
|
1155
1158
|
var RedisCacheStrategy = class {
|
|
1156
1159
|
/**
|
|
1157
1160
|
* Creates a new Redis cache strategy.
|
|
@@ -1189,7 +1192,7 @@ var RedisCacheStrategy = class {
|
|
|
1189
1192
|
const redisKey = this.buildRedisKey(key);
|
|
1190
1193
|
const serializedEntry = JSON.stringify(entry);
|
|
1191
1194
|
const ttlMs = entry.expiresAt - Date.now();
|
|
1192
|
-
const ttlSeconds = Math.max(1, Math.ceil(ttlMs /
|
|
1195
|
+
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / TIME_CONSTANTS.MILLISECONDS_PER_SECOND));
|
|
1193
1196
|
await this.client.set(redisKey, serializedEntry, "EX", ttlSeconds);
|
|
1194
1197
|
this.stats.setCount++;
|
|
1195
1198
|
}
|
|
@@ -1313,8 +1316,8 @@ var RedisCacheStrategy = class {
|
|
|
1313
1316
|
commandTimeout: this.config.commandTimeout ?? defaultOptions.commandTimeout,
|
|
1314
1317
|
enableOfflineQueue: defaultOptions.enableOfflineQueue
|
|
1315
1318
|
});
|
|
1316
|
-
await new Promise((
|
|
1317
|
-
client.on("ready",
|
|
1319
|
+
await new Promise((resolve2, reject) => {
|
|
1320
|
+
client.on("ready", resolve2);
|
|
1318
1321
|
client.on("error", reject);
|
|
1319
1322
|
});
|
|
1320
1323
|
return client;
|
|
@@ -1360,7 +1363,7 @@ var CacheManager = class {
|
|
|
1360
1363
|
const finalTtl = ttl ?? this.config.ttl;
|
|
1361
1364
|
const entry = {
|
|
1362
1365
|
data: value,
|
|
1363
|
-
expiresAt: Date.now() + finalTtl *
|
|
1366
|
+
expiresAt: Date.now() + finalTtl * TIME_CONSTANTS.MILLISECONDS_PER_SECOND,
|
|
1364
1367
|
createdAt: Date.now()
|
|
1365
1368
|
};
|
|
1366
1369
|
await this.strategy.set(key, entry);
|
|
@@ -1453,8 +1456,6 @@ var CacheManager = class {
|
|
|
1453
1456
|
await this.strategy.dispose?.();
|
|
1454
1457
|
}
|
|
1455
1458
|
};
|
|
1456
|
-
|
|
1457
|
-
// src/domain/featureFlags/provider.ts
|
|
1458
1459
|
var FeatureFlagProvider = class {
|
|
1459
1460
|
/**
|
|
1460
1461
|
* Creates a new feature flag provider.
|
|
@@ -1689,7 +1690,7 @@ var FeatureFlagProvider = class {
|
|
|
1689
1690
|
void this.refresh().catch((error) => {
|
|
1690
1691
|
this.log("Auto-refresh failed:", error);
|
|
1691
1692
|
});
|
|
1692
|
-
}, this.config.refreshInterval *
|
|
1693
|
+
}, this.config.refreshInterval * TIME_CONSTANTS.MILLISECONDS_PER_SECOND);
|
|
1693
1694
|
}
|
|
1694
1695
|
}
|
|
1695
1696
|
/**
|
|
@@ -1860,31 +1861,53 @@ var MemoryFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
1860
1861
|
}
|
|
1861
1862
|
}
|
|
1862
1863
|
/**
|
|
1863
|
-
* Updates a flag
|
|
1864
|
+
* Updates a flag in memory.
|
|
1864
1865
|
*
|
|
1865
|
-
* @param
|
|
1866
|
-
* @param value - The new value
|
|
1867
|
-
* @param updateProps - Optional properties to update
|
|
1866
|
+
* @param flagOrKey - Either a complete flag object or a flag key
|
|
1867
|
+
* @param value - The new value (only used when first param is a key)
|
|
1868
|
+
* @param updateProps - Optional properties to update (only used when first param is a key)
|
|
1868
1869
|
*/
|
|
1869
|
-
updateFlag(
|
|
1870
|
-
|
|
1870
|
+
async updateFlag(flagOrKey, value, updateProps) {
|
|
1871
|
+
let key;
|
|
1872
|
+
let updatedFlag;
|
|
1873
|
+
if (typeof flagOrKey === "string") {
|
|
1874
|
+
key = flagOrKey;
|
|
1875
|
+
const existingFlag = this.flags.find((f) => f.key === key);
|
|
1876
|
+
if (!existingFlag) {
|
|
1877
|
+
this.log(`Flag with key ${key} not found in memory`);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (value === void 0) {
|
|
1881
|
+
this.log(`Value is required when updating flag by key`);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
updatedFlag = {
|
|
1885
|
+
...existingFlag,
|
|
1886
|
+
value,
|
|
1887
|
+
type: this.inferFlagType(value),
|
|
1888
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1889
|
+
updatedBy: "memory-runtime",
|
|
1890
|
+
...updateProps
|
|
1891
|
+
};
|
|
1892
|
+
} else {
|
|
1893
|
+
const flag = flagOrKey;
|
|
1894
|
+
key = flag.key;
|
|
1895
|
+
updatedFlag = {
|
|
1896
|
+
...flag,
|
|
1897
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1898
|
+
updatedBy: flag.updatedBy || "memory-runtime"
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
const flagIndex = this.flags.findIndex((f) => f.key === key);
|
|
1871
1902
|
if (flagIndex === -1) {
|
|
1872
1903
|
this.log(`Flag with key ${key} not found in memory`);
|
|
1873
1904
|
return;
|
|
1874
1905
|
}
|
|
1875
|
-
const updatedFlag = {
|
|
1876
|
-
...this.flags[flagIndex],
|
|
1877
|
-
value,
|
|
1878
|
-
type: this.inferFlagType(value),
|
|
1879
|
-
updatedAt: /* @__PURE__ */ new Date(),
|
|
1880
|
-
updatedBy: "memory-runtime",
|
|
1881
|
-
...updateProps
|
|
1882
|
-
};
|
|
1883
1906
|
this.flags[flagIndex] = updatedFlag;
|
|
1884
1907
|
this.engine.setFlags(this.flags);
|
|
1885
1908
|
void this.cacheManager.clear();
|
|
1886
1909
|
this.notifySubscribers();
|
|
1887
|
-
this.log(`Updated flag: ${key}
|
|
1910
|
+
this.log(`Updated flag: ${key}`);
|
|
1888
1911
|
}
|
|
1889
1912
|
/**
|
|
1890
1913
|
* Adds a new flag to memory at runtime.
|
|
@@ -1994,6 +2017,18 @@ var MemoryFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
1994
2017
|
getCurrentRules() {
|
|
1995
2018
|
return [...this.rules];
|
|
1996
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Updates the features object and syncs all flags.
|
|
2022
|
+
* This allows updating the FEATURES constant at runtime.
|
|
2023
|
+
*
|
|
2024
|
+
* @param newFeatures - New features object to sync with
|
|
2025
|
+
*/
|
|
2026
|
+
async syncFeatures(newFeatures) {
|
|
2027
|
+
this.log("Syncing with new FEATURES values");
|
|
2028
|
+
this.features = newFeatures;
|
|
2029
|
+
await this.refresh();
|
|
2030
|
+
this.log(`Synced ${Object.keys(newFeatures).length} features`);
|
|
2031
|
+
}
|
|
1997
2032
|
/**
|
|
1998
2033
|
* Resets the memory provider to its initial state.
|
|
1999
2034
|
*/
|
|
@@ -2027,13 +2062,18 @@ var MemoryFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2027
2062
|
}
|
|
2028
2063
|
}
|
|
2029
2064
|
};
|
|
2030
|
-
|
|
2031
|
-
|
|
2065
|
+
var readFile2 = promisify(fs.readFile);
|
|
2066
|
+
var writeFile2 = promisify(fs.writeFile);
|
|
2067
|
+
var access2 = promisify(fs.access);
|
|
2068
|
+
var mkdir2 = promisify(fs.mkdir);
|
|
2032
2069
|
var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
2033
2070
|
static {
|
|
2034
2071
|
__name(this, "FileFeatureFlagProvider");
|
|
2035
2072
|
}
|
|
2036
2073
|
fileWatcher;
|
|
2074
|
+
lastFileContent;
|
|
2075
|
+
fileCheckInterval;
|
|
2076
|
+
rules = [];
|
|
2037
2077
|
/**
|
|
2038
2078
|
* Creates a new file feature flag provider.
|
|
2039
2079
|
*
|
|
@@ -2043,8 +2083,15 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2043
2083
|
constructor(config, features) {
|
|
2044
2084
|
super(config, features);
|
|
2045
2085
|
this.validateConfig();
|
|
2046
|
-
|
|
2047
|
-
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Initializes the provider and sets up file watching if enabled.
|
|
2089
|
+
*/
|
|
2090
|
+
async initialize() {
|
|
2091
|
+
await super.initialize();
|
|
2092
|
+
if (this.config.fileConfig?.shouldWatchForChanges) {
|
|
2093
|
+
this.setupFileWatcher();
|
|
2094
|
+
}
|
|
2048
2095
|
}
|
|
2049
2096
|
/**
|
|
2050
2097
|
* Fetches flags and rules from the configuration file.
|
|
@@ -2053,9 +2100,89 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2053
2100
|
* @returns Promise resolving to flags and rules from file
|
|
2054
2101
|
*/
|
|
2055
2102
|
async fetchData() {
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2103
|
+
const { filePath, format } = this.config.fileConfig;
|
|
2104
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
2105
|
+
try {
|
|
2106
|
+
await access2(resolvedPath, fs.constants.R_OK);
|
|
2107
|
+
const content = await readFile2(resolvedPath, "utf-8");
|
|
2108
|
+
this.lastFileContent = content;
|
|
2109
|
+
const data = await this.parseFileContent(content, format);
|
|
2110
|
+
this.validateFileData(data);
|
|
2111
|
+
this.rules = data.rules || [];
|
|
2112
|
+
return {
|
|
2113
|
+
flags: data.flags || [],
|
|
2114
|
+
rules: data.rules || []
|
|
2115
|
+
};
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
return this.handleFetchDataError(error, resolvedPath, format);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Parses file content based on format.
|
|
2122
|
+
*
|
|
2123
|
+
* @private
|
|
2124
|
+
*/
|
|
2125
|
+
async parseFileContent(content, format) {
|
|
2126
|
+
if (format === "json") {
|
|
2127
|
+
return this.parseJSON(content);
|
|
2128
|
+
} else if (format === "yaml") {
|
|
2129
|
+
return await this.parseYAML(content);
|
|
2130
|
+
}
|
|
2131
|
+
throw new Error(`Unsupported file format: ${format}`);
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Handles errors for fetchData, including file creation and fallback.
|
|
2135
|
+
*
|
|
2136
|
+
* @private
|
|
2137
|
+
*/
|
|
2138
|
+
async handleFetchDataError(error, resolvedPath, format) {
|
|
2139
|
+
const isFileNotFound = this.isFileNotFoundError(error);
|
|
2140
|
+
if (isFileNotFound && this.config.shouldFallbackToDefaults) {
|
|
2141
|
+
return await this.handleFileNotFound(resolvedPath, format);
|
|
2142
|
+
}
|
|
2143
|
+
this.log(`Error reading file ${resolvedPath}:`, error);
|
|
2144
|
+
if (this.config.shouldFallbackToDefaults) {
|
|
2145
|
+
return this.handleFallbackToDefaults();
|
|
2146
|
+
}
|
|
2147
|
+
throw error;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Type guard for NodeJS.ErrnoException.
|
|
2151
|
+
*/
|
|
2152
|
+
isFileNotFoundError(error) {
|
|
2153
|
+
return error instanceof Error && typeof error.code === "string" && error.code === "ENOENT";
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Handles the case when the file is not found and fallback is enabled.
|
|
2157
|
+
*/
|
|
2158
|
+
async handleFileNotFound(resolvedPath, format) {
|
|
2159
|
+
this.log(`File not found at ${resolvedPath}, creating with default values`);
|
|
2160
|
+
try {
|
|
2161
|
+
await this.createDefaultFile(resolvedPath, format);
|
|
2162
|
+
const content = await readFile2(resolvedPath, "utf-8");
|
|
2163
|
+
this.lastFileContent = content;
|
|
2164
|
+
const data = await this.parseFileContent(content, format);
|
|
2165
|
+
return {
|
|
2166
|
+
flags: data.flags || [],
|
|
2167
|
+
rules: data.rules || []
|
|
2168
|
+
};
|
|
2169
|
+
} catch (createError) {
|
|
2170
|
+
this.log("Error creating default file:", createError);
|
|
2171
|
+
return {
|
|
2172
|
+
flags: this.createDefaultFlags(),
|
|
2173
|
+
rules: []
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Handles fallback to default flags and rules.
|
|
2179
|
+
*/
|
|
2180
|
+
handleFallbackToDefaults() {
|
|
2181
|
+
this.log("Falling back to default values");
|
|
2182
|
+
return {
|
|
2183
|
+
flags: this.createDefaultFlags(),
|
|
2184
|
+
rules: []
|
|
2185
|
+
};
|
|
2059
2186
|
}
|
|
2060
2187
|
/**
|
|
2061
2188
|
* Validates the file provider configuration.
|
|
@@ -2078,6 +2205,165 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2078
2205
|
throw new Error('File format must be either "json" or "yaml"');
|
|
2079
2206
|
}
|
|
2080
2207
|
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Resolves the file path, supporting relative and absolute paths.
|
|
2210
|
+
*
|
|
2211
|
+
* @private
|
|
2212
|
+
* @param filePath - The file path from configuration
|
|
2213
|
+
* @returns Resolved absolute file path
|
|
2214
|
+
*/
|
|
2215
|
+
resolveFilePath(filePath) {
|
|
2216
|
+
let pathToResolve = filePath;
|
|
2217
|
+
if (!pathToResolve) {
|
|
2218
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2219
|
+
const __dirname = path.dirname(__filename);
|
|
2220
|
+
pathToResolve = path.join(__dirname, "../../../config/feature-provider.json");
|
|
2221
|
+
}
|
|
2222
|
+
if (path.isAbsolute(pathToResolve)) {
|
|
2223
|
+
return pathToResolve;
|
|
2224
|
+
}
|
|
2225
|
+
return path.resolve(process.cwd(), pathToResolve);
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Parses JSON content.
|
|
2229
|
+
*
|
|
2230
|
+
* @private
|
|
2231
|
+
* @param content - File content
|
|
2232
|
+
* @returns Parsed data
|
|
2233
|
+
*/
|
|
2234
|
+
parseJSON(content) {
|
|
2235
|
+
try {
|
|
2236
|
+
return JSON.parse(content);
|
|
2237
|
+
} catch (error) {
|
|
2238
|
+
throw new Error(
|
|
2239
|
+
`Invalid JSON format: ${error instanceof Error ? error.message : String(error)}`
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Parses YAML content.
|
|
2245
|
+
*
|
|
2246
|
+
* @private
|
|
2247
|
+
* @param content - File content
|
|
2248
|
+
* @returns Parsed data
|
|
2249
|
+
*/
|
|
2250
|
+
async parseYAML(content) {
|
|
2251
|
+
try {
|
|
2252
|
+
const data = yaml.parse(content);
|
|
2253
|
+
return data;
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
throw new Error(
|
|
2256
|
+
`Invalid YAML format: ${error instanceof Error ? error.message : String(error)}`
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Validates the structure of file data.
|
|
2262
|
+
*
|
|
2263
|
+
* @private
|
|
2264
|
+
* @param data - Parsed file data
|
|
2265
|
+
*/
|
|
2266
|
+
validateFileData(data) {
|
|
2267
|
+
if (!data || typeof data !== "object") {
|
|
2268
|
+
throw new Error("File must contain a valid object");
|
|
2269
|
+
}
|
|
2270
|
+
const fileData = data;
|
|
2271
|
+
this.checkFlagsArray(fileData.flags);
|
|
2272
|
+
this.checkRulesArray(fileData.rules);
|
|
2273
|
+
}
|
|
2274
|
+
checkFlagsArray(flags) {
|
|
2275
|
+
if (flags && !Array.isArray(flags)) {
|
|
2276
|
+
throw new Error('"flags" must be an array');
|
|
2277
|
+
}
|
|
2278
|
+
if (Array.isArray(flags)) {
|
|
2279
|
+
this.validateFlags(flags);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
checkRulesArray(rules) {
|
|
2283
|
+
if (rules && !Array.isArray(rules)) {
|
|
2284
|
+
throw new Error('"rules" must be an array');
|
|
2285
|
+
}
|
|
2286
|
+
if (Array.isArray(rules)) {
|
|
2287
|
+
this.validateRules(rules);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
validateFlags(flags) {
|
|
2291
|
+
flags.forEach((flag, index) => {
|
|
2292
|
+
if (!flag || typeof flag !== "object") {
|
|
2293
|
+
throw new Error(`Flag at index ${index} must be an object`);
|
|
2294
|
+
}
|
|
2295
|
+
const flagObj = flag;
|
|
2296
|
+
if (!flagObj.key || typeof flagObj.key !== "string") {
|
|
2297
|
+
throw new Error(`Flag at index ${index} must have a "key" property`);
|
|
2298
|
+
}
|
|
2299
|
+
if (flagObj.value === void 0) {
|
|
2300
|
+
throw new Error(`Flag "${flagObj.key}" must have a "value" property`);
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
validateRules(rules) {
|
|
2305
|
+
rules.forEach((rule, index) => {
|
|
2306
|
+
if (!rule || typeof rule !== "object") {
|
|
2307
|
+
throw new Error(`Rule at index ${index} must be an object`);
|
|
2308
|
+
}
|
|
2309
|
+
const ruleObj = rule;
|
|
2310
|
+
if (!ruleObj.id || typeof ruleObj.id !== "string") {
|
|
2311
|
+
throw new Error(`Rule at index ${index} must have an "id" property`);
|
|
2312
|
+
}
|
|
2313
|
+
if (!ruleObj.flagKey || typeof ruleObj.flagKey !== "string") {
|
|
2314
|
+
throw new Error(`Rule "${ruleObj.id}" must have a "flagKey" property`);
|
|
2315
|
+
}
|
|
2316
|
+
if (!Array.isArray(ruleObj.conditions)) {
|
|
2317
|
+
throw new Error(`Rule "${ruleObj.id}" must have a "conditions" array`);
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Creates a default configuration file with values from features.
|
|
2323
|
+
*
|
|
2324
|
+
* @private
|
|
2325
|
+
* @param filePath - Path where to create the file
|
|
2326
|
+
* @param format - File format (json or yaml)
|
|
2327
|
+
*/
|
|
2328
|
+
async createDefaultFile(filePath, format) {
|
|
2329
|
+
const dir = path.dirname(filePath);
|
|
2330
|
+
await mkdir2(dir, { recursive: true });
|
|
2331
|
+
const defaultData = {
|
|
2332
|
+
flags: this.createDefaultFlags(),
|
|
2333
|
+
rules: []
|
|
2334
|
+
};
|
|
2335
|
+
let content;
|
|
2336
|
+
if (format === "json") {
|
|
2337
|
+
content = JSON.stringify(defaultData, null, FORMAT_CONSTANTS.JSON_INDENT_SPACES);
|
|
2338
|
+
} else {
|
|
2339
|
+
content = yaml.stringify(defaultData);
|
|
2340
|
+
}
|
|
2341
|
+
await writeFile2(filePath, content, "utf-8");
|
|
2342
|
+
this.log(`Created default feature flag file at: ${filePath}`);
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Creates default flags from the features configuration.
|
|
2346
|
+
*
|
|
2347
|
+
* @private
|
|
2348
|
+
* @returns Array of default flags
|
|
2349
|
+
*/
|
|
2350
|
+
createDefaultFlags() {
|
|
2351
|
+
return Object.entries(this.features).map(([key, value]) => ({
|
|
2352
|
+
key,
|
|
2353
|
+
value,
|
|
2354
|
+
isEnabled: true,
|
|
2355
|
+
name: key,
|
|
2356
|
+
description: `Default flag for ${key}`,
|
|
2357
|
+
type: typeof value === "boolean" ? "boolean" : typeof value === "number" ? "number" : typeof value === "string" ? "string" : "json",
|
|
2358
|
+
environment: "development",
|
|
2359
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
2360
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
2361
|
+
createdBy: "system",
|
|
2362
|
+
updatedBy: "system",
|
|
2363
|
+
metadata: {},
|
|
2364
|
+
tags: []
|
|
2365
|
+
}));
|
|
2366
|
+
}
|
|
2081
2367
|
/**
|
|
2082
2368
|
* Sets up file watching for hot reload if enabled.
|
|
2083
2369
|
*
|
|
@@ -2087,8 +2373,32 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2087
2373
|
if (!this.config.fileConfig?.shouldWatchForChanges) {
|
|
2088
2374
|
return;
|
|
2089
2375
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2376
|
+
const { filePath } = this.config.fileConfig;
|
|
2377
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
2378
|
+
try {
|
|
2379
|
+
this.fileWatcher = fs.watch(resolvedPath, async (eventType) => {
|
|
2380
|
+
if (eventType === "change") {
|
|
2381
|
+
this.log(`File changed: ${resolvedPath}`);
|
|
2382
|
+
if (this.fileCheckInterval) {
|
|
2383
|
+
clearTimeout(this.fileCheckInterval);
|
|
2384
|
+
}
|
|
2385
|
+
this.fileCheckInterval = setTimeout(async () => {
|
|
2386
|
+
try {
|
|
2387
|
+
const content = await readFile2(resolvedPath, "utf-8");
|
|
2388
|
+
if (content !== this.lastFileContent) {
|
|
2389
|
+
this.log("File content changed, refreshing...");
|
|
2390
|
+
await this.refresh();
|
|
2391
|
+
}
|
|
2392
|
+
} catch (error) {
|
|
2393
|
+
this.log("Error reading changed file:", error);
|
|
2394
|
+
}
|
|
2395
|
+
}, this.config.fileConfig?.fileCheckInterval ?? FILE_CHECK_INTERVAL_DEFAULT);
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
this.log(`File watching enabled for: ${resolvedPath}`);
|
|
2399
|
+
} catch (error) {
|
|
2400
|
+
this.log(`Failed to set up file watching: ${error}`);
|
|
2401
|
+
}
|
|
2092
2402
|
}
|
|
2093
2403
|
/**
|
|
2094
2404
|
* Disposes of the file provider and stops file watching.
|
|
@@ -2096,10 +2406,57 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2096
2406
|
dispose() {
|
|
2097
2407
|
super.dispose();
|
|
2098
2408
|
if (this.fileWatcher) {
|
|
2099
|
-
|
|
2409
|
+
this.fileWatcher.close();
|
|
2100
2410
|
this.fileWatcher = void 0;
|
|
2101
2411
|
this.log("File watching stopped");
|
|
2102
2412
|
}
|
|
2413
|
+
if (this.fileCheckInterval) {
|
|
2414
|
+
clearTimeout(this.fileCheckInterval);
|
|
2415
|
+
this.fileCheckInterval = void 0;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Refreshes the provider by fetching latest data from the file.
|
|
2420
|
+
* Overrides base class to store rules.
|
|
2421
|
+
*
|
|
2422
|
+
* @returns Promise that resolves when refresh is complete
|
|
2423
|
+
*/
|
|
2424
|
+
async refresh() {
|
|
2425
|
+
await super.refresh();
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Updates the features object and writes to file.
|
|
2429
|
+
* This allows updating the FEATURES at runtime and persisting to file.
|
|
2430
|
+
*
|
|
2431
|
+
* @param newFeatures - New features object to sync with
|
|
2432
|
+
*/
|
|
2433
|
+
async syncFeatures(newFeatures) {
|
|
2434
|
+
this.log("Syncing with new FEATURES values and updating file");
|
|
2435
|
+
this.features = newFeatures;
|
|
2436
|
+
const fileData = {
|
|
2437
|
+
flags: this.createDefaultFlags(),
|
|
2438
|
+
rules: this.rules || []
|
|
2439
|
+
};
|
|
2440
|
+
const { filePath, format } = this.config.fileConfig;
|
|
2441
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
2442
|
+
try {
|
|
2443
|
+
let content;
|
|
2444
|
+
if (format === "json") {
|
|
2445
|
+
content = JSON.stringify(fileData, null, FORMAT_CONSTANTS.JSON_INDENT_SPACES);
|
|
2446
|
+
} else {
|
|
2447
|
+
content = yaml.stringify(fileData);
|
|
2448
|
+
}
|
|
2449
|
+
await writeFile2(resolvedPath, content, "utf-8");
|
|
2450
|
+
this.lastFileContent = content;
|
|
2451
|
+
this.engine.updateDefaults(newFeatures);
|
|
2452
|
+
await this.refresh();
|
|
2453
|
+
this.log(`Synced ${Object.keys(newFeatures).length} features to file: ${resolvedPath}`);
|
|
2454
|
+
} catch (error) {
|
|
2455
|
+
this.log("Error syncing features to file:", error);
|
|
2456
|
+
throw new Error(
|
|
2457
|
+
`Failed to sync features to file: ${error instanceof Error ? error.message : String(error)}`
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2103
2460
|
}
|
|
2104
2461
|
/**
|
|
2105
2462
|
* Gets information about the file provider.
|
|
@@ -2107,18 +2464,23 @@ var FileFeatureFlagProvider = class extends FeatureFlagProvider {
|
|
|
2107
2464
|
* @returns File provider information
|
|
2108
2465
|
*/
|
|
2109
2466
|
getFileInfo() {
|
|
2467
|
+
const filePath = this.config.fileConfig?.filePath;
|
|
2468
|
+
const resolvedPath = filePath ? this.resolveFilePath(filePath) : void 0;
|
|
2469
|
+
let lastModified;
|
|
2470
|
+
if (resolvedPath) {
|
|
2471
|
+
try {
|
|
2472
|
+
const stats = fs.statSync(resolvedPath);
|
|
2473
|
+
lastModified = stats.mtime;
|
|
2474
|
+
} catch {
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2110
2477
|
return {
|
|
2111
|
-
filePath
|
|
2478
|
+
filePath,
|
|
2479
|
+
resolvedPath,
|
|
2112
2480
|
format: this.config.fileConfig?.format,
|
|
2113
2481
|
isWatchEnabled: Boolean(this.config.fileConfig?.shouldWatchForChanges),
|
|
2114
|
-
isImplemented:
|
|
2115
|
-
|
|
2116
|
-
"File reading and parsing logic",
|
|
2117
|
-
"YAML and JSON format support",
|
|
2118
|
-
"File watching for hot reload",
|
|
2119
|
-
"Standard file path configuration",
|
|
2120
|
-
"File validation and error handling"
|
|
2121
|
-
]
|
|
2482
|
+
isImplemented: true,
|
|
2483
|
+
lastModified
|
|
2122
2484
|
};
|
|
2123
2485
|
}
|
|
2124
2486
|
};
|
|
@@ -2431,8 +2793,6 @@ Examples:
|
|
|
2431
2793
|
};
|
|
2432
2794
|
}
|
|
2433
2795
|
};
|
|
2434
|
-
|
|
2435
|
-
// src/domain/featureFlags/providers/factory.ts
|
|
2436
2796
|
var PROVIDER_REGISTRY = {
|
|
2437
2797
|
memory: MemoryFeatureFlagProvider,
|
|
2438
2798
|
file: FileFeatureFlagProvider,
|
|
@@ -2535,25 +2895,69 @@ var FeatureFlagProviderFactory = class {
|
|
|
2535
2895
|
};
|
|
2536
2896
|
}
|
|
2537
2897
|
/**
|
|
2538
|
-
* Creates a default
|
|
2898
|
+
* Creates a default provider.
|
|
2899
|
+
* Uses file provider if features are not provided, memory provider otherwise.
|
|
2539
2900
|
*
|
|
2540
|
-
* @param features - Record of feature flag keys to their default values
|
|
2901
|
+
* @param features - Record of feature flag keys to their default values (optional)
|
|
2541
2902
|
* @param overrides - Optional configuration overrides
|
|
2542
|
-
* @returns
|
|
2903
|
+
* @returns Provider instance
|
|
2543
2904
|
*/
|
|
2544
2905
|
static createDefault(features, overrides) {
|
|
2906
|
+
const provider = features ? "memory" : "file";
|
|
2545
2907
|
const defaultConfig = {
|
|
2546
|
-
provider
|
|
2908
|
+
provider,
|
|
2547
2909
|
isCacheEnabled: true,
|
|
2548
|
-
cacheTtl:
|
|
2549
|
-
// 5 minutes
|
|
2910
|
+
cacheTtl: FEATURE_FLAG_CACHE_TTL_DEFAULT,
|
|
2550
2911
|
refreshInterval: 0,
|
|
2551
2912
|
// No auto-refresh
|
|
2552
2913
|
isLoggingEnabled: false,
|
|
2553
2914
|
shouldFallbackToDefaults: true,
|
|
2554
2915
|
...overrides
|
|
2555
2916
|
};
|
|
2556
|
-
|
|
2917
|
+
if (defaultConfig.provider === "file" && !defaultConfig.fileConfig) {
|
|
2918
|
+
defaultConfig.fileConfig = {
|
|
2919
|
+
filePath: FEATURE_FLAG_FILE_PATHS.DEFAULT,
|
|
2920
|
+
format: "json",
|
|
2921
|
+
shouldWatchForChanges: false
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
return this.create(defaultConfig, features ?? {});
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Type guard to check if a provider supports feature syncing.
|
|
2928
|
+
*
|
|
2929
|
+
* @param provider - The provider instance to check
|
|
2930
|
+
* @returns True if provider has syncFeatures method
|
|
2931
|
+
*/
|
|
2932
|
+
static isSyncableProvider(provider) {
|
|
2933
|
+
return "syncFeatures" in provider && typeof provider.syncFeatures === "function";
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Updates features on a provider if it supports the syncFeatures method.
|
|
2937
|
+
* This is useful for providers like MemoryProvider that can update their features at runtime.
|
|
2938
|
+
*
|
|
2939
|
+
* @param provider - The provider instance
|
|
2940
|
+
* @param newFeatures - New features to sync
|
|
2941
|
+
* @returns Promise that resolves when sync is complete
|
|
2942
|
+
* @throws Error if provider doesn't support feature syncing
|
|
2943
|
+
*/
|
|
2944
|
+
static async syncFeatures(provider, newFeatures) {
|
|
2945
|
+
if (this.isSyncableProvider(provider)) {
|
|
2946
|
+
await provider.syncFeatures(newFeatures);
|
|
2947
|
+
} else {
|
|
2948
|
+
throw new Error(
|
|
2949
|
+
`Provider type does not support feature syncing. Only providers with syncFeatures method (like MemoryProvider and FileProvider) support this operation.`
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Checks if a provider supports feature syncing.
|
|
2955
|
+
*
|
|
2956
|
+
* @param provider - The provider instance to check
|
|
2957
|
+
* @returns True if provider supports syncFeatures method
|
|
2958
|
+
*/
|
|
2959
|
+
static supportsFeaturesSync(provider) {
|
|
2960
|
+
return this.isSyncableProvider(provider);
|
|
2557
2961
|
}
|
|
2558
2962
|
/**
|
|
2559
2963
|
* Validates provider configuration before instantiation.
|
|
@@ -2606,8 +3010,11 @@ var FeatureFlagProviderFactory = class {
|
|
|
2606
3010
|
validator();
|
|
2607
3011
|
}
|
|
2608
3012
|
static validateFileConfig(config) {
|
|
2609
|
-
if (
|
|
2610
|
-
|
|
3013
|
+
if (config.fileConfig) {
|
|
3014
|
+
const { format } = config.fileConfig;
|
|
3015
|
+
if (format && !["json", "yaml"].includes(format)) {
|
|
3016
|
+
throw new Error('File format must be either "json" or "yaml"');
|
|
3017
|
+
}
|
|
2611
3018
|
}
|
|
2612
3019
|
}
|
|
2613
3020
|
static validateRedisConfig(config) {
|