Skip to content

Commit 3111c16

Browse files
committed
feat(usage): Add a FeatureUsageStore and move Identity to the DB
Summary: This is a WIP Depends on D3799 on billing.nylas.com This adds a `FeatureUsageStore` which determines whether a feature can be used or not. It also allows us to record "using" a feature. Feature Usage is ultimately backed by the Nylas Identity and cached locally in the Identity object. Since feature usage is attached to the Nylas Identity, we move the whole Identity object (except for the ID) to the database. This includes a migration (with tests!) to move the Nylas Identity from the config into the Database. We still, however, need the Nylas ID to stay in the config so it can be synchronously accessed by the /browser process on bootup when determining what windows to show. It's also convenient to know what the Nylas ID is by looking at the config. There's logic (with tests!) to make sure these stay in sync. If you delete the Nylas ID from the config, it'll be the same as logging you out. The schema for the feature usage can be found in more detail on D3799. By the time it reaches Nylas Mail, the Nylas ID object has a `feature_usage` attribute that has each feature (keyed by the feature name) and information about the plans attached to it. The schema Nylas Mail sees looks like: ``` "feature_usage": { "snooze": { quota: 10, peroid: 'monthly', used_in_period: 8, feature_limit_name: 'Snooze Group A', }, } ``` See D3799 for more info about how these are generated. One final change that's in here is how Stores are loaded. Most of our core stores are loaded at require time, but now things like the IdentityStore need to do asynchronous things on activation. In reality most of our stores do this and it's a miracle it hasn't caused more problems! Now when stores activate we optionally look for an `activate` method and `await` for it. This was necessary so downstream classes (like the Onboarding Store), see a fully initialized IdentityStore by the time it's time to use them Test Plan: New tests! Reviewers: khamidou, juan, halla Reviewed By: juan Differential Revision: https://phab.nylas.com/D3808
1 parent 5a38305 commit 3111c16

16 files changed

+499
-104
lines changed

internal_packages/onboarding/lib/onboarding-store.es6

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,10 @@ class OnboardingStore extends NylasStore {
137137
this.trigger();
138138
}
139139

140-
_onAuthenticationJSONReceived = (json) => {
140+
_onAuthenticationJSONReceived = async (json) => {
141141
const isFirstAccount = AccountStore.accounts().length === 0;
142142

143-
Actions.setNylasIdentity(json);
143+
await IdentityStore.saveIdentity(json);
144144

145145
setTimeout(() => {
146146
if (isFirstAccount) {

spec/auto-update-manager-spec.coffee

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ describe "AutoUpdateManager", ->
1111
get: (key) =>
1212
if key is 'nylas.accounts'
1313
return @accounts
14-
if key is 'nylas.identity.id'
15-
return @nylasIdentityId
1614
if key is 'env'
1715
return 'production'
1816
onDidChange: (key, callback) =>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {TaskQueueStatusStore} from 'nylas-exports'
2+
import FeatureUsageStore from '../../src/flux/stores/feature-usage-store'
3+
import Task from '../../src/flux/tasks/task'
4+
import SendFeatureUsageEventTask from '../../src/flux/tasks/send-feature-usage-event-task'
5+
import IdentityStore from '../../src/flux/stores/identity-store'
6+
7+
describe("FeatureUsageStore", function featureUsageStoreSpec() {
8+
beforeEach(() => {
9+
this.oldIdent = IdentityStore._identity;
10+
IdentityStore._identity = {id: 'foo'}
11+
IdentityStore._identity.feature_usage = {
12+
"is-usable": {
13+
quota: 10,
14+
peroid: 'monthly',
15+
used_in_period: 8,
16+
feature_limit_name: 'Usable Group A',
17+
},
18+
"not-usable": {
19+
quota: 10,
20+
peroid: 'monthly',
21+
used_in_period: 10,
22+
feature_limit_name: 'Unusable Group A',
23+
},
24+
}
25+
});
26+
27+
afterEach(() => {
28+
IdentityStore._identity = this.oldIdent
29+
});
30+
31+
describe("isUsable", () => {
32+
it("returns true if a feature hasn't met it's quota", () => {
33+
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true)
34+
});
35+
36+
it("returns false if a feature is at its quota", () => {
37+
expect(FeatureUsageStore.isUsable("not-usable")).toBe(false)
38+
});
39+
40+
it("warns if asking for an unsupported feature", () => {
41+
spyOn(NylasEnv, "reportError")
42+
expect(FeatureUsageStore.isUsable("unsupported")).toBe(false)
43+
expect(NylasEnv.reportError).toHaveBeenCalled()
44+
});
45+
});
46+
47+
describe("useFeature", () => {
48+
beforeEach(() => {
49+
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success));
50+
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => {
51+
IdentityStore._identity = ident
52+
})
53+
spyOn(TaskQueueStatusStore, "waitForPerformLocal").andReturn(Promise.resolve())
54+
});
55+
56+
it("returns the num remaining if successful", async () => {
57+
let numLeft = await FeatureUsageStore.useFeature('is-usable');
58+
expect(numLeft).toBe(1)
59+
numLeft = await FeatureUsageStore.useFeature('is-usable');
60+
expect(numLeft).toBe(0)
61+
});
62+
63+
it("throws if it was over quota", async () => {
64+
try {
65+
await FeatureUsageStore.useFeature("not-usable");
66+
throw new Error("This should throw")
67+
} catch (err) {
68+
expect(err.message).toMatch(/not usable/)
69+
}
70+
});
71+
72+
it("throws if using an unsupported feature", async () => {
73+
spyOn(NylasEnv, "reportError")
74+
try {
75+
await FeatureUsageStore.useFeature("unsupported");
76+
throw new Error("This should throw")
77+
} catch (err) {
78+
expect(err.message).toMatch(/supported/)
79+
}
80+
});
81+
});
82+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {ipcRenderer} from 'electron';
2+
import {KeyManager, DatabaseTransaction, SendFeatureUsageEventTask} from 'nylas-exports'
3+
import IdentityStore from '../../src/flux/stores/identity-store'
4+
5+
const TEST_NYLAS_ID = "icihsnqh4pwujyqihlrj70vh"
6+
const TEST_TOKEN = "test-token"
7+
8+
describe("IdentityStore", function identityStoreSpec() {
9+
beforeEach(() => {
10+
this.identityJSON = {
11+
valid_until: 1500093224,
12+
firstname: "Nylas 050",
13+
lastname: "Test",
14+
free_until: 1500006814,
15+
email: "nylas050test@evanmorikawa.com",
16+
id: TEST_NYLAS_ID,
17+
seen_welcome_page: true,
18+
}
19+
});
20+
21+
it("logs out of nylas identity properly", async () => {
22+
IdentityStore._identity = this.identityJSON;
23+
spyOn(NylasEnv.config, 'unset')
24+
spyOn(KeyManager, "deletePassword")
25+
spyOn(ipcRenderer, "send")
26+
spyOn(DatabaseTransaction.prototype, "persistJSONBlob").andReturn(Promise.resolve())
27+
28+
const promise = IdentityStore._onLogoutNylasIdentity()
29+
IdentityStore._onIdentityChanged(null)
30+
return promise.then(() => {
31+
expect(KeyManager.deletePassword).toHaveBeenCalled()
32+
expect(ipcRenderer.send).toHaveBeenCalled()
33+
expect(ipcRenderer.send.calls[0].args[1]).toBe("application:relaunch-to-initial-windows")
34+
expect(DatabaseTransaction.prototype.persistJSONBlob).toHaveBeenCalled()
35+
const ident = DatabaseTransaction.prototype.persistJSONBlob.calls[0].args[1]
36+
expect(ident).toBe(null)
37+
})
38+
});
39+
40+
it("can log a feature usage event", () => {
41+
spyOn(IdentityStore, "nylasIDRequest");
42+
spyOn(IdentityStore, "saveIdentity");
43+
IdentityStore._identity = this.identityJSON
44+
IdentityStore._identity.token = TEST_TOKEN;
45+
IdentityStore._onEnvChanged()
46+
const t = new SendFeatureUsageEventTask("snooze");
47+
t.performRemote()
48+
const opts = IdentityStore.nylasIDRequest.calls[0].args[0]
49+
expect(opts).toEqual({
50+
method: "POST",
51+
url: "https://billing.nylas.com/n1/user/feature_usage_event",
52+
body: {
53+
feature_name: 'snooze',
54+
},
55+
})
56+
});
57+
58+
describe("returning the identity object", () => {
59+
it("returns the identity as null if it looks blank", () => {
60+
IdentityStore._identity = null;
61+
expect(IdentityStore.identity()).toBe(null);
62+
IdentityStore._identity = {};
63+
expect(IdentityStore.identity()).toBe(null);
64+
IdentityStore._identity = {token: 'bad'};
65+
expect(IdentityStore.identity()).toBe(null);
66+
});
67+
68+
it("returns a proper clone of the identity", () => {
69+
IdentityStore._identity = {id: 'bar', deep: {obj: 'baz'}};
70+
const ident = IdentityStore.identity();
71+
IdentityStore._identity.deep.obj = 'changed';
72+
expect(ident.deep.obj).toBe('baz');
73+
});
74+
});
75+
});

src/K2

Submodule K2 updated from d85ca0a to ae7815e

src/browser/application.es6

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {EventEmitter} from 'events';
1010

1111
import WindowManager from './window-manager';
1212
import FileListCache from './file-list-cache';
13+
import DatabaseReader from './database-reader';
14+
import ConfigMigrator from './config-migrator';
1315
import ApplicationMenu from './application-menu';
1416
import AutoUpdateManager from './auto-update-manager';
1517
import SystemTrayManager from './system-tray-manager';
@@ -24,7 +26,7 @@ let clipboard = null;
2426
// The application's singleton class.
2527
//
2628
export default class Application extends EventEmitter {
27-
start(options) {
29+
async start(options) {
2830
const {resourcePath, configDirPath, version, devMode, specMode, safeMode} = options;
2931

3032
// Normalize to make sure drive letter case is consistent on Windows
@@ -38,12 +40,18 @@ export default class Application extends EventEmitter {
3840
this.fileListCache = new FileListCache();
3941
this.nylasProtocolHandler = new NylasProtocolHandler(this.resourcePath, this.safeMode);
4042

43+
this.databaseReader = new DatabaseReader({configDirPath, specMode});
44+
await this.databaseReader.open();
45+
4146
const Config = require('../config');
4247
const config = new Config();
4348
this.config = config;
4449
this.configPersistenceManager = new ConfigPersistenceManager({configDirPath, resourcePath});
4550
config.load();
4651

52+
this.configMigrator = new ConfigMigrator(this.config, this.databaseReader);
53+
this.configMigrator.migrate()
54+
4755
this.packageMigrationManager = new PackageMigrationManager({config, configDirPath, version})
4856
this.packageMigrationManager.migrate()
4957

@@ -52,7 +60,7 @@ export default class Application extends EventEmitter {
5260
initializeInBackground = false;
5361
}
5462

55-
this.autoUpdateManager = new AutoUpdateManager(version, config, specMode);
63+
this.autoUpdateManager = new AutoUpdateManager(version, config, specMode, this.databaseReader);
5664
this.applicationMenu = new ApplicationMenu(version);
5765
this.windowManager = new WindowManager({
5866
resourcePath: this.resourcePath,
@@ -123,7 +131,6 @@ export default class Application extends EventEmitter {
123131
}
124132
}
125133

126-
127134
// On Windows, removing a file can fail if a process still has it open. When
128135
// we close windows and log out, we need to wait for these processes to completely
129136
// exit and then delete the file. It's hard to tell when this happens, so we just
@@ -160,7 +167,7 @@ export default class Application extends EventEmitter {
160167
openWindowsForTokenState() {
161168
const accounts = this.config.get('nylas.accounts');
162169
const hasAccount = accounts && accounts.length > 0;
163-
const hasN1ID = this.config.get('nylas.identity.id');
170+
const hasN1ID = this._getNylasId();
164171

165172
if (hasAccount && hasN1ID) {
166173
this.windowManager.ensureWindow(WindowManager.MAIN_WINDOW);
@@ -173,7 +180,14 @@ export default class Application extends EventEmitter {
173180
}
174181
}
175182

183+
_getNylasId() {
184+
const identity = this.databaseReader.getJSONBlob("NylasID") || {}
185+
return identity.id
186+
}
187+
176188
_relaunchToInitialWindows = ({resetConfig, resetDatabase} = {}) => {
189+
// This will re-fetch the NylasID to update the feed url
190+
this.autoUpdateManager.updateFeedURL()
177191
this.setDatabasePhase('close');
178192
this.windowManager.destroyAllWindows();
179193

@@ -270,6 +284,10 @@ export default class Application extends EventEmitter {
270284

271285
this.on('application:relaunch-to-initial-windows', this._relaunchToInitialWindows);
272286

287+
this.on('application:onIdentityChanged', () => {
288+
this.autoUpdateManager.updateFeedURL()
289+
});
290+
273291
this.on('application:quit', () => {
274292
app.quit()
275293
});

src/browser/auto-update-manager.es6

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,28 @@ const preferredChannel = 'nylas-mail'
1818

1919
export default class AutoUpdateManager extends EventEmitter {
2020

21-
constructor(version, config, specMode) {
21+
constructor(version, config, specMode, databaseReader) {
2222
super();
2323

2424
this.state = IdleState;
2525
this.version = version;
2626
this.config = config;
27+
this.databaseReader = databaseReader
2728
this.specMode = specMode;
2829
this.preferredChannel = preferredChannel;
2930

30-
this._updateFeedURL();
31+
this.updateFeedURL();
3132

32-
this.config.onDidChange(
33-
'nylas.identity.id',
34-
this._updateFeedURL
35-
);
3633
this.config.onDidChange(
3734
'nylas.accounts',
38-
this._updateFeedURL
35+
this.updateFeedURL
3936
);
4037

4138
setTimeout(() => this.setupAutoUpdater(), 0);
4239
}
4340

4441
parameters = () => {
45-
let updaterId = this.config.get("nylas.identity.id");
42+
let updaterId = (this.databaseReader.getJSONBlob("NylasID") || {}).id
4643
if (!updaterId) {
4744
updaterId = "anonymous";
4845
}
@@ -66,7 +63,7 @@ export default class AutoUpdateManager extends EventEmitter {
6663
};
6764
}
6865

69-
_updateFeedURL = () => {
66+
updateFeedURL = () => {
7067
const params = this.parameters();
7168

7269
let host = `edgehill.nylas.com`;

src/browser/config-migrator.es6

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export default class ConfigMigrator {
2+
constructor(config, database) {
3+
this.config = config;
4+
this.database = database;
5+
}
6+
7+
migrate() {
8+
/**
9+
* In version before 1.0.21 we stored the Nylas ID Identity in the Config.
10+
* After 1.0.21 we moved it into the JSONBlob Database Store.
11+
*/
12+
const oldIdentity = this.config.get("nylas.identity") || {};
13+
if (!oldIdentity.id) return;
14+
const key = "NylasID"
15+
const q = `REPLACE INTO JSONBlob (id, data, client_id) VALUES (?,?,?)`;
16+
const jsonBlobData = {
17+
id: key,
18+
clientId: key,
19+
serverId: key,
20+
json: oldIdentity,
21+
}
22+
this.database.database.prepare(q).run([key, JSON.stringify(jsonBlobData), key])
23+
this.config.set("nylas.identity", null)
24+
}
25+
}

src/browser/database-reader.es6

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {setupDatabase, databasePath} from '../database-helpers'
2+
3+
export default class DatabaseReader {
4+
constructor({configDirPath, specMode}) {
5+
this.databasePath = databasePath(configDirPath, specMode)
6+
}
7+
8+
async open() {
9+
this.database = await setupDatabase(this.databasePath)
10+
}
11+
12+
getJSONBlob(key) {
13+
const q = `SELECT * FROM JSONBlob WHERE id = '${key}'`;
14+
try {
15+
const row = this.database.prepare(q).get();
16+
if (!row || !row.data) return null
17+
return (JSON.parse(row.data) || {}).json
18+
} catch (err) {
19+
return null
20+
}
21+
}
22+
}

src/flux/actions.es6

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ class Actions {
149149
/*
150150
Public: Manage the Nylas identity
151151
*/
152-
static setNylasIdentity = ActionScopeWindow;
153152
static logoutNylasIdentity = ActionScopeWindow;
154153

155154
/*

0 commit comments

Comments
 (0)