From b48f84785f670ea1e5c937bc016f9a3e29a49bb5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 26 Jul 2023 12:25:01 -0700 Subject: [PATCH] feat: Add migration configuration and basic migration. (#213) --- .../__tests__/LDClient.migrations.test.ts | 2 +- .../sdk-server/__tests__/Migration.test.ts | 514 ++++++++++++++++++ packages/shared/sdk-server/src/Migration.ts | 247 +++++++++ .../shared/sdk-server/src/api/LDMigration.ts | 87 +++ .../src/api/data/LDMigrationStage.ts | 2 +- .../src/api/options/LDMigrationOptions.ts | 127 +++++ .../sdk-server/src/api/options/index.ts | 1 + 7 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/Migration.test.ts create mode 100644 packages/shared/sdk-server/src/Migration.ts create mode 100644 packages/shared/sdk-server/src/api/LDMigration.ts create mode 100644 packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index a24a0214e..7d6c3af70 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -71,7 +71,7 @@ describe('given an LDClient with test data', () => { LDMigrationStage.DualWrite, LDMigrationStage.Shadow, LDMigrationStage.Live, - LDMigrationStage.Rampdown, + LDMigrationStage.RampDown, LDMigrationStage.Complete, ])('returns the default value if the flag does not exist: default = %p', async (stage) => { const res = await client.variationMigration('no-flag', { key: 'test-key' }, stage); diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts new file mode 100644 index 000000000..22cc76b1c --- /dev/null +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -0,0 +1,514 @@ +import { + LDClientImpl, + LDConcurrentExecution, + LDErrorTracking, + LDExecutionOrdering, + LDLatencyTracking, + LDMigrationStage, + LDSerialExecution, +} from '../src'; +import { LDClientCallbacks } from '../src/LDClientImpl'; +import Migration, { LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import { TestData } from '../src/integrations'; +import basicPlatform from './evaluation/mocks/platform'; +import makeCallbacks from './makeCallbacks'; + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let callbacks: LDClientCallbacks; + + beforeEach(async () => { + td = new TestData(); + callbacks = makeCallbacks(false); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + }, + callbacks + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + /** Custom matcher for write results. */ + expect.extend({ + toMatchMigrationResult(received, expected) { + const { authoritative, nonAuthoritative } = expected; + const { authoritative: actualAuth, nonAuthoritative: actualNonAuth } = received; + + if (authoritative.origin !== actualAuth.origin) { + return { + pass: false, + message: () => + `Expected authoritative origin: ${authoritative.origin}, but received: ${actualAuth.origin}`, + }; + } + if (authoritative.success !== actualAuth.success) { + return { + pass: false, + message: () => `Expected authoritative success, but received error: ${actualAuth.error}`, + }; + } + if (authoritative.success) { + if (actualAuth.result !== authoritative.result) { + return { + pass: false, + message: () => + `Expected authoritative result: ${authoritative.result}, received: ${actualAuth.result}`, + }; + } + } else if (actualAuth.error?.message !== authoritative.error?.message) { + return { + pass: false, + message: () => + `Expected authoritative error: ${authoritative.error?.message}, received: ${actualAuth.error?.message}`, + }; + } + if (nonAuthoritative) { + if (!actualNonAuth) { + return { + pass: false, + message: () => `Expected no authoritative result, but did not receive one.`, + }; + } + if (nonAuthoritative.origin !== actualNonAuth.origin) { + return { + pass: false, + message: () => + `Expected non-authoritative origin: ${nonAuthoritative.origin}, but received: ${actualNonAuth.origin}`, + }; + } + if (nonAuthoritative.success !== actualNonAuth.success) { + return { + pass: false, + message: () => + `Expected authoritative success, but received error: ${actualNonAuth.error}`, + }; + } + if (nonAuthoritative.success) { + if (actualNonAuth.result !== nonAuthoritative.result) { + return { + pass: false, + message: () => + `Expected non-authoritative result: ${nonAuthoritative.result}, received: ${actualNonAuth.result}`, + }; + } + } else if (actualNonAuth.error?.message !== nonAuthoritative.error?.message) { + return { + pass: false, + message: () => + `Expected nonauthoritative error: ${nonAuthoritative.error?.message}, error: ${actualNonAuth.error?.message}`, + }; + } + } else if (actualNonAuth) { + return { + pass: false, + message: () => `Expected no non-authoritative result, received: ${actualNonAuth}`, + }; + } + return { pass: true, message: () => '' }; + }, + }); + + describe.each([ + [new LDSerialExecution(LDExecutionOrdering.Fixed), 'serial fixed'], + [new LDSerialExecution(LDExecutionOrdering.Random), 'serial random'], + [new LDConcurrentExecution(), 'concurrent'], + ])('given different execution methods: %p %p', (execution) => { + it.each([ + [ + LDMigrationStage.Off, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.DualWrite, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Shadow, + 'old', + { + authoritative: { origin: 'old', result: true, success: true }, + nonAuthoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Live, + 'new', + { + nonAuthoritative: { origin: 'old', result: true, success: true }, + authoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.RampDown, + 'new', + { + nonAuthoritative: { origin: 'old', result: true, success: true }, + authoritative: { origin: 'new', result: false, success: true }, + }, + ], + [ + LDMigrationStage.Complete, + 'new', + { + authoritative: { origin: 'new', result: false, success: true }, + nonAuthoritative: undefined, + }, + ], + ])( + 'uses the correct authoritative source: %p, read: %p, write: %j.', + async (stage, readValue, writeMatch) => { + const migration = new Migration(client, { + execution, + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeTruthy(); + expect(read.origin).toEqual(readValue); + // Type guards needed for typescript. + if (read.success) { + expect(read.result).toEqual(readValue); + } + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + // @ts-ignore Extended without writing types. + expect(write).toMatchMigrationResult(writeMatch); + } + ); + }); + + it.each([ + [LDMigrationStage.Off, 'old'], + [LDMigrationStage.DualWrite, 'old'], + [LDMigrationStage.Shadow, 'old'], + [LDMigrationStage.Live, 'new'], + [LDMigrationStage.RampDown, 'new'], + [LDMigrationStage.Complete, 'new'], + ])('handles read errors for stage: %p', async (stage, authority) => { + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationError(new Error('new')), + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => LDMigrationError(new Error('old')), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeFalsy(); + expect(read.origin).toEqual(authority); + // Type guards needed for typescript. + if (!read.success) { + expect(read.error.message).toEqual(authority); + } + }); + + it.each([ + [LDMigrationStage.Off, 'old'], + [LDMigrationStage.DualWrite, 'old'], + [LDMigrationStage.Shadow, 'old'], + [LDMigrationStage.Live, 'new'], + [LDMigrationStage.RampDown, 'new'], + [LDMigrationStage.Complete, 'new'], + ])('handles exceptions for stage: %p', async (stage, authority) => { + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => { + throw new Error('new'); + }, + writeNew: async () => LDMigrationSuccess(false), + readOld: async () => { + throw new Error('old'); + }, + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const read = await migration.read(flagKey, { key: 'test-key' }, defaultStage!); + expect(read.success).toBeFalsy(); + expect(read.origin).toEqual(authority); + // Type guards needed for typescript. + if (!read.success) { + expect(read.error.message).toEqual(authority); + } + }); + + it.each([ + [ + LDMigrationStage.Off, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.DualWrite, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Shadow, + 'old', + true, + false, + { + authoritative: { origin: 'old', success: false, error: new Error('old') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Live, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.RampDown, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + [ + LDMigrationStage.Complete, + 'new', + false, + true, + { + authoritative: { origin: 'new', success: false, error: new Error('new') }, + nonAuthoritative: undefined, + }, + ], + ])( + 'stops writes on error: %p, %p, %p, %p', + async (stage, origin, oldWrite, newWrite, writeMatch) => { + let oldWriteCalled = false; + let newWriteCalled = false; + + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + newWriteCalled = true; + return LDMigrationError(new Error('new')); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + oldWriteCalled = true; + return LDMigrationError(new Error('old')); + }, + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + // @ts-ignore + expect(write).toMatchMigrationResult(writeMatch); + + expect(oldWriteCalled).toEqual(oldWrite); + expect(newWriteCalled).toEqual(newWrite); + } + ); + + it.each([ + [LDMigrationStage.Off, 'old', true, false], + [LDMigrationStage.DualWrite, 'old', true, false], + [LDMigrationStage.Shadow, 'old', true, false], + [LDMigrationStage.Live, 'new', false, true], + [LDMigrationStage.RampDown, 'new', false, true], + [LDMigrationStage.Complete, 'new', false, true], + ])('stops writes on exception: %p, %p, %p, %p', async (stage, origin, oldWrite, newWrite) => { + let oldWriteCalled = false; + let newWriteCalled = false; + + const migration = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + newWriteCalled = true; + throw new Error('new'); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + oldWriteCalled = true; + throw new Error('old'); + }, + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(stage)); + + // Get a default value that is not the value under test. + const defaultStage = Object.values(LDMigrationStage).find((item) => item !== stage); + + const write = await migration.write(flagKey, { key: 'test-key' }, defaultStage!); + expect(write.authoritative.success).toBeFalsy(); + expect(write.authoritative.origin).toEqual(origin); + if (!write.authoritative.success) { + expect(write.authoritative.error.message).toEqual(origin); + } + expect(oldWriteCalled).toEqual(oldWrite); + expect(newWriteCalled).toEqual(newWrite); + }); + + it('handles the case where the authoritative write succeeds, but the non-authoritative fails', async () => { + const migrationA = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => { + throw new Error('new'); + }, + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => LDMigrationSuccess(true), + }); + + const flagKey = 'migration'; + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.DualWrite)); + + const writeA = await migrationA.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeA).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'old', + }, + nonAuthoritative: { + success: false, + error: new Error('new'), + origin: 'new', + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Shadow)); + + const writeB = await migrationA.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeB).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'old', + }, + nonAuthoritative: { + success: false, + error: new Error('new'), + origin: 'new', + }, + }); + + const migrationB = new Migration(client, { + execution: new LDSerialExecution(LDExecutionOrdering.Fixed), + latencyTracking: LDLatencyTracking.Disabled, + errorTracking: LDErrorTracking.Disabled, + readNew: async () => LDMigrationSuccess('new'), + writeNew: async () => LDMigrationSuccess(true), + readOld: async () => LDMigrationSuccess('old'), + writeOld: async () => { + throw new Error('old'); + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.Live)); + + const writeC = await migrationB.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeC).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'new', + }, + nonAuthoritative: { + success: false, + error: new Error('old'), + origin: 'old', + }, + }); + + td.update(td.flag(flagKey).valueForAll(LDMigrationStage.RampDown)); + + const writeD = await migrationB.write(flagKey, { key: 'test-key' }, LDMigrationStage.Off); + // @ts-ignore + expect(writeD).toMatchMigrationResult({ + authoritative: { + success: true, + result: true, + origin: 'new', + }, + nonAuthoritative: { + success: false, + error: new Error('old'), + origin: 'old', + }, + }); + }); +}); diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts new file mode 100644 index 000000000..f837503ef --- /dev/null +++ b/packages/shared/sdk-server/src/Migration.ts @@ -0,0 +1,247 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { LDClient, LDMigrationStage } from './api'; +import { + LDMigrationOptions, + LDSerialExecution, + LDConcurrentExecution, + LDExecution, + LDExecutionOrdering, + LDMethodResult, +} from './api/options/LDMigrationOptions'; +import { LDMigration, LDMigrationReadResult, LDMigrationWriteResult } from './api/LDMigration'; + +type MultipleReadResult = { + fromOld: LDMethodResult; + fromNew: LDMethodResult; +}; + +async function safeCall( + method: () => Promise> +): Promise> { + try { + // Awaiting to allow catching. + const res = await method(); + return res; + } catch (error: any) { + return { + success: false, + error, + }; + } +} + +async function readSequentialRandom( + config: LDMigrationOptions +): Promise> { + // This number is not used for a purpose requiring cryptographic security. + const randomIndex = Math.floor(Math.random() * 2); + + // Effectively flip a coin and do it on one order or the other. + if (randomIndex === 0) { + const fromOld = await safeCall(() => config.readOld()); + const fromNew = await safeCall(() => config.readNew()); + return { fromOld, fromNew }; + } + const fromNew = await safeCall(() => config.readNew()); + const fromOld = await safeCall(() => config.readOld()); + return { fromOld, fromNew }; +} + +async function readSequentialFixed( + config: LDMigrationOptions +): Promise> { + const fromOld = await safeCall(() => config.readOld()); + const fromNew = await safeCall(() => config.readNew()); + return { fromOld, fromNew }; +} + +async function readConcurrent( + config: LDMigrationOptions +): Promise> { + const fromOldPromise = safeCall(() => config.readOld()); + const fromNewPromise = safeCall(() => config.readNew()); + + const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); + + return { fromOld, fromNew }; +} + +async function read( + config: LDMigrationOptions, + execution: LDSerialExecution | LDConcurrentExecution +): Promise> { + if (execution.type === LDExecution.Serial) { + const serial = execution as LDSerialExecution; + if (serial.ordering === LDExecutionOrdering.Fixed) { + return readSequentialFixed(config); + } + return readSequentialRandom(config); + } + return readConcurrent(config); +} + +export function LDMigrationSuccess(result: TResult): LDMethodResult { + return { + success: true, + result, + }; +} + +export function LDMigrationError(error: Error): { success: false; error: Error } { + return { + success: false, + error, + }; +} + +export default class Migration + implements LDMigration +{ + private readonly execution: LDSerialExecution | LDConcurrentExecution; + + private readonly readTable: { + [index: string]: ( + config: LDMigrationOptions + ) => Promise>; + } = { + [LDMigrationStage.Off]: async ( + config: LDMigrationOptions + ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), + [LDMigrationStage.DualWrite]: async ( + config: LDMigrationOptions + ) => ({ origin: 'old', ...(await safeCall(() => config.readOld())) }), + [LDMigrationStage.Shadow]: async ( + config: LDMigrationOptions + ) => { + const { fromOld } = await read(config, this.execution); + + // TODO: Consistency check. + + return { origin: 'old', ...fromOld }; + }, + [LDMigrationStage.Live]: async ( + config: LDMigrationOptions + ) => { + const { fromNew } = await read(config, this.execution); + + // TODO: Consistency check. + + return { origin: 'new', ...fromNew }; + }, + [LDMigrationStage.RampDown]: async ( + config: LDMigrationOptions + ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), + [LDMigrationStage.Complete]: async ( + config: LDMigrationOptions + ) => ({ origin: 'new', ...(await safeCall(() => config.readNew())) }), + }; + + private readonly writeTable: { + [index: string]: ( + config: LDMigrationOptions + ) => Promise>; + } = { + [LDMigrationStage.Off]: async ( + config: LDMigrationOptions + ) => ({ authoritative: { origin: 'old', ...(await safeCall(() => config.writeOld())) } }), + [LDMigrationStage.DualWrite]: async ( + config: LDMigrationOptions + ) => { + const fromOld = await safeCall(() => config.writeOld()); + if (!fromOld.success) { + return { + authoritative: { origin: 'old', ...fromOld }, + }; + } + + const fromNew = await safeCall(() => config.writeNew()); + + return { + authoritative: { origin: 'old', ...fromOld }, + nonAuthoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Shadow]: async ( + config: LDMigrationOptions + ) => { + const fromOld = await safeCall(() => config.writeOld()); + if (!fromOld.success) { + return { + authoritative: { origin: 'old', ...fromOld }, + }; + } + + const fromNew = await safeCall(() => config.writeNew()); + + return { + authoritative: { origin: 'old', ...fromOld }, + nonAuthoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Live]: async ( + config: LDMigrationOptions + ) => { + const fromNew = await safeCall(() => config.writeNew()); + if (!fromNew.success) { + return { authoritative: { origin: 'new', ...fromNew } }; + } + + const fromOld = await safeCall(() => config.writeOld()); + + return { + nonAuthoritative: { origin: 'old', ...fromOld }, + authoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.RampDown]: async ( + config: LDMigrationOptions + ) => { + const fromNew = await safeCall(() => config.writeNew()); + if (!fromNew.success) { + return { authoritative: { origin: 'new', ...fromNew } }; + } + + const fromOld = await safeCall(() => config.writeOld()); + + return { + nonAuthoritative: { origin: 'old', ...fromOld }, + authoritative: { origin: 'new', ...fromNew }, + }; + }, + [LDMigrationStage.Complete]: async ( + config: LDMigrationOptions + ) => ({ authoritative: { origin: 'new', ...(await safeCall(() => config.writeNew())) } }), + }; + + constructor( + private readonly client: LDClient, + private readonly config: + | LDMigrationOptions + | LDMigrationOptions + ) { + if (config.execution) { + this.execution = config.execution; + } else { + this.execution = new LDConcurrentExecution(); + } + } + + async read( + key: string, + context: LDContext, + defaultStage: LDMigrationStage + ): Promise> { + const stage = await this.client.variationMigration(key, context, defaultStage); + return this.readTable[stage](this.config); + } + + async write( + key: string, + context: LDContext, + defaultStage: LDMigrationStage + ): Promise> { + const stage = await this.client.variationMigration(key, context, defaultStage); + + return this.writeTable[stage](this.config); + } +} diff --git a/packages/shared/sdk-server/src/api/LDMigration.ts b/packages/shared/sdk-server/src/api/LDMigration.ts new file mode 100644 index 000000000..9eb063d8a --- /dev/null +++ b/packages/shared/sdk-server/src/api/LDMigration.ts @@ -0,0 +1,87 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; +import { LDMigrationStage } from './data/LDMigrationStage'; + +/** + * Specifies the origin of the result or error. + * + * Results from `readOld` or `writeOld` will be 'old'. + * Results from `readNew` or `writeNew` will be 'new'. + */ +export type LDMigrationOrigin = 'old' | 'new'; + +/** + * Result of a component of an LDMigration. + * + * Should not need to be used by a consumer of this API directly. + */ +export type LDMigrationResult = + | { + success: true; + origin: LDMigrationOrigin; + result: TResult; + } + | { + success: false; + origin: LDMigrationOrigin; + error: any; + }; + +/** + * Result of a migration read operation. + */ +export type LDMigrationReadResult = LDMigrationResult; + +/** + * Result of a migration write operation. + * + * Authoritative writes are done before non-authoritative, so the authoritative + * field should contain either an error or a result. + * + * If the authoritative write fails, then the non-authoritative operation will + * not be executed. When this happens the nonAuthoritative field will not be + * populated. + * + * When the non-authoritative operation is executed, then it will result in + * either a result or an error and the field will be populated as such. + */ +export type LDMigrationWriteResult = { + authoritative: LDMigrationResult; + nonAuthoritative?: LDMigrationResult; +}; + +/** + * Interface for a migration. + * + * TKTK + */ +export interface LDMigration { + /** + * TKTK + * + * @param key The key of the flag controlling the migration. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default migration step. Used if the value is not available from + * LaunchDarkly. + */ + read( + key: string, + context: LDContext, + defaultValue: LDMigrationStage + ): Promise>; + + /** + * TKTK + * + * @param key The key of the flag controlling the migration. + * @param context The context requesting the flag. The client will generate an analytics event to + * register this context with LaunchDarkly if the context does not already exist. + * @param defaultValue The default migration step. Used if the value is not available from + * LaunchDarkly. + */ + write( + key: string, + context: LDContext, + defaultValue: LDMigrationStage + ): Promise>; +} diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts index c582a4f7c..b262ddce7 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationStage.ts @@ -3,7 +3,7 @@ export enum LDMigrationStage { DualWrite = 'dualwrite', Shadow = 'shadow', Live = 'live', - Rampdown = 'rampdown', + RampDown = 'rampdown', Complete = 'complete', } diff --git a/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts new file mode 100644 index 000000000..9f829cb45 --- /dev/null +++ b/packages/shared/sdk-server/src/api/options/LDMigrationOptions.ts @@ -0,0 +1,127 @@ +/* eslint-disable max-classes-per-file */ +// Disabling max classes per file as these are tag classes without +// logic implementation. + +/** + * When execution is sequential this enum is used to control if execution + * should be in a fixed or random order. + */ +export enum LDExecutionOrdering { + Fixed, + Random, +} + +/** + * Tag used to determine if execution should be serial or concurrent. + * Callers should not need to use this directly. + */ +export enum LDExecution { + /** + * Execution will be serial. One read method will be executed fully before + * the other read method. + */ + Serial, + /** + * Execution will be concurrent. The execution of the read methods will be + * started and then resolved concurrently. + */ + Concurrent, +} + +/** + * Settings for latency tracking. + */ +export enum LDLatencyTracking { + Enabled, + Disabled, +} + +/** + * Settings for error tracking. + */ +export enum LDErrorTracking { + Enabled, + Disabled, +} + +/** + * Migration methods may return an LDMethodResult. + * The implementation includes methods for creating results conveniently. + * + * An implementation may also throw an exception to represent an error. + */ +export type LDMethodResult = + | { + success: true; + result: TResult; + } + | { + success: false; + error: any; + }; + +/** + * Configuration class for configuring serial execution of a migration. + */ +export class LDSerialExecution { + readonly type: LDExecution = LDExecution.Serial; + + constructor(public readonly ordering: LDExecutionOrdering) {} +} + +/** + * Configuration class for configuring concurrent execution of a migration. + */ +export class LDConcurrentExecution { + readonly type: LDExecution = LDExecution.Concurrent; +} + +/** + * Configuration for a migration. + */ +export interface LDMigrationOptions { + /** + * Configure how the migration should execute. If omitted the execution will + * be concurrent. + */ + execution?: LDSerialExecution | LDConcurrentExecution; + + /** + * Configure the latency tracking for the migration. + * + * Defaults to {@link LDLatencyTracking.Enabled}. + */ + latencyTracking?: LDLatencyTracking; + + /** + * Configure the error tracking for the migration. + * + * Defaults to {@link LDErrorTracking.Enabled}. + */ + errorTracking?: LDErrorTracking; + + /** + * TKTK + */ + readNew: () => Promise>; + + /** + * TKTK + */ + writeNew: () => Promise>; + + /** + * TKTK + */ + readOld: () => Promise>; + + /** + * TKTK + */ + writeOld: () => Promise>; + + /** + * TKTK + */ + check?: (a: TMigrationRead, b: TMigrationRead) => boolean; +} diff --git a/packages/shared/sdk-server/src/api/options/index.ts b/packages/shared/sdk-server/src/api/options/index.ts index a3467b9cc..1e7b63de7 100644 --- a/packages/shared/sdk-server/src/api/options/index.ts +++ b/packages/shared/sdk-server/src/api/options/index.ts @@ -2,3 +2,4 @@ export * from './LDBigSegmentsOptions'; export * from './LDOptions'; export * from './LDProxyOptions'; export * from './LDTLSOptions'; +export * from './LDMigrationOptions';