Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 101 additions & 43 deletions src/scripts/launchdarkly-migrations/migrate_between_ld_instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ interface SyncManifestEnv {
interface SyncManifestFlag {
version: number;
lastModified?: string;
contentHash?: string; // Hash of variations+defaults+env config; LD may not bump version for value-only changes
environments: Record<string, SyncManifestEnv>;
}

Expand Down Expand Up @@ -533,6 +534,19 @@ if (allViewKeys.size > 0) {

// ==================== Incremental Sync ====================

/** Hash of migratable flag content; LD may not bump version for variation value changes */
async function flagContentHash(flag: any, envKeys: string[]): Promise<string> {
const stripIds = (v: any[]) => (v || []).map(({ _id, ...rest }: any) => rest);
const stripRuleIds = (r: any) => ({ ...r, clauses: (r.clauses || []).map(({ _id, ...c }: any) => c) });
const envs: Record<string, unknown> = {};
for (const k of envKeys) {
const e = flag.environments?.[k];
if (e) envs[k] = { offVariation: e.offVariation, fallthrough: e.fallthrough, rules: (e.rules || []).map(stripRuleIds) };
}
const payload = { variations: stripIds(flag.variations || []), defaults: flag.defaults, envs };
return sha256HexUtf8(JSON.stringify(payload));
}

const syncManifestPath = `./data/launchdarkly-migrations/sync-manifest-${inputArgs.projKeySource}-${inputArgs.projKeyDest}.json`;
let previousManifest: SyncManifest | null = null;
const updatedManifestFlags: Record<string, SyncManifestFlag> = {};
Expand Down Expand Up @@ -1308,41 +1322,44 @@ for (const [index, flagkey] of flagList.entries()) {
}
}
if (allEnvsUnchanged) {
// Sync lifecycle (archived/deprecated) even when versions match — LD may not bump version for lifecycle-only changes
const destKey = flag.key;
let lifecycleSynced = false;
try {
const destReq = ldAPIRequest(apiKey, domain, `flags/${inputArgs.projKeyDest}/${destKey}`);
const destResp = await rateLimitRequest(destReq, 'flags');
if (destResp.status === 200) {
const destFlag = await destResp.json();
const changed = (a: any, b: any) => JSON.stringify(a) !== JSON.stringify(b);
const lifecyclePatches: any[] = [];
if (flag.archived !== undefined && changed(flag.archived, destFlag.archived))
lifecyclePatches.push(buildPatch("archived", "replace", flag.archived));
if (flag.deprecated !== undefined && changed(flag.deprecated, destFlag.deprecated))
lifecyclePatches.push(buildPatch("deprecated", "replace", flag.deprecated));
if (flag.deprecatedDate !== undefined && changed(flag.deprecatedDate, destFlag.deprecatedDate))
lifecyclePatches.push(buildPatch("deprecatedDate", "replace", flag.deprecatedDate));
if (lifecyclePatches.length > 0) {
const patchResp = await dryRunAwarePatch(
inputArgs.dryRun || false, apiKey, domain,
`flags/${inputArgs.projKeyDest}/${destKey}`, lifecyclePatches, false, 'flags', 'lifecycle (archived/deprecated)');
if (patchResp.status >= 200 && patchResp.status < 300) {
console.log(Colors.gray(`\t → synced lifecycle (archived/deprecated)`));
lifecycleSynced = true;
const currentHash = await flagContentHash(flag, envkeys);
if (!prevEntry.contentHash || prevEntry.contentHash !== currentHash) allEnvsUnchanged = false;
if (allEnvsUnchanged) {
// Sync lifecycle (archived/deprecated) even when versions match — LD may not bump version for lifecycle-only changes
const destKey = flag.key;
let lifecycleSynced = false;
try {
const destReq = ldAPIRequest(apiKey, domain, `flags/${inputArgs.projKeyDest}/${destKey}`);
const destResp = await rateLimitRequest(destReq, 'flags');
if (destResp.status === 200) {
const destFlag = await destResp.json();
const changed = (a: any, b: any) => JSON.stringify(a) !== JSON.stringify(b);
const lifecyclePatches: any[] = [];
if (flag.archived !== undefined && changed(flag.archived, destFlag.archived))
lifecyclePatches.push(buildPatch("archived", "replace", flag.archived));
if (flag.deprecated !== undefined && changed(flag.deprecated, destFlag.deprecated))
lifecyclePatches.push(buildPatch("deprecated", "replace", flag.deprecated));
if (flag.deprecatedDate !== undefined && changed(flag.deprecatedDate, destFlag.deprecatedDate))
lifecyclePatches.push(buildPatch("deprecatedDate", "replace", flag.deprecatedDate));
if (lifecyclePatches.length > 0) {
const patchResp = await dryRunAwarePatch(
inputArgs.dryRun || false, apiKey, domain,
`flags/${inputArgs.projKeyDest}/${destKey}`, lifecyclePatches, false, 'flags', 'lifecycle (archived/deprecated)');
if (patchResp.status >= 200 && patchResp.status < 300) {
console.log(Colors.gray(`\t → synced lifecycle (archived/deprecated)`));
lifecycleSynced = true;
}
}
}
} catch (_) {
// ignore
}
} catch (_) {
// ignore
const ts = flag.creationDate ? `, updated ${flag.creationDate}` : '';
console.log(Colors.gray(`\t✓ ${flag.key}: unchanged (v${flag._version}${ts})${lifecycleSynced ? ', lifecycle synced' : ''}, skipping`));
updatedManifestFlags[flag.key] = { ...prevEntry, contentHash: currentHash };
incrementalSkipCount++;
continue;
}
const ts = flag.creationDate ? `, updated ${flag.creationDate}` : '';
console.log(Colors.gray(`\t✓ ${flag.key}: unchanged (v${flag._version}${ts})${lifecycleSynced ? ', lifecycle synced' : ''}, skipping`));
// Carry forward the previous manifest entry unchanged
updatedManifestFlags[flag.key] = prevEntry;
incrementalSkipCount++;
continue;
}
}
}
Expand Down Expand Up @@ -1590,27 +1607,67 @@ for (const [index, flagkey] of flagList.entries()) {
if (flag.deprecatedDate !== undefined && changed(flag.deprecatedDate, destinationFlag.deprecatedDate))
flagLevelPatches.push(buildPatch("deprecatedDate", "replace", flag.deprecatedDate));

// Variations and defaults must be patched together to avoid index conflicts
// Strip _id from both sides before comparing
// Variations: avoid "Cannot delete the default on variation" via targeted updates
const stripIds = (v: any[]) => v.map(({ _id, ...rest }: any) => rest);
const destVarsClean = destinationFlag.variations ? stripIds(destinationFlag.variations) : [];
if (newVariations?.length > 0 && changed(newVariations, destVarsClean)) {
flagLevelPatches.push(buildPatch("variations", "replace", newVariations));
if (flag.defaults) flagLevelPatches.push(buildPatch("defaults", "replace", flag.defaults));
} else if (flag.defaults && changed(flag.defaults, destinationFlag.defaults)) {
flagLevelPatches.push(buildPatch("defaults", "replace", flag.defaults));
const variationsChanged = newVariations?.length > 0 && changed(newVariations, destVarsClean);
const destCount = destinationFlag.variations?.length ?? 0;
const newCount = newVariations?.length ?? 0;
const existingMatch = destCount > 0 && newCount >= destCount &&
newVariations.slice(0, destCount).every((v, i) => JSON.stringify(v) === JSON.stringify(destVarsClean[i]));
const useReplace = newCount < destCount || (newCount > destCount && !existingMatch);

let envPrePatchOk = true;
if (variationsChanged && useReplace) {
const safeIdx = 0;
const prePatches: any[] = [buildPatch("defaults", "replace", { onVariation: safeIdx, offVariation: safeIdx })];
for (const [key, env] of Object.entries(destinationFlag.environments || {})) {
const e = env as any;
if (!e) continue;
if (e.offVariation !== safeIdx) prePatches.push(buildPatch(`environments/${key}/offVariation`, "replace", safeIdx));
if (e.fallthrough) prePatches.push(buildPatch(`environments/${key}/fallthrough`, "replace", { variation: safeIdx }));
}
Comment on lines +1623 to +1629
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-patch iterates over all destinationFlag.environments and rewrites offVariation/fallthrough for every destination environment. If the destination project has environments that are not in the selected envkeys (or not mapped via --envMap), those envs will be modified here but never restored later (since the env patch loop only runs for envkeys). Consider restricting pre-patches to only the destination env keys that will be migrated (and/or explicitly restoring the original values after variations are updated).

Copilot uses AI. Check for mistakes.
console.log(Colors.gray(`\tPre-patching defaults + env (${prePatches.length} patch(es)) before variations...`));
const resp = await dryRunAwarePatch(inputArgs.dryRun || false, apiKey, domain,
`flags/${inputArgs.projKeyDest}/${createdFlagKey}`, prePatches, false, 'flags', 'pre-patch');
if (resp.status < 200 || resp.status >= 300) {
console.log(Colors.yellow(`\t⚠ Pre-patch failed (${resp.status}): ${await resp.text()}`));
flagsWithErrors.add(createdFlagKey);
envPrePatchOk = false;
} else console.log(Colors.green(`\t✓ Pre-patch applied`));
}

if (variationsChanged && envPrePatchOk) {
if (newCount > destCount && existingMatch) {
for (let i = destCount; i < newCount; i++) flagLevelPatches.push({ path: "/variations/-", op: "add", value: newVariations[i] });
} else if (newCount === destCount) {
for (let i = 0; i < newCount; i++) {
const src = newVariations[i], dest = destVarsClean[i];
if (!dest) continue;
if (src?.value !== dest?.value) flagLevelPatches.push({ path: `/variations/${i}/value`, op: "replace", value: src?.value });
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src?.value !== dest?.value will treat any object/array variation values as different even when they are deeply equal (because they’re different references after JSON parse), causing unnecessary PATCHes. Use the existing changed() deep comparison (or another deep-equals) for value comparison, similar to how you detect other changes in this function.

Suggested change
if (src?.value !== dest?.value) flagLevelPatches.push({ path: `/variations/${i}/value`, op: "replace", value: src?.value });
if (changed(src?.value, dest?.value)) flagLevelPatches.push({ path: `/variations/${i}/value`, op: "replace", value: src?.value });

Copilot uses AI. Check for mistakes.
if (src?.name !== dest?.name && src?.name !== undefined) flagLevelPatches.push({ path: `/variations/${i}/name`, op: "replace", value: src?.name });
}
Comment on lines +1644 to +1649
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The targeted same-count variation updates only patch value and (conditionally) name. If the source variation omits name but the destination has one, this won’t clear it, leaving the destination out of sync even though variationsChanged was true. Also, other fields present in variations (e.g. description) are never reconciled in this branch. Consider handling name removal (JSON Patch remove) and patching any other supported variation fields (or falling back to a full variations replace when differences are outside the supported targeted fields).

Suggested change
for (let i = 0; i < newCount; i++) {
const src = newVariations[i], dest = destVarsClean[i];
if (!dest) continue;
if (src?.value !== dest?.value) flagLevelPatches.push({ path: `/variations/${i}/value`, op: "replace", value: src?.value });
if (src?.name !== dest?.name && src?.name !== undefined) flagLevelPatches.push({ path: `/variations/${i}/name`, op: "replace", value: src?.name });
}
const supportedVariationFields = new Set(["value", "name", "description"]);
const variationPatches: any[] = [];
let requiresFullVariationReplace = false;
for (let i = 0; i < newCount; i++) {
const src = newVariations[i] || {};
const dest = destVarsClean[i] || {};
const srcKeys = Object.keys(src);
const destKeys = Object.keys(dest);
const allKeys = new Set([...srcKeys, ...destKeys]);
for (const key of allKeys) {
if (supportedVariationFields.has(key)) continue;
if (JSON.stringify(src[key]) !== JSON.stringify(dest[key])) {
requiresFullVariationReplace = true;
break;
}
}
if (requiresFullVariationReplace) break;
for (const key of supportedVariationFields) {
const srcHasKey = Object.prototype.hasOwnProperty.call(src, key);
const destHasKey = Object.prototype.hasOwnProperty.call(dest, key);
if (!srcHasKey && !destHasKey) continue;
const srcValue = src[key];
const destValue = dest[key];
if (JSON.stringify(srcValue) === JSON.stringify(destValue)) continue;
if (!srcHasKey && destHasKey) {
variationPatches.push({ path: `/variations/${i}/${key}`, op: "remove" });
} else if (srcHasKey && !destHasKey) {
variationPatches.push({ path: `/variations/${i}/${key}`, op: "add", value: srcValue });
} else {
variationPatches.push({ path: `/variations/${i}/${key}`, op: "replace", value: srcValue });
}
}
}
if (requiresFullVariationReplace) {
flagLevelPatches.push(buildPatch("variations", "replace", newVariations));
} else {
flagLevelPatches.push(...variationPatches);
}

Copilot uses AI. Check for mistakes.
} else {
flagLevelPatches.push(buildPatch("variations", "replace", newVariations));
}
}
if (!variationsChanged && flag.defaults && changed(flag.defaults, destinationFlag.defaults))
flagLevelPatches.push(buildPatch("defaults", "replace", flag.defaults));

if (flagLevelPatches.length > 0) {
console.log(Colors.gray(`\tUpdating flag-level properties (${flagLevelPatches.length} field(s))...`));
const flagLevelResp = await dryRunAwarePatch(
inputArgs.dryRun || false, apiKey, domain,
const flagLevelResp = await dryRunAwarePatch(inputArgs.dryRun || false, apiKey, domain,
`flags/${inputArgs.projKeyDest}/${createdFlagKey}`, flagLevelPatches, false, 'flags', 'flag-level properties');
if (flagLevelResp.status >= 200 && flagLevelResp.status < 300) {
console.log(Colors.green(`\t✓ Flag-level properties updated`));
if (variationsChanged && flag.defaults) {
const dr = await dryRunAwarePatch(inputArgs.dryRun || false, apiKey, domain,
`flags/${inputArgs.projKeyDest}/${createdFlagKey}`, [buildPatch("defaults", "replace", flag.defaults)], false, 'flags', 'defaults');
if (dr.status >= 200 && dr.status < 300) console.log(Colors.green(`\t✓ Defaults updated`));
else { console.log(Colors.yellow(`\t⚠ Defaults failed (${dr.status})`)); flagsWithErrors.add(createdFlagKey); }
}
} else {
const errText = await flagLevelResp.text();
console.log(Colors.yellow(`\t⚠ Flag-level update failed (${flagLevelResp.status}): ${errText}`));
console.log(Colors.yellow(`\t⚠ Flag-level update failed (${flagLevelResp.status}): ${await flagLevelResp.text()}`));
flagsWithErrors.add(createdFlagKey);
}
}
Expand Down Expand Up @@ -1683,10 +1740,11 @@ for (const [index, flagkey] of flagList.entries()) {
}
}

// Track flag version for sync manifest (flag-level lastModified comes from creationDate or _version)
// Track flag version + content hash for sync manifest
updatedManifestFlags[flag.key] = {
version: flag._version ?? 0,
lastModified: flag.creationDate,
contentHash: await flagContentHash(flag, envkeys),
environments: flagManifestEnvs,
};
}
Expand Down