@vida-global/core 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/activeRecord/README.md +2 -6
- package/lib/activeRecord/baseRecord.js +68 -33
- package/lib/activeRecord/db/migrator.js +14 -0
- package/lib/cache/memoryCache.js +31 -7
- package/package.json +1 -1
- package/test/activeRecord/baseRecord.test.js +125 -3
- package/test/activeRecord/db/migrator.test.js +21 -0
- package/test/activeRecord/helpers/baseRecordMocks.js +23 -4
- package/test/cache/memoryCache.test.js +76 -0
|
@@ -98,7 +98,7 @@ module.exports = {
|
|
|
98
98
|
Available migration helpers:
|
|
99
99
|
|
|
100
100
|
```js
|
|
101
|
-
createTable(tableName, details, { timestamps })
|
|
101
|
+
createTable(tableName, details, { timestamps, paranoid })
|
|
102
102
|
dropTable(tableName)
|
|
103
103
|
addColumn(tableName, columnName, columnDetails)
|
|
104
104
|
removeColumn(tableName, columnName)
|
|
@@ -111,6 +111,7 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
|
111
111
|
**Conventions:**
|
|
112
112
|
|
|
113
113
|
- `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
|
|
114
|
+
- Pass `{ paranoid: true }` to add a nullable `deleted_at` column. Models over that table soft delete: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at`, so `destroy()` sets the column and ordinary queries skip the row. Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a deleted row will still block re-creating the same one.
|
|
114
115
|
- Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
|
|
115
116
|
- Each migration runs inside a transaction.
|
|
116
117
|
|
|
@@ -307,8 +308,3 @@ class User extends ActiveRecord.BaseRecord {
|
|
|
307
308
|
```
|
|
308
309
|
|
|
309
310
|
See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
## TODO
|
|
313
|
-
|
|
314
|
-
- **Paranoid (soft delete).** The infrastructure for a `deleted_at` column is wired in `initializationOptions`, but `paranoid: true` is not set, so soft deletes are not yet usable out of the box.
|
|
@@ -337,30 +337,39 @@ class BaseRecord extends Model {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
|
|
340
|
-
static
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
marshalledValue = JSON.stringify(value.toJSON());
|
|
344
|
-
marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
|
|
345
|
-
} else {
|
|
346
|
-
marshalledValue = JSON.stringify(value);
|
|
347
|
-
}
|
|
348
|
-
return marshalledValue;
|
|
340
|
+
static async clearCacheKey(key) {
|
|
341
|
+
const client = await this._getRedisClient();
|
|
342
|
+
await client.del(key);
|
|
349
343
|
}
|
|
350
344
|
|
|
351
345
|
|
|
352
|
-
static
|
|
353
|
-
|
|
354
|
-
|
|
346
|
+
static async forEachCacheKeyByPattern(pattern, callback, count=100) {
|
|
347
|
+
const client = await this._getRedisClient();
|
|
348
|
+
let batch = [];
|
|
355
349
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return JSON.parse(value);
|
|
350
|
+
for await (const scanned of client.scanIterator({ MATCH: pattern, COUNT: count })) {
|
|
351
|
+
batch.push(...this._scannedKeysAsArray(scanned));
|
|
352
|
+
if (batch.length < count) continue;
|
|
353
|
+
|
|
354
|
+
await callback(batch, client);
|
|
355
|
+
batch = [];
|
|
363
356
|
}
|
|
357
|
+
|
|
358
|
+
if (batch.length) await callback(batch, client);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
// redis 4 yields one key at a time; redis 5 yields an array of keys per scan.
|
|
363
|
+
static _scannedKeysAsArray(scanned) {
|
|
364
|
+
if (Array.isArray(scanned)) return scanned;
|
|
365
|
+
return [scanned];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
static async clearCacheKeysByPattern(pattern) {
|
|
370
|
+
await this.forEachCacheKeyByPattern(pattern, async (keys, client) => {
|
|
371
|
+
await client.del(keys);
|
|
372
|
+
});
|
|
364
373
|
}
|
|
365
374
|
|
|
366
375
|
|
|
@@ -369,9 +378,8 @@ class BaseRecord extends Model {
|
|
|
369
378
|
|
|
370
379
|
const pk = this.constructor.primaryKeyAttribute;
|
|
371
380
|
const key = this.constructor._recordCacheKey(this[pk]);
|
|
372
|
-
|
|
381
|
+
await this.constructor.clearCacheKey(key);
|
|
373
382
|
|
|
374
|
-
await client.del(key);
|
|
375
383
|
this.constructor.debugLog('Cache Del', key);
|
|
376
384
|
}
|
|
377
385
|
|
|
@@ -389,18 +397,6 @@ class BaseRecord extends Model {
|
|
|
389
397
|
}
|
|
390
398
|
|
|
391
399
|
|
|
392
|
-
static get redisClientFactory() {
|
|
393
|
-
return redisClientFactory;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
static async _getRedisClient() {
|
|
398
|
-
const client = this.redisClientFactory();
|
|
399
|
-
await client.connect();
|
|
400
|
-
return client;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
400
|
static get isCacheable() { return false; }
|
|
405
401
|
|
|
406
402
|
|
|
@@ -416,6 +412,45 @@ class BaseRecord extends Model {
|
|
|
416
412
|
}
|
|
417
413
|
|
|
418
414
|
|
|
415
|
+
static _marshallDataForCaching(value) {
|
|
416
|
+
let marshalledValue;
|
|
417
|
+
if (value instanceof this) {
|
|
418
|
+
marshalledValue = JSON.stringify(value.toJSON());
|
|
419
|
+
marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
|
|
420
|
+
} else {
|
|
421
|
+
marshalledValue = JSON.stringify(value);
|
|
422
|
+
}
|
|
423
|
+
return marshalledValue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
static _unmarshallCachedData(value) {
|
|
428
|
+
if (value == null) return null;
|
|
429
|
+
if (typeof value != 'string') return value;
|
|
430
|
+
|
|
431
|
+
if (value.startsWith(this.recordCachingPrefix)) {
|
|
432
|
+
const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
|
|
433
|
+
value = value.replace(regExp, '');
|
|
434
|
+
const data = JSON.parse(value);
|
|
435
|
+
return new this(data, {isNewRecord: false});
|
|
436
|
+
} else {
|
|
437
|
+
return JSON.parse(value);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
static get _redisClientFactory() {
|
|
443
|
+
return redisClientFactory;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
static async _getRedisClient() {
|
|
448
|
+
const client = this._redisClientFactory();
|
|
449
|
+
await client.connect();
|
|
450
|
+
return client;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
419
454
|
/***********************************************************************************************
|
|
420
455
|
* MISC
|
|
421
456
|
***********************************************************************************************/
|
|
@@ -72,6 +72,9 @@ class Migrator {
|
|
|
72
72
|
if (options.timestamps !== false) {
|
|
73
73
|
details = {...details, ...this.defaultTimestampColumns};
|
|
74
74
|
}
|
|
75
|
+
if (options.paranoid) {
|
|
76
|
+
details = {...details, ...this.deletedAtColumn};
|
|
77
|
+
}
|
|
75
78
|
|
|
76
79
|
details = this.normalizeTableDetails(details);
|
|
77
80
|
await this.#sequelizeQueryInterface.createTable(tableName, details);
|
|
@@ -113,6 +116,17 @@ class Migrator {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
|
|
119
|
+
// Records use soft deletes when the table has this column: initializationOptions turns paranoid
|
|
120
|
+
// on for any model whose schema includes it.
|
|
121
|
+
get deletedAtColumn() {
|
|
122
|
+
return {
|
|
123
|
+
deleted_at: {
|
|
124
|
+
allowNull: true,
|
|
125
|
+
type: this.DataTypes.DATE }
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
116
130
|
async dropTable(tableName) {
|
|
117
131
|
await this.#sequelizeQueryInterface.dropTable(tableName);
|
|
118
132
|
}
|
package/lib/cache/memoryCache.js
CHANGED
|
@@ -14,6 +14,16 @@ class MemoryCache {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
async delete({ namespace, key } = {}) {
|
|
18
|
+
this.#validateNamespace(namespace);
|
|
19
|
+
|
|
20
|
+
const cacheKey = this.#createCacheKey({ namespace, key });
|
|
21
|
+
const entryDeleted = this.#entries.delete(cacheKey);
|
|
22
|
+
const fetchInvalidated = this.#removePendingFetch(cacheKey);
|
|
23
|
+
return entryDeleted || fetchInvalidated;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
17
27
|
async fetch(options = {}, fetcher) {
|
|
18
28
|
this.#validateNamespace(options.namespace);
|
|
19
29
|
this.#validateTtl(options.ttlSeconds);
|
|
@@ -26,13 +36,15 @@ class MemoryCache {
|
|
|
26
36
|
const pending = this.#pendingFetches.get(cacheKey);
|
|
27
37
|
if (pending) return this.#buildFetchResult(await pending, false);
|
|
28
38
|
|
|
29
|
-
const fetch = this.#
|
|
39
|
+
const fetch = this.#runFetcher(fetcher);
|
|
30
40
|
this.#pendingFetches.set(cacheKey, fetch);
|
|
31
41
|
|
|
32
42
|
try {
|
|
33
|
-
|
|
43
|
+
const value = await fetch;
|
|
44
|
+
this.#cacheFetchedValue(cacheKey, options.ttlSeconds, fetch, value);
|
|
45
|
+
return this.#buildFetchResult(value, false);
|
|
34
46
|
} finally {
|
|
35
|
-
this.#
|
|
47
|
+
this.#removePendingFetch(cacheKey, fetch);
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
50
|
|
|
@@ -46,10 +58,22 @@ class MemoryCache {
|
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
|
|
49
|
-
async #
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
async #runFetcher(fetcher) {
|
|
62
|
+
return fetcher();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#cacheFetchedValue(cacheKey, ttlSeconds, fetch, value) {
|
|
67
|
+
if (this.#pendingFetches.get(cacheKey) !== fetch) return;
|
|
68
|
+
this.#set(cacheKey, ttlSeconds, value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#removePendingFetch(cacheKey, expectedFetch) {
|
|
73
|
+
const currentFetch = this.#pendingFetches.get(cacheKey);
|
|
74
|
+
if (expectedFetch !== undefined && currentFetch !== expectedFetch) return false;
|
|
75
|
+
|
|
76
|
+
return this.#pendingFetches.delete(cacheKey);
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ const { Connection } = require('../../lib/activeRecord/db/connection'
|
|
|
3
3
|
const { getActiveRecordSchema } = require('../../lib/activeRecord/db/schema');
|
|
4
4
|
const importSchema = require('../../lib/activeRecord/db/importSchema');
|
|
5
5
|
const { Model, Sequelize } = require('sequelize');
|
|
6
|
-
const { mockRedisClientFactory } = require('./helpers/baseRecordMocks');
|
|
6
|
+
const { mockRedisClientFactory, mockScannedKeys, randomCacheKeys } = require('./helpers/baseRecordMocks');
|
|
7
7
|
const TestHelpers = require('@vida-global/test-helpers');
|
|
8
8
|
const { camelize } = require('inflection');
|
|
9
9
|
const Helpers = require('./helpers/baseRecord');
|
|
@@ -503,7 +503,7 @@ describe('BaseRecord', () => {
|
|
|
503
503
|
describe('Caching', () => {
|
|
504
504
|
class Person extends BaseRecord {
|
|
505
505
|
static get isCacheable() { return true; }
|
|
506
|
-
static get
|
|
506
|
+
static get _redisClientFactory() { return mockRedisClientFactory; }
|
|
507
507
|
}
|
|
508
508
|
let redisClient
|
|
509
509
|
beforeEach(() => {
|
|
@@ -772,6 +772,116 @@ describe('BaseRecord', () => {
|
|
|
772
772
|
});
|
|
773
773
|
|
|
774
774
|
|
|
775
|
+
describe('BaseRecord.clearCacheKey', () => {
|
|
776
|
+
it ('deletes the given key from cache', async () => {
|
|
777
|
+
const key = `Person#find:${Math.random()}`;
|
|
778
|
+
|
|
779
|
+
await Person.clearCacheKey(key);
|
|
780
|
+
expect(redisClient.del).toHaveBeenCalledTimes(1);
|
|
781
|
+
expect(redisClient.del).toHaveBeenCalledWith(key);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
describe('BaseRecord.forEachCacheKeyByPattern', () => {
|
|
787
|
+
const pattern = `Person#find:*`;
|
|
788
|
+
|
|
789
|
+
it ('scans with the given pattern and a default count of 100', async () => {
|
|
790
|
+
mockScannedKeys([]);
|
|
791
|
+
|
|
792
|
+
await Person.forEachCacheKeyByPattern(pattern, jest.fn());
|
|
793
|
+
|
|
794
|
+
expect(redisClient.scanIterator).toHaveBeenCalledTimes(1);
|
|
795
|
+
expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: 100 });
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it ('passes a custom count to the scan', async () => {
|
|
799
|
+
const count = TestHelpers.Faker.Math.randomNumber();
|
|
800
|
+
mockScannedKeys([]);
|
|
801
|
+
|
|
802
|
+
await Person.forEachCacheKeyByPattern(pattern, jest.fn(), count);
|
|
803
|
+
|
|
804
|
+
expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: count });
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it ('buffers the individually yielded keys into one batch per count', async () => {
|
|
808
|
+
const keys = randomCacheKeys(5);
|
|
809
|
+
const callback = jest.fn();
|
|
810
|
+
mockScannedKeys(keys);
|
|
811
|
+
|
|
812
|
+
await Person.forEachCacheKeyByPattern(pattern, callback, 2);
|
|
813
|
+
|
|
814
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
815
|
+
expect(callback).toHaveBeenNthCalledWith(1, keys.slice(0, 2), redisClient);
|
|
816
|
+
expect(callback).toHaveBeenNthCalledWith(2, keys.slice(2, 4), redisClient);
|
|
817
|
+
expect(callback).toHaveBeenNthCalledWith(3, keys.slice(4), redisClient);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it ('invokes the callback once when fewer keys than the count are scanned', async () => {
|
|
821
|
+
const keys = randomCacheKeys(2);
|
|
822
|
+
const callback = jest.fn();
|
|
823
|
+
mockScannedKeys(keys);
|
|
824
|
+
|
|
825
|
+
await Person.forEachCacheKeyByPattern(pattern, callback);
|
|
826
|
+
|
|
827
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
828
|
+
expect(callback).toHaveBeenCalledWith(keys, redisClient);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it ('flattens keys yielded as arrays', async () => {
|
|
832
|
+
const keys = randomCacheKeys(3);
|
|
833
|
+
const callback = jest.fn();
|
|
834
|
+
mockScannedKeys([keys.slice(0, 2), keys.slice(2)]);
|
|
835
|
+
|
|
836
|
+
await Person.forEachCacheKeyByPattern(pattern, callback);
|
|
837
|
+
|
|
838
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
839
|
+
expect(callback).toHaveBeenCalledWith(keys, redisClient);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it ('does not invoke the callback when the scan yields nothing', async () => {
|
|
843
|
+
const callback = jest.fn();
|
|
844
|
+
mockScannedKeys([]);
|
|
845
|
+
|
|
846
|
+
await Person.forEachCacheKeyByPattern(pattern, callback);
|
|
847
|
+
|
|
848
|
+
expect(callback).not.toHaveBeenCalled();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
describe('BaseRecord.clearCacheKeysByPattern', () => {
|
|
854
|
+
const pattern = `Person#find:*`;
|
|
855
|
+
|
|
856
|
+
it ('scans for matching keys with the given pattern', async () => {
|
|
857
|
+
mockScannedKeys([]);
|
|
858
|
+
|
|
859
|
+
await Person.clearCacheKeysByPattern(pattern);
|
|
860
|
+
|
|
861
|
+
expect(redisClient.scanIterator).toHaveBeenCalledTimes(1);
|
|
862
|
+
expect(redisClient.scanIterator).toHaveBeenCalledWith({ MATCH: pattern, COUNT: 100 });
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it ('deletes all scanned keys in a single batched call', async () => {
|
|
866
|
+
const keys = randomCacheKeys(3);
|
|
867
|
+
mockScannedKeys(keys);
|
|
868
|
+
|
|
869
|
+
await Person.clearCacheKeysByPattern(pattern);
|
|
870
|
+
|
|
871
|
+
expect(redisClient.del).toHaveBeenCalledTimes(1);
|
|
872
|
+
expect(redisClient.del).toHaveBeenCalledWith(keys);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it ('does not delete when the scan yields nothing', async () => {
|
|
876
|
+
mockScannedKeys([]);
|
|
877
|
+
|
|
878
|
+
await Person.clearCacheKeysByPattern(pattern);
|
|
879
|
+
|
|
880
|
+
expect(redisClient.del).not.toHaveBeenCalled();
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
|
|
775
885
|
describe('BaseRecord.#clearSelfCache', () => {
|
|
776
886
|
it ('deletes the key from cache', async () => {
|
|
777
887
|
const pk = Person.primaryKeyAttribute;
|
|
@@ -783,6 +893,18 @@ describe('BaseRecord', () => {
|
|
|
783
893
|
expect(redisClient.del).toHaveBeenCalledTimes(1);
|
|
784
894
|
expect(redisClient.del).toHaveBeenCalledWith(key);
|
|
785
895
|
});
|
|
896
|
+
|
|
897
|
+
it ('delegates deletion to clearCacheKey', async () => {
|
|
898
|
+
const pk = Person.primaryKeyAttribute;
|
|
899
|
+
const id = Math.random();
|
|
900
|
+
const key = Person._recordCacheKey(id);
|
|
901
|
+
const person = new Person({[pk]: id});
|
|
902
|
+
const clearSpy = jest.spyOn(Person, 'clearCacheKey');
|
|
903
|
+
|
|
904
|
+
await person.clearSelfCache();
|
|
905
|
+
expect(clearSpy).toHaveBeenCalledTimes(1);
|
|
906
|
+
expect(clearSpy).toHaveBeenCalledWith(key);
|
|
907
|
+
});
|
|
786
908
|
});
|
|
787
909
|
|
|
788
910
|
|
|
@@ -790,7 +912,7 @@ describe('BaseRecord', () => {
|
|
|
790
912
|
describe.each(hooks)('BaseRecord._after%sCacheHook', (hook) => {
|
|
791
913
|
class Cacheable extends BaseRecord {
|
|
792
914
|
static get isCacheable() { return true; }
|
|
793
|
-
static get
|
|
915
|
+
static get _redisClientFactory() { return mockRedisClientFactory; }
|
|
794
916
|
}
|
|
795
917
|
const hookMethod = `_after${hook}CacheHook`;
|
|
796
918
|
|
|
@@ -61,6 +61,27 @@ describe('Migrator', () => {
|
|
|
61
61
|
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it ('adds a deleted_at column for paranoid tables', async () => {
|
|
65
|
+
const tableName = Helpers.randomString();
|
|
66
|
+
const details = { ...defaultColumns };
|
|
67
|
+
|
|
68
|
+
await Helpers.migrator.createTable(tableName, details, { paranoid: true });
|
|
69
|
+
|
|
70
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
|
|
71
|
+
...defaultColumns,
|
|
72
|
+
deleted_at: { allowNull: true, type: 'DATE' },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it ('leaves deleted_at off tables that are not paranoid', async () => {
|
|
77
|
+
const tableName = Helpers.randomString();
|
|
78
|
+
const details = { ...defaultColumns };
|
|
79
|
+
|
|
80
|
+
await Helpers.migrator.createTable(tableName, details);
|
|
81
|
+
|
|
82
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, defaultColumns);
|
|
83
|
+
});
|
|
84
|
+
|
|
64
85
|
it ('snake_cases created table columns', async () => {
|
|
65
86
|
const tableName = Helpers.randomString();
|
|
66
87
|
const details = { ...defaultColumns, myColumn: 1 };
|
|
@@ -10,6 +10,11 @@ const dataTypeKey2 = TestHelpers.Faker.Text.randomString();
|
|
|
10
10
|
const dataTypeValue2 = TestHelpers.Faker.Text.randomString();
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
function randomCacheKeys(count) {
|
|
14
|
+
return Array.from({ length: count }, () => `Person#find:${TestHelpers.Faker.Text.randomString()}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
13
18
|
/***************************************************************************************************
|
|
14
19
|
* MOCKS
|
|
15
20
|
***************************************************************************************************/
|
|
@@ -42,14 +47,26 @@ class MockSequelize {
|
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
const redisClient = {
|
|
45
|
-
del:
|
|
46
|
-
mGet:
|
|
47
|
-
mSet:
|
|
48
|
-
connect:
|
|
50
|
+
del: jest.fn(),
|
|
51
|
+
mGet: jest.fn(),
|
|
52
|
+
mSet: jest.fn(),
|
|
53
|
+
connect: jest.fn(),
|
|
54
|
+
scanIterator: jest.fn(),
|
|
49
55
|
}
|
|
50
56
|
const mockRedisClientFactory = () => redisClient;
|
|
51
57
|
|
|
52
58
|
|
|
59
|
+
// redis 4's scanIterator yields one key string at a time. Pass an array element to model the
|
|
60
|
+
// batches redis 5 yields instead.
|
|
61
|
+
function mockScannedKeys(scanned) {
|
|
62
|
+
redisClient.scanIterator.mockImplementation(async function* () {
|
|
63
|
+
for (const item of scanned) {
|
|
64
|
+
yield item;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
53
70
|
module.exports = {
|
|
54
71
|
dataTypeKey1,
|
|
55
72
|
dataTypeValue1,
|
|
@@ -57,5 +74,7 @@ module.exports = {
|
|
|
57
74
|
dataTypeValue2,
|
|
58
75
|
MockModel,
|
|
59
76
|
mockRedisClientFactory,
|
|
77
|
+
mockScannedKeys,
|
|
60
78
|
MockSequelize,
|
|
79
|
+
randomCacheKeys,
|
|
61
80
|
}
|
|
@@ -16,6 +16,82 @@ describe('MemoryCache', () => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
describe('#delete', () => {
|
|
20
|
+
const options = {
|
|
21
|
+
namespace: 'squire:shop-data',
|
|
22
|
+
key: { shopId: 'shop-1' },
|
|
23
|
+
ttlSeconds: 300,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
it('deletes a cached value', async () => {
|
|
28
|
+
await cache.fetch(options, async () => ({ version: 1 }));
|
|
29
|
+
|
|
30
|
+
await expect(cache.delete(options)).resolves.toBe(true);
|
|
31
|
+
await expect(cache.fetch(options, async () => ({ version: 2 }))).resolves.toEqual({
|
|
32
|
+
value: { version: 2 },
|
|
33
|
+
cacheHit: false,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
it('deletes values using an equivalent object key', async () => {
|
|
39
|
+
await cache.fetch({
|
|
40
|
+
...options,
|
|
41
|
+
key: { agentId: 'agent-1', shopId: 'shop-1' },
|
|
42
|
+
}, async () => ({ version: 1 }));
|
|
43
|
+
|
|
44
|
+
await expect(cache.delete({
|
|
45
|
+
namespace: options.namespace,
|
|
46
|
+
key: { shopId: 'shop-1', agentId: 'agent-1' },
|
|
47
|
+
})).resolves.toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
it('returns false when the value is not cached', async () => {
|
|
52
|
+
await expect(cache.delete(options)).resolves.toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
it('invalidates an in-flight fetch without interfering with its replacement', async () => {
|
|
57
|
+
let resolveOldFetch;
|
|
58
|
+
let resolveNewFetch;
|
|
59
|
+
const oldFetcher = jest.fn(() => new Promise((resolve) => {
|
|
60
|
+
resolveOldFetch = resolve;
|
|
61
|
+
}));
|
|
62
|
+
const newFetcher = jest.fn(() => new Promise((resolve) => {
|
|
63
|
+
resolveNewFetch = resolve;
|
|
64
|
+
}));
|
|
65
|
+
const oldFetch = cache.fetch(options, oldFetcher);
|
|
66
|
+
await expect(cache.delete(options)).resolves.toBe(true);
|
|
67
|
+
const newFetch = cache.fetch(options, newFetcher);
|
|
68
|
+
|
|
69
|
+
resolveOldFetch({ version: 1 });
|
|
70
|
+
await expect(oldFetch).resolves.toEqual({ value: { version: 1 }, cacheHit: false });
|
|
71
|
+
const joinedFetcher = jest.fn();
|
|
72
|
+
const joinedFetch = cache.fetch(options, joinedFetcher);
|
|
73
|
+
resolveNewFetch({ version: 2 });
|
|
74
|
+
|
|
75
|
+
await expect(Promise.all([newFetch, joinedFetch])).resolves.toEqual([
|
|
76
|
+
{ value: { version: 2 }, cacheHit: false },
|
|
77
|
+
{ value: { version: 2 }, cacheHit: false },
|
|
78
|
+
]);
|
|
79
|
+
expect(oldFetcher).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(newFetcher).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(joinedFetcher).not.toHaveBeenCalled();
|
|
82
|
+
await expect(cache.fetch(options, joinedFetcher)).resolves.toEqual({
|
|
83
|
+
value: { version: 2 },
|
|
84
|
+
cacheHit: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
it('requires a namespace', async () => {
|
|
90
|
+
await expect(cache.delete()).rejects.toThrow('MemoryCache namespace is required.');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
|
|
19
95
|
describe('#fetch', () => {
|
|
20
96
|
const options = {
|
|
21
97
|
namespace: 'squire:shop-data',
|