Skip to content

Commit

Permalink
feat!: Implement Migrations. Refactor for client SDKs. (#293)
Browse files Browse the repository at this point in the history
Co-authored-by: Yusinto Ngadiman <[email protected]>
Co-authored-by: Yusinto Ngadiman <[email protected]>
Co-authored-by: LaunchDarklyReleaseBot <[email protected]>
  • Loading branch information
4 people authored Oct 16, 2023
1 parent a9531ad commit c66aa6e
Show file tree
Hide file tree
Showing 202 changed files with 9,361 additions and 2,929 deletions.
11 changes: 11 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module.exports = {
plugins: ['@typescript-eslint', 'prettier'],
ignorePatterns: ['**/dist/**', '**/vercel/examples/**'],
rules: {
'@typescript-eslint/lines-between-class-members': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^__' },
],
'prettier/prettier': ['error'],
'class-methods-use-this': 'off',
'import/no-extraneous-dependencies': [
Expand All @@ -18,5 +23,11 @@ module.exports = {
devDependencies: ['**/jest*.ts', '**/*.test.ts', '**/rollup.config.ts'],
},
],
'import/default': 'error',
'import/export': 'error',
'import/no-self-import': 'error',
'import/no-cycle': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': 'error',
},
};
25 changes: 25 additions & 0 deletions .github/workflows/mocks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: shared/mocks

on:
push:
branches: [main, 'feat/**']
paths-ignore:
- '**.md' #Do not need to run CI for markdown changes.
pull_request:
branches: [main, 'feat/**']
paths-ignore:
- '**.md'

jobs:
build-test-mocks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- id: shared
name: Shared CI Steps
uses: ./actions/ci
with:
workspace_name: '@launchdarkly/private-js-mocks'
workspace_path: packages/shared/mocks
should_build_docs: false
5 changes: 4 additions & 1 deletion actions/ci/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ inputs:
workspace_path:
description: 'Path to the package to release.'
required: true

should_build_docs:
description: 'Whether docs should be built. It will be by default.'
default: true
runs:
using: composite
steps:
Expand Down Expand Up @@ -40,4 +42,5 @@ runs:

- name: Build Docs
shell: bash
if: ${{inputs.should_build_docs == 'true'}}
run: yarn build:doc -- ${{ inputs.workspace_path }}
3 changes: 3 additions & 0 deletions contract-tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ app.get('/', (req, res) => {
'tags',
'big-segments',
'user-type',
'migrations',
'event-sampling',
'strongly-typed',
],
});
});
Expand Down
192 changes: 187 additions & 5 deletions contract-tests/sdkClientEntity.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import ld from 'node-server-sdk';
import got from 'got';
import ld, {
createMigration,
LDConcurrentExecution,
LDExecutionOrdering,
LDMigrationError,
LDMigrationSuccess,
LDSerialExecution,
} from 'node-server-sdk';

import BigSegmentTestStore from './BigSegmentTestStore.js';
import { Log, sdkLogger } from './log.js';
Expand All @@ -9,7 +17,7 @@ export { badCommandError };
export function makeSdkConfig(options, tag) {
const cf = {
logger: sdkLogger(tag),
diagnosticOptOut: true
diagnosticOptOut: true,
};
const maybeTime = (seconds) =>
seconds === undefined || seconds === null ? undefined : seconds / 1000;
Expand Down Expand Up @@ -55,6 +63,30 @@ export function makeSdkConfig(options, tag) {
return cf;
}

function getExecution(order) {
switch (order) {
case 'serial': {
return new LDSerialExecution(LDExecutionOrdering.Fixed);
}
case 'random': {
return new LDSerialExecution(LDExecutionOrdering.Random);
}
case 'concurrent': {
return new LDConcurrentExecution();
}
default: {
throw new Error('Unsupported execution order.');
}
}
}

function makeMigrationPostOptions(payload) {
if (payload) {
return { body: payload };
}
return {};
}

export async function newSdkClientEntity(options) {
const c = {};
const log = Log(options.tag);
Expand Down Expand Up @@ -93,10 +125,65 @@ export async function newSdkClientEntity(options) {
case 'evaluate': {
const pe = params.evaluate;
if (pe.detail) {
return await client.variationDetail(pe.flagKey, pe.context || pe.user, pe.defaultValue);
switch (pe.valueType) {
case 'bool':
return await client.boolVariationDetail(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
);
case 'int': // Intentional fallthrough.
case 'double':
return await client.numberVariationDetail(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
);
case 'string':
return await client.stringVariationDetail(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
);
default:
return await client.variationDetail(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
);
}
} else {
const value = await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue);
return { value };
switch (pe.valueType) {
case 'bool':
return {
value: await client.boolVariation(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
),
};
case 'int': // Intentional fallthrough.
case 'double':
return {
value: await client.numberVariation(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
),
};
case 'string':
return {
value: await client.stringVariation(
pe.flagKey,
pe.context || pe.user,
pe.defaultValue,
),
};
default:
return {
value: await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue),
};
}
}
}

Expand Down Expand Up @@ -127,6 +214,101 @@ export async function newSdkClientEntity(options) {
case 'getBigSegmentStoreStatus':
return await client.bigSegmentStoreStatusProvider.requireStatus();

case 'migrationVariation':
const migrationVariation = params.migrationVariation;
const res = await client.migrationVariation(
migrationVariation.key,
migrationVariation.context,
migrationVariation.defaultStage,
);
return { result: res.value };

case 'migrationOperation':
const migrationOperation = params.migrationOperation;
const readExecutionOrder = migrationOperation.readExecutionOrder;

const migration = createMigration(client, {
execution: getExecution(readExecutionOrder),
latencyTracking: migrationOperation.trackLatency,
errorTracking: migrationOperation.trackErrors,
check: migrationOperation.trackConsistency ? (a, b) => a === b : undefined,
readNew: async (payload) => {
try {
const res = await got.post(
migrationOperation.newEndpoint,
makeMigrationPostOptions(payload),
);
return LDMigrationSuccess(res.body);
} catch (err) {
return LDMigrationError(err.message);
}
},
writeNew: async (payload) => {
try {
const res = await got.post(
migrationOperation.newEndpoint,
makeMigrationPostOptions(payload),
);
return LDMigrationSuccess(res.body);
} catch (err) {
return LDMigrationError(err.message);
}
},
readOld: async (payload) => {
try {
const res = await got.post(
migrationOperation.oldEndpoint,
makeMigrationPostOptions(payload),
);
return LDMigrationSuccess(res.body);
} catch (err) {
return LDMigrationError(err.message);
}
},
writeOld: async (payload) => {
try {
const res = await got.post(
migrationOperation.oldEndpoint,
makeMigrationPostOptions(payload),
);
return LDMigrationSuccess(res.body);
} catch (err) {
return LDMigrationError(err.message);
}
},
});

switch (migrationOperation.operation) {
case 'read': {
const res = await migration.read(
migrationOperation.key,
migrationOperation.context,
migrationOperation.defaultStage,
migrationOperation.payload,
);
if (res.success) {
return { result: res.result };
} else {
return { result: res.error };
}
}
case 'write': {
const res = await migration.write(
migrationOperation.key,
migrationOperation.context,
migrationOperation.defaultStage,
migrationOperation.payload,
);

if (res.authoritative.success) {
return { result: res.authoritative.result };
} else {
return { result: res.authoritative.error };
}
}
}
return undefined;

default:
throw badCommandError;
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"name": "@launchdarkly/js-core",
"workspaces": [
"packages/shared/common",
"packages/shared/mocks",
"packages/shared/sdk-client",
"packages/shared/sdk-server",
"packages/shared/sdk-server-edge",
"packages/shared/akamai-edgeworker-sdk",
Expand Down
3 changes: 1 addition & 2 deletions packages/sdk/akamai-base/example/ldClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ const flagData = `

class MyCustomStoreProvider implements EdgeProvider {
// root key is formatted as LD-Env-{Launchdarkly environment client ID}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async get(rootKey: string): Promise<string> {
async get(_rootKey: string): Promise<string> {
// you should provide an implementation to retrieve your flags from launchdarkly's https://sdk.launchdarkly.com/sdk/latest-all endpoint.
// see https://docs.launchdarkly.com/sdk/features/flags-from-files for more information.
return flagData;
Expand Down
30 changes: 16 additions & 14 deletions packages/sdk/server-node/__tests__/LDClientNode.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LDContext } from '@launchdarkly/js-server-sdk-common';
import { logger } from '@launchdarkly/private-js-mocks';

import { init } from '../src';

Expand All @@ -10,23 +11,24 @@ it('fires ready event in offline mode', (done) => {
});
});

it('fires the failed event if initialization fails', (done) => {
it('fires the failed event if initialization fails', async () => {
jest.useFakeTimers();

const failedHandler = jest.fn().mockName('failedHandler');
const client = init('sdk_key', {
updateProcessor: {
start: (fn: (err: any) => void) => {
setTimeout(() => {
fn(new Error('BAD THINGS'));
}, 0);
sendEvents: false,
logger,
updateProcessor: (clientContext, dataSourceUpdates, initSuccessHandler, errorHandler) => ({
start: () => {
setTimeout(() => errorHandler?.(new Error('Something unexpected happened')), 0);
},
stop: () => {},
close: () => {},
sendEvents: false,
},
});
client.on('failed', () => {
client.close();
done();
close: jest.fn(),
}),
});
client.on('failed', failedHandler);
jest.runAllTimers();

expect(failedHandler).toBeCalledWith(new Error('Something unexpected happened'));
});

// These tests are done in the node implementation because common doesn't have a crypto
Expand Down
12 changes: 4 additions & 8 deletions packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,14 @@ import {
TestHttpServer,
} from 'launchdarkly-js-test-helpers';

import { basicLogger, LDClient, LDLogger } from '../src';
import { logger } from '@launchdarkly/private-js-mocks';

import { LDClient } from '../src';
import LDClientNode from '../src/LDClientNode';

describe('When using a TLS connection', () => {
let client: LDClient;
let server: TestHttpServer;
let logger: LDLogger;

beforeEach(() => {
logger = basicLogger({
destination: () => {},
});
});

it('can connect via HTTPS to a server with a self-signed certificate, if CA is specified', async () => {
server = await TestHttpServer.startSecure();
Expand Down Expand Up @@ -87,6 +82,7 @@ describe('When using a TLS connection', () => {
stream: false,
tlsParams: { ca: server.certificate },
diagnosticOptOut: true,
logger,
});

await client.waitForInitialization();
Expand Down
Loading

0 comments on commit c66aa6e

Please sign in to comment.