@vida-global/core 1.1.13 → 1.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/.github/workflows/npm-publish.yml +28 -0
- package/index.js +2 -0
- package/lib/active_record/baseRecord.js +167 -5
- package/lib/redis/index.js +5 -0
- package/lib/redis/redisClient.js +45 -0
- package/package.json +2 -1
- package/test/active_record/baseRecord.test.js +366 -15
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
|
|
3
|
+
|
|
4
|
+
name: Node.js Package
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- release/production
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
publish:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: '24'
|
|
23
|
+
registry-url: 'https://registry.npmjs.org'
|
|
24
|
+
- run: npm ci
|
|
25
|
+
env:
|
|
26
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_READ_TOKEN }}
|
|
27
|
+
- run: npm test
|
|
28
|
+
- run: npm publish
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const { AuthorizationError,
|
|
|
4
4
|
VidaServerController } = require('./lib/server');
|
|
5
5
|
const { logger } = require('./lib/logger');
|
|
6
6
|
const ActiveRecord = require('./lib/active_record');
|
|
7
|
+
const { redisClientFactory } = require('./lib/redis');
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
@@ -12,6 +13,7 @@ module.exports = {
|
|
|
12
13
|
HttpClient,
|
|
13
14
|
HttpError,
|
|
14
15
|
logger,
|
|
16
|
+
redisClientFactory,
|
|
15
17
|
VidaServer,
|
|
16
18
|
VidaServerController,
|
|
17
19
|
};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const { Connection, DEFAULT_DATABASE_ID } = require('./db/connection');
|
|
2
2
|
const { getActiveRecordSchema } = require('./db/schema');
|
|
3
|
+
const { logger } = require('../logger');
|
|
3
4
|
const { Model, Op } = require('sequelize');
|
|
5
|
+
const nodeUtil = require('util');
|
|
6
|
+
const { redisClientFactory } = require('../redis');
|
|
4
7
|
const { tableize } = require('inflection');
|
|
5
8
|
const utils = require('./utils');
|
|
6
|
-
const nodeUtil = require('util');
|
|
7
|
-
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class BaseRecord extends Model {
|
|
@@ -26,8 +27,15 @@ class BaseRecord extends Model {
|
|
|
26
27
|
/***********************************************************************************************
|
|
27
28
|
* QUERIES
|
|
28
29
|
***********************************************************************************************/
|
|
29
|
-
static async find(
|
|
30
|
-
|
|
30
|
+
static async find(ids) {
|
|
31
|
+
if (this.isCacheable) {
|
|
32
|
+
return await this._cachedFind(...arguments);
|
|
33
|
+
} else if (Array.isArray(ids)) {
|
|
34
|
+
const pk = this.primaryKeyAttribute
|
|
35
|
+
return await this.where({[pk]: ids});
|
|
36
|
+
} else {
|
|
37
|
+
return await this.findByPk(ids);
|
|
38
|
+
}
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
|
|
@@ -61,10 +69,19 @@ class BaseRecord extends Model {
|
|
|
61
69
|
|
|
62
70
|
this.init(schema, options);
|
|
63
71
|
|
|
72
|
+
this.initializeHooks();
|
|
73
|
+
|
|
64
74
|
this._initialized = true;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
|
|
78
|
+
static initializeHooks() {
|
|
79
|
+
if (this.isCacheable) {
|
|
80
|
+
this.addHook('afterSave', this._afterSaveCacheHook);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
68
85
|
/***********************************************************************************************
|
|
69
86
|
* DB CONNECTION
|
|
70
87
|
***********************************************************************************************/
|
|
@@ -96,13 +113,158 @@ class BaseRecord extends Model {
|
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
|
|
116
|
+
/***********************************************************************************************
|
|
117
|
+
* CACHING
|
|
118
|
+
***********************************************************************************************/
|
|
119
|
+
static async _cachedFind(ids, { clear=false }={}) {
|
|
120
|
+
const multiFind = Array.isArray(ids)
|
|
121
|
+
const idsToFind = multiFind ? ids : [ids];
|
|
122
|
+
const keyPairs = idsToFind.map(id => [this._recordCacheKey(id), id]);
|
|
123
|
+
const keysToIds = Object.fromEntries(keyPairs);
|
|
124
|
+
const idsToKeys = Object.fromEntries(keyPairs.map(p => p.reverse()));
|
|
125
|
+
const keys = Object.keys(keysToIds);
|
|
126
|
+
|
|
127
|
+
let records = await this.cachedFetch(keys, { clear }, async (missedKeys) => {
|
|
128
|
+
const idsToFind = missedKeys.map(key => keysToIds[key]);
|
|
129
|
+
const pk = this.primaryKeyAttribute
|
|
130
|
+
const records = await this.where({[pk]: idsToFind});
|
|
131
|
+
const keysToRecords = records.map(r => [idsToKeys[r[pk]], r])
|
|
132
|
+
return Object.fromEntries(keysToRecords);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
records = Object.values(records);
|
|
136
|
+
if (!multiFind) return records[0] || null;
|
|
137
|
+
return records.filter(r => Boolean(r));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
static async cachedIds(key, where, { clear=false }={}) {
|
|
142
|
+
const pk = this.primaryKeyAttribute;
|
|
143
|
+
const result = await this.cachedFetch([key], { clear }, async () => {
|
|
144
|
+
const rows = await this.findAll({ where, attributes: [pk], raw: true});
|
|
145
|
+
return {[key]: rows.map(row => row[pk])};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return result[key];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
static async cachedFetch(keys, { clear=false }, fetcher) {
|
|
153
|
+
const client = await this._getRedisClient();
|
|
154
|
+
const cachedValues = clear ? [] : await client.mGet(keys);
|
|
155
|
+
const kvPairs = keys.map((key,idx) => {
|
|
156
|
+
const val = this._unmarshallCachedData(cachedValues[idx]);
|
|
157
|
+
return [key, val]
|
|
158
|
+
});
|
|
159
|
+
const cachedData = Object.fromEntries(kvPairs);
|
|
160
|
+
const missedKeys = keys.filter((key, idx) => cachedValues[idx] == null);
|
|
161
|
+
const hitKeys = keys.filter(key => !missedKeys.includes(key));
|
|
162
|
+
|
|
163
|
+
this.debugLog('Cache Hit', hitKeys.join(', ') || '-');
|
|
164
|
+
|
|
165
|
+
let fetchedData = {};
|
|
166
|
+
if (missedKeys.length) {
|
|
167
|
+
this.debugLog('Cache Miss', missedKeys.join(', '));
|
|
168
|
+
fetchedData = await fetcher(missedKeys);
|
|
169
|
+
await this._cacheFetchedData(fetchedData)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {...cachedData, ...fetchedData};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
static async _cacheFetchedData(fetchedData) {
|
|
177
|
+
const toCache = {};
|
|
178
|
+
|
|
179
|
+
for (const [key, value] of Object.entries(fetchedData)) {
|
|
180
|
+
toCache[key] = this._marshallDataForCaching(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const client = await this._getRedisClient();
|
|
184
|
+
await client.mSet(toCache);
|
|
185
|
+
this.debugLog('Cache Set', Object.keys(fetchedData).join(', '));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
static _marshallDataForCaching(value) {
|
|
190
|
+
let marshalledValue;
|
|
191
|
+
if (value instanceof this) {
|
|
192
|
+
marshalledValue = JSON.stringify(value.toJSON());
|
|
193
|
+
marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
|
|
194
|
+
} else {
|
|
195
|
+
marshalledValue = JSON.stringify(value);
|
|
196
|
+
}
|
|
197
|
+
return marshalledValue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
static _unmarshallCachedData(value) {
|
|
202
|
+
if (value == null) return null;
|
|
203
|
+
if (typeof value != 'string') return value;
|
|
204
|
+
|
|
205
|
+
if (value.startsWith(this.recordCachingPrefix)) {
|
|
206
|
+
const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
|
|
207
|
+
value = value.replace(regExp, '');
|
|
208
|
+
const data = JSON.parse(value);
|
|
209
|
+
return new this(data, {isNewRecord: false});
|
|
210
|
+
} else {
|
|
211
|
+
return JSON.parse(value);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async clearSelfCache() {
|
|
217
|
+
if (!this.constructor.isCacheable) return;
|
|
218
|
+
|
|
219
|
+
const pk = this.constructor.primaryKeyAttribute;
|
|
220
|
+
const key = this.constructor._recordCacheKey(this[pk]);
|
|
221
|
+
const client = await this.constructor._getRedisClient();
|
|
222
|
+
|
|
223
|
+
await client.del(key);
|
|
224
|
+
this.constructor.debugLog('Cache Del', key);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async updateCache() {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
static get recordCachingPrefix() {
|
|
232
|
+
return `AR:${this.name}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
static _recordCacheKey(id) {
|
|
237
|
+
return `${this.name}#find:${id}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
static async _getRedisClient() {
|
|
242
|
+
return await redisClientFactory();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
static get isCacheable() { return false; }
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
static async _afterSaveCacheHook(record, options) {
|
|
250
|
+
await record.clearSelfCache();
|
|
251
|
+
await record.updateCache(options);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
99
255
|
/***********************************************************************************************
|
|
100
256
|
* MISC
|
|
101
257
|
***********************************************************************************************/
|
|
102
258
|
[nodeUtil.inspect.custom](opts) {
|
|
103
259
|
const args = Array.from(arguments);
|
|
104
260
|
args.shift();
|
|
105
|
-
|
|
261
|
+
const str = `${this.constructor.name} ${nodeUtil.inspect(this.dataValues)}`;
|
|
262
|
+
return nodeUtil.inspect(str, ...args);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
static debugLog(tag, log) {
|
|
267
|
+
logger.debug(`[AR:${this.name}][${tag}] ${log}`);
|
|
106
268
|
}
|
|
107
269
|
}
|
|
108
270
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { logger } = require('../logger');
|
|
3
|
+
const redis = require('redis');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
let redisSingleton;
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async function initRedisClientSingleton() {
|
|
10
|
+
if (redisSingleton) return;
|
|
11
|
+
|
|
12
|
+
const certLocation = process.env.REDIS_CERT_LOCATION || `${process.cwd()}/redis_ca.pem`;
|
|
13
|
+
const cert = fs.readFileSync(certLocation)
|
|
14
|
+
|
|
15
|
+
redisSingleton = redis.createClient({
|
|
16
|
+
socket: {
|
|
17
|
+
host: process.env.REDIS_HOST,
|
|
18
|
+
port: parseInt(process.env.REDIS_PORT),
|
|
19
|
+
tls: true,
|
|
20
|
+
ca: [cert]
|
|
21
|
+
},
|
|
22
|
+
password: process.env.REDIS_PASSWORD
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
redisSingleton.on('ready', (err) => {
|
|
26
|
+
logger.info('Redis Client Connected');
|
|
27
|
+
});
|
|
28
|
+
redisSingleton .on('error', (err) => {
|
|
29
|
+
const errno = err.errno ? ` (${err.errno})` : '';
|
|
30
|
+
logger.error(`Redis: ${err.message}${errno}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await redisSingleton.connect();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async function redisClientFactory() {
|
|
38
|
+
await initRedisClientSingleton();
|
|
39
|
+
return redisSingleton;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
redisClientFactory
|
|
45
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vida-global/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Core libraries for supporting Vida development",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "ISC",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"pino": "^9.6.0",
|
|
26
26
|
"pino-http": "^10.4.0",
|
|
27
27
|
"pino-pretty": "^13.0.0",
|
|
28
|
+
"redis": "^5.0.0",
|
|
28
29
|
"response-time": "^2.3.3",
|
|
29
30
|
"sequelize": "^6.37.7",
|
|
30
31
|
"sequelize-cli": "^6.6.3",
|
|
@@ -4,6 +4,7 @@ const { ConnectionConfiguration } = require('../../lib/active_record/db/connecti
|
|
|
4
4
|
const { getActiveRecordSchema } = require('../../lib/active_record/db/schema');
|
|
5
5
|
const importSchema = require('../../lib/active_record/db/importSchema');
|
|
6
6
|
const { Model, Sequelize } = require('sequelize');
|
|
7
|
+
const { redisClientFactory } = require('../../lib/redis');
|
|
7
8
|
const TestHelpers = require('@vida-global/test-helpers');
|
|
8
9
|
|
|
9
10
|
|
|
@@ -17,12 +18,25 @@ connectionSpy.mockImplementation(() => mockSequelize);
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
jest.mock('sequelize', () => {
|
|
21
|
+
const TestHelpers = require('@vida-global/test-helpers');
|
|
20
22
|
class MockModel {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
constructor(data, opts={}) {
|
|
24
|
+
this.dataValues = data || {}
|
|
25
|
+
this.options = opts;
|
|
26
|
+
for (const [k,v] of Object.entries(this.dataValues)) {
|
|
27
|
+
this[k] = v;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
static addHook = jest.fn()
|
|
31
|
+
static init = jest.fn()
|
|
32
|
+
static findByPk = jest.fn();
|
|
33
|
+
static findAll = jest.fn();
|
|
34
|
+
static primaryKeyAttribute = TestHelpers.Faker.Text.randomString();
|
|
35
|
+
toJSON() { return this.dataValues }
|
|
24
36
|
}
|
|
25
|
-
|
|
37
|
+
|
|
38
|
+
MockModel.findAll.mockImplementation(() => []);
|
|
39
|
+
|
|
26
40
|
const dataTypeKey1 = TestHelpers.Faker.Text.randomString();
|
|
27
41
|
const dataTypeValue1 = TestHelpers.Faker.Text.randomString();
|
|
28
42
|
const dataTypeKey2 = TestHelpers.Faker.Text.randomString();
|
|
@@ -41,6 +55,17 @@ const dataTypeValue1 = Sequelize.DataTypes[dataTypeKey1].types.postgres[0];
|
|
|
41
55
|
const dataTypeValue2 = Sequelize.DataTypes[dataTypeKey2].types.postgres[0];
|
|
42
56
|
|
|
43
57
|
|
|
58
|
+
jest.mock('../../lib/redis', () => {
|
|
59
|
+
const client = {
|
|
60
|
+
del: jest.fn(),
|
|
61
|
+
mGet: jest.fn(),
|
|
62
|
+
mSet: jest.fn(),
|
|
63
|
+
}
|
|
64
|
+
const redisClientFactory = () => client;
|
|
65
|
+
|
|
66
|
+
return { redisClientFactory };
|
|
67
|
+
});
|
|
68
|
+
|
|
44
69
|
jest.mock('../../lib/active_record/db/importSchema', () => jest.fn());
|
|
45
70
|
importSchema.mockImplementation(() => ({created_at: {type: dataTypeValue1}, updated_at: {type: dataTypeValue2}}));
|
|
46
71
|
|
|
@@ -50,16 +75,16 @@ afterEach(() => {
|
|
|
50
75
|
});
|
|
51
76
|
|
|
52
77
|
|
|
53
|
-
describe('
|
|
54
|
-
describe('
|
|
78
|
+
describe('BaseRecord', () => {
|
|
79
|
+
describe('BaseRecord.constructor', () => {
|
|
55
80
|
it ('will throw an error when creating an instance of BaseRecord', () => {
|
|
56
81
|
expect(() => new BaseRecord()).toThrow();
|
|
57
82
|
});
|
|
58
83
|
});
|
|
59
84
|
|
|
60
85
|
|
|
61
|
-
describe('
|
|
62
|
-
it ('calls `
|
|
86
|
+
describe('BaseRecord.initialize', () => {
|
|
87
|
+
it ('calls `getBaseRecordSchema` and passes the response to init', () => {
|
|
63
88
|
class User extends BaseRecord {}
|
|
64
89
|
User.initialize();
|
|
65
90
|
const tableDetails = {created_at: {type: Sequelize.DataTypes[dataTypeKey1]},
|
|
@@ -115,7 +140,7 @@ describe('ActiveRecord', () => {
|
|
|
115
140
|
});
|
|
116
141
|
|
|
117
142
|
|
|
118
|
-
describe('
|
|
143
|
+
describe('BaseRecord.tableName', () => {
|
|
119
144
|
it ('defaults to the puralized version of the class name', () => {
|
|
120
145
|
class User extends BaseRecord {}
|
|
121
146
|
expect(User._tableName).toEqual('users');
|
|
@@ -140,19 +165,30 @@ describe('ActiveRecord', () => {
|
|
|
140
165
|
});
|
|
141
166
|
|
|
142
167
|
|
|
143
|
-
describe('
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
describe('BaseRecord.find', () => {
|
|
169
|
+
const pk = TestHelpers.Faker.Text.randomString();
|
|
170
|
+
class Person extends BaseRecord {}
|
|
171
|
+
Person.initialize();
|
|
172
|
+
Person.primaryKeyAttribute = pk;
|
|
173
|
+
|
|
174
|
+
it ('calls the sequelize `findByPk` method when passed one value', async () => {
|
|
175
|
+
const id = Math.random();
|
|
148
176
|
await Person.find(id);
|
|
149
177
|
expect(Person.findByPk).toHaveBeenCalledTimes(1);
|
|
150
178
|
expect(Person.findByPk).toHaveBeenCalledWith(id);
|
|
151
179
|
});
|
|
180
|
+
|
|
181
|
+
it ('calls the sequelize `findAll` method when passed multiple value', async () => {
|
|
182
|
+
const id1 = Math.random();
|
|
183
|
+
const id2 = Math.random();
|
|
184
|
+
await Person.find([id1, id2]);
|
|
185
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id2]}});
|
|
187
|
+
});
|
|
152
188
|
});
|
|
153
189
|
|
|
154
190
|
|
|
155
|
-
describe('
|
|
191
|
+
describe('BaseRecord.where', () => {
|
|
156
192
|
const column = TestHelpers.Faker.Text.randomString();
|
|
157
193
|
const value = TestHelpers.Faker.Text.randomString();
|
|
158
194
|
const condition = {[column]: value};
|
|
@@ -176,4 +212,319 @@ describe('ActiveRecord', () => {
|
|
|
176
212
|
expect(Person.findAll).toHaveBeenCalledWith(expected);
|
|
177
213
|
});
|
|
178
214
|
});
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
describe('Caching', () => {
|
|
218
|
+
class Person extends BaseRecord {
|
|
219
|
+
static get isCacheable() { return true; }
|
|
220
|
+
}
|
|
221
|
+
let redisClient
|
|
222
|
+
beforeEach(async () => {
|
|
223
|
+
redisClient = await redisClientFactory();
|
|
224
|
+
redisClient.mGet.mockImplementation(keys => new Array(keys.length));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('BaseRecord.find', () => {
|
|
228
|
+
const findSpy = jest.spyOn(Person, '_cachedFind');
|
|
229
|
+
it ('calls _cachedFindByPk when one id is passed', async () => {
|
|
230
|
+
const id = Math.random();
|
|
231
|
+
await Person.find(id);
|
|
232
|
+
expect(Person.findByPk).toHaveBeenCalledTimes(0);
|
|
233
|
+
expect(findSpy).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(findSpy).toHaveBeenCalledWith(id);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it ('calls _cachedFind when multiple ids are passed', async () => {
|
|
238
|
+
const id1 = Math.random();
|
|
239
|
+
const id2 = Math.random();
|
|
240
|
+
await Person.find([id1, id2]);
|
|
241
|
+
expect(Person.findByPk).toHaveBeenCalledTimes(0);
|
|
242
|
+
expect(findSpy).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(findSpy).toHaveBeenCalledWith([id1, id2]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
describe('BaseRecord._cachedFind', () => {
|
|
249
|
+
const pk = Person.primaryKeyAttribute;
|
|
250
|
+
const id1 = Math.random();
|
|
251
|
+
const id2 = Math.random();
|
|
252
|
+
const id3 = Math.random();
|
|
253
|
+
const id4 = Math.random();
|
|
254
|
+
|
|
255
|
+
it ('converts ids to cache keys and fetches from cache', async () => {
|
|
256
|
+
await Person._cachedFind([id1, id2]);
|
|
257
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([
|
|
259
|
+
`Person#find:${id1}`,
|
|
260
|
+
`Person#find:${id2}`,
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it ('fetches misseds id from the database and caches them', async () => {
|
|
265
|
+
const key1 = `Person#find:${id1}`;
|
|
266
|
+
const key2 = `Person#find:${id2}`;
|
|
267
|
+
const key3 = `Person#find:${id3}`;
|
|
268
|
+
Person.findAll.mockImplementation(() => [new Person({[pk]:id1}), new Person({[pk]:id3})]);
|
|
269
|
+
redisClient.mGet.mockImplementation(keys => [null, '{}', null]);
|
|
270
|
+
await Person._cachedFind([id1, id2, id3]);
|
|
271
|
+
|
|
272
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([key1, key2, key3]);
|
|
274
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id3]}});
|
|
276
|
+
expect(redisClient.mSet).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(redisClient.mSet).toHaveBeenCalledWith({
|
|
278
|
+
[key1]: `AR:Person:{"${pk}":${id1}}`,
|
|
279
|
+
[key3]: `AR:Person:{"${pk}":${id3}}`,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it ('does not hit the database for cache hits', async () => {
|
|
284
|
+
redisClient.mGet.mockImplementation(keys => [`AR:Person:{}`, `AR:Person:{}`]);
|
|
285
|
+
await Person._cachedFind([id1, id2]);
|
|
286
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
287
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([`Person#find:${id1}`, `Person#find:${id2}`]);
|
|
288
|
+
expect(Person.findAll).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it ('does hit the database when `clear` is true', async () => {
|
|
292
|
+
redisClient.mGet.mockImplementation(keys => [`AR:Person:{}`, `AR:Person:{}`]);
|
|
293
|
+
await Person._cachedFind([id1, id2], {clear: true});
|
|
294
|
+
expect(redisClient.mGet).not.toHaveBeenCalled();
|
|
295
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
296
|
+
expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id2]}});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it ('returns an array of active record objects if an array was passed', async () => {
|
|
300
|
+
const p1 = new Person({[pk]: id1});
|
|
301
|
+
const p2 = new Person({[pk]: id2});
|
|
302
|
+
const p3 = new Person({[pk]: id3});
|
|
303
|
+
Person.findAll.mockImplementation(() => [p1, p3]);
|
|
304
|
+
redisClient.mGet.mockImplementation(keys => [null, `AR:Person:${JSON.stringify(p2.toJSON())}`, null]);
|
|
305
|
+
|
|
306
|
+
const results = await Person._cachedFind([id1, id2, id3, id4]);
|
|
307
|
+
expect(results.length).toEqual(3);
|
|
308
|
+
expect(results).toContain(p1);
|
|
309
|
+
expect(results).toContain(p3);
|
|
310
|
+
expect(results.filter(r => r[pk] == id2).length).toEqual(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it ('returns a single active record object if an array was not passed', async () => {
|
|
314
|
+
const p1 = new Person({[pk]: id1});
|
|
315
|
+
Person.findAll.mockImplementation(() => [p1]);
|
|
316
|
+
const results = await Person._cachedFind(id1);
|
|
317
|
+
expect(results).toEqual(p1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it ('sets `isNewRecord` to false for records from cache', async () => {
|
|
321
|
+
redisClient.mGet.mockImplementation(keys => [`AR:Person:{}`]);
|
|
322
|
+
const person = await Person._cachedFind(id1);
|
|
323
|
+
expect(person.options.isNewRecord).toBeFalsy();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
describe('BaseRecord.cachedIds', () => {
|
|
329
|
+
const key = TestHelpers.Faker.Text.randomString();
|
|
330
|
+
const colName = TestHelpers.Faker.Text.randomString();
|
|
331
|
+
const val = TestHelpers.Faker.Text.randomString();
|
|
332
|
+
const where = {[colName]: val};
|
|
333
|
+
const ids = [Math.random(), Math.random(), Math.random()];
|
|
334
|
+
const pk = Person.primaryKeyAttribute;
|
|
335
|
+
const rows = ids.map(id => ({[pk]: id}));
|
|
336
|
+
|
|
337
|
+
it ('fetches ids from the database and sets them in cache', async () => {
|
|
338
|
+
Person.findAll.mockImplementation(() => rows);
|
|
339
|
+
await Person.cachedIds(key, where)
|
|
340
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
341
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([key]);
|
|
342
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
343
|
+
expect(Person.findAll).toHaveBeenCalledWith({ where, attributes: [pk], raw: true });
|
|
344
|
+
expect(redisClient.mSet).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(redisClient.mSet).toHaveBeenCalledWith({
|
|
346
|
+
[key]: JSON.stringify(ids)
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it ('does not hit the database for cache hits', async () => {
|
|
351
|
+
redisClient.mGet.mockImplementation(keys => [JSON.stringify(ids)]);
|
|
352
|
+
await Person.cachedIds(key, where)
|
|
353
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
354
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([key]);
|
|
355
|
+
expect(Person.findAll).not.toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
it ('does hit the database when `clear` is true', async () => {
|
|
360
|
+
redisClient.mGet.mockImplementation(keys => [JSON.stringify(ids)]);
|
|
361
|
+
await Person.cachedIds(key, where, {clear: true})
|
|
362
|
+
expect(redisClient.mGet).not.toHaveBeenCalled();
|
|
363
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
364
|
+
expect(Person.findAll).toHaveBeenCalledWith({ where, attributes: [pk], raw: true });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it ('does hit the database when `clear` is true', async () => {
|
|
368
|
+
redisClient.mGet.mockImplementation(keys => [JSON.stringify(ids)]);
|
|
369
|
+
await Person.cachedIds(key, where, {clear: true})
|
|
370
|
+
expect(redisClient.mGet).not.toHaveBeenCalled();
|
|
371
|
+
expect(Person.findAll).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect(Person.findAll).toHaveBeenCalledWith({ where, attributes: [pk], raw: true });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
describe('BaseRecord.cachedFetch', () => {
|
|
378
|
+
const key1 = Math.random();
|
|
379
|
+
const key2 = Math.random();
|
|
380
|
+
const key3 = Math.random();
|
|
381
|
+
const fetcher = jest.fn();
|
|
382
|
+
|
|
383
|
+
fetcher.mockImplementation(() => ({}));
|
|
384
|
+
|
|
385
|
+
it ('does a redis multi get with all keys', async () => {
|
|
386
|
+
await Person.cachedFetch([key1, key2, key3], {}, fetcher);
|
|
387
|
+
expect(redisClient.mGet).toHaveBeenCalledTimes(1);
|
|
388
|
+
expect(redisClient.mGet).toHaveBeenCalledWith([key1, key2, key3]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it ('calls the fetcher for any cache misses', async () => {
|
|
392
|
+
redisClient.mGet.mockImplementation(() => [null, '{}', null]);
|
|
393
|
+
await Person.cachedFetch([key1, key2, key3], {}, fetcher);
|
|
394
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
395
|
+
expect(fetcher).toHaveBeenCalledWith([key1, key3]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it ('does not call the fetcher if there are no cache misses', async () => {
|
|
399
|
+
redisClient.mGet.mockImplementation(() => ['{}', '{}', '{}']);
|
|
400
|
+
await Person.cachedFetch([key1, key2, key3], {}, fetcher);
|
|
401
|
+
expect(fetcher).toHaveBeenCalledTimes(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it ('ignores the cache if `clear` is true', async () => {
|
|
405
|
+
redisClient.mGet.mockImplementation(() => ['{}', '{}', '{}']);
|
|
406
|
+
await Person.cachedFetch([key1, key2, key3], {clear: true}, fetcher);
|
|
407
|
+
expect(redisClient.mGet).not.toHaveBeenCalled();
|
|
408
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
409
|
+
expect(fetcher).toHaveBeenCalledWith([key1, key2, key3]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it ('calls BaseRecord._cacheFetchedData with data returned by the fetcher', async () => {
|
|
413
|
+
const cacheSpy = jest.spyOn(Person, '_cacheFetchedData');
|
|
414
|
+
const obj1 = {};
|
|
415
|
+
const obj2 = {};
|
|
416
|
+
const data = {[key1]: obj1, [key2]: obj2};
|
|
417
|
+
redisClient.mGet.mockImplementation(() => [null, null]);
|
|
418
|
+
fetcher.mockImplementation(() => data);
|
|
419
|
+
|
|
420
|
+
await Person.cachedFetch([key1, key2], {}, fetcher);
|
|
421
|
+
|
|
422
|
+
expect(cacheSpy).toHaveBeenCalledTimes(1);
|
|
423
|
+
expect(cacheSpy).toHaveBeenCalledWith(data);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it ('returns data from cache hits and misses', async () => {
|
|
427
|
+
const pk = Person.primaryKeyAttribute;
|
|
428
|
+
const id1 = Math.random();
|
|
429
|
+
const id2 = Math.random();
|
|
430
|
+
const id3 = Math.random();
|
|
431
|
+
const obj1 = {[pk]: id1};
|
|
432
|
+
const obj2 = {[pk]: id2};
|
|
433
|
+
const obj3 = {[pk]: id3};
|
|
434
|
+
const data = {[key1]: obj1};
|
|
435
|
+
redisClient.mGet.mockImplementation(() => [null, JSON.stringify(obj2), JSON.stringify(obj3)]);
|
|
436
|
+
fetcher.mockImplementation(() => data);
|
|
437
|
+
|
|
438
|
+
const results = await Person.cachedFetch([key1, key2, key3], {}, fetcher);
|
|
439
|
+
|
|
440
|
+
expect(Object.keys(results).length).toEqual(3);
|
|
441
|
+
expect(results[key1]).toBe(obj1);
|
|
442
|
+
|
|
443
|
+
expect(results[key2]).not.toBe(obj2);
|
|
444
|
+
expect(results[key2]).toEqual(obj2);
|
|
445
|
+
|
|
446
|
+
expect(results[key3]).not.toBe(obj3);
|
|
447
|
+
expect(results[key3]).toEqual(obj3);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
describe('BaseRecord._cacheFetchedData', () => {
|
|
453
|
+
const key1 = TestHelpers.Faker.Text.randomString();
|
|
454
|
+
const key2 = TestHelpers.Faker.Text.randomString();
|
|
455
|
+
const key3 = TestHelpers.Faker.Text.randomString();
|
|
456
|
+
const key4 = TestHelpers.Faker.Text.randomString();
|
|
457
|
+
|
|
458
|
+
it ('marshalls data and does a redis multi set', async () => {
|
|
459
|
+
const obj1Key = TestHelpers.Faker.Text.randomString();
|
|
460
|
+
const obj1Val = TestHelpers.Faker.Text.randomString();
|
|
461
|
+
const obj1 = {[obj1Key]: obj1Val};
|
|
462
|
+
const obj2Key = TestHelpers.Faker.Text.randomString();
|
|
463
|
+
const obj2Val = TestHelpers.Faker.Text.randomString();
|
|
464
|
+
const obj2 = {[obj2Key]: obj2Val};
|
|
465
|
+
|
|
466
|
+
const person1 = new Person(obj1)
|
|
467
|
+
const person2 = new Person(obj2)
|
|
468
|
+
|
|
469
|
+
await Person._cacheFetchedData({
|
|
470
|
+
[key1]: obj1,
|
|
471
|
+
[key2]: obj2,
|
|
472
|
+
[key3]: person1,
|
|
473
|
+
[key4]: person2,
|
|
474
|
+
});
|
|
475
|
+
const cachedData = {
|
|
476
|
+
[key1]: JSON.stringify(obj1),
|
|
477
|
+
[key2]: JSON.stringify(obj2),
|
|
478
|
+
[key3]: `AR:Person:${JSON.stringify(obj1)}`,
|
|
479
|
+
[key4]: `AR:Person:${JSON.stringify(obj2)}`,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
expect(redisClient.mSet).toHaveBeenCalledTimes(1);
|
|
483
|
+
expect(redisClient.mSet).toHaveBeenCalledWith(cachedData);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
describe('BaseRecord.#clearSelfCache', () => {
|
|
489
|
+
it ('deletes the key from cache', async () => {
|
|
490
|
+
const pk = Person.primaryKeyAttribute;
|
|
491
|
+
const id = Math.random();
|
|
492
|
+
const key = Person._recordCacheKey(id);
|
|
493
|
+
const person = new Person({[pk]: id});
|
|
494
|
+
|
|
495
|
+
await person.clearSelfCache();
|
|
496
|
+
expect(redisClient.del).toHaveBeenCalledTimes(1);
|
|
497
|
+
expect(redisClient.del).toHaveBeenCalledWith(key);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
describe('BaseRecord._afterSaveCacheHook', () => {
|
|
503
|
+
it ('is called as an after save hook for cached models', () => {
|
|
504
|
+
expect(Person.addHook).toHaveBeenCalledTimes(0);
|
|
505
|
+
Person.initialize();
|
|
506
|
+
expect(Person.addHook).toHaveBeenCalledTimes(1);
|
|
507
|
+
expect(Person.addHook).toHaveBeenCalledWith('afterSave', Person._afterSaveCacheHook);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it ('is not called as an after save hook for non cached models', () => {
|
|
511
|
+
class Foo extends BaseRecord {}
|
|
512
|
+
Foo.initialize();
|
|
513
|
+
expect(Person.addHook).toHaveBeenCalledTimes(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it ('calls BaseRecord#clearSelfCache and BaseRecord#updateCache', async () => {
|
|
517
|
+
const person = new Person();
|
|
518
|
+
const clearSpy = jest.spyOn(person, 'clearSelfCache');
|
|
519
|
+
const updateSpy = jest.spyOn(person, 'updateCache');
|
|
520
|
+
const options = {};
|
|
521
|
+
|
|
522
|
+
await Person._afterSaveCacheHook(person,options);
|
|
523
|
+
|
|
524
|
+
expect(clearSpy).toHaveBeenCalledTimes(1);
|
|
525
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
526
|
+
expect(updateSpy).toHaveBeenCalledWith(options);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
});
|
|
179
530
|
});
|