Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
a090170
Work on initial scaffold.
kinyoklion May 19, 2022
0b470ea
Update index exports.
kinyoklion May 19, 2022
93fbac6
Add an extra blank line.
kinyoklion May 19, 2022
8be08da
Attempt using mixin for event emitter.
kinyoklion May 23, 2022
5302683
Add implementation for emitting events for big segment status.
kinyoklion May 23, 2022
263fccd
Add documentation.
kinyoklion May 23, 2022
831b964
Fix lint and interface hierarchy.
kinyoklion May 23, 2022
3b1ece5
Start implementation.
kinyoklion May 23, 2022
cec9858
Progress on option handling.
kinyoklion May 23, 2022
954a135
Add application tags support.
kinyoklion May 23, 2022
1a0dffc
Un-generalize tags.
kinyoklion May 23, 2022
a141aeb
Fix tag formatting.
kinyoklion May 23, 2022
40d27e3
Comments.
kinyoklion May 23, 2022
f429571
Add internal to validated options.
kinyoklion May 23, 2022
5792122
Add tests for type checks. Finish implementing type checks.
kinyoklion May 24, 2022
b93ed50
Add remaining validator tests.
kinyoklion May 24, 2022
82599e2
Improve coverage collection. Start implementing application tags tests.
kinyoklion May 24, 2022
ef67115
Add test logger and finish tag tests.
kinyoklion May 24, 2022
cd8357a
Start testing the Configuration class.
kinyoklion May 24, 2022
7ea76b5
Fix validation. Start on individual option tests.
kinyoklion May 24, 2022
82788fd
Add remaining config tests.
kinyoklion May 24, 2022
9740584
Make sure to check resolves in the logger.
kinyoklion May 24, 2022
2517279
Start implementation.
kinyoklion May 24, 2022
b573151
Implement check for service endpoints consistency. Improve testing me…
kinyoklion May 25, 2022
4283204
Merge branch 'main' into rlamb/sc-154352/options-parsing
kinyoklion May 25, 2022
2f55d1a
Merge branch 'main' into rlamb/sc-154351/attributes-and-context
kinyoklion May 25, 2022
9080329
Remove unintended file.
kinyoklion May 25, 2022
c59c5b9
Merge branch 'rlamb/sc-154352/options-parsing' into rlamb/sc-154351/a…
kinyoklion May 25, 2022
af34183
Empty context.
kinyoklion May 25, 2022
5e0d9b1
Update server-sdk-common/src/options/Configuration.ts
kinyoklion May 25, 2022
3b207fd
Progress
kinyoklion May 25, 2022
79dd5d6
Refactor expectMessages.
kinyoklion May 25, 2022
ab50ad1
Merge branch 'rlamb/sc-154352/options-parsing' into rlamb/sc-154351/a…
kinyoklion May 25, 2022
2b38d40
Show what was received.
kinyoklion May 25, 2022
36c584d
Merge master
kinyoklion May 25, 2022
59ed4c8
Finish moving things.
kinyoklion May 25, 2022
a977353
Move validation. Implement more of Context.
kinyoklion May 25, 2022
a935f1a
Basic context and attribute reference structure.
kinyoklion May 25, 2022
ba71331
Add attribute reference tests.
kinyoklion May 25, 2022
a7a04ea
Add some comments.
kinyoklion May 25, 2022
db8e51d
Flesh out public interface for contexts. Add more documentation.
kinyoklion May 26, 2022
e23954b
Finish adding context tests.
kinyoklion May 26, 2022
7fc75e1
Move logging settings/interfaces to sdk-common, implement loggers, im…
kinyoklion May 27, 2022
bbd02bb
Lint
kinyoklion May 27, 2022
8d9d3fa
Remove incorrect comment.
kinyoklion May 27, 2022
de3a5e4
Add in memory data store.
kinyoklion May 27, 2022
fe0801a
Add an async facade for using stores.
kinyoklion May 27, 2022
7765856
Add unit tests.
kinyoklion May 27, 2022
2612b6d
Make memory store members private.
kinyoklion May 27, 2022
37a03cd
Update server-sdk-common/src/store/AsyncStoreFacade.ts
kinyoklion Jun 1, 2022
48282d2
Update server-sdk-common/src/store/AsyncStoreFacade.ts
kinyoklion Jun 1, 2022
30240ae
Resolve merge conflicts.
kinyoklion Jun 1, 2022
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
150 changes: 150 additions & 0 deletions server-sdk-common/__tests__/store/InMemoryFeatureStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import AsyncStoreFacade from '../../src/store/AsyncStoreFacade';
import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore';
import VersionedDataKinds from '../../src/store/VersionedDataKinds';

// To simplify testing the memory store will be wrapped with the async facade.
// Writing the tests with callbacks would make them much more difficult to follow.

describe('given an empty feature store and async facade', () => {
const store = new AsyncStoreFacade(new InMemoryFeatureStore());

it('can be initialized', async () => {
await store.init({});
const initialized = await store.initialized();
expect(initialized).toBeTruthy();
});
});

describe('given an initialized feature store', () => {
let featureStore: AsyncStoreFacade;
const feature1 = { key: 'foo', version: 10 };
const feature2 = { key: 'bar', version: 10 };

beforeEach(async () => {
featureStore = new AsyncStoreFacade(new InMemoryFeatureStore());
return featureStore.init({
[VersionedDataKinds.Features.namespace]: {
foo: feature1,
bar: feature2,
},
[VersionedDataKinds.Segments.namespace]: {},
});
});

it('gets an existing feature', async () => {
const feature = await featureStore.get(VersionedDataKinds.Features, 'foo');
expect(feature).toStrictEqual(feature1);
});

it('gets null for a feature that does not exist', async () => {
const feature = await featureStore.get(VersionedDataKinds.Features, 'unknown');
expect(feature).toBeNull();
});

it('gets all features', async () => {
const features = await featureStore.all(VersionedDataKinds.Features);
expect(features).toStrictEqual({
foo: feature1,
bar: feature2,
});
});

it('does not upsert an older version', async () => {
await featureStore.upsert(VersionedDataKinds.Features, {
...feature1,
version: feature1.version - 1,
});
const feature = await featureStore.get(VersionedDataKinds.Features, 'foo');
expect(feature).toEqual(feature1);
});

it('does upsert a newer version', async () => {
const updatedFeature = {
...feature1,
version: feature1.version + 1,
};
await featureStore.upsert(VersionedDataKinds.Features, updatedFeature);
const feature = await featureStore.get(VersionedDataKinds.Features, 'foo');
expect(feature).toEqual(updatedFeature);
});

it('does upsert a new feature', async () => {
const newFeature = {
key: 'new-feature',
version: feature1.version + 1,
};
await featureStore.upsert(VersionedDataKinds.Features, newFeature);
const feature = await featureStore.get(VersionedDataKinds.Features, newFeature.key);
expect(feature).toEqual(newFeature);
});

it('handles race conditions in upserts', async () => {
const ver1 = { key: feature1.key, version: feature1.version + 1 };
const ver2 = { key: feature1.key, version: feature1.version + 2 };

// Intentionally not awaiting these.
const p1 = featureStore.upsert(VersionedDataKinds.Features, ver1);
const p2 = featureStore.upsert(VersionedDataKinds.Features, ver2);

// Let them both finish.
await Promise.all([p2, p1]);

const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key);
expect(feature).toEqual(ver2);
});

it('deletes with newer version', async () => {
featureStore.delete(VersionedDataKinds.Features, feature1.key, feature1.version + 1);
const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key);
expect(feature).toBeNull();
});

it('does not delete with older version', async () => {
featureStore.delete(VersionedDataKinds.Features, feature1.key, feature1.version - 1);
const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key);
expect(feature).toStrictEqual(feature1);
});

it('allows deleting an unknown feature', async () => {
featureStore.delete(VersionedDataKinds.Features, 'unknown', 10);
const feature = await featureStore.get(VersionedDataKinds.Features, 'unknown');
expect(feature).toBeNull();
});

it('does not upsert older version after delete', async () => {
const key = 'featureKey';
featureStore.delete(VersionedDataKinds.Features, key, 10);

featureStore.upsert(VersionedDataKinds.Features, {
key,
version: 9,
});
const feature = await featureStore.get(VersionedDataKinds.Features, key);
expect(feature).toBeNull();
});

it('does upsert newer version after delete', async () => {
const key = 'featureKey';
featureStore.delete(VersionedDataKinds.Features, key, 10);

featureStore.upsert(VersionedDataKinds.Features, {
key,
version: 11,
});
const feature = await featureStore.get(VersionedDataKinds.Features, key);
expect(feature).toStrictEqual({
key,
version: 11,
});
});

it('does upsert a new item of unknown kind', async () => {
const newPotato = {
key: 'new-feature',
version: 1,
};
await featureStore.upsert({namespace: 'potato'}, newPotato);
const feature = await featureStore.get({namespace: 'potato'}, newPotato.key);
expect(feature).toEqual(newPotato);
});
});
39 changes: 34 additions & 5 deletions server-sdk-common/src/api/subsystems/LDFeatureStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
import { DataKind } from '../interfaces';

/**
* Represents an item which can be stored in the feature store.
*/
export interface LDFeatureStoreItem {
Copy link
Member Author

Choose a reason for hiding this comment

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

These interfaces should all be compatible with the data as it already exists. They were just untyped before.
For TS, if people had been using these wrong, and they had implemented their own stores, then they would have to update the types.

Copy link
Member Author

Choose a reason for hiding this comment

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

If they used it correctly, then no changes.

deleted?: boolean;
version: number;
// The actual data associated with the item.
[attribute: string]: any;
}

/**
* When upserting an item it must contain a key.
*/
export interface LDKeyedFeatureStoreItem extends LDFeatureStoreItem {
key: string;
}

/**
* Represents the storage for a single kind of data. e.g. 'flag' or 'segment'.
*/
export interface LDFeatureStoreKindData {[key: string]: LDFeatureStoreItem }

/**
* Represents the storage for the full data store.
*/
export interface LDFeatureStoreDataStorage {[namespace: string]: LDFeatureStoreKindData }

/**
* Interface for a feature store component.
*
Expand Down Expand Up @@ -30,7 +59,7 @@ export interface LDFeatureStore {
* Will be called with the retrieved entity, or null if not found. The actual type of the result
* value is [[interfaces.VersionedData]].
*/
get(kind: object, key: string, callback: (res: object) => void): void;
get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void;

/**
* Get all entities from a collection.
Expand All @@ -46,7 +75,7 @@ export interface LDFeatureStore {
* Will be called with the resulting map. The actual type of the result value is
* `interfaces.KeyedItems<VersionedData>`.
*/
all(kind: object, callback: (res: object) => void): void;
all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void;

/**
* Initialize the store, overwriting any existing data.
Expand All @@ -59,7 +88,7 @@ export interface LDFeatureStore {
* @param callback
* Will be called when the store has been initialized.
*/
init(allData: object, callback: () => void): void;
init(allData: LDFeatureStoreDataStorage, callback: () => void): void;

/**
* Delete an entity from the store.
Expand All @@ -83,7 +112,7 @@ export interface LDFeatureStore {
* @param callback
* Will be called when the delete operation is complete.
*/
delete(kind: object, key: string, version: string, callback: () => void): void;
delete(kind: DataKind, key: string, version: number, callback: () => void): void;

/**
* Add an entity or update an existing entity.
Expand All @@ -101,7 +130,7 @@ export interface LDFeatureStore {
* @param callback
* Will be called after the upsert operation is complete.
*/
upsert(kind: object, data: object, callback: () => void): void;
upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void;

/**
* Tests whether the store is initialized.
Expand Down
75 changes: 75 additions & 0 deletions server-sdk-common/src/store/AsyncStoreFacade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DataKind } from '../api/interfaces';
Copy link
Member Author

Choose a reason for hiding this comment

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

Our existing interface uses callbacks. We will still want to be able to use stores written against them. But we will not want to use callbacks because they make the code an unmaintainable hellscape. So, I am providing this adapter so the code can be written sanely.

import {
LDFeatureStore,
LDFeatureStoreDataStorage,
LDFeatureStoreItem,
LDFeatureStoreKindData,
LDKeyedFeatureStoreItem,
} from '../api/subsystems';

/**
* A basic wrapper to make async methods with callbacks into promises.
*
* @param method
* @returns A promisified version of the method.
*/
function promisify<T>(method: (callback: (val: T) => void) => void): Promise<T> {
return new Promise<T>((resolve) => {
method((val: T) => { resolve(val); });
});
}

/**
* Provides an async interface to a feature store.
*
* This allows for using a store using async/await instead of callbacks.
*
* @internal
*/
export default class AsyncStoreFacade {
private store: LDFeatureStore;

constructor(store: LDFeatureStore) {
this.store = store;
}

async get(kind: DataKind, key: string): Promise<LDFeatureStoreItem | null> {
return promisify((cb) => {
this.store.get(kind, key, cb);
});
}

async all(kind: DataKind): Promise<LDFeatureStoreKindData> {
return promisify((cb) => {
this.store.all(kind, cb);
});
}

async init(allData: LDFeatureStoreDataStorage): Promise<void> {
return promisify((cb) => {
this.store.init(allData, cb);
});
}

async delete(kind: DataKind, key: string, version: number): Promise<void> {
return promisify((cb) => {
this.store.delete(kind, key, version, cb);
});
}

async upsert(kind: DataKind, data: LDKeyedFeatureStoreItem): Promise<void> {
return promisify((cb) => {
this.store.upsert(kind, data, cb);
});
}

async initialized(): Promise<boolean> {
return promisify((cb) => {
this.store.initialized(cb);
});
}

close(): void {
this.store.close();
}
}
91 changes: 91 additions & 0 deletions server-sdk-common/src/store/InMemoryFeatureStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { DataKind } from '../api/interfaces';
import {
LDFeatureStoreDataStorage,
LDKeyedFeatureStoreItem,
LDFeatureStore,
LDFeatureStoreKindData,
LDFeatureStoreItem,
} from '../api/subsystems';

/**
* Clone an object using JSON. This will not preserve
* non-JSON types (like functions).
* @param obj
* @returns A clone of the object.
*/
function clone(obj: any): any {
return JSON.parse(JSON.stringify(obj));
}

export default class InMemoryFeatureStore implements LDFeatureStore {
private allData: LDFeatureStoreDataStorage = {};

private initCalled = false;

private addItem(kind: DataKind, key: string, item: LDFeatureStoreItem) {
let items = this.allData[kind.namespace];
if (!items) {
items = {};
this.allData[kind.namespace] = items;
}
if (Object.hasOwnProperty.call(items, key)) {
const old = items[key];
if (!old || old.version < item.version) {
items[key] = item;
}
} else {
items[key] = item;
}
}

get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void {
const items = this.allData[kind.namespace];
if (items) {
if (Object.prototype.hasOwnProperty.call(items, key)) {
const item = items[key];
if (item && !item.deleted) {
return callback?.(item);
}
}
}
return callback?.(null);
}

all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void {
const result: LDFeatureStoreKindData = {};
const items = this.allData[kind.namespace] ?? {};
Object.entries(items).forEach(([key, item]) => {
if (item && !item.deleted) {
result[key] = item;
}
});
callback?.(result);
}

init(allData: LDFeatureStoreDataStorage, callback: () => void): void {
this.initCalled = true;
this.allData = allData as LDFeatureStoreDataStorage;
callback?.();
}

delete(kind: DataKind, key: string, version: number, callback: () => void): void {
const deletedItem = { version, deleted: true };
this.addItem(kind, key, deletedItem);
callback?.();
}

upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void {
const item = clone(data);
this.addItem(kind, item.key, item);
callback?.();
}

initialized(callback: (isInitialized: boolean) => void): void {
return callback?.(this.initCalled);
}

/* eslint-disable class-methods-use-this */
close(): void {
// For the memory store this is a no-op.
}
}
Loading