@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,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flagsmith REST API client — Phase-1 config snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Flagsmith has no bulk-export endpoint, so the fetcher stitches per-resource
|
|
5
|
+
* calls: project → environments → features → per-env featurestates → segments
|
|
6
|
+
* → feature-segments (priority) → edge identities → edge identity overrides
|
|
7
|
+
* → tags.
|
|
8
|
+
*
|
|
9
|
+
* Auth is `Authorization: Api-Key <token>` — note the `Api-Key` prefix (not
|
|
10
|
+
* `Bearer`, and not bare-token like LaunchDarkly). Rate-limiting headers are
|
|
11
|
+
* unpublished; we program against the response: back off on 429 using
|
|
12
|
+
* `Retry-After` (standard) or `X-RateLimit-Reset` (Flagsmith's variant), and
|
|
13
|
+
* step the inter-request throttle up briefly when we hit one (mirrors the
|
|
14
|
+
* Phase-2 generator's empirical backoff loop). On any non-429 error we throw
|
|
15
|
+
* immediately — only rate limiting is retried.
|
|
16
|
+
*
|
|
17
|
+
* Pagination is standard Django REST `{count, next, previous, results}` with
|
|
18
|
+
* absolute `next` URLs. Edge-identities listing is the one exception: it uses
|
|
19
|
+
* `{results, last_evaluated_key}` cursor pagination via a query-string param.
|
|
20
|
+
*/
|
|
21
|
+
const PROD_BASE_URL = 'https://api.flagsmith.com/api/v1';
|
|
22
|
+
/** Page size for Django REST endpoints that accept `page_size`. Conservative for memory + 429 risk. */
|
|
23
|
+
const PAGE_LIMIT = 100;
|
|
24
|
+
/** Fallback wait when a 429 carries no usable headers. Mirrors the Phase-2 generator's base. */
|
|
25
|
+
const DEFAULT_BACKOFF_MS = 2000;
|
|
26
|
+
/** Hard cap on retries per request before we give up. */
|
|
27
|
+
const MAX_RETRIES = 6;
|
|
28
|
+
let baseUrl = PROD_BASE_URL;
|
|
29
|
+
export function setFlagsmithBaseUrl(url) {
|
|
30
|
+
baseUrl = url;
|
|
31
|
+
}
|
|
32
|
+
export function selectFlagsmithBaseUrl() {
|
|
33
|
+
return process.env.FLAGSMITH_API_URL ?? PROD_BASE_URL;
|
|
34
|
+
}
|
|
35
|
+
export function applyFlagsmithBaseUrl() {
|
|
36
|
+
baseUrl = selectFlagsmithBaseUrl();
|
|
37
|
+
}
|
|
38
|
+
/** Real timer-backed sleep — swapped out in tests so MSW runs don't actually wait. */
|
|
39
|
+
function timerSleep(ms) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
setTimeout(resolve, ms);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Test seam: a no-op sleep is swapped in so MSW tests don't actually wait. */
|
|
45
|
+
let sleepImpl = timerSleep;
|
|
46
|
+
export function __setSleepForTests(fn) {
|
|
47
|
+
sleepImpl = fn;
|
|
48
|
+
}
|
|
49
|
+
export function __resetSleepForTests() {
|
|
50
|
+
sleepImpl = timerSleep;
|
|
51
|
+
}
|
|
52
|
+
export class FlagsmithApiError extends Error {
|
|
53
|
+
status;
|
|
54
|
+
constructor(status, statusText, url, body) {
|
|
55
|
+
super(`Flagsmith API request failed: ${status} ${statusText} — ${url}\n${body}`);
|
|
56
|
+
this.name = 'FlagsmithApiError';
|
|
57
|
+
this.status = status;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Compute the wait derived from a 429 response's headers.
|
|
62
|
+
*
|
|
63
|
+
* Flagsmith honours the standard `Retry-After` (seconds) and also sometimes
|
|
64
|
+
* sends `X-RateLimit-Reset` — which the Phase-2 generator observed as either
|
|
65
|
+
* seconds-from-now OR an epoch-ms timestamp. Heuristic: > 10^10 means
|
|
66
|
+
* epoch-ms. We bump the wait UP for either signal (never down) so a tight
|
|
67
|
+
* 429 storm doesn't burn through the retry budget faster than the API wants.
|
|
68
|
+
*/
|
|
69
|
+
function backoffMsFromHeaders(headers, attempt) {
|
|
70
|
+
// Exponential base: 2s, 4s, 8s, 16s, 32s, 60s (clamped).
|
|
71
|
+
let wait = Math.min(60_000, 2000 * 2 ** attempt);
|
|
72
|
+
const retryAfter = headers.get('Retry-After');
|
|
73
|
+
if (retryAfter !== null) {
|
|
74
|
+
const sec = Number(retryAfter);
|
|
75
|
+
if (Number.isFinite(sec))
|
|
76
|
+
wait = Math.max(wait, sec * 1000);
|
|
77
|
+
}
|
|
78
|
+
const reset = headers.get('X-RateLimit-Reset') ?? headers.get('X-Ratelimit-Reset');
|
|
79
|
+
if (reset !== null) {
|
|
80
|
+
const n = Number(reset);
|
|
81
|
+
if (Number.isFinite(n)) {
|
|
82
|
+
// > 10^10 ⇒ epoch ms; ≤ 10^10 ⇒ seconds-from-now.
|
|
83
|
+
const ms = n > 1e10 ? Math.max(0, n - Date.now()) : n * 1000;
|
|
84
|
+
wait = Math.max(wait, ms + 500);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return Math.min(60_000, wait);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Single GET with `Api-Key` auth, 429 backoff against `Retry-After` /
|
|
91
|
+
* `X-RateLimit-Reset`, and an exponential fallback. Non-429 errors throw
|
|
92
|
+
* immediately — only rate limiting is retried.
|
|
93
|
+
*/
|
|
94
|
+
export async function apiFetch(path, apiKey) {
|
|
95
|
+
const url = path.startsWith('http') ? path : `${baseUrl}${path}`;
|
|
96
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
97
|
+
// eslint-disable-next-line no-await-in-loop
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
headers: {
|
|
100
|
+
Accept: 'application/json',
|
|
101
|
+
Authorization: `Api-Key ${apiKey}`,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (res.status === 429) {
|
|
105
|
+
if (attempt === MAX_RETRIES) {
|
|
106
|
+
// eslint-disable-next-line no-await-in-loop
|
|
107
|
+
const body = await res.text().catch(() => '(no body)');
|
|
108
|
+
throw new FlagsmithApiError(429, res.statusText, url, body);
|
|
109
|
+
}
|
|
110
|
+
// eslint-disable-next-line no-await-in-loop
|
|
111
|
+
await sleepImpl(backoffMsFromHeaders(res.headers, attempt) || DEFAULT_BACKOFF_MS);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
// eslint-disable-next-line no-await-in-loop
|
|
116
|
+
const body = await res.text().catch(() => '(no body)');
|
|
117
|
+
throw new FlagsmithApiError(res.status, res.statusText, url, body);
|
|
118
|
+
}
|
|
119
|
+
return res.json();
|
|
120
|
+
}
|
|
121
|
+
// Unreachable — the loop either returns or throws.
|
|
122
|
+
throw new FlagsmithApiError(429, 'Too Many Requests', url, 'retries exhausted');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Walks a Django-REST paginated collection. The first page is fetched at
|
|
126
|
+
* `firstPath`; subsequent pages follow the absolute `next` URL until null.
|
|
127
|
+
* Flagsmith's `next` is a full `https://api.flagsmith.com/...` URL; `apiFetch`
|
|
128
|
+
* treats absolute URLs as-is, so this works under the configurable base URL
|
|
129
|
+
* for tests (we strip the production host off so the test base URL applies).
|
|
130
|
+
*/
|
|
131
|
+
async function paginate(firstPath, apiKey) {
|
|
132
|
+
const collected = [];
|
|
133
|
+
let nextPath = firstPath;
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
while (nextPath && !seen.has(nextPath)) {
|
|
136
|
+
seen.add(nextPath);
|
|
137
|
+
// eslint-disable-next-line no-await-in-loop
|
|
138
|
+
const page = (await apiFetch(nextPath, apiKey));
|
|
139
|
+
if (Array.isArray(page.results))
|
|
140
|
+
collected.push(...page.results);
|
|
141
|
+
nextPath = page.next ? rewriteNextUrl(page.next) : undefined;
|
|
142
|
+
}
|
|
143
|
+
return collected;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Flagsmith's `next` field is the full production URL. For tests we point at
|
|
147
|
+
* `https://flagsmith.test/api/v1` via setFlagsmithBaseUrl; strip the prod
|
|
148
|
+
* host so the next-page fetch goes through the test server too.
|
|
149
|
+
*/
|
|
150
|
+
function rewriteNextUrl(next) {
|
|
151
|
+
if (baseUrl === PROD_BASE_URL)
|
|
152
|
+
return next;
|
|
153
|
+
if (next.startsWith(PROD_BASE_URL))
|
|
154
|
+
return baseUrl + next.slice(PROD_BASE_URL.length);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
/** `GET /projects/{id}/` — used as the cheap auth probe AND as a source of `use_edge_identities`. */
|
|
158
|
+
export async function fetchProject(apiKey, projectId) {
|
|
159
|
+
return apiFetch(`/projects/${encodeURIComponent(String(projectId))}/`, apiKey);
|
|
160
|
+
}
|
|
161
|
+
/** `GET /environments/?project={id}` — paginated env list. */
|
|
162
|
+
export async function fetchEnvironments(apiKey, projectId) {
|
|
163
|
+
const qs = new URLSearchParams({ page_size: String(PAGE_LIMIT), project: String(projectId) });
|
|
164
|
+
return paginate(`/environments/?${qs}`, apiKey);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* `GET /projects/{pk}/features/` — paginated feature list. Each result
|
|
168
|
+
* includes inline `multivariate_options[]` which we sort ASC by id (API6) so
|
|
169
|
+
* downstream code sees variations in definition order, not the reverse order
|
|
170
|
+
* the API returns.
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchFeatures(apiKey, projectId) {
|
|
173
|
+
const qs = new URLSearchParams({ page_size: String(PAGE_LIMIT) });
|
|
174
|
+
const features = await paginate(`/projects/${encodeURIComponent(String(projectId))}/features/?${qs}`, apiKey);
|
|
175
|
+
for (const feature of features) {
|
|
176
|
+
if (Array.isArray(feature.multivariate_options) && feature.multivariate_options.length > 1) {
|
|
177
|
+
feature.multivariate_options = [...feature.multivariate_options].sort((a, b) => a.id - b.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return features;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `GET /features/featurestates/?environment={env_id}` — per-env featurestates.
|
|
184
|
+
*
|
|
185
|
+
* This is the unified listing that returns BOTH env-default rows
|
|
186
|
+
* (`feature_segment == null && identity == null`) AND segment-override rows
|
|
187
|
+
* (`feature_segment != null`). The plan §4.1 suggests
|
|
188
|
+
* `/environments/{api_key}/featurestates/`, but empirically on v2-versioned
|
|
189
|
+
* envs that endpoint silently omits segment-override rows. The
|
|
190
|
+
* `/features/featurestates/?environment=` path is the only read endpoint that
|
|
191
|
+
* returns both on a v2 env, so we use it uniformly across v1 and v2 envs.
|
|
192
|
+
*
|
|
193
|
+
* Identity overrides are NOT in this listing on edge-enabled projects — see
|
|
194
|
+
* `fetchEdgeIdentityOverrides`.
|
|
195
|
+
*/
|
|
196
|
+
export async function fetchEnvFeatureStates(apiKey, envId) {
|
|
197
|
+
const qs = new URLSearchParams({ environment: String(envId), page_size: String(PAGE_LIMIT) });
|
|
198
|
+
return paginate(`/features/featurestates/?${qs}`, apiKey);
|
|
199
|
+
}
|
|
200
|
+
/** `GET /projects/{pk}/segments/` — project-scoped segment pool. */
|
|
201
|
+
export async function fetchSegments(apiKey, projectId) {
|
|
202
|
+
const qs = new URLSearchParams({ page_size: String(PAGE_LIMIT) });
|
|
203
|
+
return paginate(`/projects/${encodeURIComponent(String(projectId))}/segments/?${qs}`, apiKey);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `GET /features/feature-segments/?environment={env_id}&feature={feature_id}` —
|
|
207
|
+
* the per-env, per-feature segment priority list. Required by the converter
|
|
208
|
+
* to order the segment-override rules (Quonfig is a strict first-match list).
|
|
209
|
+
*
|
|
210
|
+
* The endpoint REJECTS calls without `feature` (`{feature: ["This field is
|
|
211
|
+
* required."]}`), so we have to walk every feature × env combo. This is N×E
|
|
212
|
+
* calls in the worst case — acceptable for the corpus (~110 features × 2 envs
|
|
213
|
+
* = ~220 calls) and roughly the same order as fetchEnvFeatureStates.
|
|
214
|
+
*/
|
|
215
|
+
export async function fetchFeatureSegments(apiKey, envId, featureId) {
|
|
216
|
+
const qs = new URLSearchParams({
|
|
217
|
+
environment: String(envId),
|
|
218
|
+
feature: String(featureId),
|
|
219
|
+
page_size: String(PAGE_LIMIT),
|
|
220
|
+
});
|
|
221
|
+
return paginate(`/features/feature-segments/?${qs}`, apiKey);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* `GET /environments/{api_key}/edge-identities/` — paginated edge-identity
|
|
225
|
+
* listing. Pagination here is CURSOR-based (`last_evaluated_key`) not page
|
|
226
|
+
* numbers — the Flagsmith dynamo-backed edge store can't offset-paginate.
|
|
227
|
+
*
|
|
228
|
+
* The fetcher uses this only to surface identifiers for the report; the
|
|
229
|
+
* actual identity-override featurestates come from
|
|
230
|
+
* `fetchEdgeIdentityOverrides`, which is a one-call-per-env shortcut.
|
|
231
|
+
*/
|
|
232
|
+
export async function fetchEdgeIdentities(apiKey, envApiKey) {
|
|
233
|
+
const collected = [];
|
|
234
|
+
let cursor = null;
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
for (;;) {
|
|
237
|
+
const qs = new URLSearchParams({ page_size: String(PAGE_LIMIT) });
|
|
238
|
+
if (cursor !== null)
|
|
239
|
+
qs.set('last_evaluated_key', cursor);
|
|
240
|
+
// eslint-disable-next-line no-await-in-loop
|
|
241
|
+
const page = (await apiFetch(`/environments/${encodeURIComponent(envApiKey)}/edge-identities/?${qs}`, apiKey));
|
|
242
|
+
if (Array.isArray(page.results))
|
|
243
|
+
collected.push(...page.results);
|
|
244
|
+
if (!page.last_evaluated_key || seen.has(page.last_evaluated_key))
|
|
245
|
+
break;
|
|
246
|
+
seen.add(page.last_evaluated_key);
|
|
247
|
+
cursor = page.last_evaluated_key;
|
|
248
|
+
}
|
|
249
|
+
return collected;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* `GET /environments/{api_key}/edge-identity-overrides` — every identity
|
|
253
|
+
* override in the env, flat. Each row is `{identifier, identity_uuid,
|
|
254
|
+
* feature_state}` so we can index by feature without walking N identities.
|
|
255
|
+
*
|
|
256
|
+
* Note: the path has NO trailing slash (unlike most Flagsmith paths). This
|
|
257
|
+
* endpoint does not paginate in the verified shape — it returns
|
|
258
|
+
* `{results: [...]}` directly.
|
|
259
|
+
*/
|
|
260
|
+
export async function fetchEdgeIdentityOverrides(apiKey, envApiKey) {
|
|
261
|
+
const data = (await apiFetch(`/environments/${encodeURIComponent(envApiKey)}/edge-identity-overrides`, apiKey));
|
|
262
|
+
return Array.isArray(data.results) ? data.results : [];
|
|
263
|
+
}
|
|
264
|
+
/** `GET /projects/{pk}/tags/` — project-scoped tag pool. */
|
|
265
|
+
export async function fetchTags(apiKey, projectId) {
|
|
266
|
+
const qs = new URLSearchParams({ page_size: String(PAGE_LIMIT) });
|
|
267
|
+
return paginate(`/projects/${encodeURIComponent(String(projectId))}/tags/?${qs}`, apiKey);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Stitch the whole Phase-1 snapshot: project → environments → features →
|
|
271
|
+
* per-env featurestates (split into env-default + segment-overrides) → segments
|
|
272
|
+
* → feature-segments (per env, per feature with segment overrides) → edge
|
|
273
|
+
* identity overrides → tags.
|
|
274
|
+
*
|
|
275
|
+
* Walk count grows as 1 (project) + 1 (envs) + 1 (segments) + 1 (tags) + E ×
|
|
276
|
+
* (1 featurestates + 1 edge-id-overrides + S features-with-segov ×
|
|
277
|
+
* feature-segments). For the 110-feature, 2-env, 5 features-with-segov live
|
|
278
|
+
* corpus: ~1 + 1 + 1 + 1 + 2 × (1 + 1 + 5) = ~17 calls. The featurestates
|
|
279
|
+
* endpoint is paginated under 100/page so larger projects multiply that
|
|
280
|
+
* featurestates leg.
|
|
281
|
+
*
|
|
282
|
+
* `sinceEpochMs` is currently unused — decision D2 says Phase 1 is always a
|
|
283
|
+
* full re-snapshot. It survives in the signature so reporting (Epic 5) can
|
|
284
|
+
* compute "what's new since last run" against the same snapshot.
|
|
285
|
+
*/
|
|
286
|
+
export async function fetchSnapshot(apiKey, projectId) {
|
|
287
|
+
const project = await fetchProject(apiKey, projectId);
|
|
288
|
+
const environments = await fetchEnvironments(apiKey, projectId);
|
|
289
|
+
const features = await fetchFeatures(apiKey, projectId);
|
|
290
|
+
const segments = await fetchSegments(apiKey, projectId);
|
|
291
|
+
const tags = await fetchTags(apiKey, projectId);
|
|
292
|
+
// Index featurestates by env_api_key, then by feature_id. We split each env's
|
|
293
|
+
// featurestate listing into env-default rows (feature_segment == null) and
|
|
294
|
+
// segment-override rows (feature_segment != null) up-front so the per-feature
|
|
295
|
+
// bundle assembly below is a lookup, not a scan.
|
|
296
|
+
const fsByEnvByFeature = new Map();
|
|
297
|
+
for (const env of environments) {
|
|
298
|
+
// eslint-disable-next-line no-await-in-loop
|
|
299
|
+
const featureStates = await fetchEnvFeatureStates(apiKey, env.id);
|
|
300
|
+
const perFeature = new Map();
|
|
301
|
+
for (const fs of featureStates) {
|
|
302
|
+
const bucket = perFeature.get(fs.feature) ?? { default: null, segment_overrides: [] };
|
|
303
|
+
if (fs.feature_segment === null) {
|
|
304
|
+
bucket.default = fs;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
bucket.segment_overrides.push(fs);
|
|
308
|
+
}
|
|
309
|
+
perFeature.set(fs.feature, bucket);
|
|
310
|
+
}
|
|
311
|
+
fsByEnvByFeature.set(env.api_key, perFeature);
|
|
312
|
+
}
|
|
313
|
+
// Edge-identity overrides indexed by env_api_key → feature_id.
|
|
314
|
+
const idovByEnvByFeature = new Map();
|
|
315
|
+
if (project.use_edge_identities) {
|
|
316
|
+
for (const env of environments) {
|
|
317
|
+
// eslint-disable-next-line no-await-in-loop
|
|
318
|
+
const overrides = await fetchEdgeIdentityOverrides(apiKey, env.api_key);
|
|
319
|
+
const byFeature = new Map();
|
|
320
|
+
for (const ov of overrides) {
|
|
321
|
+
const list = byFeature.get(ov.feature_state.feature) ?? [];
|
|
322
|
+
list.push(ov);
|
|
323
|
+
byFeature.set(ov.feature_state.feature, list);
|
|
324
|
+
}
|
|
325
|
+
idovByEnvByFeature.set(env.api_key, byFeature);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// feature-segments listing (per env, per feature) for priority. Only fetched
|
|
329
|
+
// for features that actually have segment-override featurestates in this env
|
|
330
|
+
// — saves N×E calls on the common case where most features have no segovs.
|
|
331
|
+
const fsegByEnvByFeature = new Map();
|
|
332
|
+
for (const env of environments) {
|
|
333
|
+
const perFeature = fsByEnvByFeature.get(env.api_key) ?? new Map();
|
|
334
|
+
const featuresWithSegov = [];
|
|
335
|
+
for (const [featureId, bucket] of perFeature) {
|
|
336
|
+
if (bucket.segment_overrides.length > 0)
|
|
337
|
+
featuresWithSegov.push(featureId);
|
|
338
|
+
}
|
|
339
|
+
const byFeature = new Map();
|
|
340
|
+
for (const featureId of featuresWithSegov) {
|
|
341
|
+
// eslint-disable-next-line no-await-in-loop
|
|
342
|
+
const rows = await fetchFeatureSegments(apiKey, env.id, featureId);
|
|
343
|
+
byFeature.set(featureId, rows);
|
|
344
|
+
}
|
|
345
|
+
fsegByEnvByFeature.set(env.api_key, byFeature);
|
|
346
|
+
}
|
|
347
|
+
// Stitch the per-feature bundles together.
|
|
348
|
+
const stitched = features.map((feature) => stitchFeatureBundle(feature, environments, fsByEnvByFeature, idovByEnvByFeature, fsegByEnvByFeature));
|
|
349
|
+
return { environments, features: stitched, project, segments, tags };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* For one feature, assemble its per-env bundle. Sorts segment-override
|
|
353
|
+
* featurestates by their `feature_segment` priority (ASC) using the
|
|
354
|
+
* feature-segments lookup, so the converter sees them in evaluation order.
|
|
355
|
+
*/
|
|
356
|
+
function stitchFeatureBundle(feature, environments, fsByEnvByFeature, idovByEnvByFeature, fsegByEnvByFeature) {
|
|
357
|
+
const featurestates_by_env = {};
|
|
358
|
+
const feature_segments_by_env = {};
|
|
359
|
+
for (const env of environments) {
|
|
360
|
+
const perFeature = fsByEnvByFeature.get(env.api_key) ?? new Map();
|
|
361
|
+
const bucket = perFeature.get(feature.id) ?? { default: null, segment_overrides: [] };
|
|
362
|
+
const idov = idovByEnvByFeature.get(env.api_key)?.get(feature.id) ?? [];
|
|
363
|
+
const fseg = fsegByEnvByFeature.get(env.api_key)?.get(feature.id) ?? [];
|
|
364
|
+
// Sort segment-override featurestates by priority ASC, using the
|
|
365
|
+
// feature_segments table. Each fs.feature_segment is either a number ID or
|
|
366
|
+
// an inline {id, priority, ...} object — handle both.
|
|
367
|
+
const priorityById = new Map();
|
|
368
|
+
for (const fs of fseg)
|
|
369
|
+
priorityById.set(fs.id, fs.priority);
|
|
370
|
+
const sortedSegov = [...bucket.segment_overrides].sort((a, b) => {
|
|
371
|
+
const pa = priorityFor(a, priorityById);
|
|
372
|
+
const pb = priorityFor(b, priorityById);
|
|
373
|
+
return pa - pb;
|
|
374
|
+
});
|
|
375
|
+
featurestates_by_env[env.api_key] = {
|
|
376
|
+
default: bucket.default,
|
|
377
|
+
identity_overrides: idov,
|
|
378
|
+
segment_overrides: sortedSegov,
|
|
379
|
+
};
|
|
380
|
+
feature_segments_by_env[env.api_key] = fseg;
|
|
381
|
+
}
|
|
382
|
+
return { feature, feature_segments_by_env, featurestates_by_env };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolve a segment-override featurestate's priority. `feature_segment` on the
|
|
386
|
+
* row comes as either a numeric ID (legacy endpoint) or an inline `{id,
|
|
387
|
+
* priority, ...}` object (the `/features/featurestates/` endpoint we use).
|
|
388
|
+
* Fall back to a huge number so an unresolved row sorts last instead of NaN.
|
|
389
|
+
*/
|
|
390
|
+
function priorityFor(fs, priorityById) {
|
|
391
|
+
const fsRef = fs.feature_segment;
|
|
392
|
+
if (fsRef === null || fsRef === undefined)
|
|
393
|
+
return Number.MAX_SAFE_INTEGER;
|
|
394
|
+
if (typeof fsRef === 'object' && typeof fsRef.priority === 'number')
|
|
395
|
+
return fsRef.priority;
|
|
396
|
+
if (typeof fsRef === 'number') {
|
|
397
|
+
const p = priorityById.get(fsRef);
|
|
398
|
+
if (typeof p === 'number')
|
|
399
|
+
return p;
|
|
400
|
+
}
|
|
401
|
+
return Number.MAX_SAFE_INTEGER;
|
|
402
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 type { ConversionReport } from '../../quonfig-target/report.js';
|
|
19
|
+
import type { QuonfigCriterion } from '../../quonfig-target/ruleset.js';
|
|
20
|
+
import type { FlagsmithFeatureStateValueEnvelope, FlagsmithFeatureWithStates, FlagsmithSegment, FlagsmithSegmentCondition, FlagsmithTag } from './types.js';
|
|
21
|
+
/**
|
|
22
|
+
* Operator-mapping result. A clean criterion translation OR a whole-rule drop
|
|
23
|
+
* with a precise reason. Returned per condition; the rule is dropped if any
|
|
24
|
+
* condition drops.
|
|
25
|
+
*/
|
|
26
|
+
type ConditionMapping = {
|
|
27
|
+
criterion: QuonfigCriterion;
|
|
28
|
+
} | {
|
|
29
|
+
drop: true;
|
|
30
|
+
reason: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Flagsmith feature names land directly under `feature-flags/` — Flagsmith
|
|
34
|
+
* enforces a lower-case-hyphenated naming policy on most projects, so the
|
|
35
|
+
* key is already filesystem-safe. We still defensively replace any `/` so
|
|
36
|
+
* a name can never spawn a nested directory.
|
|
37
|
+
*/
|
|
38
|
+
export declare function flagOutputPath(name: string): string;
|
|
39
|
+
export declare function segmentOutputPath(name: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Unwrap the `feature_state_value` field as it appears on the
|
|
42
|
+
* `/features/featurestates/` endpoint (envelope shape on env-default and
|
|
43
|
+
* segment-override rows; scalar on `/edge-identity-overrides` per API5).
|
|
44
|
+
* Returns the raw JS value Flagsmith would have served before any Quonfig
|
|
45
|
+
* type coercion.
|
|
46
|
+
*/
|
|
47
|
+
export declare function coerceEnvelopeOrScalar(v: FlagsmithFeatureStateValueEnvelope | boolean | null | number | string | undefined): boolean | null | number | string;
|
|
48
|
+
/**
|
|
49
|
+
* Map one Flagsmith condition to a Quonfig criterion, or signal a whole-rule
|
|
50
|
+
* drop with a reason. Implements plan §5.3 verbatim.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mapCondition(condition: FlagsmithSegmentCondition): ConditionMapping;
|
|
53
|
+
/**
|
|
54
|
+
* Translate one Flagsmith feature (with all its per-env state) into a Quonfig
|
|
55
|
+
* feature-flag config object.
|
|
56
|
+
*/
|
|
57
|
+
export declare function translateFeature(bundle: FlagsmithFeatureWithStates, report: ConversionReport, options?: {
|
|
58
|
+
/** Map from env api_key → user-facing env name. The converter slugifies the name for the Quonfig env id. */
|
|
59
|
+
envNameByApiKey?: Map<string, string>;
|
|
60
|
+
segmentNameById?: Map<number, string>;
|
|
61
|
+
tags?: FlagsmithTag[];
|
|
62
|
+
}): Record<string, unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* Translate one Flagsmith segment into a Quonfig segment config object.
|
|
65
|
+
*
|
|
66
|
+
* Quonfig segments are boolean ("is the context in the segment?") and have no
|
|
67
|
+
* per-environment state, so all rules collapse into `default.rules`,
|
|
68
|
+
* first-match-wins. A rule whose conditions all map yields a true-valued rule;
|
|
69
|
+
* a trailing ALWAYS_TRUE serves false (not in segment).
|
|
70
|
+
*/
|
|
71
|
+
export declare function translateSegment(segment: FlagsmithSegment, report: ConversionReport): Record<string, unknown>;
|
|
72
|
+
export {};
|