diff --git a/functions/package.json b/functions/package.json index 511092d99..949114b7f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,7 +12,9 @@ "test": "npm run test:unit && npm run test:firestore", "test:firestore": "firebase -c ../firebase-test.json emulators:exec --only firestore,functions --project=demo-deliberate-lab \"npx jest --runInBand $npm_package_config_firestore_tests\"", "test:unit": "npx jest --testPathIgnorePatterns=$npm_package_config_firestore_tests", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "migrate:variable-configs": "npx tsx src/migrations/migrate-variable-configs.ts", + "migrate:variable-configs:dry-run": "npx tsx src/migrations/migrate-variable-configs.ts --dry-run" }, "engines": { "node": "22" diff --git a/functions/src/migrations/migrate-variable-configs.ts b/functions/src/migrations/migrate-variable-configs.ts new file mode 100644 index 000000000..a21364062 --- /dev/null +++ b/functions/src/migrations/migrate-variable-configs.ts @@ -0,0 +1,333 @@ +/** + * One-time migration script to convert old variable configs (pre-v19) to new format. + * + * This script: + * 1. Queries experiments created on or after Nov 3, 2025 (when the variable feature + * was introduced in commit c6a19676) + * 2. Checks if any have old-format variable configs + * 3. Migrates them to the new format + * 4. Updates the documents in Firestore + * + * Usage: + * cd functions + * npm run migrate:variable-configs:dry-run # Preview changes + * npm run migrate:variable-configs # Apply changes + * + * Or directly: + * npx tsx src/migrations/migrate-variable-configs.ts [--dry-run] + * + * Options: + * --dry-run Preview changes without writing to database + */ + +import * as admin from 'firebase-admin'; +import {type TSchema} from '@sinclair/typebox'; +import { + Experiment, + RandomPermutationVariableConfig, + VariableConfig, + VariableConfigType, + VariableScope, + VariableType, + EXPERIMENT_VERSION_ID, + generateId, + SeedStrategy, + createShuffleConfig, +} from '@deliberation-lab/utils'; + +// Initialize Firebase Admin (uses GOOGLE_APPLICATION_CREDENTIALS or emulator) +if (!admin.apps.length) { + admin.initializeApp({ + projectId: process.env.GCLOUD_PROJECT || 'deliberate-lab', + }); +} + +const db = admin.firestore(); + +// ************************************************************************* // +// DATE FILTER FOR VARIABLE FEATURE // +// ************************************************************************* // + +// The RandomPermutationVariableConfig feature was introduced on Nov 3, 2025 +// (commit c6a19676). Only experiments created on or after this date could +// have variable configs that need migration. +const VARIABLE_FEATURE_START_DATE = new Date('2025-11-03T00:00:00Z'); + +// ************************************************************************* // +// OLD FORMAT TYPES // +// ************************************************************************* // + +interface OldRandomPermutationVariableConfig { + id: string; + type: VariableConfigType.RANDOM_PERMUTATION; + seedStrategy: SeedStrategy; + variableNames: string[]; + schema: TSchema; + values: string[]; +} + +type LegacyOrNewConfig = VariableConfig | OldRandomPermutationVariableConfig; + +// ************************************************************************* // +// MIGRATION LOGIC // +// ************************************************************************* // + +function isOldFormatConfig( + config: LegacyOrNewConfig, +): config is OldRandomPermutationVariableConfig { + return ( + 'variableNames' in config && 'schema' in config && !('definition' in config) + ); +} + +function mapSeedStrategyToScope(seedStrategy: SeedStrategy): VariableScope { + switch (seedStrategy) { + case SeedStrategy.EXPERIMENT: + return VariableScope.EXPERIMENT; + case SeedStrategy.COHORT: + return VariableScope.COHORT; + case SeedStrategy.PARTICIPANT: + case SeedStrategy.CUSTOM: + default: + return VariableScope.PARTICIPANT; + } +} + +function migrateVariableConfig( + config: LegacyOrNewConfig, +): VariableConfig | null { + if (!isOldFormatConfig(config)) { + return config; + } + + if (config.type === VariableConfigType.RANDOM_PERMUTATION) { + const oldConfig = config; + const scope = mapSeedStrategyToScope(oldConfig.seedStrategy); + + const firstName = oldConfig.variableNames[0] || 'variable'; + const baseName = firstName.replace(/_\d+$/, ''); + + return { + id: oldConfig.id || generateId(), + type: VariableConfigType.RANDOM_PERMUTATION, + scope, + definition: { + name: baseName, + description: '', + schema: VariableType.array(oldConfig.schema), + }, + shuffleConfig: createShuffleConfig({ + shuffle: true, + seed: oldConfig.seedStrategy, + }), + values: oldConfig.values, + numToSelect: oldConfig.variableNames.length, + expandListToSeparateVariables: oldConfig.variableNames.length > 1, + }; + } + + console.warn(`Unknown old config type, skipping:`, config); + return null; +} + +function migrateVariableConfigs( + configs: LegacyOrNewConfig[], +): VariableConfig[] { + const migrated: VariableConfig[] = []; + + for (const config of configs) { + const result = migrateVariableConfig(config); + if (result !== null) { + migrated.push(result); + } + } + + return migrated; +} + +// ************************************************************************* // +// MAIN MIGRATION SCRIPT // +// ************************************************************************* // + +interface MigrationResult { + experimentId: string; + experimentName: string; + versionId: number; + dateCreated: string; + hadOldConfigs: boolean; + configCount: number; + migratedCount: number; + error?: string; +} + +async function migrateExperiments(dryRun: boolean): Promise { + console.log(`\n${'='.repeat(60)}`); + console.log(`Variable Config Migration Script`); + console.log( + `Mode: ${dryRun ? 'DRY RUN (no changes will be written)' : 'LIVE'}`, + ); + console.log(`${'='.repeat(60)}\n`); + + // Convert start date to Firestore Timestamp + const startTimestamp = admin.firestore.Timestamp.fromDate( + VARIABLE_FEATURE_START_DATE, + ); + + console.log( + `Filtering experiments created on or after: ${VARIABLE_FEATURE_START_DATE.toISOString()}\n`, + ); + + const results: MigrationResult[] = []; + + // Query experiments created on or after the variable feature was introduced + const experimentsSnapshot = await db + .collection('experiments') + .where('metadata.dateCreated', '>=', startTimestamp) + .get(); + console.log(`Found ${experimentsSnapshot.size} experiments to check.\n`); + + for (const doc of experimentsSnapshot.docs) { + const experiment = doc.data() as Experiment; + const experimentId = doc.id; + const experimentName = experiment.metadata?.name || 'Unnamed'; + const dateCreated = experiment.metadata?.dateCreated + ? new Date(experiment.metadata.dateCreated.seconds * 1000) + .toISOString() + .split('T')[0] + : 'unknown'; + + const result: MigrationResult = { + experimentId, + experimentName, + versionId: experiment.versionId || 0, + dateCreated, + hadOldConfigs: false, + configCount: 0, + migratedCount: 0, + }; + + try { + const variableConfigs = experiment.variableConfigs || []; + result.configCount = variableConfigs.length; + + if (variableConfigs.length === 0) { + results.push(result); + continue; + } + + // Check if any configs are in old format + const hasOldConfigs = variableConfigs.some(isOldFormatConfig); + + if (!hasOldConfigs) { + results.push(result); + continue; + } + + result.hadOldConfigs = true; + + // Migrate the configs + const migratedConfigs = migrateVariableConfigs(variableConfigs); + result.migratedCount = migratedConfigs.length; + + console.log( + `\n[${experimentId}] "${experimentName}" (v${experiment.versionId})`, + ); + const oldConfigCount = variableConfigs.filter(isOldFormatConfig).length; + console.log( + ` - Found ${oldConfigCount} old-format variable config(s) out of ${variableConfigs.length} total`, + ); + + // Show what will be migrated (only old configs) + for (const config of variableConfigs) { + if (isOldFormatConfig(config)) { + const migratedConfig = migrateVariableConfig(config); + console.log(` - Config "${config.variableNames?.join(', ')}":`); + console.log( + ` Old: variableNames=[${config.variableNames?.join(', ')}], seedStrategy=${config.seedStrategy}`, + ); + if ( + migratedConfig && + migratedConfig.type === VariableConfigType.RANDOM_PERMUTATION + ) { + const migrated = migratedConfig as RandomPermutationVariableConfig; + console.log( + ` New: definition.name="${migrated.definition?.name}", scope=${migrated.scope}, expandListToSeparateVariables=${migrated.expandListToSeparateVariables}`, + ); + } else { + console.log(` New: FAILED TO MIGRATE`); + } + } + } + + if (!dryRun) { + // Update the experiment document + await doc.ref.update({ + variableConfigs: migratedConfigs, + versionId: EXPERIMENT_VERSION_ID, + }); + console.log(` - Updated experiment in Firestore`); + } else { + console.log(` - [DRY RUN] Would update experiment in Firestore`); + } + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + console.error(` - Error: ${result.error}`); + } + + results.push(result); + } + + return results; +} + +function printSummary(results: MigrationResult[], dryRun: boolean) { + console.log(`\n${'='.repeat(60)}`); + console.log('MIGRATION SUMMARY'); + console.log(`${'='.repeat(60)}\n`); + + const total = results.length; + const withOldConfigs = results.filter((r) => r.hadOldConfigs).length; + const errors = results.filter((r) => r.error).length; + + console.log(`Total experiments checked: ${total}`); + console.log(`Experiments with old configs: ${withOldConfigs}`); + console.log(`Errors: ${errors}`); + + if (withOldConfigs > 0) { + console.log(`\nExperiments that ${dryRun ? 'would be' : 'were'} migrated:`); + for (const result of results.filter((r) => r.hadOldConfigs)) { + const status = result.error + ? `ERROR: ${result.error}` + : dryRun + ? 'would migrate' + : 'migrated'; + console.log( + ` - [${result.experimentId}] "${result.experimentName}" (v${result.versionId}, created ${result.dateCreated}) - ${status}`, + ); + } + } + + if (dryRun && withOldConfigs > 0) { + console.log(`\nTo apply these changes, run without --dry-run flag.`); + } +} + +// ************************************************************************* // +// ENTRY POINT // +// ************************************************************************* // + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + + try { + const results = await migrateExperiments(dryRun); + printSummary(results, dryRun); + process.exit(0); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +main(); diff --git a/utils/src/index.ts b/utils/src/index.ts index 54d228ecc..3873eac7e 100644 --- a/utils/src/index.ts +++ b/utils/src/index.ts @@ -32,6 +32,7 @@ export * from './cohort.validation'; // Variable export * from './variables'; export * from './variables.utils'; +export * from './variables.legacy.utils'; export * from './variables.schema.utils'; export * from './variables.template'; export * from './variables.validation'; diff --git a/utils/src/variables.legacy.utils.test.ts b/utils/src/variables.legacy.utils.test.ts new file mode 100644 index 000000000..9937edcd3 --- /dev/null +++ b/utils/src/variables.legacy.utils.test.ts @@ -0,0 +1,342 @@ +import {Type} from '@sinclair/typebox'; +import {SeedStrategy} from './utils/random.utils'; +import { + migrateVariableConfig, + migrateVariableConfigs, + isOldFormatConfig, + OldRandomPermutationVariableConfig, +} from './variables.legacy.utils'; +import { + createRandomPermutationVariableConfig, + createStaticVariableConfig, + extractVariablesFromVariableConfigs, +} from './variables.utils'; +import { + RandomPermutationVariableConfig, + VariableConfig, + VariableConfigType, + VariableScope, +} from './variables'; + +describe('isOldFormatConfig', () => { + it('should return true for old-format configs', () => { + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-config', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['charity'], + schema: Type.String(), + values: [], + }; + + expect(isOldFormatConfig(oldConfig)).toBe(true); + }); + + it('should return false for new-format configs', () => { + const newConfig = createRandomPermutationVariableConfig({ + scope: VariableScope.COHORT, + definition: { + name: 'charity', + description: '', + schema: Type.Array(Type.String()), + }, + values: [], + }); + + expect(isOldFormatConfig(newConfig)).toBe(false); + }); + + it('should return false for static configs', () => { + const staticConfig = createStaticVariableConfig({ + scope: VariableScope.EXPERIMENT, + definition: { + name: 'static_var', + description: '', + schema: Type.String(), + }, + value: JSON.stringify('test'), + }); + + expect(isOldFormatConfig(staticConfig)).toBe(false); + }); +}); + +describe('migrateVariableConfig', () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + afterEach(() => { + consoleInfoSpy.mockClear(); + consoleWarnSpy.mockClear(); + }); + + it('should pass through new-format configs unchanged', () => { + const newConfig = createRandomPermutationVariableConfig({ + scope: VariableScope.COHORT, + definition: { + name: 'charity', + description: 'A charity', + schema: Type.Array(Type.String()), + }, + values: [JSON.stringify('Charity A')], + }); + + const result = migrateVariableConfig(newConfig); + + expect(result).toEqual(newConfig); + expect(consoleInfoSpy).not.toHaveBeenCalled(); + }); + + it('should migrate old-format RandomPermutation config with single variableName', () => { + // Old format config structure (pre-v19) + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-config-id', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['charity'], + schema: Type.String(), + values: [JSON.stringify('Charity A'), JSON.stringify('Charity B')], + }; + + const result = migrateVariableConfig(oldConfig); + const migrated = result as RandomPermutationVariableConfig; + + expect(result).not.toBeNull(); + expect(migrated.type).toBe(VariableConfigType.RANDOM_PERMUTATION); + expect(migrated.id).toBe('old-config-id'); + expect(migrated.scope).toBe(VariableScope.COHORT); + expect(migrated.definition.name).toBe('charity'); + expect(migrated.shuffleConfig.seed).toBe(SeedStrategy.COHORT); + expect(migrated.numToSelect).toBe(1); + expect(migrated.expandListToSeparateVariables).toBe(false); + expect(consoleInfoSpy).toHaveBeenCalled(); + }); + + it('should migrate old-format config with multiple variableNames (indexed pattern)', () => { + // Old format with charity_1, charity_2 pattern + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-indexed-config', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.PARTICIPANT, + variableNames: ['charity_1', 'charity_2', 'charity_3'], + schema: Type.String(), + values: [ + JSON.stringify('Charity A'), + JSON.stringify('Charity B'), + JSON.stringify('Charity C'), + ], + }; + + const result = migrateVariableConfig(oldConfig); + const migrated = result as RandomPermutationVariableConfig; + + expect(result).not.toBeNull(); + expect(migrated.definition.name).toBe('charity'); + expect(migrated.numToSelect).toBe(3); + expect(migrated.expandListToSeparateVariables).toBe(true); + expect(migrated.scope).toBe(VariableScope.PARTICIPANT); + }); + + it('should map SeedStrategy.EXPERIMENT to VariableScope.EXPERIMENT', () => { + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'test', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.EXPERIMENT, + variableNames: ['var'], + schema: Type.String(), + values: [JSON.stringify('value')], + }; + + const result = migrateVariableConfig(oldConfig); + const migrated = result as RandomPermutationVariableConfig; + + expect(migrated.scope).toBe(VariableScope.EXPERIMENT); + }); + + it('should map SeedStrategy.CUSTOM to VariableScope.PARTICIPANT', () => { + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'test', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.CUSTOM, + variableNames: ['var'], + schema: Type.String(), + values: [JSON.stringify('value')], + }; + + const result = migrateVariableConfig(oldConfig); + const migrated = result as RandomPermutationVariableConfig; + + expect(migrated.scope).toBe(VariableScope.PARTICIPANT); + }); + + it('should generate an ID if old config lacks one', () => { + // Use type assertion for config missing id field + const oldConfig = { + id: '', // Empty ID should trigger generation + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['var'], + schema: Type.String(), + values: [], + } as OldRandomPermutationVariableConfig; + + const result = migrateVariableConfig(oldConfig); + + expect(result).not.toBeNull(); + expect(result!.id).toBeDefined(); + expect(result!.id.length).toBeGreaterThan(0); + }); + + it('should handle empty variableNames array', () => { + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'test', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: [], + schema: Type.String(), + values: [JSON.stringify('value')], + }; + + const result = migrateVariableConfig(oldConfig); + const migrated = result as RandomPermutationVariableConfig; + + expect(result).not.toBeNull(); + expect(migrated.definition.name).toBe('variable'); // fallback name + expect(migrated.numToSelect).toBe(0); + }); +}); + +describe('migrateVariableConfigs', () => { + it('should migrate an array of mixed old and new configs', () => { + const newConfig = createStaticVariableConfig({ + scope: VariableScope.EXPERIMENT, + definition: { + name: 'static_var', + description: '', + schema: Type.String(), + }, + value: JSON.stringify('hello'), + }); + + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-config', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['charity'], + schema: Type.String(), + values: [JSON.stringify('Charity A')], + }; + + const result = migrateVariableConfigs([newConfig, oldConfig]); + + expect(result).toHaveLength(2); + // First should be unchanged static config + expect(result[0]).toEqual(newConfig); + // Second should be migrated + const migrated = result[1] as RandomPermutationVariableConfig; + expect(migrated.definition.name).toBe('charity'); + }); + + it('should filter out configs that fail migration', () => { + const validConfig = createStaticVariableConfig({ + scope: VariableScope.EXPERIMENT, + definition: { + name: 'valid', + description: '', + schema: Type.String(), + }, + value: JSON.stringify('test'), + }); + + // An unknown old format that cannot be migrated (missing seedStrategy) + const unknownOldConfig = { + id: 'unknown', + type: 'unknown_type' as VariableConfigType, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['x'], + schema: Type.String(), + values: [], + } as OldRandomPermutationVariableConfig; + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = migrateVariableConfigs([validConfig, unknownOldConfig]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(validConfig); + + consoleWarnSpy.mockRestore(); + }); + + it('should handle empty array', () => { + const result = migrateVariableConfigs([]); + expect(result).toEqual([]); + }); +}); + +describe('extractVariablesFromVariableConfigs with legacy migration', () => { + it('should handle old-format configs transparently', () => { + // Old format config that would crash without migration + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-config', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['charity_1', 'charity_2'], + schema: Type.String(), + values: [JSON.stringify('Charity A'), JSON.stringify('Charity B')], + }; + + // Suppress console.info during test + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + + // This should NOT throw, thanks to migration + // Cast to VariableConfig[] since extractVariablesFromVariableConfigs expects that type + const result = extractVariablesFromVariableConfigs([ + oldConfig as unknown as VariableConfig, + ]); + + // Should create charity_1 and charity_2 definitions + expect(result['charity_1']).toBeDefined(); + expect(result['charity_2']).toBeDefined(); + expect(result['charity_1'].name).toBe('charity_1'); + expect(result['charity_2'].name).toBe('charity_2'); + + consoleInfoSpy.mockRestore(); + }); + + it('should handle mixed old and new configs', () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + + const newConfig = createStaticVariableConfig({ + scope: VariableScope.EXPERIMENT, + definition: { + name: 'new_var', + description: '', + schema: Type.String(), + }, + value: JSON.stringify('test'), + }); + + // Old config with multiple variableNames (will expand to indexed variables) + const oldConfig: OldRandomPermutationVariableConfig = { + id: 'old-config', + type: VariableConfigType.RANDOM_PERMUTATION, + seedStrategy: SeedStrategy.COHORT, + variableNames: ['old_var_1', 'old_var_2'], + schema: Type.String(), + values: [JSON.stringify('value1'), JSON.stringify('value2')], + }; + + const result = extractVariablesFromVariableConfigs([ + newConfig, + oldConfig as unknown as VariableConfig, + ]); + + expect(result['new_var']).toBeDefined(); + // Old config with 2 variableNames expands to old_var_1, old_var_2 + expect(result['old_var_1']).toBeDefined(); + expect(result['old_var_2']).toBeDefined(); + + consoleInfoSpy.mockRestore(); + }); +}); diff --git a/utils/src/variables.legacy.utils.ts b/utils/src/variables.legacy.utils.ts new file mode 100644 index 000000000..6a8f1e59f --- /dev/null +++ b/utils/src/variables.legacy.utils.ts @@ -0,0 +1,159 @@ +/** + * Legacy variable config migration utilities. + * + * This module provides backwards compatibility for old variable config formats + * (pre-v19) that used a different structure. The migration functions convert + * these old formats to the new format transparently. + */ + +import {generateId} from './shared'; +import {SeedStrategy, createShuffleConfig} from './utils/random.utils'; +import {type TSchema} from '@sinclair/typebox'; +import { + RandomPermutationVariableConfig, + VariableConfig, + VariableConfigType, + VariableScope, + VariableType, +} from './variables'; + +// ************************************************************************* // +// OLD FORMAT TYPES (for reference and migration) // +// ************************************************************************* // + +/** + * Old variable config format (pre-v19) for reference. + * These interfaces are used only for migration detection and conversion. + */ +export interface OldRandomPermutationVariableConfig { + id: string; + type: VariableConfigType.RANDOM_PERMUTATION; + seedStrategy: SeedStrategy; + variableNames: string[]; + schema: TSchema; + values: string[]; +} + +// ************************************************************************* // +// MIGRATION UTILITIES // +// ************************************************************************* // + +/** + * Type guard to detect old-format variable configs. + * Old configs have `variableNames` and `schema` at root level, + * and lack the `definition` field. + */ +export function isOldFormatConfig( + config: VariableConfig | OldRandomPermutationVariableConfig, +): config is OldRandomPermutationVariableConfig { + return ( + 'variableNames' in config && 'schema' in config && !('definition' in config) + ); +} + +/** + * Maps old SeedStrategy to new VariableScope. + * The old seedStrategy controlled both randomization seed AND storage scope. + * In the new format, these are separate concerns, but for migration we use + * the seedStrategy to determine both. + */ +function mapSeedStrategyToScope(seedStrategy: SeedStrategy): VariableScope { + switch (seedStrategy) { + case SeedStrategy.EXPERIMENT: + return VariableScope.EXPERIMENT; + case SeedStrategy.COHORT: + return VariableScope.COHORT; + case SeedStrategy.PARTICIPANT: + case SeedStrategy.CUSTOM: + default: + return VariableScope.PARTICIPANT; + } +} + +/** + * Migrate a single old-format variable config to the new format. + * Returns the migrated config, or the original if already in new format. + * + * Old format had: + * - variableNames: string[] (array of variable names) + * - schema: TSchema (at config level) + * - seedStrategy: SeedStrategy + * + * New format has: + * - definition: { name, description, schema } + * - scope: VariableScope + * - shuffleConfig: ShuffleConfig + * - expandListToSeparateVariables: boolean + */ +export function migrateVariableConfig( + config: VariableConfig | OldRandomPermutationVariableConfig, +): VariableConfig | null { + // If already in new format, return as-is + if (!isOldFormatConfig(config)) { + return config; + } + + // Migrate old RandomPermutation format + if (config.type === VariableConfigType.RANDOM_PERMUTATION) { + const oldConfig = config as OldRandomPermutationVariableConfig; + const scope = mapSeedStrategyToScope(oldConfig.seedStrategy); + + // Determine base variable name + // Old format could have multiple names like ["charity_1", "charity_2"] + // We extract the base name by removing trailing _N suffix if present + const firstName = oldConfig.variableNames[0] || 'variable'; + const baseName = firstName.replace(/_\d+$/, ''); + + const migratedConfig: RandomPermutationVariableConfig = { + id: oldConfig.id || generateId(), + type: VariableConfigType.RANDOM_PERMUTATION, + scope, + definition: { + name: baseName, + description: '', + // Wrap in array type since RandomPermutation produces arrays + schema: VariableType.array(oldConfig.schema), + }, + shuffleConfig: createShuffleConfig({ + shuffle: true, + seed: oldConfig.seedStrategy, + }), + values: oldConfig.values, + numToSelect: oldConfig.variableNames.length, + // Enable expansion to create separate variables (charity_1, charity_2, etc.) + expandListToSeparateVariables: oldConfig.variableNames.length > 1, + }; + + console.info( + `Migrated old variable config "${firstName}" to new format with base name "${baseName}"`, + ); + + return migratedConfig; + } + + // Unknown old format - log warning and skip + console.warn( + 'Unknown old variable config format, skipping migration:', + config, + ); + return null; +} + +/** + * Migrate an array of variable configs, filtering out any that fail migration. + * This is the main entry point for migrating experiment variable configs. + */ +export function migrateVariableConfigs( + configs: (VariableConfig | OldRandomPermutationVariableConfig)[], +): VariableConfig[] { + const migrated: VariableConfig[] = []; + + for (const config of configs) { + const result = migrateVariableConfig(config); + if (result !== null) { + migrated.push(result); + } + } + + return migrated; +} diff --git a/utils/src/variables.utils.ts b/utils/src/variables.utils.ts index 57350d33a..773b7e5d4 100644 --- a/utils/src/variables.utils.ts +++ b/utils/src/variables.utils.ts @@ -16,11 +16,14 @@ import { VariableScope, VariableType, } from './variables'; +import {migrateVariableConfigs} from './variables.legacy.utils'; /** * Extract variable definitions from variable configs. * Returns a map of variable name to definition. * + * Automatically migrates old-format configs (pre-v19) to the new format. + * * For RandomPermutation configs with expandListToSeparateVariables: * Creates definitions for indexed variables (name_1, name_2, etc.) * based on the number of values that will be selected. @@ -28,9 +31,11 @@ import { export function extractVariablesFromVariableConfigs( configs: VariableConfig[], ): Record { + // Migrate any old-format configs to new format + const migratedConfigs = migrateVariableConfigs(configs); const variableDefinitions: Record = {}; - for (const config of configs) { + for (const config of migratedConfigs) { switch (config.type) { case VariableConfigType.STATIC: variableDefinitions[config.definition.name] = config.definition;