Skip to content

Commit

Permalink
allow skipping JSON parsing data adapter result (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenny-statsig authored Jul 9, 2024
1 parent b7c7ede commit 0fa1a9d
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 51 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "statsig-node-lite",
"version": "0.2.1",
"version": "0.2.2",
"description": "A slimmed version of the Statsig Node.js SDK.",
"main": "dist/index.js",
"scripts": {
Expand Down
92 changes: 53 additions & 39 deletions src/SpecStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,11 @@ export default class SpecStore {

private async _initIDLists(): Promise<void> {
const adapter = this.dataAdapter;
const bootstrapIdLists = await adapter?.get(DataAdapterKey.IDLists);
if (adapter && typeof bootstrapIdLists?.result === 'string') {
await this.syncIdListsFromDataAdapter(adapter, bootstrapIdLists.result);
if (adapter) {
const success = await this.syncIdListsFromDataAdapter();
if (!success) {
await this.syncIdListsFromNetwork();
}
} else {
await this.syncIdListsFromNetwork();
}
Expand Down Expand Up @@ -333,7 +335,8 @@ export default class SpecStore {
DataAdapterKey.Rulesets,
);
if (result && !error) {
const configSpecs = JSON.parse(result);
const configSpecs =
typeof result === 'string' ? JSON.parse(result) : result;
if (this._process(configSpecs)) {
this.initReason = 'DataAdapter';
}
Expand Down Expand Up @@ -449,9 +452,8 @@ export default class SpecStore {
const adapter = this.dataAdapter;
const shouldSyncFromAdapter =
adapter?.supportsPollingUpdatesFor?.(DataAdapterKey.IDLists) === true;
const adapterIdLists = await adapter?.get(DataAdapterKey.IDLists);
if (shouldSyncFromAdapter && typeof adapterIdLists?.result === 'string') {
await this.syncIdListsFromDataAdapter(adapter, adapterIdLists.result);
if (shouldSyncFromAdapter) {
await this.syncIdListsFromDataAdapter();
} else {
await this.syncIdListsFromNetwork();
}
Expand Down Expand Up @@ -577,41 +579,53 @@ export default class SpecStore {
return reverseMapping;
}

private async syncIdListsFromDataAdapter(
dataAdapter: IDataAdapter,
listsLookupString: string,
): Promise<void> {
const lookup = IDListUtil.parseBootstrapLookup(listsLookupString);
if (!lookup) {
return;
}

const tasks: Promise<void>[] = [];
for (const name of lookup) {
tasks.push(
new Promise(async (resolve) => {
const data = await dataAdapter.get(
IDListUtil.getIdListDataStoreKey(name),
);
if (!data.result) {
return;
}
private async syncIdListsFromDataAdapter(): Promise<boolean> {
try {
const dataAdapter = this.dataAdapter;
if (!dataAdapter) {
return false;
}
const { result: adapterIdLists } = await dataAdapter.get(
DataAdapterKey.IDLists,
);
if (!adapterIdLists) {
return false;
}
const lookup = IDListUtil.parseBootstrapLookup(adapterIdLists);
if (!lookup) {
return false;
}

this.store.idLists[name] = {
ids: {},
readBytes: 0,
url: 'bootstrap',
fileID: 'bootstrap',
creationTime: 0,
};
const tasks: Promise<void>[] = [];
for (const name of lookup) {
tasks.push(
new Promise(async (resolve) => {
const { result: data } = await dataAdapter.get(
IDListUtil.getIdListDataStoreKey(name),
);
if (!data || typeof data !== 'string') {
return;
}

this.store.idLists[name] = {
ids: {},
readBytes: 0,
url: 'bootstrap',
fileID: 'bootstrap',
creationTime: 0,
};

IDListUtil.updateIdList(this.store.idLists, name, data);
resolve();
}),
);
}

IDListUtil.updateIdList(this.store.idLists, name, data.result);
resolve();
}),
);
await Promise.all(tasks);
return true;
} catch {
return false;
}

await Promise.all(tasks);
}

private async syncIdListsFromNetwork(): Promise<void> {
Expand Down
78 changes: 70 additions & 8 deletions src/__tests__/DataAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import * as statsigsdk from '../index';
import exampleConfigSpecs from './jest.setup';
import TestDataAdapter, { TestSyncingDataAdapter } from './TestDataAdapter';
import TestDataAdapter, {
TestObjectDataAdapter,
TestSyncingDataAdapter,
} from './TestDataAdapter';
import { GatesForIdListTest } from './BootstrapWithDataAdapter.data';

jest.mock('node-fetch', () => jest.fn());
import fetch from 'node-fetch';
import { DataAdapterKey } from '../interfaces/IDataAdapter';
import { DataAdapterKey, IDataAdapter } from '../interfaces/IDataAdapter';
import StatsigInstanceUtils from '../StatsigInstanceUtils';
import StatsigTestUtils from './StatsigTestUtils';

Expand All @@ -27,11 +30,12 @@ describe('DataAdapter', () => {
custom: { level: 9 },
};

async function loadStore(dataAdapter: TestDataAdapter) {
async function loadStore(dataAdapter: IDataAdapter) {
// Manually load data into adapter store
const gates: unknown[] = [];
let gates: unknown[] = [];
const configs: unknown[] = [];
gates.push(exampleConfigSpecs.gate);
gates = gates.concat(GatesForIdListTest);
configs.push(exampleConfigSpecs.config);
const time = Date.now();
await dataAdapter.initialize();
Expand All @@ -46,6 +50,11 @@ describe('DataAdapter', () => {
}),
time,
);
await dataAdapter.set(DataAdapterKey.IDLists, '["user_id_list"]');
await dataAdapter.set(
DataAdapterKey.IDLists + '::user_id_list',
'+Z/hEKLio\n+M5m6a10x\n',
);
}

beforeEach(() => {
Expand Down Expand Up @@ -146,7 +155,7 @@ describe('DataAdapter', () => {
await statsig.initialize('secret-key', statsigOptions);

const { result } = await dataAdapter.get(DataAdapterKey.Rulesets);
const configSpecs = JSON.parse(result!);
const configSpecs = JSON.parse(result as string);

// Check gates
const gates = configSpecs['feature_gates'];
Expand Down Expand Up @@ -202,13 +211,14 @@ describe('DataAdapter', () => {
});

const { result } = await dataAdapter.get(DataAdapterKey.Rulesets);
const configSpecs = JSON.parse(result!);
const configSpecs = JSON.parse(result as string);

// Check gates
const gates = configSpecs['feature_gates'];

const expectedGates: unknown[] = [];
let expectedGates: unknown[] = [];
expectedGates.push(exampleConfigSpecs.gate);
expectedGates = expectedGates.concat(GatesForIdListTest);
expect(gates).toEqual(expectedGates);

// Check configs
Expand Down Expand Up @@ -324,7 +334,7 @@ describe('DataAdapter', () => {
name: 'Seattle Seahawks',
yearFounded: 1974,
});
})
});

it('still initializes id lists from the network', async () => {
isNetworkEnabled = true;
Expand Down Expand Up @@ -450,4 +460,56 @@ describe('DataAdapter', () => {
});
});
});

describe('when data adapter returns a raw object', () => {
const dataAdapter = new TestObjectDataAdapter();

beforeEach(() => {
StatsigInstanceUtils.setInstance(null);
});

afterEach(async () => {
statsig.shutdown();
});

it('fetches config specs from adapter when network is down', async () => {
await loadStore(dataAdapter);

// Initialize without network
await statsig.initialize('secret-key', {
localMode: true,
...statsigOptions,
dataAdapter,
});

// Check gates
expect(statsig.checkGateSync(user, 'nfl_gate')).toEqual(true);

// Check configs
const config = await statsig.getConfig(
user,
exampleConfigSpecs.config.name,
);
expect(config.getValue('seahawks', null)).toEqual({
name: 'Seattle Seahawks',
yearFounded: 1974,
});
});

it('fetches id lists from adapter when network is down', async () => {
await loadStore(dataAdapter);

// Initialize without network
await statsig.initialize('secret-key', {
localMode: true,
...statsigOptions,
dataAdapter,
});

// Check gates
expect(
statsig.checkGateSync({ userID: 'a-user' }, 'test_id_list'),
).toEqual(true);
});
});
});
23 changes: 23 additions & 0 deletions src/__tests__/TestDataAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,26 @@ export class TestSyncingDataAdapter extends TestDataAdapter {
return this.keysToSync.includes(key);
}
}

export class TestObjectDataAdapter {
public store: Record<string, object | string> = {};

get(key: string): Promise<AdapterResponse> {
return Promise.resolve({ result: this.store[key], time: Date.now() });
}
set(key: string, value: string, time?: number | undefined): Promise<void> {
if (key === DataAdapterKey.Rulesets || key === DataAdapterKey.IDLists) {
this.store[key] = JSON.parse(value);
} else {
this.store[key] = value;
}
return Promise.resolve();
}
initialize(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
this.store = {};
return Promise.resolve();
}
}
2 changes: 1 addition & 1 deletion src/interfaces/IDataAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type AdapterResponse = {
result?: string;
result?: string | object;
time?: number;
error?: Error;
};
Expand Down
6 changes: 4 additions & 2 deletions src/utils/IDListUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ export default abstract class IDListUtil {
return input as IDListsLookupResponse;
}

static parseBootstrapLookup(input: string): IDListsLookupBootstrap | null {
static parseBootstrapLookup(
input: string | object,
): IDListsLookupBootstrap | null {
try {
const result = JSON.parse(input);
const result = typeof input === 'string' ? JSON.parse(input) : input;
if (Array.isArray(result)) {
return result as IDListsLookupBootstrap;
}
Expand Down

0 comments on commit 0fa1a9d

Please sign in to comment.