@vida-global/core 2.3.2 → 2.3.3

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.
@@ -249,7 +249,7 @@ user.fullName; // "Ada Lovelace"
249
249
  Opt a model in to Redis-backed caching by overriding `isCacheable`. With caching on:
250
250
 
251
251
  - `Model.find(id)` and `Model.find([id1, id2])` consult Redis first and fall back to the database for misses, repopulating the cache for next time.
252
- - `afterSave` and `afterDestroy` hooks automatically invalidate the cached record.
252
+ - An `afterCommit` hook automatically invalidates the cached record, so a write that is rolled back never disturbs the cache.
253
253
  - Cache keys are namespaced as `AR:<ClassName>` (`<ClassName>#find:<id>`).
254
254
 
255
255
  ```js
@@ -294,7 +294,29 @@ User.addHook('afterSave', async (user, options) => {
294
294
  });
295
295
  ```
296
296
 
297
- See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the full list of hook points. The caching layer above uses `afterSave` and `afterDestroy` under the hood when `isCacheable` is true.
297
+ See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the full list of hook points.
298
+
299
+
300
+ ### afterCommit
301
+
302
+ Every hook above runs *inside* the transaction the write belongs to, so its work is undone if that
303
+ transaction rolls back. Use `afterCommit` for anything that must not happen until the data is
304
+ durable — cache invalidation, broadcasts, enqueuing jobs:
305
+
306
+ ```js
307
+ User.addHook('afterCommit', async (user, options) => {
308
+ await broadcast('users.changed', { id: user.id }); // never fires if the transaction rolls back
309
+ });
310
+ ```
311
+
312
+ - Fires once per record written, after the transaction commits. A write outside a transaction fires
313
+ it immediately.
314
+ - Fires on create, update, destroy, restore and upsert. Bulk statements such as
315
+ `Model.destroy({ where })` only fire it when `individualHooks: true` is set, the same as
316
+ `afterDestroy`.
317
+ - With nested transactions, only the outermost commit fires it. A savepoint that commits inside a
318
+ transaction that later rolls back fires nothing.
319
+ - A record written more than once in the same transaction still fires each callback once.
298
320
 
299
321
 
300
322
  ## Associations
@@ -0,0 +1,167 @@
1
+ // Sequelize has no model level afterCommit hook, only Transaction#afterCommit. This module holds
2
+ // the callbacks registered for one and defers them onto the transaction a write belongs to, so
3
+ // BaseRecord can offer afterCommit alongside the hooks sequelize does provide.
4
+
5
+ // Keyed by model class so callbacks stay scoped to the model they were registered on. Module scope
6
+ // is what keeps them unreachable from outside: a static # field is unreadable through a subclass,
7
+ // and a plain static field on BaseRecord would be one collection shared by every model.
8
+ const modelCallbacks = new WeakMap();
9
+ const claimedRegistrations = new WeakMap();
10
+
11
+
12
+ /***************************************************************************************************
13
+ * REGISTRATION
14
+ ***************************************************************************************************/
15
+ function addCallback(model, name, fn) {
16
+ const callback = buildCallback(name, fn);
17
+ ownCallbacks(model).push(callback);
18
+ }
19
+
20
+
21
+ // Sequelize's addHook takes either (name, fn) or (fn), and stores the named form as an object so it
22
+ // can be removed by name later. Match both.
23
+ function buildCallback(name, fn) {
24
+ if (typeof name === 'function') return name;
25
+
26
+ return { name, fn };
27
+ }
28
+
29
+
30
+ function removeCallback(model, name) {
31
+ const remaining = ownCallbacks(model).filter(callback => !callbackMatches(callback, name));
32
+ modelCallbacks.set(model, remaining);
33
+ }
34
+
35
+
36
+ // Only named callbacks are removable by name. An unnamed one carries its function name, which would
37
+ // otherwise match a string by accident.
38
+ function callbackMatches(callback, name) {
39
+ if (typeof name === 'function') return callback === name;
40
+ if (typeof callback === 'object') return callback.name === name;
41
+
42
+ return false;
43
+ }
44
+
45
+
46
+ function ownCallbacks(model) {
47
+ if (!modelCallbacks.has(model)) modelCallbacks.set(model, []);
48
+
49
+ return modelCallbacks.get(model);
50
+ }
51
+
52
+
53
+ /***************************************************************************************************
54
+ * LOOKUP
55
+ ***************************************************************************************************/
56
+ // Ancestors first, so callbacks run in the order they were registered as you read down a hierarchy.
57
+ function callbacksFor(model) {
58
+ const callbacks = [];
59
+ for (const ancestor of modelChain(model)) {
60
+ callbacks.unshift(...inheritableCallbacks(ancestor));
61
+ }
62
+ return callbacks;
63
+ }
64
+
65
+
66
+ function inheritableCallbacks(model) {
67
+ const callbacks = modelCallbacks.get(model) || [];
68
+ return callbacks.map(callback => callbackFunction(callback));
69
+ }
70
+
71
+
72
+ function callbackFunction(callback) {
73
+ if (typeof callback === 'function') return callback;
74
+
75
+ return callback.fn;
76
+ }
77
+
78
+
79
+ function* modelChain(model) {
80
+ let ancestor = model;
81
+ while (typeof ancestor === 'function') {
82
+ yield ancestor;
83
+ ancestor = Object.getPrototypeOf(ancestor);
84
+ }
85
+ }
86
+
87
+
88
+ /***************************************************************************************************
89
+ * DISPATCH
90
+ ***************************************************************************************************/
91
+ async function runCallbacks(model, record, options) {
92
+ const transaction = rootTransaction(options.transaction);
93
+
94
+ if (!transaction) return await callImmediately(model, record, options);
95
+
96
+ deferToTransaction(transaction, model, record, options);
97
+ }
98
+
99
+
100
+ // A savepoint commits while its parent can still roll back, so the callbacks belong to the
101
+ // outermost transaction.
102
+ function rootTransaction(transaction) {
103
+ let root = transaction;
104
+ while (root?.parent) {
105
+ root = root.parent;
106
+ }
107
+ return root;
108
+ }
109
+
110
+
111
+ async function callImmediately(model, record, options) {
112
+ for (const callback of callbacksFor(model)) {
113
+ await callback.call(model, record, options);
114
+ }
115
+ }
116
+
117
+
118
+ function deferToTransaction(transaction, model, record, options) {
119
+ for (const callback of callbacksFor(model)) {
120
+ deferCallback(transaction, callback, model, record, options);
121
+ }
122
+ }
123
+
124
+
125
+ function deferCallback(transaction, callback, model, record, options) {
126
+ if (!claimRegistration(transaction, callback, record)) return;
127
+
128
+ transaction.afterCommit(async () => await callback.call(model, record, options));
129
+ }
130
+
131
+
132
+ /***************************************************************************************************
133
+ * DEDUPLICATION
134
+ ***************************************************************************************************/
135
+ // Overlapping hooks can report the same write twice. Each callback runs once per record per
136
+ // transaction; returns false when this one is already spoken for.
137
+ function claimRegistration(transaction, callback, record) {
138
+ const records = claimedRecords(transaction, callback);
139
+ if (records.has(record)) return false;
140
+
141
+ records.add(record);
142
+ return true;
143
+ }
144
+
145
+
146
+ // A WeakSet so claiming a record doesn't hold it in memory for the life of the transaction.
147
+ function claimedRecords(transaction, callback) {
148
+ const callbacks = transactionClaims(transaction);
149
+ if (!callbacks.has(callback)) callbacks.set(callback, new WeakSet());
150
+
151
+ return callbacks.get(callback);
152
+ }
153
+
154
+
155
+ function transactionClaims(transaction) {
156
+ if (!claimedRegistrations.has(transaction)) claimedRegistrations.set(transaction, new Map());
157
+
158
+ return claimedRegistrations.get(transaction);
159
+ }
160
+
161
+
162
+ module.exports = {
163
+ addCallback,
164
+ callbacksFor,
165
+ removeCallback,
166
+ runCallbacks,
167
+ }
@@ -6,10 +6,14 @@ const { Model, Op, Sequelize } = require('sequelize');
6
6
  const nodeUtil = require('util');
7
7
  const { redisClientFactory } = require('../redis');
8
8
  const { camelize, tableize } = require('inflection');
9
+ const afterCommitHooks = require('./afterCommitHooks');
9
10
  const utils = require('./utils');
10
11
  const validations = require('./validations');
11
12
 
12
13
 
14
+ const AFTER_COMMIT = 'afterCommit';
15
+
16
+
13
17
  class BaseRecord extends Model {
14
18
  static _connection;
15
19
  static _initialized = false;
@@ -197,14 +201,76 @@ class BaseRecord extends Model {
197
201
  }
198
202
 
199
203
 
204
+ /***********************************************************************************************
205
+ * HOOKS
206
+ ***********************************************************************************************/
200
207
  static initializeHooks() {
208
+ this._initializeAfterCommitHooks();
209
+
201
210
  if (this.isCacheable) {
202
- this.addHook('afterSave', this._afterSaveCacheHook);
203
- this.addHook('afterDestroy', this._afterDestroyCacheHook);
211
+ this.addHook(AFTER_COMMIT, this._afterCommitCacheHook);
212
+ }
213
+ }
214
+
215
+
216
+ static _initializeAfterCommitHooks() {
217
+ // Every hook that reports a write to a single record. afterSave is deliberately the only save hook
218
+ // listed: sequelize proxies it into afterCreate and afterUpdate, so adding those would double fire.
219
+ const AFTER_COMMIT_SOURCE_HOOKS = ['afterSave', 'afterDestroy', 'afterRestore', 'afterUpsert'];
220
+ for (const hookType of AFTER_COMMIT_SOURCE_HOOKS) {
221
+ this.addHook(hookType, this._afterCommitHandler);
204
222
  }
205
223
  }
206
224
 
207
225
 
226
+ static addHook(hookType, name, fn) {
227
+ if (hookType != AFTER_COMMIT) return super.addHook(hookType, name, fn);
228
+
229
+ afterCommitHooks.addCallback(this, name, fn);
230
+ return this;
231
+ }
232
+
233
+
234
+ static removeHook(hookType, name) {
235
+ if (hookType != AFTER_COMMIT) return super.removeHook(hookType, name);
236
+
237
+ afterCommitHooks.removeCallback(this, name);
238
+ return this;
239
+ }
240
+
241
+
242
+ static hasHook(hookType) {
243
+ if (hookType != AFTER_COMMIT) return super.hasHook(hookType);
244
+
245
+ return Boolean(afterCommitHooks.callbacksFor(this).length);
246
+ }
247
+
248
+
249
+ // Sequelize aliases hasHooks onto its own hasHook, so overriding hasHook alone leaves it behind.
250
+ static hasHooks(hookType) {
251
+ return this.hasHook(hookType);
252
+ }
253
+
254
+
255
+ // Sequelize generates a shorthand registration method for each of its hook types. Match it.
256
+ static afterCommit(name, fn) {
257
+ return this.addHook(AFTER_COMMIT, name, fn);
258
+ }
259
+
260
+
261
+ static async _afterCommitHandler(recordOrResult, options) {
262
+ const record = this._afterCommitRecord(recordOrResult);
263
+ await afterCommitHooks.runCallbacks(this, record, options);
264
+ }
265
+
266
+
267
+ static _afterCommitRecord(recordOrResult) {
268
+ if (Array.isArray(recordOrResult)) return recordOrResult[0]; // afterUpsert: [record, created]
269
+
270
+ return recordOrResult;
271
+ }
272
+
273
+
208
274
  /***********************************************************************************************
209
275
  * VALIDATIONS
210
276
  ***********************************************************************************************/
@@ -350,7 +416,7 @@ class BaseRecord extends Model {
350
416
  const client = await this._getRedisClient();
351
417
  const cachedValues = clear ? [] : await client.mGet(keys);
352
418
  const kvPairs = keys.map((key,idx) => {
353
- const val = this._unmarshallCachedData(cachedValues[idx]);
419
+ const val = this._decodeCachedValue(cachedValues[idx]);
354
420
  return [key, val]
355
421
  });
356
422
  const cachedData = Object.fromEntries(kvPairs);
@@ -374,7 +440,7 @@ class BaseRecord extends Model {
374
440
  const toCache = {};
375
441
 
376
442
  for (const [key, value] of Object.entries(fetchedData)) {
377
- toCache[key] = this._marshallDataForCaching(value);
443
+ toCache[key] = await this._encodeForCache(value);
378
444
  }
379
445
 
380
446
  const client = await this._getRedisClient();
@@ -448,42 +514,41 @@ class BaseRecord extends Model {
448
514
  static get isCacheable() { return false; }
449
515
 
450
516
 
451
- static async _afterSaveCacheHook(record, options) {
517
+ static async _afterCommitCacheHook(record, options) {
452
518
  await record.clearSelfCache();
453
519
  await record.updateCache(options);
454
520
  }
455
521
 
456
522
 
457
- static async _afterDestroyCacheHook(record, options) {
458
- await record.clearSelfCache();
459
- await record.updateCache(options);
523
+ // The data a record contributes to its cache entry. Override to cache associated data
524
+ // alongside the record so a single cache read returns both.
525
+ async toCachePayload() {
526
+ return this.toJSON();
460
527
  }
461
528
 
462
529
 
463
- static _marshallDataForCaching(value) {
464
- let marshalledValue;
465
- if (value instanceof this) {
466
- marshalledValue = JSON.stringify(value.toJSON());
467
- marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
468
- } else {
469
- marshalledValue = JSON.stringify(value);
470
- }
471
- return marshalledValue;
530
+ // The inverse of toCachePayload: rebuilds a record from its cached data.
531
+ static fromCachePayload(data) {
532
+ return new this(data, {isNewRecord: false});
472
533
  }
473
534
 
474
535
 
475
- static _unmarshallCachedData(value) {
536
+ static async _encodeForCache(value) {
537
+ if (!(value instanceof this)) return JSON.stringify(value);
538
+
539
+ const payload = await value.toCachePayload();
540
+ return `${this.recordCachingPrefix}:${JSON.stringify(payload)}`;
541
+ }
542
+
543
+
544
+ static _decodeCachedValue(value) {
476
545
  if (value == null) return null;
477
546
  if (typeof value != 'string') return value;
547
+ if (!value.startsWith(this.recordCachingPrefix)) return JSON.parse(value);
478
548
 
479
- if (value.startsWith(this.recordCachingPrefix)) {
480
- const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
481
- value = value.replace(regExp, '');
482
- const data = JSON.parse(value);
483
- return new this(data, {isNewRecord: false});
484
- } else {
485
- return JSON.parse(value);
486
- }
549
+ const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
550
+ const data = JSON.parse(value.replace(regExp, ''));
551
+ return this.fromCachePayload(data);
487
552
  }
488
553
 
489
554
 
@@ -70,7 +70,7 @@ const InstanceMethods = {
70
70
 
71
71
  validateIsString(value, options) {
72
72
  if (typeof value !== 'string') return 'must be a string';
73
- if (options?.allowEmpty === false && value.length === 0) return 'must not be empty';
73
+ if (options?.allowEmpty === false && value.trim().length === 0) return 'must not be empty';
74
74
 
75
75
  if (options?.length?.gte !== undefined) {
76
76
  if (value.length < options.length.gte) return `must be greater than or equal to ${options.length.gte} characters`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -0,0 +1,326 @@
1
+ const afterCommitHooks = require('../../lib/activeRecord/afterCommitHooks');
2
+ const TestHelpers = require('@vida-global/test-helpers');
3
+ const Helpers = require('./helpers/afterCommitHooks');
4
+
5
+
6
+ afterEach(() => {
7
+ jest.clearAllMocks();
8
+ });
9
+
10
+
11
+ describe('afterCommitHooks', () => {
12
+ describe('addCallback', () => {
13
+ it ('registers an unnamed callback', () => {
14
+ const model = Helpers.buildModel();
15
+ const callback = jest.fn();
16
+
17
+ afterCommitHooks.addCallback(model, callback);
18
+
19
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([callback]);
20
+ });
21
+
22
+ it ('registers a named callback', () => {
23
+ const model = Helpers.buildModel();
24
+ const name = TestHelpers.Faker.Text.randomString();
25
+ const callback = jest.fn();
26
+
27
+ afterCommitHooks.addCallback(model, name, callback);
28
+
29
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([callback]);
30
+ });
31
+
32
+ it ('keeps callbacks in the order they were registered', () => {
33
+ const model = Helpers.buildModel();
34
+ const first = jest.fn();
35
+ const second = jest.fn();
36
+
37
+ afterCommitHooks.addCallback(model, first);
38
+ afterCommitHooks.addCallback(model, second);
39
+
40
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([first, second]);
41
+ });
42
+ });
43
+
44
+
45
+ describe('removeCallback', () => {
46
+ it ('removes an unnamed callback by reference', () => {
47
+ const model = Helpers.buildModel();
48
+ const removed = jest.fn();
49
+ const remaining = jest.fn();
50
+ afterCommitHooks.addCallback(model, removed);
51
+ afterCommitHooks.addCallback(model, remaining);
52
+
53
+ afterCommitHooks.removeCallback(model, removed);
54
+
55
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([remaining]);
56
+ });
57
+
58
+ it ('removes a named callback by name', () => {
59
+ const model = Helpers.buildModel();
60
+ const name = TestHelpers.Faker.Text.randomString();
61
+ const removed = jest.fn();
62
+ const remaining = jest.fn();
63
+ afterCommitHooks.addCallback(model, name, removed);
64
+ afterCommitHooks.addCallback(model, remaining);
65
+
66
+ afterCommitHooks.removeCallback(model, name);
67
+
68
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([remaining]);
69
+ });
70
+
71
+ it ('leaves unnamed callbacks alone when removing by name', () => {
72
+ const model = Helpers.buildModel();
73
+ const callback = function namedFunction() {};
74
+ afterCommitHooks.addCallback(model, callback);
75
+
76
+ afterCommitHooks.removeCallback(model, 'namedFunction');
77
+
78
+ expect(afterCommitHooks.callbacksFor(model)).toEqual([callback]);
79
+ });
80
+
81
+ it ('does not remove callbacks registered on an ancestor', () => {
82
+ const parent = Helpers.buildModel();
83
+ const child = Helpers.buildModel(parent);
84
+ const callback = jest.fn();
85
+ afterCommitHooks.addCallback(parent, callback);
86
+
87
+ afterCommitHooks.removeCallback(child, callback);
88
+
89
+ expect(afterCommitHooks.callbacksFor(parent)).toEqual([callback]);
90
+ expect(afterCommitHooks.callbacksFor(child)).toEqual([callback]);
91
+ });
92
+ });
93
+
94
+
95
+ describe('callbacksFor', () => {
96
+ it ('returns an empty list for a model with no callbacks', () => {
97
+ expect(afterCommitHooks.callbacksFor(Helpers.buildModel())).toEqual([]);
98
+ });
99
+
100
+ it ('includes callbacks registered on an ancestor', () => {
101
+ const parent = Helpers.buildModel();
102
+ const child = Helpers.buildModel(parent);
103
+ const parentCallback = jest.fn();
104
+ const childCallback = jest.fn();
105
+ afterCommitHooks.addCallback(parent, parentCallback);
106
+ afterCommitHooks.addCallback(child, childCallback);
107
+
108
+ expect(afterCommitHooks.callbacksFor(child)).toEqual([parentCallback, childCallback]);
109
+ });
110
+
111
+ it ('does not include callbacks registered on a descendant', () => {
112
+ const parent = Helpers.buildModel();
113
+ const child = Helpers.buildModel(parent);
114
+ const childCallback = jest.fn();
115
+ afterCommitHooks.addCallback(child, childCallback);
116
+
117
+ expect(afterCommitHooks.callbacksFor(parent)).toEqual([]);
118
+ });
119
+
120
+ it ('does not include callbacks registered on a sibling', () => {
121
+ const parent = Helpers.buildModel();
122
+ const sibling = Helpers.buildModel(parent);
123
+ const otherSibling = Helpers.buildModel(parent);
124
+ const siblingCallback = jest.fn();
125
+ afterCommitHooks.addCallback(sibling, siblingCallback);
126
+
127
+ expect(afterCommitHooks.callbacksFor(otherSibling)).toEqual([]);
128
+ });
129
+ });
130
+
131
+
132
+ describe('runCallbacks', () => {
133
+ describe('without a transaction', () => {
134
+ it ('calls every registered callback with the record and options', async () => {
135
+ const model = Helpers.buildModel();
136
+ const record = Helpers.buildRecord();
137
+ const options = {};
138
+ const callback = jest.fn();
139
+ afterCommitHooks.addCallback(model, callback);
140
+
141
+ await afterCommitHooks.runCallbacks(model, record, options);
142
+
143
+ expect(callback).toHaveBeenCalledTimes(1);
144
+ expect(callback).toHaveBeenCalledWith(record, options);
145
+ });
146
+
147
+ it ('calls the callback with the model as its context', async () => {
148
+ const model = Helpers.buildModel();
149
+ let context;
150
+ afterCommitHooks.addCallback(model, function() { context = this; });
151
+
152
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), {});
153
+
154
+ expect(context).toBe(model);
155
+ });
156
+
157
+ it ('awaits each callback before running the next', async () => {
158
+ const model = Helpers.buildModel();
159
+ const order = [];
160
+ afterCommitHooks.addCallback(model, async () => {
161
+ await Promise.resolve();
162
+ order.push('first');
163
+ });
164
+ afterCommitHooks.addCallback(model, () => order.push('second'));
165
+
166
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), {});
167
+
168
+ expect(order).toEqual(['first', 'second']);
169
+ });
170
+
171
+ it ('lets a callback error propagate', async () => {
172
+ const model = Helpers.buildModel();
173
+ const message = TestHelpers.Faker.Text.randomString();
174
+ afterCommitHooks.addCallback(model, () => { throw new Error(message); });
175
+
176
+ await expect(afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), {}))
177
+ .rejects.toThrow(message);
178
+ });
179
+ });
180
+
181
+
182
+ describe('with a transaction', () => {
183
+ it ('defers the callback to the transaction instead of running it', async () => {
184
+ const model = Helpers.buildModel();
185
+ const transaction = Helpers.buildTransaction();
186
+ const callback = jest.fn();
187
+ afterCommitHooks.addCallback(model, callback);
188
+
189
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), { transaction });
190
+
191
+ expect(transaction.afterCommit).toHaveBeenCalledTimes(1);
192
+ expect(callback).toHaveBeenCalledTimes(0);
193
+ });
194
+
195
+ it ('calls the callback with the record and options once the transaction commits', async () => {
196
+ const model = Helpers.buildModel();
197
+ const transaction = Helpers.buildTransaction();
198
+ const record = Helpers.buildRecord();
199
+ const options = { transaction };
200
+ const callback = jest.fn();
201
+ afterCommitHooks.addCallback(model, callback);
202
+
203
+ await afterCommitHooks.runCallbacks(model, record, options);
204
+ await Helpers.commit(transaction);
205
+
206
+ expect(callback).toHaveBeenCalledTimes(1);
207
+ expect(callback).toHaveBeenCalledWith(record, options);
208
+ });
209
+
210
+ it ('lets a callback error propagate out of the commit', async () => {
211
+ const model = Helpers.buildModel();
212
+ const transaction = Helpers.buildTransaction();
213
+ const message = TestHelpers.Faker.Text.randomString();
214
+ afterCommitHooks.addCallback(model, () => { throw new Error(message); });
215
+
216
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), { transaction });
217
+
218
+ await expect(Helpers.commit(transaction)).rejects.toThrow(message);
219
+ });
220
+
221
+ it ('defers to the outermost transaction when the write is in a savepoint', async () => {
222
+ const model = Helpers.buildModel();
223
+ const transaction = Helpers.buildTransaction();
224
+ const savepoint = Helpers.buildSavepoint(transaction);
225
+ afterCommitHooks.addCallback(model, jest.fn());
226
+
227
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), { transaction: savepoint });
228
+
229
+ expect(transaction.afterCommit).toHaveBeenCalledTimes(1);
230
+ expect(savepoint.afterCommit).toHaveBeenCalledTimes(0);
231
+ });
232
+
233
+ it ('defers to the outermost transaction through nested savepoints', async () => {
234
+ const model = Helpers.buildModel();
235
+ const transaction = Helpers.buildTransaction();
236
+ const outerPoint = Helpers.buildSavepoint(transaction);
237
+ const innerPoint = Helpers.buildSavepoint(outerPoint);
238
+ afterCommitHooks.addCallback(model, jest.fn());
239
+
240
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), { transaction: innerPoint });
241
+
242
+ expect(transaction.afterCommit).toHaveBeenCalledTimes(1);
243
+ expect(outerPoint.afterCommit).toHaveBeenCalledTimes(0);
244
+ expect(innerPoint.afterCommit).toHaveBeenCalledTimes(0);
245
+ });
246
+ });
247
+
248
+
249
+ describe('deduplication', () => {
250
+ it ('defers a callback once when the same record is reported twice', async () => {
251
+ const model = Helpers.buildModel();
252
+ const transaction = Helpers.buildTransaction();
253
+ const record = Helpers.buildRecord();
254
+ const callback = jest.fn();
255
+ afterCommitHooks.addCallback(model, callback);
256
+
257
+ await afterCommitHooks.runCallbacks(model, record, { transaction });
258
+ await afterCommitHooks.runCallbacks(model, record, { transaction });
259
+ await Helpers.commit(transaction);
260
+
261
+ expect(callback).toHaveBeenCalledTimes(1);
262
+ });
263
+
264
+ it ('defers a callback for each distinct record', async () => {
265
+ const model = Helpers.buildModel();
266
+ const transaction = Helpers.buildTransaction();
267
+ const first = Helpers.buildRecord();
268
+ const second = Helpers.buildRecord();
269
+ const callback = jest.fn();
270
+ afterCommitHooks.addCallback(model, callback);
271
+
272
+ await afterCommitHooks.runCallbacks(model, first, { transaction });
273
+ await afterCommitHooks.runCallbacks(model, second, { transaction });
274
+ await Helpers.commit(transaction);
275
+
276
+ expect(callback).toHaveBeenCalledTimes(2);
277
+ });
278
+
279
+ it ('defers every registered callback for the same record', async () => {
280
+ const model = Helpers.buildModel();
281
+ const transaction = Helpers.buildTransaction();
282
+ const first = jest.fn();
283
+ const second = jest.fn();
284
+ afterCommitHooks.addCallback(model, first);
285
+ afterCommitHooks.addCallback(model, second);
286
+
287
+ await afterCommitHooks.runCallbacks(model, Helpers.buildRecord(), { transaction });
288
+ await Helpers.commit(transaction);
289
+
290
+ expect(first).toHaveBeenCalledTimes(1);
291
+ expect(second).toHaveBeenCalledTimes(1);
292
+ });
293
+
294
+ it ('treats the same record in a separate transaction as a new registration', async () => {
295
+ const model = Helpers.buildModel();
296
+ const first = Helpers.buildTransaction();
297
+ const second = Helpers.buildTransaction();
298
+ const record = Helpers.buildRecord();
299
+ const callback = jest.fn();
300
+ afterCommitHooks.addCallback(model, callback);
301
+
302
+ await afterCommitHooks.runCallbacks(model, record, { transaction: first });
303
+ await afterCommitHooks.runCallbacks(model, record, { transaction: second });
304
+ await Helpers.commit(first);
305
+ await Helpers.commit(second);
306
+
307
+ expect(callback).toHaveBeenCalledTimes(2);
308
+ });
309
+
310
+ it ('deduplicates against the outermost transaction across savepoints', async () => {
311
+ const model = Helpers.buildModel();
312
+ const transaction = Helpers.buildTransaction();
313
+ const savepoint = Helpers.buildSavepoint(transaction);
314
+ const record = Helpers.buildRecord();
315
+ const callback = jest.fn();
316
+ afterCommitHooks.addCallback(model, callback);
317
+
318
+ await afterCommitHooks.runCallbacks(model, record, { transaction });
319
+ await afterCommitHooks.runCallbacks(model, record, { transaction: savepoint });
320
+ await Helpers.commit(transaction);
321
+
322
+ expect(callback).toHaveBeenCalledTimes(1);
323
+ });
324
+ });
325
+ });
326
+ });
@@ -844,6 +844,98 @@ describe('BaseRecord', () => {
844
844
  });
845
845
 
846
846
 
847
+ describe('BaseRecord#toCachePayload', () => {
848
+ it ('returns the record as JSON', async () => {
849
+ const attrName = TestHelpers.Faker.Text.randomString();
850
+ const attrVal = TestHelpers.Faker.Text.randomString();
851
+ const person = new Person({[attrName]: attrVal});
852
+
853
+ await expect(person.toCachePayload()).resolves.toEqual({[attrName]: attrVal});
854
+ });
855
+ });
856
+
857
+
858
+ describe('BaseRecord.fromCachePayload', () => {
859
+ it ('builds a record that is not new', () => {
860
+ const attrName = TestHelpers.Faker.Text.randomString();
861
+ const attrVal = TestHelpers.Faker.Text.randomString();
862
+
863
+ const person = Person.fromCachePayload({[attrName]: attrVal});
864
+
865
+ expect(person instanceof Person).toBeTruthy();
866
+ expect(person[attrName]).toEqual(attrVal);
867
+ expect(person.options.isNewRecord).toBeFalsy();
868
+ });
869
+ });
870
+
871
+
872
+ describe('cache payload overrides', () => {
873
+ const EXTRA_KEY = 'nicknames';
874
+
875
+ class Player extends Person {
876
+ #nicknames = null;
877
+
878
+
879
+ get nicknames() { return this.#nicknames; }
880
+ set nicknames(names) { this.#nicknames = names; }
881
+
882
+
883
+ async toCachePayload() {
884
+ const payload = await super.toCachePayload();
885
+ payload[EXTRA_KEY] = await this.fetchNicknames();
886
+ return payload;
887
+ }
888
+
889
+
890
+ static fromCachePayload(data) {
891
+ const { [EXTRA_KEY]: nicknames, ...attributes } = data;
892
+ const player = super.fromCachePayload(attributes);
893
+ player.nicknames = nicknames;
894
+ return player;
895
+ }
896
+
897
+
898
+ async fetchNicknames() { return []; }
899
+ }
900
+
901
+ const attrName = TestHelpers.Faker.Text.randomString();
902
+ const attrVal = TestHelpers.Faker.Text.randomString();
903
+ const nickname = TestHelpers.Faker.Text.randomString();
904
+ const payload = {[attrName]: attrVal, [EXTRA_KEY]: [nickname]};
905
+ const cached = `AR:Player:${JSON.stringify(payload)}`;
906
+
907
+ it ('writes the associated data into the cached value', async () => {
908
+ const key = TestHelpers.Faker.Text.randomString();
909
+ const player = new Player({[attrName]: attrVal});
910
+ jest.spyOn(player, 'fetchNicknames').mockResolvedValue([nickname]);
911
+
912
+ await Player._cacheFetchedData({[key]: player});
913
+
914
+ expect(redisClient.mSet).toHaveBeenCalledTimes(1);
915
+ expect(redisClient.mSet).toHaveBeenCalledWith({[key]: cached});
916
+ });
917
+
918
+ it ('restores the associated data when decoding, keeping it out of the attributes', () => {
919
+ const player = Player._decodeCachedValue(cached);
920
+
921
+ expect(player.nicknames).toEqual([nickname]);
922
+ expect(player[attrName]).toEqual(attrVal);
923
+ expect(player.dataValues[EXTRA_KEY]).toBeUndefined();
924
+ });
925
+
926
+ it ('returns the record and its associated data from a single cache read', async () => {
927
+ const id = Math.random();
928
+ redisClient.mGet.mockImplementation(() => [cached]);
929
+
930
+ const player = await Player._cachedFind(id);
931
+
932
+ expect(redisClient.mGet).toHaveBeenCalledTimes(1);
933
+ expect(Player.findAll).not.toHaveBeenCalled();
934
+ expect(player.nicknames).toEqual([nickname]);
935
+ });
936
+ });
937
+
938
+
847
939
  describe('BaseRecord.clearCacheKey', () => {
848
940
  it ('deletes the given key from cache', async () => {
849
941
  const key = `Person#find:${Math.random()}`;
@@ -980,34 +1072,42 @@ describe('BaseRecord', () => {
980
1072
  });
981
1073
 
982
1074
 
983
- const hooks = ['Save', 'Destroy'];
984
- describe.each(hooks)('BaseRecord._after%sCacheHook', (hook) => {
985
- class Cacheable extends BaseRecord {
986
- static get isCacheable() { return true; }
987
- static get _redisClientFactory() { return mockRedisClientFactory; }
988
- }
989
- const hookMethod = `_after${hook}CacheHook`;
1075
+ describe('BaseRecord._afterCommitCacheHook', () => {
1076
+ const buildCacheable = () => {
1077
+ class Cacheable extends BaseRecord {
1078
+ static get isCacheable() { return true; }
1079
+ static get _redisClientFactory() { return mockRedisClientFactory; }
1080
+ }
1081
+ return Cacheable;
1082
+ };
1083
+
1084
+
1085
+ it ('is registered as an afterCommit hook for cached models', () => {
1086
+ const Cacheable = buildCacheable();
1087
+ const addSpy = jest.spyOn(Cacheable, 'addHook');
990
1088
 
991
- it (`is called as an after ${hook.toLowerCase()} hook for cached models`, () => {
992
- expect(Cacheable.addHook).toHaveBeenCalledTimes(0);
993
1089
  Cacheable.initialize();
994
- expect(Cacheable.addHook).toHaveBeenCalledTimes(2);
995
- expect(Cacheable.addHook).toHaveBeenCalledWith(`after${hook}`, Cacheable[hookMethod]);
1090
+
1091
+ expect(addSpy).toHaveBeenCalledWith('afterCommit', Cacheable._afterCommitCacheHook);
996
1092
  });
997
1093
 
998
- it ('is not called as an after save hook for non cached models', () => {
1094
+ it ('is not registered for non cached models', () => {
999
1095
  class Foo extends BaseRecord {}
1096
+ const addSpy = jest.spyOn(Foo, 'addHook');
1097
+
1000
1098
  Foo.initialize();
1001
- expect(Cacheable.addHook).toHaveBeenCalledTimes(0);
1099
+
1100
+ expect(addSpy).not.toHaveBeenCalledWith('afterCommit', expect.anything());
1002
1101
  });
1003
1102
 
1004
1103
  it ('calls BaseRecord#clearSelfCache and BaseRecord#updateCache', async () => {
1104
+ const Cacheable = buildCacheable();
1005
1105
  const person = new Cacheable();
1006
1106
  const clearSpy = jest.spyOn(person, 'clearSelfCache');
1007
1107
  const updateSpy = jest.spyOn(person, 'updateCache');
1008
1108
  const options = {};
1009
1109
 
1010
- await Cacheable[hookMethod](person,options);
1110
+ await Cacheable._afterCommitCacheHook(person, options);
1011
1111
 
1012
1112
  expect(clearSpy).toHaveBeenCalledTimes(1);
1013
1113
  expect(updateSpy).toHaveBeenCalledTimes(1);
@@ -1017,6 +1117,204 @@ describe('BaseRecord', () => {
1017
1117
  });
1018
1118
 
1019
1119
 
1120
+ describe('afterCommit hooks', () => {
1121
+ const sourceHooks = ['afterSave', 'afterDestroy', 'afterRestore', 'afterUpsert'];
1122
+
1123
+ describe('BaseRecord.addHook', () => {
1124
+ it ('delegates hook types sequelize supports to the parent implementation', () => {
1125
+ class User extends BaseRecord {}
1126
+ const callback = jest.fn();
1127
+
1128
+ User.addHook('beforeSave', callback);
1129
+
1130
+ expect(Model.addHook).toHaveBeenCalledTimes(1);
1131
+ expect(Model.addHook).toHaveBeenCalledWith('beforeSave', callback, undefined);
1132
+ });
1133
+
1134
+ it ('does not delegate afterCommit to the parent implementation', () => {
1135
+ class User extends BaseRecord {}
1136
+
1137
+ User.addHook('afterCommit', jest.fn());
1138
+
1139
+ expect(Model.addHook).toHaveBeenCalledTimes(0);
1140
+ });
1141
+
1142
+ it ('registers the callback so it runs on commit', async () => {
1143
+ class User extends BaseRecord {}
1144
+ const callback = jest.fn();
1145
+ const record = new User();
1146
+ const options = {};
1147
+ User.addHook('afterCommit', callback);
1148
+
1149
+ await User._afterCommitHandler(record, options);
1150
+
1151
+ expect(callback).toHaveBeenCalledTimes(1);
1152
+ expect(callback).toHaveBeenCalledWith(record, options);
1153
+ });
1154
+
1155
+ it ('registers a named callback', async () => {
1156
+ class User extends BaseRecord {}
1157
+ const callback = jest.fn();
1158
+ User.addHook('afterCommit', TestHelpers.Faker.Text.randomString(), callback);
1159
+
1160
+ await User._afterCommitHandler(new User(), {});
1161
+
1162
+ expect(callback).toHaveBeenCalledTimes(1);
1163
+ });
1164
+ });
1165
+
1166
+
1167
+ describe('BaseRecord.removeHook', () => {
1168
+ it ('delegates hook types sequelize supports to the parent implementation', () => {
1169
+ class User extends BaseRecord {}
1170
+ const name = TestHelpers.Faker.Text.randomString();
1171
+
1172
+ User.removeHook('beforeSave', name);
1173
+
1174
+ expect(Model.removeHook).toHaveBeenCalledTimes(1);
1175
+ expect(Model.removeHook).toHaveBeenCalledWith('beforeSave', name);
1176
+ });
1177
+
1178
+ it ('removes a registered afterCommit callback', async () => {
1179
+ class User extends BaseRecord {}
1180
+ const callback = jest.fn();
1181
+ User.addHook('afterCommit', callback);
1182
+
1183
+ User.removeHook('afterCommit', callback);
1184
+ await User._afterCommitHandler(new User(), {});
1185
+
1186
+ expect(Model.removeHook).toHaveBeenCalledTimes(0);
1187
+ expect(callback).toHaveBeenCalledTimes(0);
1188
+ });
1189
+ });
1190
+
1191
+
1192
+ describe('BaseRecord.hasHook', () => {
1193
+ it ('delegates hook types sequelize supports to the parent implementation', () => {
1194
+ class User extends BaseRecord {}
1195
+
1196
+ User.hasHook('beforeSave');
1197
+
1198
+ expect(Model.hasHook).toHaveBeenCalledTimes(1);
1199
+ expect(Model.hasHook).toHaveBeenCalledWith('beforeSave');
1200
+ });
1201
+
1202
+ it ('reports false when no afterCommit callback is registered', () => {
1203
+ class User extends BaseRecord {}
1204
+
1205
+ expect(User.hasHook('afterCommit')).toBe(false);
1206
+ });
1207
+
1208
+ it ('reports true once an afterCommit callback is registered', () => {
1209
+ class User extends BaseRecord {}
1210
+ User.addHook('afterCommit', jest.fn());
1211
+
1212
+ expect(User.hasHook('afterCommit')).toBe(true);
1213
+ });
1214
+ });
1215
+
1216
+
1217
+ describe('BaseRecord.hasHooks', () => {
1218
+ it ('reports on afterCommit the same way hasHook does', () => {
1219
+ class User extends BaseRecord {}
1220
+ User.addHook('afterCommit', jest.fn());
1221
+
1222
+ expect(User.hasHooks('afterCommit')).toBe(true);
1223
+ });
1224
+ });
1225
+
1226
+
1227
+ describe('BaseRecord.afterCommit', () => {
1228
+ it ('registers the callback like addHook does', async () => {
1229
+ class User extends BaseRecord {}
1230
+ const callback = jest.fn();
1231
+
1232
+ User.afterCommit(callback);
1233
+ await User._afterCommitHandler(new User(), {});
1234
+
1235
+ expect(callback).toHaveBeenCalledTimes(1);
1236
+ });
1237
+ });
1238
+
1239
+
1240
+ describe('BaseRecord.initializeHooks', () => {
1241
+ it.each(sourceHooks)('registers the afterCommit handler on %s', (hookType) => {
1242
+ class User extends BaseRecord {}
1243
+
1244
+ User.initialize();
1245
+
1246
+ expect(Model.addHook).toHaveBeenCalledWith(hookType, User._afterCommitHandler, undefined);
1247
+ });
1248
+
1249
+ it ('does not register the handler on afterCreate or afterUpdate', () => {
1250
+ class User extends BaseRecord {}
1251
+
1252
+ User.initialize();
1253
+
1254
+ expect(Model.addHook).not.toHaveBeenCalledWith('afterCreate', expect.anything(), undefined);
1255
+ expect(Model.addHook).not.toHaveBeenCalledWith('afterUpdate', expect.anything(), undefined);
1256
+ });
1257
+ });
1258
+
1259
+
1260
+ describe('BaseRecord._afterCommitHandler', () => {
1261
+ it ('runs callbacks inherited from a parent model', async () => {
1262
+ class User extends BaseRecord {}
1263
+ class Admin extends User {}
1264
+ const callback = jest.fn();
1265
+ User.addHook('afterCommit', callback);
1266
+
1267
+ await Admin._afterCommitHandler(new Admin(), {});
1268
+
1269
+ expect(callback).toHaveBeenCalledTimes(1);
1270
+ });
1271
+
1272
+ it ('does not run callbacks registered on an unrelated model', async () => {
1273
+ class User extends BaseRecord {}
1274
+ class Account extends BaseRecord {}
1275
+ const callback = jest.fn();
1276
+ User.addHook('afterCommit', callback);
1277
+
1278
+ await Account._afterCommitHandler(new Account(), {});
1279
+
1280
+ expect(callback).toHaveBeenCalledTimes(0);
1281
+ });
1282
+
1283
+ it ('unwraps the record from the array afterUpsert reports', async () => {
1284
+ class User extends BaseRecord {}
1285
+ const callback = jest.fn();
1286
+ const record = new User();
1287
+ const options = {};
1288
+ User.addHook('afterCommit', callback);
1289
+
1290
+ await User._afterCommitHandler([record, true], options);
1291
+
1292
+ expect(callback).toHaveBeenCalledWith(record, options);
1293
+ });
1294
+
1295
+ it ('defers to the transaction when the write is in one', async () => {
1296
+ class User extends BaseRecord {}
1297
+ const transaction = { afterCommit: jest.fn(), parent: null };
1298
+ const callback = jest.fn();
1299
+ User.addHook('afterCommit', callback);
1300
+
1301
+ await User._afterCommitHandler(new User(), { transaction });
1302
+
1303
+ expect(transaction.afterCommit).toHaveBeenCalledTimes(1);
1304
+ expect(callback).toHaveBeenCalledTimes(0);
1305
+ });
1306
+
1307
+ it ('lets a callback error propagate', async () => {
1308
+ class User extends BaseRecord {}
1309
+ const message = TestHelpers.Faker.Text.randomString();
1310
+ User.addHook('afterCommit', () => { throw new Error(message); });
1311
+
1312
+ await expect(User._afterCommitHandler(new User(), {})).rejects.toThrow(message);
1313
+ });
1314
+ });
1315
+ });
1316
+
1317
+
1020
1318
  describe('.documentationSchema', () => {
1021
1319
  const buildModel = (attributes) => {
1022
1320
  class Thing extends BaseRecord {}
@@ -0,0 +1,47 @@
1
+ const TestHelpers = require('@vida-global/test-helpers');
2
+
3
+
4
+ /***************************************************************************************************
5
+ * MOCKS
6
+ ***************************************************************************************************/
7
+ function buildTransaction() {
8
+ return { afterCommit: jest.fn(), parent: null };
9
+ }
10
+
11
+
12
+ function buildSavepoint(parent) {
13
+ return { afterCommit: jest.fn(), parent };
14
+ }
15
+
16
+
17
+ // The callbacks module keys everything off the model class itself, so any class works as a stand in.
18
+ function buildModel(parent) {
19
+ if (parent) return class Child extends parent {};
20
+
21
+ return class Model {};
22
+ }
23
+
24
+
25
+ function buildRecord() {
26
+ return { id: TestHelpers.Faker.Text.randomString() };
27
+ }
28
+
29
+
30
+ /***************************************************************************************************
31
+ * ASSERTIONS
32
+ ***************************************************************************************************/
33
+ // Nothing runs until the transaction commits, so tests drive the deferred callbacks by hand.
34
+ async function commit(transaction) {
35
+ for (const [deferred] of transaction.afterCommit.mock.calls) {
36
+ await deferred();
37
+ }
38
+ }
39
+
40
+
41
+ module.exports = {
42
+ buildModel,
43
+ buildRecord,
44
+ buildSavepoint,
45
+ buildTransaction,
46
+ commit,
47
+ }
@@ -27,6 +27,8 @@ class MockModel {
27
27
  }
28
28
  };
29
29
  static addHook = jest.fn()
30
+ static removeHook = jest.fn()
31
+ static hasHook = jest.fn()
30
32
  static init = jest.fn()
31
33
  static findByPk = jest.fn();
32
34
  static findAll = jest.fn();