@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.
@@ -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 _marshallDataForCaching(value) {
341
- let marshalledValue;
342
- if (value instanceof this) {
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 _unmarshallCachedData(value) {
353
- if (value == null) return null;
354
- if (typeof value != 'string') return value;
346
+ static async forEachCacheKeyByPattern(pattern, callback, count=100) {
347
+ const client = await this._getRedisClient();
348
+ let batch = [];
355
349
 
356
- if (value.startsWith(this.recordCachingPrefix)) {
357
- const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
358
- value = value.replace(regExp, '');
359
- const data = JSON.parse(value);
360
- return new this(data, {isNewRecord: false});
361
- } else {
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
- const client = await this.constructor._getRedisClient();
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 = `${description}_${new Date().getTime()}`;
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(/_(?<version>\d+)(\.(?<databaseId>\w+))?\.js$/);
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
- const migrator = this;
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
- const migrator = this;
36
- await this.#connection.queryInterface.transaction(async () => {
37
- await migrator.migrationModule.down.call(migrator);
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
- await this.#sequelizeQueryInterface.removeIndex(tableName, indexNameOrAttributes, options);
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
 
@@ -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.#fetch(options, fetcher, cacheKey);
39
+ const fetch = this.#runFetcher(fetcher);
30
40
  this.#pendingFetches.set(cacheKey, fetch);
31
41
 
32
42
  try {
33
- return this.#buildFetchResult(await fetch, false);
43
+ const value = await fetch;
44
+ this.#cacheFetchedValue(cacheKey, options.ttlSeconds, fetch, value);
45
+ return this.#buildFetchResult(value, false);
34
46
  } finally {
35
- this.#pendingFetches.delete(cacheKey);
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 #fetch(options, fetcher, cacheKey) {
50
- const value = await fetcher();
51
- this.#set(cacheKey, options.ttlSeconds, value);
52
- return value;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -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 redisClientFactory() { return mockRedisClientFactory; }
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 redisClientFactory() { return mockRedisClientFactory; }
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
- if (options.name) expectedOptions.name = options.name;
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: jest.fn(),
46
- mGet: jest.fn(),
47
- mSet: jest.fn(),
48
- connect: jest.fn(),
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',