@vida-global/core 2.1.2 → 2.2.1
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 +27 -8
- package/lib/activeRecord/baseRecord.js +68 -33
- package/lib/activeRecord/db/migration.js +2 -2
- package/lib/activeRecord/db/migrator.js +85 -18
- 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 +176 -8
- package/test/activeRecord/helpers/baseRecordMocks.js +23 -4
- package/test/activeRecord/helpers/migrator.js +16 -2
- package/test/cache/memoryCache.test.js +76 -0
|
@@ -72,6 +72,8 @@ Wire up the migration CLI by adding scripts to `package.json`:
|
|
|
72
72
|
|
|
73
73
|
Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
|
|
74
74
|
|
|
75
|
+
Migration files are named `<timestamp>_<description>.js`, with the database id appended before the extension when one is given — for example `1754500000000_createUsers.js` or `1754500000000_createUsers.metrics.js`. The leading timestamp is the version recorded in `migration_versions` and the order migrations run in, so files sort chronologically by name. Files that don't match this pattern are ignored.
|
|
76
|
+
|
|
75
77
|
```js
|
|
76
78
|
module.exports = {
|
|
77
79
|
up: async function() {
|
|
@@ -98,11 +100,11 @@ module.exports = {
|
|
|
98
100
|
Available migration helpers:
|
|
99
101
|
|
|
100
102
|
```js
|
|
101
|
-
createTable(tableName, details, { timestamps })
|
|
103
|
+
createTable(tableName, details, { timestamps, paranoid })
|
|
102
104
|
dropTable(tableName)
|
|
103
105
|
addColumn(tableName, columnName, columnDetails)
|
|
104
106
|
removeColumn(tableName, columnName)
|
|
105
|
-
addIndex(tableName, fields, { concurrently, unique, name, where })
|
|
107
|
+
addIndex(tableName, fields, { concurrently, unique, name, using, where })
|
|
106
108
|
removeIndex(tableName, indexNameOrAttributes, concurrently=false)
|
|
107
109
|
renameColumn(tableName, oldName, newName)
|
|
108
110
|
changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
@@ -111,8 +113,30 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
|
111
113
|
**Conventions:**
|
|
112
114
|
|
|
113
115
|
- `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
|
|
116
|
+
- Pass `{ paranoid: true }` to `createTable` to add a nullable `deleted_at` column for soft deletes.
|
|
117
|
+
- Models over that table soft delete without any extra configuration: `initializationOptions` turns on Sequelize's `paranoid` for any table whose schema has `deleted_at` alongside the timestamp columns, so `destroy()` sets `deleted_at` and ordinary queries skip the row. A table created with `{ paranoid: true, timestamps: false }` has the column but does not qualify.
|
|
118
|
+
- Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a soft-deleted row will still block re-creating the same one.
|
|
119
|
+
- An index created without a `name` gets `idx_<table>_<columns>`, so `addIndex('users', ['team_id', 'email'])` creates `idx_users_team_id_email`. `removeIndex` applies the same default, which means an index added by its columns can be dropped by its columns. Postgres truncates identifiers at 63 characters, so name long multi-column indexes explicitly.
|
|
114
120
|
- Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
|
|
115
|
-
- Each migration runs inside a transaction.
|
|
121
|
+
- Each migration runs inside a transaction, so a migration that throws part way through leaves the database untouched.
|
|
122
|
+
|
|
123
|
+
Postgres refuses a few statements inside a transaction block — `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` most of all. A migration that needs one of those opts out of the transaction entirely:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
module.exports = {
|
|
127
|
+
transactional: false,
|
|
128
|
+
|
|
129
|
+
up: async function() {
|
|
130
|
+
await this.addIndex('users', ['team_id'], { concurrently: true });
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
down: async function() {
|
|
134
|
+
await this.removeIndex('users', ['team_id'], true);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
An opted-out migration is **not** rolled back if it fails, so keep it to the single statement that needs it and put everything else in its own migration.
|
|
116
140
|
|
|
117
141
|
|
|
118
142
|
## Models
|
|
@@ -307,8 +331,3 @@ class User extends ActiveRecord.BaseRecord {
|
|
|
307
331
|
```
|
|
308
332
|
|
|
309
333
|
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
|
***********************************************************************************************/
|
|
@@ -101,7 +101,7 @@ function generateMigrationFile(description, databaseId) {
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
function fileNameForMigration(description, databaseId) {
|
|
104
|
-
const name = `${
|
|
104
|
+
const name = `${new Date().getTime()}_${description}`;
|
|
105
105
|
const dbSuffix = databaseId ? `.${databaseId}` : '';
|
|
106
106
|
return `${name}${dbSuffix}.js`;
|
|
107
107
|
}
|
|
@@ -113,7 +113,7 @@ function fileNameForMigration(description, databaseId) {
|
|
|
113
113
|
function getMigrations() {
|
|
114
114
|
const files = fs.readdirSync(utils.migrationsDirectory);
|
|
115
115
|
const migrations = files.map(f => {
|
|
116
|
-
const m = f.match(
|
|
116
|
+
const m = f.match(/^(?<version>\d+)_[^.]+(\.(?<databaseId>\w+))?\.js$/);
|
|
117
117
|
if (!m) return;
|
|
118
118
|
return {fileName: f, databaseId: m.groups.databaseId, version: m.groups.version};
|
|
119
119
|
});
|
|
@@ -8,6 +8,7 @@ class Migrator {
|
|
|
8
8
|
#connection;
|
|
9
9
|
#fileName;
|
|
10
10
|
#migrationModule;
|
|
11
|
+
#transaction = null;
|
|
11
12
|
|
|
12
13
|
DataTypes = Sequelize.DataTypes;
|
|
13
14
|
Operators = Op;
|
|
@@ -22,19 +23,32 @@ class Migrator {
|
|
|
22
23
|
async run() {
|
|
23
24
|
if (!this.migrationModule.up) throw new Error('Missing migration `up`');
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
await this.#connection.queryInterface.transaction(async () => {
|
|
27
|
-
await migrator.migrationModule.up.call(migrator);
|
|
28
|
-
});
|
|
26
|
+
await this.perform(() => this.migrationModule.up.call(this));
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
async rollback() {
|
|
33
31
|
if (!this.migrationModule.down) return;
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
await this.perform(() => this.migrationModule.down.call(this));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async perform(callback) {
|
|
38
|
+
if (!this.transactional) return await callback();
|
|
39
|
+
|
|
40
|
+
await this.performInTransaction(callback);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async performInTransaction(callback) {
|
|
45
|
+
await this.#connection.queryInterface.transaction(async transaction => {
|
|
46
|
+
this.#transaction = transaction;
|
|
47
|
+
try {
|
|
48
|
+
await callback();
|
|
49
|
+
} finally {
|
|
50
|
+
this.#transaction = null;
|
|
51
|
+
}
|
|
38
52
|
});
|
|
39
53
|
}
|
|
40
54
|
|
|
@@ -58,6 +72,14 @@ class Migrator {
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
|
|
75
|
+
// Migrations opt out with `transactional: false`. Needed for statements Postgres refuses to
|
|
76
|
+
// run inside a transaction block, most notably CREATE/DROP INDEX CONCURRENTLY. An opted-out
|
|
77
|
+
// migration is not rolled back when it fails.
|
|
78
|
+
get transactional() {
|
|
79
|
+
return this.migrationModule.transactional !== false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
importMigrationModule() {
|
|
62
84
|
const filePath = `${utils.migrationsDirectory}/${this.#fileName}`;
|
|
63
85
|
return require(filePath);
|
|
@@ -72,9 +94,12 @@ class Migrator {
|
|
|
72
94
|
if (options.timestamps !== false) {
|
|
73
95
|
details = {...details, ...this.defaultTimestampColumns};
|
|
74
96
|
}
|
|
97
|
+
if (options.paranoid) {
|
|
98
|
+
details = {...details, ...this.deletedAtColumn};
|
|
99
|
+
}
|
|
75
100
|
|
|
76
101
|
details = this.normalizeTableDetails(details);
|
|
77
|
-
await this.#sequelizeQueryInterface.createTable(tableName, details);
|
|
102
|
+
await this.#sequelizeQueryInterface.createTable(tableName, details, this.queryOptions);
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
|
|
@@ -113,26 +138,35 @@ class Migrator {
|
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
|
|
141
|
+
get deletedAtColumn() {
|
|
142
|
+
return {
|
|
143
|
+
deleted_at: {
|
|
144
|
+
allowNull: true,
|
|
145
|
+
type: this.DataTypes.DATE }
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
116
150
|
async dropTable(tableName) {
|
|
117
|
-
await this.#sequelizeQueryInterface.dropTable(tableName);
|
|
151
|
+
await this.#sequelizeQueryInterface.dropTable(tableName, this.queryOptions);
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
|
|
121
155
|
async addColumn(tableName, columnName, columnDetails) {
|
|
122
|
-
await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails);
|
|
156
|
+
await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails, this.queryOptions);
|
|
123
157
|
}
|
|
124
158
|
|
|
125
159
|
|
|
126
160
|
async removeColumn(tableName, columnName) {
|
|
127
|
-
await this.#sequelizeQueryInterface.removeColumn(tableName, columnName);
|
|
161
|
+
await this.#sequelizeQueryInterface.removeColumn(tableName, columnName, this.queryOptions);
|
|
128
162
|
}
|
|
129
163
|
|
|
130
164
|
|
|
131
|
-
async addIndex(tableName, fields, { concurrently, unique, name, using, where }) {
|
|
132
|
-
const options = {fields: fields};
|
|
165
|
+
async addIndex(tableName, fields, { concurrently, unique, name, using, where }={}) {
|
|
166
|
+
const options = {fields: fields, ...this.queryOptions};
|
|
167
|
+
options.name = name || this.defaultIndexName(tableName, fields);
|
|
133
168
|
if (concurrently) options.concurrently = true;
|
|
134
169
|
if (unique) options.unique = true;
|
|
135
|
-
if (name) options.name = name;
|
|
136
170
|
if (using) options.using = using;
|
|
137
171
|
if (where) options.where = where;
|
|
138
172
|
|
|
@@ -141,20 +175,53 @@ class Migrator {
|
|
|
141
175
|
|
|
142
176
|
|
|
143
177
|
async removeIndex(tableName, indexNameOrAttributes, concurrently=false) {
|
|
144
|
-
const options
|
|
178
|
+
const options = this.queryOptions;
|
|
145
179
|
if (concurrently) options.concurrently = true;
|
|
146
|
-
|
|
180
|
+
|
|
181
|
+
const indexName = this.indexNameFor(tableName, indexNameOrAttributes);
|
|
182
|
+
await this.#sequelizeQueryInterface.removeIndex(tableName, indexName, options);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/***********************************************************************************************
|
|
187
|
+
* INDEX NAMING
|
|
188
|
+
***********************************************************************************************/
|
|
189
|
+
// Indexes created without an explicit name get `idx_<table>_<columns>`. removeIndex applies the
|
|
190
|
+
// same default so an index can be dropped by its columns, the way it was added.
|
|
191
|
+
indexNameFor(tableName, indexNameOrAttributes) {
|
|
192
|
+
if (typeof indexNameOrAttributes === 'string') return indexNameOrAttributes;
|
|
193
|
+
return this.defaultIndexName(tableName, indexNameOrAttributes);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
defaultIndexName(tableName, fields) {
|
|
198
|
+
const columnNames = fields.map(field => this.indexColumnName(field));
|
|
199
|
+
return `idx_${tableName}_${columnNames.join('_')}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
indexColumnName(field) {
|
|
204
|
+
if (typeof field === 'string') return field;
|
|
205
|
+
return field.name;
|
|
147
206
|
}
|
|
148
207
|
|
|
149
208
|
|
|
150
209
|
async renameColumn(tableName, oldName, newName) {
|
|
151
210
|
newName = underscore(newName);
|
|
152
|
-
await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName);
|
|
211
|
+
await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName, this.queryOptions);
|
|
153
212
|
}
|
|
154
213
|
|
|
155
214
|
|
|
156
215
|
async changeColumn(tableName, columnName, dataTypeOrOptions) {
|
|
157
|
-
await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions);
|
|
216
|
+
await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions, this.queryOptions);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
/***********************************************************************************************
|
|
221
|
+
* QUERY OPTIONS
|
|
222
|
+
***********************************************************************************************/
|
|
223
|
+
get queryOptions() {
|
|
224
|
+
return { transaction: this.#transaction };
|
|
158
225
|
}
|
|
159
226
|
|
|
160
227
|
|
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
|
|
|
@@ -45,6 +45,97 @@ describe('Migrator', () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
describe('Migrator transaction threading', () => {
|
|
49
|
+
it ('passes the open transaction to operations run inside `up`', async () => {
|
|
50
|
+
const tableName = Helpers.randomString();
|
|
51
|
+
|
|
52
|
+
await Helpers.runInMigration(async function() {
|
|
53
|
+
await this.createTable(tableName, {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(
|
|
57
|
+
tableName,
|
|
58
|
+
expect.any(Object),
|
|
59
|
+
Helpers.inTransaction,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it ('passes the open transaction to operations run inside `down`', async () => {
|
|
64
|
+
const tableName = Helpers.randomString();
|
|
65
|
+
Helpers.migrationModule.down.mockImplementationOnce(async function() {
|
|
66
|
+
await this.dropTable(tableName);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await Helpers.migrator.rollback();
|
|
70
|
+
|
|
71
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.inTransaction);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it ('clears the transaction once the migration finishes', async () => {
|
|
75
|
+
const tableName = Helpers.randomString();
|
|
76
|
+
|
|
77
|
+
await Helpers.runInMigration(async function() {});
|
|
78
|
+
await Helpers.migrator.dropTable(tableName);
|
|
79
|
+
|
|
80
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it ('clears the transaction when the migration throws', async () => {
|
|
84
|
+
const tableName = Helpers.randomString();
|
|
85
|
+
|
|
86
|
+
await expect(Helpers.runInMigration(async function() {
|
|
87
|
+
throw new Error('migration blew up');
|
|
88
|
+
})).rejects.toThrow('migration blew up');
|
|
89
|
+
await Helpers.migrator.dropTable(tableName);
|
|
90
|
+
|
|
91
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
describe('Migrator opting out of the transaction', () => {
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
delete Helpers.migrationModule.transactional;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it ('runs `up` outside a transaction when the migration sets transactional to false', async () => {
|
|
102
|
+
const tableName = Helpers.randomString();
|
|
103
|
+
Helpers.migrationModule.transactional = false;
|
|
104
|
+
|
|
105
|
+
await Helpers.runInMigration(async function() {
|
|
106
|
+
await this.addIndex(tableName, ['name'], { concurrently: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(Helpers.transactionSpy).not.toHaveBeenCalled();
|
|
110
|
+
expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
|
|
111
|
+
fields: ['name'],
|
|
112
|
+
name: `idx_${tableName}_name`,
|
|
113
|
+
concurrently: true,
|
|
114
|
+
...Helpers.noTransaction,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it ('runs `down` outside a transaction when the migration sets transactional to false', async () => {
|
|
119
|
+
const tableName = Helpers.randomString();
|
|
120
|
+
Helpers.migrationModule.transactional = false;
|
|
121
|
+
Helpers.migrationModule.down.mockImplementationOnce(async function() {
|
|
122
|
+
await this.dropTable(tableName);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await Helpers.migrator.rollback();
|
|
126
|
+
|
|
127
|
+
expect(Helpers.transactionSpy).not.toHaveBeenCalled();
|
|
128
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it ('still uses a transaction when transactional is unset', async () => {
|
|
132
|
+
await Helpers.migrator.run();
|
|
133
|
+
|
|
134
|
+
expect(Helpers.transactionSpy).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
|
|
48
139
|
describe('Migrator#createTable', () => {
|
|
49
140
|
const defaultColumns = {
|
|
50
141
|
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' },
|
|
@@ -58,7 +149,39 @@ describe('Migrator', () => {
|
|
|
58
149
|
|
|
59
150
|
await Helpers.migrator.createTable(tableName, details);
|
|
60
151
|
|
|
61
|
-
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
|
|
152
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details, Helpers.noTransaction);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it ('adds a nullable deleted_at column when paranoid is set', async () => {
|
|
156
|
+
const tableName = Helpers.randomString();
|
|
157
|
+
const details = { ...defaultColumns };
|
|
158
|
+
|
|
159
|
+
await Helpers.migrator.createTable(tableName, details, { paranoid: true });
|
|
160
|
+
|
|
161
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
|
|
162
|
+
...defaultColumns,
|
|
163
|
+
deleted_at: { allowNull: true, type: 'DATE' },
|
|
164
|
+
}, Helpers.noTransaction);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it ('omits deleted_at when paranoid is not set', async () => {
|
|
168
|
+
const tableName = Helpers.randomString();
|
|
169
|
+
|
|
170
|
+
await Helpers.migrator.createTable(tableName, { ...defaultColumns });
|
|
171
|
+
|
|
172
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
173
|
+
expect(columns).not.toHaveProperty('deleted_at');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it ('adds deleted_at alongside timestamps: false', async () => {
|
|
177
|
+
const tableName = Helpers.randomString();
|
|
178
|
+
|
|
179
|
+
await Helpers.migrator.createTable(tableName, {}, { paranoid: true, timestamps: false });
|
|
180
|
+
|
|
181
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
182
|
+
expect(columns).toHaveProperty('deleted_at');
|
|
183
|
+
expect(columns).not.toHaveProperty('created_at');
|
|
184
|
+
expect(columns).not.toHaveProperty('updated_at');
|
|
62
185
|
});
|
|
63
186
|
|
|
64
187
|
it ('snake_cases created table columns', async () => {
|
|
@@ -70,7 +193,7 @@ describe('Migrator', () => {
|
|
|
70
193
|
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
|
|
71
194
|
...defaultColumns,
|
|
72
195
|
my_column: 1,
|
|
73
|
-
});
|
|
196
|
+
}, Helpers.noTransaction);
|
|
74
197
|
});
|
|
75
198
|
});
|
|
76
199
|
|
|
@@ -95,7 +218,7 @@ describe('Migrator', () => {
|
|
|
95
218
|
|
|
96
219
|
await Helpers.migrator.renameColumn(tableName, oldName, 'myColumn');
|
|
97
220
|
|
|
98
|
-
expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column');
|
|
221
|
+
expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column', Helpers.noTransaction);
|
|
99
222
|
});
|
|
100
223
|
});
|
|
101
224
|
|
|
@@ -108,12 +231,12 @@ describe('Migrator', () => {
|
|
|
108
231
|
])('builds addIndex options for %s', async (_label, options) => {
|
|
109
232
|
const tableName = Helpers.randomString();
|
|
110
233
|
const fields = [Helpers.randomString(), Helpers.randomString()];
|
|
111
|
-
const expectedOptions = { fields };
|
|
234
|
+
const expectedOptions = { fields, ...Helpers.noTransaction };
|
|
112
235
|
|
|
113
236
|
if (options.concurrently) expectedOptions.concurrently = true;
|
|
114
237
|
if (options.unique) expectedOptions.unique = true;
|
|
115
238
|
if (options.where) expectedOptions.where = options.where;
|
|
116
|
-
|
|
239
|
+
expectedOptions.name = options.name || `idx_${tableName}_${fields.join('_')}`;
|
|
117
240
|
if (options.using) expectedOptions.using = options.using;
|
|
118
241
|
|
|
119
242
|
await Helpers.migrator.addIndex(tableName, fields, options);
|
|
@@ -126,8 +249,8 @@ describe('Migrator', () => {
|
|
|
126
249
|
|
|
127
250
|
describe('Migrator#removeIndex', () => {
|
|
128
251
|
it.each([
|
|
129
|
-
['default options', false, {}],
|
|
130
|
-
['concurrently', true, { concurrently: true }],
|
|
252
|
+
['default options', false, { transaction: null }],
|
|
253
|
+
['concurrently', true, { concurrently: true, transaction: null }],
|
|
131
254
|
])('passes removeIndex options for %s', async (_label, concurrently, expectedOptions) => {
|
|
132
255
|
const tableName = Helpers.randomString();
|
|
133
256
|
const fields = [Helpers.randomString(), Helpers.randomString()];
|
|
@@ -137,9 +260,54 @@ describe('Migrator', () => {
|
|
|
137
260
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
|
|
138
261
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
|
|
139
262
|
tableName,
|
|
140
|
-
fields
|
|
263
|
+
`idx_${tableName}_${fields.join('_')}`,
|
|
141
264
|
expectedOptions,
|
|
142
265
|
);
|
|
143
266
|
});
|
|
267
|
+
|
|
268
|
+
it ('leaves an explicit index name alone', async () => {
|
|
269
|
+
const tableName = Helpers.randomString();
|
|
270
|
+
const indexName = Helpers.randomString();
|
|
271
|
+
|
|
272
|
+
await Helpers.migrator.removeIndex(tableName, indexName);
|
|
273
|
+
|
|
274
|
+
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
|
|
275
|
+
tableName,
|
|
276
|
+
indexName,
|
|
277
|
+
Helpers.noTransaction,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
describe('Migrator#defaultIndexName', () => {
|
|
284
|
+
it ('builds idx_<table>_<columns> from a single column', () => {
|
|
285
|
+
expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it ('joins multiple columns with underscores', () => {
|
|
289
|
+
expect(Helpers.migrator.defaultIndexName('users', ['team_id', 'email'])).toBe('idx_users_team_id_email');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it ('reads the column name off object-style fields', () => {
|
|
293
|
+
const fields = ['team_id', { name: 'email', order: 'DESC' }];
|
|
294
|
+
|
|
295
|
+
expect(Helpers.migrator.defaultIndexName('users', fields)).toBe('idx_users_team_id_email');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
describe('Migrator#addIndex default naming', () => {
|
|
301
|
+
it ('names an index when no options are passed at all', async () => {
|
|
302
|
+
const tableName = Helpers.randomString();
|
|
303
|
+
|
|
304
|
+
await Helpers.migrator.addIndex(tableName, ['team_id']);
|
|
305
|
+
|
|
306
|
+
expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
|
|
307
|
+
fields: ['team_id'],
|
|
308
|
+
name: `idx_${tableName}_team_id`,
|
|
309
|
+
...Helpers.noTransaction,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
144
312
|
});
|
|
145
313
|
});
|
|
@@ -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
|
}
|
|
@@ -13,6 +13,9 @@ const connectionConfig = { database: ' ', host: ' ', password: ' ', username:
|
|
|
13
13
|
const migrationModule = { up: jest.fn(), down: jest.fn() };
|
|
14
14
|
const randomNumber = TestHelpers.Faker.Math.randomNumber
|
|
15
15
|
const randomString = TestHelpers.Faker.Text.randomString;
|
|
16
|
+
const transaction = { id: randomString() };
|
|
17
|
+
const noTransaction = { transaction: null };
|
|
18
|
+
const inTransaction = { transaction };
|
|
16
19
|
const mockQueryInterface = {
|
|
17
20
|
createTable: jest.fn(),
|
|
18
21
|
dropTable: jest.fn(),
|
|
@@ -52,7 +55,7 @@ jest.spyOn(migrator, 'migrationModule', 'get').mockImplementation(() => migratio
|
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
const transactionSpy = jest.spyOn(QueryInterface.prototype, 'transaction');
|
|
55
|
-
transactionSpy.mockImplementation(callback => callback());
|
|
58
|
+
transactionSpy.mockImplementation(callback => callback(transaction));
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
/***************************************************************************************************
|
|
@@ -61,18 +64,29 @@ transactionSpy.mockImplementation(callback => callback());
|
|
|
61
64
|
async function expectQueryInterfaceCall(methodName, args) {
|
|
62
65
|
await migrator[methodName](...args);
|
|
63
66
|
expect(mockQueryInterface[methodName]).toHaveBeenCalledTimes(1);
|
|
64
|
-
expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args);
|
|
67
|
+
expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args, noTransaction);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
// Runs `operation` from inside a migration's `up` so it sees the open transaction.
|
|
72
|
+
async function runInMigration(operation) {
|
|
73
|
+
migrationModule.up.mockImplementationOnce(operation);
|
|
74
|
+
await migrator.run();
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
|
|
68
78
|
module.exports = {
|
|
69
79
|
expectQueryInterfaceCall,
|
|
70
80
|
...Fixtures,
|
|
81
|
+
inTransaction,
|
|
71
82
|
migrationModule,
|
|
72
83
|
migrator,
|
|
73
84
|
mockQueryInterface,
|
|
85
|
+
noTransaction,
|
|
74
86
|
randomColumnDetails,
|
|
75
87
|
randomNumber,
|
|
76
88
|
randomString,
|
|
89
|
+
runInMigration,
|
|
90
|
+
transaction,
|
|
77
91
|
transactionSpy,
|
|
78
92
|
};
|
|
@@ -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',
|