@vida-global/core 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,7 +98,7 @@ module.exports = {
98
98
  Available migration helpers:
99
99
 
100
100
  ```js
101
- createTable(tableName, details, { timestamps })
101
+ createTable(tableName, details, { timestamps, paranoid })
102
102
  dropTable(tableName)
103
103
  addColumn(tableName, columnName, columnDetails)
104
104
  removeColumn(tableName, columnName)
@@ -111,6 +111,7 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
111
111
  **Conventions:**
112
112
 
113
113
  - `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
114
+ - Pass `{ paranoid: true }` to add a nullable `deleted_at` column. Models over that table soft delete: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at`, so `destroy()` sets the column and ordinary queries skip the row. Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a deleted row will still block re-creating the same one.
114
115
  - Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
115
116
  - Each migration runs inside a transaction.
116
117
 
@@ -307,8 +308,3 @@ class User extends ActiveRecord.BaseRecord {
307
308
  ```
308
309
 
309
310
  See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
310
-
311
-
312
- ## TODO
313
-
314
- - **Paranoid (soft delete).** The infrastructure for a `deleted_at` column is wired in `initializationOptions`, but `paranoid: true` is not set, so soft deletes are not yet usable out of the box.
@@ -337,30 +337,39 @@ class BaseRecord extends Model {
337
337
  }
338
338
 
339
339
 
340
- static _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
  ***********************************************************************************************/
@@ -72,6 +72,9 @@ class Migrator {
72
72
  if (options.timestamps !== false) {
73
73
  details = {...details, ...this.defaultTimestampColumns};
74
74
  }
75
+ if (options.paranoid) {
76
+ details = {...details, ...this.deletedAtColumn};
77
+ }
75
78
 
76
79
  details = this.normalizeTableDetails(details);
77
80
  await this.#sequelizeQueryInterface.createTable(tableName, details);
@@ -113,6 +116,17 @@ class Migrator {
113
116
  }
114
117
 
115
118
 
119
+ // Records use soft deletes when the table has this column: initializationOptions turns paranoid
120
+ // on for any model whose schema includes it.
121
+ get deletedAtColumn() {
122
+ return {
123
+ deleted_at: {
124
+ allowNull: true,
125
+ type: this.DataTypes.DATE }
126
+ };
127
+ }
128
+
129
+
116
130
  async dropTable(tableName) {
117
131
  await this.#sequelizeQueryInterface.dropTable(tableName);
118
132
  }
@@ -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.0",
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
 
@@ -61,6 +61,27 @@ describe('Migrator', () => {
61
61
  expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
62
62
  });
63
63
 
64
+ it ('adds a deleted_at column for paranoid tables', async () => {
65
+ const tableName = Helpers.randomString();
66
+ const details = { ...defaultColumns };
67
+
68
+ await Helpers.migrator.createTable(tableName, details, { paranoid: true });
69
+
70
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
71
+ ...defaultColumns,
72
+ deleted_at: { allowNull: true, type: 'DATE' },
73
+ });
74
+ });
75
+
76
+ it ('leaves deleted_at off tables that are not paranoid', async () => {
77
+ const tableName = Helpers.randomString();
78
+ const details = { ...defaultColumns };
79
+
80
+ await Helpers.migrator.createTable(tableName, details);
81
+
82
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, defaultColumns);
83
+ });
84
+
64
85
  it ('snake_cases created table columns', async () => {
65
86
  const tableName = Helpers.randomString();
66
87
  const details = { ...defaultColumns, myColumn: 1 };
@@ -10,6 +10,11 @@ const dataTypeKey2 = TestHelpers.Faker.Text.randomString();
10
10
  const dataTypeValue2 = TestHelpers.Faker.Text.randomString();
11
11
 
12
12
 
13
+ function randomCacheKeys(count) {
14
+ return Array.from({ length: count }, () => `Person#find:${TestHelpers.Faker.Text.randomString()}`);
15
+ }
16
+
17
+
13
18
  /***************************************************************************************************
14
19
  * MOCKS
15
20
  ***************************************************************************************************/
@@ -42,14 +47,26 @@ class MockSequelize {
42
47
 
43
48
 
44
49
  const redisClient = {
45
- del: 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
  }
@@ -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',