Skip to content

Commit 5b37d28

Browse files
committed
Added currentCountQuery support for flag limits to grandfather usage
ref BAE-336 ref BAE-330 The flag limits were previously simple on/off switches that would disable features entirely when enabled. This would cause issues when introducing new limits to existing plans where customers are already using the affected features, as it would immediately block their access upon implementation of new pricing. This commit adds support for a currentCountQuery function to flag limits, allowing them to check if a feature is already in use. When a feature is detected as being in use, the limit won't be enforced, effectively grandfathering existing usage. The limitAnalytics limit now includes this query to check both settings and labs flags for the trafficAnalytics feature. This change ensures that existing customers who are already using features won't lose access when new limits are introduced with the upcoming pricing changes, improving the user experience during plan transitions while still enforcing limits for new feature adoption.
1 parent b107739 commit 5b37d28

File tree

5 files changed

+191
-22
lines changed

5 files changed

+191
-22
lines changed

packages/limit-service/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ db.transaction((transacting) => {
158158

159159
### Types of limits
160160
At the moment there are four different types of limits that limit service allows to define. These types are:
161-
1. `flag` - is an "on/off" switch for certain feature. Example usecase: "disable all emails". It's identified by a `disabled: true` property in the "limits" configuration.
161+
1. `flag` - is an "on/off" switch for certain feature. Example usecase: "disable all emails". It's identified by a `disabled: true` property in the "limits" configuration. It is possible to overwrite the limit by providing a `currentCountQuery` for it. This is useful in cases where we introduce new limits to existing plans and customers have already been using the feature affected by the limit. By providing a `currentCountQuery` that detects if the feature is already in use, we won't disable it.
162162
2. `max` - checks if the maximum amount of the resource has been used up.Example usecase: "disable creating a staff user when maximum of 5 has been reached". To configure this limit add `max: NUMBER` to the configuration. The limits that support max checks are: `members`, and `staff`
163163
3. `maxPeriodic` - it's a variation of `max` type with a difference that the check is done over certain period of time. Example usecase: "disable sending emails when the sent emails count has acceded a limit for last billing period". To enable this limit define `maxPeriodic: NUMBER` in the limit configuration and provide a subscription configuration when initializing the limit service instance. The subscription object comes as a separate parameter and has to contain two properties: `startDate` and `interval`, where `startDate` is a date in ISO 8601 format and period is `'month'` (other values like `'year'` are not supported yet)
164164
4. `allowList` - checks if provided value is defined in configured "allowlist". Example usecase: "disable theme activation if it is not an official theme". To configure this limit define ` allowlist: ['VALUE_1', 'VALUE_2', 'VALUE_N']` property in the "limits" parameter.

packages/limit-service/lib/config.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ module.exports = {
5757
formatter: count => `${count / 1000000}MB`
5858
},
5959
limitStripeConnect: {},
60-
limitAnalytics: {},
60+
limitAnalytics: {
61+
currentCountQuery: async (knex) => {
62+
const key = 'trafficAnalytics';
63+
const settings = await knex('settings');
64+
const setting = settings.find(s => s.key === key);
65+
const labSettings = settings.find(s => s.key === 'labs');
66+
const labsSetting = labSettings && labSettings.value && labSettings.value[key];
67+
const labsSettingEnabled = labsSetting === true;
68+
69+
return (setting && setting.value === 'true') || labsSettingEnabled;
70+
}
71+
},
6172
limitActivityPub: {}
6273
};

packages/limit-service/lib/limit.js

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ class FlagLimit extends Limit {
264264
* @param {Number} options.config.disabled - disabled/enabled flag for the limit
265265
* @param {String} options.config.error - error message to use when limit is reached
266266
* @param {String} options.helpLink - URL to the resource explaining how the limit works
267+
* @param {Function} [options.config.currentCountQuery] - query checking the state that would be compared against the limit
267268
* @param {Object} [options.db] - instance of knex db connection that currentCountQuery can use to run state check through
268269
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
269270
*/
@@ -273,6 +274,7 @@ class FlagLimit extends Limit {
273274

274275
this.disabled = config.disabled;
275276
this.fallbackMessage = `Your plan does not support ${userFacingLimitName}. Please upgrade to enable ${userFacingLimitName}.`;
277+
this.currentCountQueryFn = config?.currentCountQuery || null;
276278
}
277279

278280
generateError() {
@@ -288,20 +290,59 @@ class FlagLimit extends Limit {
288290
}
289291

290292
/**
291-
* Flag limits are on/off so using a feature is always over the limit
293+
* @param {Object} [options]
294+
* @param {Object} [options.transacting] Transaction to run the count query on
295+
* @returns {Promise<boolean>} - returns the current count of items that would be compared against the limit
292296
*/
293-
async errorIfWouldGoOverLimit() {
294-
if (this.disabled) {
297+
async currentCountQuery(options = {}) {
298+
if (!this.currentCountQueryFn || typeof this.currentCountQueryFn !== 'function') {
299+
return false;
300+
}
301+
302+
return await this.currentCountQueryFn(options.transacting ?? this.db?.knex);
303+
}
304+
305+
// As Flag limits are on/off, we won't check against max values.
306+
// `errorIfWouldGoOverLimit` and `errorIfIsOverLimit` end up doing the same thing.
307+
async _isOrWouldOverLimitError(options = {}) {
308+
if (!this.disabled) {
309+
return;
310+
}
311+
312+
// If no currentCountQuery is provided, throw error when disabled
313+
if (!this.currentCountQueryFn || typeof this.currentCountQueryFn !== 'function') {
295314
throw this.generateError();
296315
}
316+
317+
// If currentCountQuery is provided, check if feature is in use
318+
const featureInUse = await this.currentCountQuery(options);
319+
320+
// Only throw error if feature is NOT in use (allowing grandfathering)
321+
if (!featureInUse) {
322+
throw this.generateError();
323+
}
324+
}
325+
326+
/**
327+
* Flag limits are usually on/off so using a feature is always over the limit,
328+
* unless the limit has a currentCountQuery function provided to check if the
329+
* feature is in use. This is a use case for when we introduce a new limit and
330+
* customers have already been using this feature. We don't want to take it
331+
* away from them.
332+
*/
333+
async errorIfWouldGoOverLimit(options = {}) {
334+
await this._isOrWouldOverLimitError(options);
297335
}
298336

299337
/**
300338
* Flag limits are on/off. They don't necessarily mean the limit wasn't possible to reach
301-
* NOTE: this method should not be relied on as it's impossible to check the limit was surpassed!
339+
* Exception: the limit has a currentCountQuery function provided to check if the
340+
* feature is in use. This is a use case for when we introduce a new limit and
341+
* customers have already been using this feature. We don't want to take it
342+
* away from them.
302343
*/
303-
async errorIfIsOverLimit() {
304-
return;
344+
async errorIfIsOverLimit(options = {}) {
345+
await this._isOrWouldOverLimitError(options);
305346
}
306347
}
307348

packages/limit-service/test/LimitService.test.js

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,24 @@ describe('Limit Service', function () {
131131
let limits = {
132132
staff: {max: 2},
133133
members: {max: 100},
134-
emails: {disabled: true}
134+
emails: {disabled: true},
135+
limitStripeConnect: {disabled: true},
136+
limitActivityPub: {disabled: true}
135137
};
136138

137139
limitService.loadLimits({limits, errors});
138140

139-
limitService.limits.should.be.an.Object().with.properties(['staff', 'members']);
141+
limitService.limits.should.be.an.Object().with.properties(['staff', 'members', 'emails', 'limitStripeConnect', 'limitActivityPub']);
140142
limitService.limits.staff.should.be.an.instanceOf(MaxLimit);
141143
limitService.limits.members.should.be.an.instanceOf(MaxLimit);
144+
limitService.limits.emails.should.be.an.instanceOf(FlagLimit);
145+
limitService.limits.limitStripeConnect.should.be.an.instanceOf(FlagLimit);
146+
limitService.limits.limitActivityPub.should.be.an.instanceOf(FlagLimit);
142147
limitService.isLimited('staff').should.be.true();
143148
limitService.isLimited('members').should.be.true();
144149
limitService.isLimited('emails').should.be.true();
150+
limitService.isLimited('limitStripeConnect').should.be.true();
151+
limitService.isLimited('limitActivityPub').should.be.true();
145152
});
146153

147154
it('can load camel cased limits', function () {
@@ -255,6 +262,12 @@ describe('Limit Service', function () {
255262
},
256263
customIntegrations: {
257264
disabled: true
265+
},
266+
limitStripeConnect: {
267+
disabled: true
268+
},
269+
limitActivityPub: {
270+
disabled: true
258271
}
259272
};
260273

@@ -268,7 +281,7 @@ describe('Limit Service', function () {
268281
(await limitService.checkIfAnyOverLimit()).should.be.true();
269282
});
270283

271-
it('Does not confirm if no limits are acceded', async function () {
284+
it('Confirms when a flag limit without currentCountQuery is disabled', async function () {
272285
const limitService = new LimitService();
273286

274287
let limits = {
@@ -288,10 +301,21 @@ describe('Limit Service', function () {
288301
// customThemes: {
289302
// allowlist: ['casper', 'dawn', 'lyra']
290303
// },
291-
// NOTE: the flag limit has flawed assumption of not being acceded previously
292-
// this test might fail when the flaw is addressed
293304
customIntegrations: {
294305
disabled: true
306+
// No currentCountQuery - will be considered over limit
307+
},
308+
limitAnalytics: {
309+
disabled: true,
310+
currentCountQuery: () => true // Feature is in use, so limit won't be exceeded (grandfathered)
311+
},
312+
limitStripeConnect: {
313+
disabled: true
314+
// No currentCountQuery - will be considered over limit
315+
},
316+
limitActivityPub: {
317+
disabled: true
318+
// No currentCountQuery - will be considered over limit
295319
}
296320
};
297321

@@ -302,7 +326,8 @@ describe('Limit Service', function () {
302326

303327
limitService.loadLimits({limits, errors, subscription});
304328

305-
(await limitService.checkIfAnyOverLimit()).should.be.false();
329+
// Should return true because customIntegrations is disabled without currentCountQuery
330+
(await limitService.checkIfAnyOverLimit()).should.be.true();
306331
});
307332

308333
it('Returns nothing if limit is not configured', async function () {
@@ -480,7 +505,17 @@ describe('Limit Service', function () {
480505
currentCountQuery: () => 3
481506
},
482507
customIntegrations: {
483-
disabled: true
508+
disabled: false // Not disabled, so won't be over limit
509+
},
510+
limitAnalytics: {
511+
disabled: true,
512+
currentCountQuery: () => true // Feature is in use, so limit won't be exceeded (grandfathered)
513+
},
514+
limitStripeConnect: {
515+
disabled: false // Not disabled, so won't be over limit
516+
},
517+
limitActivityPub: {
518+
disabled: false // Not disabled, so won't be over limit
484519
}
485520
};
486521

@@ -499,9 +534,11 @@ describe('Limit Service', function () {
499534
testData: 'true'
500535
};
501536

537+
// Should return false because no limits are exceeded
502538
(await limitService.checkIfAnyOverLimit(options)).should.be.false();
503539

504-
sinon.assert.callCount(flagSpy, 1);
540+
// We have 4 flag limits now: customIntegrations, limitAnalytics, limitStripeConnect, and limitActivityPub
541+
sinon.assert.callCount(flagSpy, 4);
505542
sinon.assert.alwaysCalledWithExactly(flagSpy, options);
506543

507544
sinon.assert.callCount(maxSpy, 2);

packages/limit-service/test/limit.test.js

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,24 @@ const {MaxLimit, AllowlistLimit, FlagLimit, MaxPeriodicLimit} = require('../lib/
99

1010
describe('Limit Service', function () {
1111
describe('Flag Limit', function () {
12-
it('do nothing if is over limit', async function () {
13-
// NOTE: the behavior of flag limit in "is over limit" usecase is flawed and should not be relied on
14-
// possible solution could be throwing an error to prevent clients from using it?
12+
it('throws if is over limit when disabled', async function () {
1513
const config = {
1614
disabled: true
1715
};
18-
const limit = new FlagLimit({name: 'flaggy', config, errors});
16+
const limit = new FlagLimit({name: 'limitFlaggy', config, errors});
1917

20-
const result = await limit.errorIfIsOverLimit();
21-
should(result).be.undefined();
18+
try {
19+
await limit.errorIfIsOverLimit();
20+
should.fail(limit, 'Should have errored');
21+
} catch (err) {
22+
should.exist(err);
23+
should.exist(err.errorType);
24+
should.equal(err.errorType, 'HostLimitError');
25+
should.exist(err.errorDetails);
26+
should.equal(err.errorDetails.name, 'limitFlaggy');
27+
should.exist(err.message);
28+
should.equal(err.message, 'Your plan does not support flaggy. Please upgrade to enable flaggy.');
29+
}
2230
});
2331

2432
it('throws if would go over limit', async function () {
@@ -43,6 +51,78 @@ describe('Limit Service', function () {
4351
should.equal(err.message, 'Your plan does not support flaggy. Please upgrade to enable flaggy.');
4452
}
4553
});
54+
55+
it('does not throw if feature is in use when currentCountQuery returns true', async function () {
56+
const config = {
57+
disabled: true,
58+
currentCountQuery: () => true
59+
};
60+
const limit = new FlagLimit({name: 'flaggy', config, errors});
61+
62+
const result = await limit.errorIfIsOverLimit();
63+
should(result).be.undefined();
64+
});
65+
66+
it('throws if feature is not in use when currentCountQuery returns false', async function () {
67+
const config = {
68+
disabled: true,
69+
currentCountQuery: () => false
70+
};
71+
const limit = new FlagLimit({name: 'limitFlaggy', config, errors});
72+
73+
try {
74+
await limit.errorIfIsOverLimit();
75+
should.fail(limit, 'Should have errored');
76+
} catch (err) {
77+
should.exist(err);
78+
should.equal(err.errorType, 'HostLimitError');
79+
}
80+
});
81+
82+
it('calls currentCountQuery with transacting option', async function () {
83+
const currentCountQueryStub = sinon.stub().resolves(true);
84+
const config = {
85+
disabled: true,
86+
currentCountQuery: currentCountQueryStub
87+
};
88+
const db = {
89+
knex: 'connection'
90+
};
91+
const limit = new FlagLimit({name: 'flaggy', config, db, errors});
92+
const transaction = 'transaction';
93+
94+
await limit.errorIfIsOverLimit({transacting: transaction});
95+
96+
sinon.assert.calledOnce(currentCountQueryStub);
97+
sinon.assert.calledWithExactly(currentCountQueryStub, transaction);
98+
});
99+
100+
it('errorIfWouldGoOverLimit behaves the same as errorIfIsOverLimit', async function () {
101+
const config = {
102+
disabled: true,
103+
currentCountQuery: () => true
104+
};
105+
const limit = new FlagLimit({name: 'flaggy', config, errors});
106+
107+
// Should not throw when feature is in use
108+
const result = await limit.errorIfWouldGoOverLimit();
109+
should(result).be.undefined();
110+
111+
// Should throw when feature is not in use
112+
const config2 = {
113+
disabled: true,
114+
currentCountQuery: () => false
115+
};
116+
const limit2 = new FlagLimit({name: 'limitFlaggy', config: config2, errors});
117+
118+
try {
119+
await limit2.errorIfWouldGoOverLimit();
120+
should.fail(limit2, 'Should have errored');
121+
} catch (err) {
122+
should.exist(err);
123+
should.equal(err.errorType, 'HostLimitError');
124+
}
125+
});
46126
});
47127

48128
describe('Max Limit', function () {

0 commit comments

Comments
 (0)