@quonfig/cli 0.0.50 → 0.0.51
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 +50 -48
- package/dist/commands/generate.js +21 -5
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.js +16 -5
- package/dist/init/templates.js +59 -12
- package/dist/migrate/migration-report.js +72 -11
- package/dist/migrate/quonfig-target/report.d.ts +18 -1
- package/dist/migrate/sources/flagsmith/api.d.ts +115 -0
- package/dist/migrate/sources/flagsmith/api.js +402 -0
- package/dist/migrate/sources/flagsmith/translate.d.ts +72 -0
- package/dist/migrate/sources/flagsmith/translate.js +657 -0
- package/dist/migrate/sources/flagsmith/types.d.ts +282 -0
- package/dist/migrate/sources/flagsmith/types.js +18 -0
- package/dist/migrate/sources/flagsmith.d.ts +62 -0
- package/dist/migrate/sources/flagsmith.js +380 -23
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +3 -5
- package/package.json +2 -2
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flagsmith converter — Flagsmith feature/segment JSON → `QuonfigFile`-ready
|
|
3
|
+
* objects (plain JS objects ready for `JSON.stringify`).
|
|
4
|
+
*
|
|
5
|
+
* Provider-specific topology collapse (plan §5, Epic 3). Calls the shared
|
|
6
|
+
* `quonfig-target/` verb library where the structure is identical to LD
|
|
7
|
+
* (value coercion, criterion + rule shape, report wiring) and handles the
|
|
8
|
+
* Flagsmith-shaped bits here: synthesizing the ordered rule list from
|
|
9
|
+
* `feature_state` + `segment_overrides` + `identity_overrides` (§5.1),
|
|
10
|
+
* `enabled=false` disposition per D-F1, operator mapping with `:semver`
|
|
11
|
+
* suffix detection (§5.3), multivariate weighted-values shape (§5.4),
|
|
12
|
+
* cross-env value-type coercion per D-F5.
|
|
13
|
+
*
|
|
14
|
+
* This module is a pure function — no I/O, no env reads, no module-level
|
|
15
|
+
* mutable state. Report accumulation flows through the `ConversionReport`
|
|
16
|
+
* argument supplied by the caller (the `flagsmith.ts` source).
|
|
17
|
+
*/
|
|
18
|
+
import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
19
|
+
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
20
|
+
/** Flagsmith identity-override rule keys context attributes under this propertyName. */
|
|
21
|
+
const IDENTITY_PROPERTY_NAME = 'user.key';
|
|
22
|
+
/** Suffix Flagsmith uses to switch a comparison operator from string to semver. */
|
|
23
|
+
const SEMVER_SUFFIX = ':semver';
|
|
24
|
+
/**
|
|
25
|
+
* Flagsmith feature names land directly under `feature-flags/` — Flagsmith
|
|
26
|
+
* enforces a lower-case-hyphenated naming policy on most projects, so the
|
|
27
|
+
* key is already filesystem-safe. We still defensively replace any `/` so
|
|
28
|
+
* a name can never spawn a nested directory.
|
|
29
|
+
*/
|
|
30
|
+
export function flagOutputPath(name) {
|
|
31
|
+
return `feature-flags/${normalizeKey(name)}.json`;
|
|
32
|
+
}
|
|
33
|
+
export function segmentOutputPath(name) {
|
|
34
|
+
return `segments/${normalizeKey(name)}.json`;
|
|
35
|
+
}
|
|
36
|
+
function normalizeKey(key) {
|
|
37
|
+
return key.replaceAll('/', '.');
|
|
38
|
+
}
|
|
39
|
+
/** Env name → Quonfig env id slug (matches the source-module slugifier). */
|
|
40
|
+
function slugifyEnvName(name) {
|
|
41
|
+
return name
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replaceAll(/[^\da-z]+/g, '-')
|
|
44
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Unwrap the `feature_state_value` field as it appears on the
|
|
48
|
+
* `/features/featurestates/` endpoint (envelope shape on env-default and
|
|
49
|
+
* segment-override rows; scalar on `/edge-identity-overrides` per API5).
|
|
50
|
+
* Returns the raw JS value Flagsmith would have served before any Quonfig
|
|
51
|
+
* type coercion.
|
|
52
|
+
*/
|
|
53
|
+
export function coerceEnvelopeOrScalar(v) {
|
|
54
|
+
if (v === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
if (v === null)
|
|
57
|
+
return null;
|
|
58
|
+
if (typeof v === 'object') {
|
|
59
|
+
if (v.type === 'bool')
|
|
60
|
+
return v.boolean_value ?? false;
|
|
61
|
+
if (v.type === 'int')
|
|
62
|
+
return v.integer_value ?? 0;
|
|
63
|
+
// 'unicode' (and any unknown envelope type) defaults to the string slot.
|
|
64
|
+
return v.string_value ?? '';
|
|
65
|
+
}
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a multivariate option's typed value to the raw JS value Flagsmith
|
|
70
|
+
* would have served. The option's discriminator is `type: 'unicode' | 'int' | 'bool'`.
|
|
71
|
+
*/
|
|
72
|
+
function mvOptionValue(opt) {
|
|
73
|
+
if (opt.type === 'bool')
|
|
74
|
+
return opt.boolean_value ?? false;
|
|
75
|
+
if (opt.type === 'int')
|
|
76
|
+
return opt.integer_value ?? 0;
|
|
77
|
+
return opt.string_value ?? '';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Decide the feature's Quonfig valueType. Multivariate features take their
|
|
81
|
+
* type from the variation values (all variants share a type in valid Flagsmith
|
|
82
|
+
* data); STANDARD features look at the env-default `feature_state_value` shape
|
|
83
|
+
* across every env. D-F5: if envs diverge, coerce to `string` and emit a loud
|
|
84
|
+
* report entry per diverged feature.
|
|
85
|
+
*/
|
|
86
|
+
function decideValueType(bundle, report) {
|
|
87
|
+
const { feature, featurestates_by_env } = bundle;
|
|
88
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
89
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
90
|
+
const variantValues = mvOptions.map((o) => mvOptionValue(o));
|
|
91
|
+
const types = new Set(variantValues.map((v) => inferValueType(v)));
|
|
92
|
+
if (types.size === 1) {
|
|
93
|
+
const only = [...types][0];
|
|
94
|
+
if (only === 'string' || only === 'int' || only === 'bool' || only === 'double')
|
|
95
|
+
return only;
|
|
96
|
+
}
|
|
97
|
+
// Mixed-type multivariates are vanishingly rare in Flagsmith; fall back to
|
|
98
|
+
// string so every variant serializes uniformly.
|
|
99
|
+
return 'string';
|
|
100
|
+
}
|
|
101
|
+
// STANDARD: scan every env-default row's value type. Identity/segment
|
|
102
|
+
// overrides can ALSO have divergent types in pathological data — we still
|
|
103
|
+
// only coerce when env-defaults disagree, since that's the visible case in
|
|
104
|
+
// §5.5 / D-F5; overrides on a mismatched feature lose precision the same way.
|
|
105
|
+
const envTypes = new Map();
|
|
106
|
+
for (const [envApiKey, bundleForEnv] of Object.entries(featurestates_by_env)) {
|
|
107
|
+
if (bundleForEnv.default) {
|
|
108
|
+
const raw = coerceEnvelopeOrScalar(bundleForEnv.default.feature_state_value);
|
|
109
|
+
envTypes.set(envApiKey, inferValueType(raw));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const distinct = new Set(envTypes.values());
|
|
113
|
+
if (distinct.size <= 1) {
|
|
114
|
+
const only = [...distinct][0];
|
|
115
|
+
if (only === undefined)
|
|
116
|
+
return 'string';
|
|
117
|
+
if (only === 'string' || only === 'int' || only === 'bool' || only === 'double')
|
|
118
|
+
return only;
|
|
119
|
+
// 'json' (null/object/array) is rare for Flagsmith primitives — leave as string.
|
|
120
|
+
return 'string';
|
|
121
|
+
}
|
|
122
|
+
// D-F5: multiple envs disagree on type — coerce to string and report.
|
|
123
|
+
const detail = `feature_state_value type diverges across environments (${[...envTypes.entries()]
|
|
124
|
+
.map(([env, t]) => `${env}=${t}`)
|
|
125
|
+
.join(', ')}) — Quonfig requires one valueType per flag, coerced to string`;
|
|
126
|
+
report.add('cross-env-value-type-coerced', feature.name, detail);
|
|
127
|
+
return 'string';
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Map one Flagsmith condition to a Quonfig criterion, or signal a whole-rule
|
|
131
|
+
* drop with a reason. Implements plan §5.3 verbatim.
|
|
132
|
+
*/
|
|
133
|
+
export function mapCondition(condition) {
|
|
134
|
+
const { operator, property, value } = condition;
|
|
135
|
+
const propertyName = property && property.length > 0 ? property : 'user.key';
|
|
136
|
+
const hasSemver = typeof value === 'string' && value.endsWith(SEMVER_SUFFIX);
|
|
137
|
+
const semverValue = hasSemver ? value.slice(0, -SEMVER_SUFFIX.length) : null;
|
|
138
|
+
switch (operator) {
|
|
139
|
+
case 'EQUAL': {
|
|
140
|
+
if (hasSemver) {
|
|
141
|
+
return {
|
|
142
|
+
criterion: {
|
|
143
|
+
operator: 'PROP_SEMVER_EQUAL',
|
|
144
|
+
propertyName,
|
|
145
|
+
valueToMatch: { type: 'string', value: semverValue ?? '' },
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
criterion: {
|
|
151
|
+
operator: 'PROP_IS_ONE_OF',
|
|
152
|
+
propertyName,
|
|
153
|
+
valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case 'NOT_EQUAL': {
|
|
158
|
+
if (hasSemver) {
|
|
159
|
+
return {
|
|
160
|
+
drop: true,
|
|
161
|
+
reason: 'NOT_EQUAL with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_NOT_EQUAL)',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
criterion: {
|
|
166
|
+
operator: 'PROP_IS_NOT_ONE_OF',
|
|
167
|
+
propertyName,
|
|
168
|
+
valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case 'GREATER_THAN': {
|
|
173
|
+
if (hasSemver) {
|
|
174
|
+
return {
|
|
175
|
+
criterion: {
|
|
176
|
+
operator: 'PROP_SEMVER_GREATER_THAN',
|
|
177
|
+
propertyName,
|
|
178
|
+
valueToMatch: { type: 'string', value: semverValue ?? '' },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
criterion: makeNumericCriterion('PROP_GREATER_THAN', propertyName, value),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
case 'GREATER_THAN_INCLUSIVE': {
|
|
187
|
+
if (hasSemver) {
|
|
188
|
+
return {
|
|
189
|
+
drop: true,
|
|
190
|
+
reason: 'GREATER_THAN_INCLUSIVE with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_GREATER_THAN_OR_EQUAL)',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
criterion: makeNumericCriterion('PROP_GREATER_THAN_OR_EQUAL', propertyName, value),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
case 'LESS_THAN': {
|
|
198
|
+
if (hasSemver) {
|
|
199
|
+
return {
|
|
200
|
+
criterion: {
|
|
201
|
+
operator: 'PROP_SEMVER_LESS_THAN',
|
|
202
|
+
propertyName,
|
|
203
|
+
valueToMatch: { type: 'string', value: semverValue ?? '' },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
criterion: makeNumericCriterion('PROP_LESS_THAN', propertyName, value),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
case 'LESS_THAN_INCLUSIVE': {
|
|
212
|
+
if (hasSemver) {
|
|
213
|
+
return {
|
|
214
|
+
drop: true,
|
|
215
|
+
reason: 'LESS_THAN_INCLUSIVE with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_LESS_THAN_OR_EQUAL)',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
criterion: makeNumericCriterion('PROP_LESS_THAN_OR_EQUAL', propertyName, value),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
case 'CONTAINS': {
|
|
223
|
+
return {
|
|
224
|
+
criterion: {
|
|
225
|
+
operator: 'PROP_CONTAINS_ONE_OF',
|
|
226
|
+
propertyName,
|
|
227
|
+
valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
case 'NOT_CONTAINS': {
|
|
232
|
+
return {
|
|
233
|
+
criterion: {
|
|
234
|
+
operator: 'PROP_DOES_NOT_CONTAIN_ONE_OF',
|
|
235
|
+
propertyName,
|
|
236
|
+
valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
case 'REGEX': {
|
|
241
|
+
return {
|
|
242
|
+
criterion: {
|
|
243
|
+
operator: 'PROP_MATCHES',
|
|
244
|
+
propertyName,
|
|
245
|
+
valueToMatch: { type: 'string', value: String(value ?? '') },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
case 'IS_SET': {
|
|
250
|
+
return { criterion: { operator: 'IS_PRESENT', propertyName } };
|
|
251
|
+
}
|
|
252
|
+
case 'IS_NOT_SET': {
|
|
253
|
+
return { criterion: { operator: 'IS_NOT_PRESENT', propertyName } };
|
|
254
|
+
}
|
|
255
|
+
case 'IN': {
|
|
256
|
+
// Flagsmith ships IN values as comma-separated strings, NOT JSON arrays.
|
|
257
|
+
const parts = typeof value === 'string'
|
|
258
|
+
? value
|
|
259
|
+
.split(',')
|
|
260
|
+
.map((s) => s.trim())
|
|
261
|
+
.filter((s) => s.length > 0)
|
|
262
|
+
: [];
|
|
263
|
+
return {
|
|
264
|
+
criterion: {
|
|
265
|
+
operator: 'PROP_IS_ONE_OF',
|
|
266
|
+
propertyName,
|
|
267
|
+
valueToMatch: { type: 'string_list', value: parts.length > 0 ? parts : [String(value ?? '')] },
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
case 'MODULO': {
|
|
272
|
+
return { drop: true, reason: 'Flagsmith MODULO operator has no Quonfig equivalent' };
|
|
273
|
+
}
|
|
274
|
+
case 'PERCENTAGE_SPLIT': {
|
|
275
|
+
return {
|
|
276
|
+
drop: true,
|
|
277
|
+
reason: 'Flagsmith PERCENTAGE_SPLIT inside a segment condition has no Quonfig equivalent (rule-level weighted_values only)',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
default: {
|
|
281
|
+
return { drop: true, reason: `unrecognized Flagsmith operator "${operator}"` };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function makeNumericCriterion(operator, propertyName, rawValue) {
|
|
286
|
+
const num = rawValue === null || rawValue === undefined || rawValue === '' ? 0 : Number(rawValue);
|
|
287
|
+
if (Number.isFinite(num)) {
|
|
288
|
+
return {
|
|
289
|
+
operator,
|
|
290
|
+
propertyName,
|
|
291
|
+
valueToMatch: { type: Number.isInteger(num) ? 'int' : 'double', value: num },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// Non-numeric value on a numeric operator — emit a string fallback. Flagsmith
|
|
295
|
+
// coerces at runtime so this is rare; keeping the operator preserves intent.
|
|
296
|
+
return { operator, propertyName, valueToMatch: { type: 'string', value: String(rawValue ?? '') } };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Convert a Flagsmith segment rule subtree (possibly nested with type=ALL/ANY/NONE)
|
|
300
|
+
* into a flat list of Quonfig criteria. Quonfig has no within-rule OR, so:
|
|
301
|
+
* - ALL (AND) is honored: each child contributes its criteria, ANDed.
|
|
302
|
+
* - ANY (OR) on a sibling rule list is a structural mismatch — we drop the
|
|
303
|
+
* rule with a precise reason rather than silently AND it.
|
|
304
|
+
* - NONE (NOT-ANY) — same: drop with reason.
|
|
305
|
+
*
|
|
306
|
+
* Returns null when the subtree cannot be flattened (some child dropped).
|
|
307
|
+
*/
|
|
308
|
+
function flattenSegmentRuleForSegment(rule, segment, report) {
|
|
309
|
+
if (rule.type !== 'ALL') {
|
|
310
|
+
report.add('skipped-rule', segment.name, `Flagsmith segment rule with type=${rule.type} cannot be expressed in Quonfig (no within-rule OR); whole rule dropped`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const criteria = [];
|
|
314
|
+
for (const cond of rule.conditions) {
|
|
315
|
+
const mapped = mapCondition(cond);
|
|
316
|
+
if ('drop' in mapped) {
|
|
317
|
+
report.add('skipped-rule', segment.name, `condition on "${cond.property ?? ''}" (${cond.operator}) dropped: ${mapped.reason}`);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
criteria.push(mapped.criterion);
|
|
321
|
+
}
|
|
322
|
+
for (const child of rule.rules ?? []) {
|
|
323
|
+
const sub = flattenSegmentRuleForSegment(child, segment, report);
|
|
324
|
+
if (sub === null)
|
|
325
|
+
return null;
|
|
326
|
+
criteria.push(...sub);
|
|
327
|
+
}
|
|
328
|
+
return criteria;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Convert an env-default featurestate's served value into a Quonfig RuleValue.
|
|
332
|
+
*
|
|
333
|
+
* MULTIVARIATE features serve a weighted_values rollout (using per-env override
|
|
334
|
+
* weights when present, else project-level defaults). Identity-override rules
|
|
335
|
+
* on MV features handle pinning separately (see `identityOverrideRuleValue`).
|
|
336
|
+
*/
|
|
337
|
+
function envDefaultRuleValue(bundle, envApiKey, fsState, ctx) {
|
|
338
|
+
const { feature } = bundle;
|
|
339
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
340
|
+
// enabled=false (D-F1)
|
|
341
|
+
if (fsState && !fsState.enabled) {
|
|
342
|
+
if (ctx.valueType === 'bool') {
|
|
343
|
+
return { type: 'bool', value: false };
|
|
344
|
+
}
|
|
345
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
346
|
+
const detail = `non-boolean MULTIVARIATE feature is disabled in env "${envApiKey}" — Flagsmith would have served your code's default; Quonfig now serves the stored weighted_values`;
|
|
347
|
+
ctx.report.add('enabled-false-non-boolean', feature.name, detail);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const stored = coerceEnvelopeOrScalar(fsState.feature_state_value);
|
|
351
|
+
const detail = `non-boolean feature is disabled in env "${envApiKey}" — Flagsmith would have served your code's default; Quonfig now serves ${JSON.stringify(stored)}`;
|
|
352
|
+
ctx.report.add('enabled-false-non-boolean', feature.name, detail);
|
|
353
|
+
}
|
|
354
|
+
// Fall through to served-value emission below so we still produce a value.
|
|
355
|
+
}
|
|
356
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
357
|
+
return multivariateWeightedValue(mvOptions, fsState?.multivariate_feature_state_values ?? [], ctx);
|
|
358
|
+
}
|
|
359
|
+
if (!fsState) {
|
|
360
|
+
// No default featurestate for this env (rare — partial-fetch error). Serve
|
|
361
|
+
// the value-type's zero-ish typed value so downstream validation accepts it.
|
|
362
|
+
return zeroValueFor(ctx.valueType);
|
|
363
|
+
}
|
|
364
|
+
const raw = coerceEnvelopeOrScalar(fsState.feature_state_value);
|
|
365
|
+
return toQuonfigValue(raw, ctx.valueType);
|
|
366
|
+
}
|
|
367
|
+
/** Identity-override rule value: pinned single variation OR re-weighted MV allocation (D-F7). */
|
|
368
|
+
function identityOverrideRuleValue(bundle, override, ctx) {
|
|
369
|
+
const { feature } = bundle;
|
|
370
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
371
|
+
const mvWeights = override.feature_state.multivariate_feature_state_values ?? [];
|
|
372
|
+
// enabled=false on the override: prefer the served value of the override's
|
|
373
|
+
// own feature_state_value, with the same D-F1 reporting if non-boolean.
|
|
374
|
+
if (!override.feature_state.enabled) {
|
|
375
|
+
if (ctx.valueType === 'bool') {
|
|
376
|
+
return { type: 'bool', value: false };
|
|
377
|
+
}
|
|
378
|
+
if (feature.type !== 'MULTIVARIATE') {
|
|
379
|
+
ctx.report.add('enabled-false-non-boolean', feature.name, `identity override "${override.identifier}" is disabled — Flagsmith would have served your code's default; Quonfig now serves the stored value`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// MV with re-weighted allocation (D-F7): per-identity weight vector wins.
|
|
383
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0 && mvWeights.length > 0) {
|
|
384
|
+
return multivariateWeightedValue(mvOptions, mvWeights, ctx);
|
|
385
|
+
}
|
|
386
|
+
// MV pinned to a single variation — also expressed via the SDK as a scalar
|
|
387
|
+
// `feature_state_value` matching one of the variant values. Emit as the
|
|
388
|
+
// typed scalar; Quonfig serves that variant.
|
|
389
|
+
const raw = coerceEnvelopeOrScalar(override.feature_state.feature_state_value);
|
|
390
|
+
return toQuonfigValue(raw, ctx.valueType);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Like `envDefaultRuleValue` but never emits report entries — used by the
|
|
394
|
+
* baseline `default.rules` so the D-F1 / D-F5 sentinels are only counted once
|
|
395
|
+
* (during the per-env walk).
|
|
396
|
+
*/
|
|
397
|
+
function servedValueSilent(bundle, fsState, ctx) {
|
|
398
|
+
const { feature } = bundle;
|
|
399
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
400
|
+
if (fsState && !fsState.enabled && ctx.valueType === 'bool') {
|
|
401
|
+
return { type: 'bool', value: false };
|
|
402
|
+
}
|
|
403
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
404
|
+
return multivariateWeightedValue(mvOptions, fsState?.multivariate_feature_state_values ?? [], ctx);
|
|
405
|
+
}
|
|
406
|
+
if (!fsState)
|
|
407
|
+
return zeroValueFor(ctx.valueType);
|
|
408
|
+
const raw = coerceEnvelopeOrScalar(fsState.feature_state_value);
|
|
409
|
+
return toQuonfigValue(raw, ctx.valueType);
|
|
410
|
+
}
|
|
411
|
+
/** Build a Quonfig `weighted_values` rule value from a list of MV options + (optional) per-env weights. */
|
|
412
|
+
function multivariateWeightedValue(mvOptions, perEnvWeights, ctx) {
|
|
413
|
+
// Index per-env weights by MV option id for fast lookup.
|
|
414
|
+
const weightByOption = new Map();
|
|
415
|
+
for (const w of perEnvWeights)
|
|
416
|
+
weightByOption.set(w.multivariate_feature_option, w.percentage_allocation);
|
|
417
|
+
// Flagsmith weights are percentages 0–100; Quonfig uses 0–100_000 (1000 units = 1%).
|
|
418
|
+
const weightedValues = mvOptions.map((opt) => {
|
|
419
|
+
const pct = weightByOption.get(opt.id) ?? opt.default_percentage_allocation;
|
|
420
|
+
return {
|
|
421
|
+
value: toQuonfigValue(mvOptionValue(opt), ctx.valueType),
|
|
422
|
+
weight: Math.round(pct * 1000),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
type: 'weighted_values',
|
|
427
|
+
value: {
|
|
428
|
+
hashByPropertyName: 'user.key',
|
|
429
|
+
weightedValues,
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function zeroValueFor(valueType) {
|
|
434
|
+
switch (valueType) {
|
|
435
|
+
case 'bool': {
|
|
436
|
+
return { type: 'bool', value: false };
|
|
437
|
+
}
|
|
438
|
+
case 'double': {
|
|
439
|
+
return { type: 'double', value: 0 };
|
|
440
|
+
}
|
|
441
|
+
case 'int': {
|
|
442
|
+
return { type: 'int', value: 0 };
|
|
443
|
+
}
|
|
444
|
+
case 'json': {
|
|
445
|
+
return { type: 'json', value: null };
|
|
446
|
+
}
|
|
447
|
+
case 'string_list': {
|
|
448
|
+
return { type: 'string_list', value: [] };
|
|
449
|
+
}
|
|
450
|
+
default: {
|
|
451
|
+
return { type: 'string', value: '' };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Build the variants[] array for a Quonfig feature flag. MULTIVARIATE features
|
|
457
|
+
* surface each Flagsmith option as a variant; STANDARD bool features get the
|
|
458
|
+
* implicit `true`/`false` variants Quonfig already understands; STANDARD
|
|
459
|
+
* value-typed features have an empty variants[] (Quonfig allows that for
|
|
460
|
+
* non-rollout flags).
|
|
461
|
+
*/
|
|
462
|
+
function buildVariants(feature, valueType) {
|
|
463
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
464
|
+
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
465
|
+
return mvOptions.map((opt) => {
|
|
466
|
+
const raw = mvOptionValue(opt);
|
|
467
|
+
const variant = { value: toQuonfigValue(raw, valueType) };
|
|
468
|
+
// Flagsmith doesn't give MV options a name — use the stringified value
|
|
469
|
+
// for human-readability in the Quonfig UI.
|
|
470
|
+
variant.name = String(raw);
|
|
471
|
+
return variant;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (valueType === 'bool') {
|
|
475
|
+
return [
|
|
476
|
+
{ name: 'true', value: { type: 'bool', value: true } },
|
|
477
|
+
{ name: 'false', value: { type: 'bool', value: false } },
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Translate one Flagsmith feature (with all its per-env state) into a Quonfig
|
|
484
|
+
* feature-flag config object.
|
|
485
|
+
*/
|
|
486
|
+
export function translateFeature(bundle, report, options = {}) {
|
|
487
|
+
const { feature, featurestates_by_env, feature_segments_by_env } = bundle;
|
|
488
|
+
const valueType = decideValueType(bundle, report);
|
|
489
|
+
const tagLabelById = new Map();
|
|
490
|
+
for (const t of options.tags ?? [])
|
|
491
|
+
tagLabelById.set(t.id, t.label);
|
|
492
|
+
const segmentNameById = options.segmentNameById ?? new Map();
|
|
493
|
+
// Per-env lookup: FeatureSegment.id -> Segment.id. The featurestates endpoint
|
|
494
|
+
// returns `feature_segment` as the join-table id (a number), not the
|
|
495
|
+
// underlying segment id; resolve via `feature_segments_by_env` which the
|
|
496
|
+
// fetcher already populated per (env, feature).
|
|
497
|
+
const segmentIdByFeatureSegmentId = {};
|
|
498
|
+
for (const [envApiKey, fseg] of Object.entries(feature_segments_by_env ?? {})) {
|
|
499
|
+
const m = new Map();
|
|
500
|
+
for (const fs of fseg)
|
|
501
|
+
m.set(fs.id, fs.segment);
|
|
502
|
+
segmentIdByFeatureSegmentId[envApiKey] = m;
|
|
503
|
+
}
|
|
504
|
+
const ctx = { feature, report, segmentNameById, tagLabelById, valueType };
|
|
505
|
+
// Per-env rule list synthesis (plan §5.1, D-F2 ordering): identity overrides
|
|
506
|
+
// first, then segment overrides (already sorted ASC by priority by the
|
|
507
|
+
// fetcher), then the trailing ALWAYS_TRUE serving the env default.
|
|
508
|
+
const environments = Object.entries(featurestates_by_env)
|
|
509
|
+
.map(([envApiKey, envBundle]) => {
|
|
510
|
+
const rules = [];
|
|
511
|
+
// 1. Identity-override rules (D-F2 first).
|
|
512
|
+
for (const idov of envBundle.identity_overrides) {
|
|
513
|
+
const value = identityOverrideRuleValue(bundle, idov, ctx);
|
|
514
|
+
rules.push({
|
|
515
|
+
criteria: [
|
|
516
|
+
{
|
|
517
|
+
operator: 'PROP_IS_ONE_OF',
|
|
518
|
+
propertyName: IDENTITY_PROPERTY_NAME,
|
|
519
|
+
valueToMatch: { type: 'string_list', value: [idov.identifier] },
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
value,
|
|
523
|
+
});
|
|
524
|
+
ctx.report.add('identity-override-as-rule', feature.name, `identity "${idov.identifier}" in env "${envApiKey}" inlined as a leading PROP_IS_ONE_OF rule on ${IDENTITY_PROPERTY_NAME}. ` +
|
|
525
|
+
`Quonfig v1 has no individual-target primitive — adding/removing identities now requires a config edit, not a separate UI.`);
|
|
526
|
+
}
|
|
527
|
+
// 2. Segment-override rules (D-F2 second; fetcher pre-sorted by priority ASC).
|
|
528
|
+
for (const segov of envBundle.segment_overrides) {
|
|
529
|
+
const fsegRef = segov.feature_segment;
|
|
530
|
+
const segmentId = typeof fsegRef === 'object' && fsegRef !== null
|
|
531
|
+
? fsegRef.segment
|
|
532
|
+
: typeof fsegRef === 'number'
|
|
533
|
+
? (segmentIdByFeatureSegmentId[envApiKey]?.get(fsegRef) ?? null)
|
|
534
|
+
: null;
|
|
535
|
+
const segmentName = segmentId === null ? undefined : segmentNameById.get(segmentId);
|
|
536
|
+
if (!segmentName) {
|
|
537
|
+
// Unresolvable segment reference — drop with report so it can't go silent.
|
|
538
|
+
ctx.report.add('skipped-rule', feature.name, `segment-override row in env "${envApiKey}" references segment id ${segmentId ?? '(unknown)'} which is not in the project segment pool — rule dropped`);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// Per D-F4: a segment whose rules include PERCENTAGE_SPLIT or MODULO
|
|
542
|
+
// would already be a structural mismatch. The drop happens inside the
|
|
543
|
+
// segment-file translation; here we still emit the IN_SEG override
|
|
544
|
+
// because the segment file exists. The segment-file rules may have
|
|
545
|
+
// been emptied (just the trailing catch-all left) — that's the user's
|
|
546
|
+
// signal that the override never matches anyone.
|
|
547
|
+
rules.push(buildSegmentOverrideRule(segov, segmentName, bundle, ctx));
|
|
548
|
+
}
|
|
549
|
+
// 3. Trailing ALWAYS_TRUE rule serving the env default.
|
|
550
|
+
rules.push({
|
|
551
|
+
criteria: [ALWAYS_TRUE],
|
|
552
|
+
value: envDefaultRuleValue(bundle, envApiKey, envBundle.default, ctx),
|
|
553
|
+
});
|
|
554
|
+
const readableName = options.envNameByApiKey?.get(envApiKey) ?? envApiKey;
|
|
555
|
+
return { id: slugifyEnvName(readableName), rules };
|
|
556
|
+
})
|
|
557
|
+
// Stable output order.
|
|
558
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
559
|
+
// Baseline `default` rules: serve the first env's default value (or the
|
|
560
|
+
// value-type's zero) so any env Flagsmith didn't define has a defined
|
|
561
|
+
// fallback. Mirrors LD's choice in `translateFlag`. Use the silent helper
|
|
562
|
+
// so we don't double-emit the D-F1 / D-F5 sentinel notes — those were
|
|
563
|
+
// already accumulated by the per-env walk above.
|
|
564
|
+
const firstEnvApiKey = Object.keys(featurestates_by_env)[0];
|
|
565
|
+
const firstEnvBundle = firstEnvApiKey ? featurestates_by_env[firstEnvApiKey] : undefined;
|
|
566
|
+
const defaultRules = [
|
|
567
|
+
{
|
|
568
|
+
criteria: [ALWAYS_TRUE],
|
|
569
|
+
value: firstEnvBundle ? servedValueSilent(bundle, firstEnvBundle.default, ctx) : zeroValueFor(valueType),
|
|
570
|
+
},
|
|
571
|
+
];
|
|
572
|
+
const out = {
|
|
573
|
+
key: normalizeKey(feature.name),
|
|
574
|
+
type: 'feature_flag',
|
|
575
|
+
valueType,
|
|
576
|
+
default: { rules: defaultRules },
|
|
577
|
+
environments,
|
|
578
|
+
variants: buildVariants(feature, valueType),
|
|
579
|
+
};
|
|
580
|
+
if (feature.description)
|
|
581
|
+
out.description = feature.description;
|
|
582
|
+
const tagLabels = (feature.tags ?? [])
|
|
583
|
+
.map((id) => tagLabelById.get(id))
|
|
584
|
+
.filter((label) => typeof label === 'string');
|
|
585
|
+
if (tagLabels.length > 0)
|
|
586
|
+
out.tags = tagLabels;
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
/** Build the rule that triggers when a context is in `segmentName`. */
|
|
590
|
+
function buildSegmentOverrideRule(segov, segmentName, bundle, ctx) {
|
|
591
|
+
const { feature } = bundle;
|
|
592
|
+
const mvOptions = feature.multivariate_options ?? [];
|
|
593
|
+
let value;
|
|
594
|
+
if (!segov.enabled) {
|
|
595
|
+
if (ctx.valueType === 'bool') {
|
|
596
|
+
value = { type: 'bool', value: false };
|
|
597
|
+
}
|
|
598
|
+
else if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
599
|
+
ctx.report.add('enabled-false-non-boolean', feature.name, `segment override "${segmentName}" is disabled — Flagsmith would have served your code's default; Quonfig now serves the stored weighted_values`);
|
|
600
|
+
value = multivariateWeightedValue(mvOptions, segov.multivariate_feature_state_values ?? [], ctx);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const stored = coerceEnvelopeOrScalar(segov.feature_state_value);
|
|
604
|
+
ctx.report.add('enabled-false-non-boolean', feature.name, `segment override "${segmentName}" is disabled — Flagsmith would have served your code's default; Quonfig now serves ${JSON.stringify(stored)}`);
|
|
605
|
+
value = toQuonfigValue(stored, ctx.valueType);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
609
|
+
value = multivariateWeightedValue(mvOptions, segov.multivariate_feature_state_values ?? [], ctx);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
const raw = coerceEnvelopeOrScalar(segov.feature_state_value);
|
|
613
|
+
value = toQuonfigValue(raw, ctx.valueType);
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
criteria: [
|
|
617
|
+
{
|
|
618
|
+
operator: 'IN_SEG',
|
|
619
|
+
valueToMatch: { type: 'string', value: segmentName },
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
value,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Translate one Flagsmith segment into a Quonfig segment config object.
|
|
627
|
+
*
|
|
628
|
+
* Quonfig segments are boolean ("is the context in the segment?") and have no
|
|
629
|
+
* per-environment state, so all rules collapse into `default.rules`,
|
|
630
|
+
* first-match-wins. A rule whose conditions all map yields a true-valued rule;
|
|
631
|
+
* a trailing ALWAYS_TRUE serves false (not in segment).
|
|
632
|
+
*/
|
|
633
|
+
export function translateSegment(segment, report) {
|
|
634
|
+
const rules = [];
|
|
635
|
+
for (const rule of segment.rules ?? []) {
|
|
636
|
+
const criteria = flattenSegmentRuleForSegment(rule, segment, report);
|
|
637
|
+
if (criteria === null)
|
|
638
|
+
continue;
|
|
639
|
+
rules.push({
|
|
640
|
+
criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE],
|
|
641
|
+
value: { type: 'bool', value: true },
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// Trailing catch-all: anyone not matched is not in the segment.
|
|
645
|
+
rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
|
|
646
|
+
const out = {
|
|
647
|
+
key: normalizeKey(segment.name),
|
|
648
|
+
type: 'segment',
|
|
649
|
+
valueType: 'bool',
|
|
650
|
+
default: { rules },
|
|
651
|
+
environments: [],
|
|
652
|
+
variants: [],
|
|
653
|
+
};
|
|
654
|
+
if (segment.description)
|
|
655
|
+
out.description = segment.description;
|
|
656
|
+
return out;
|
|
657
|
+
}
|