@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.
- package/lib/activeRecord/README.md +24 -2
- package/lib/activeRecord/afterCommitHooks.js +167 -0
- package/lib/activeRecord/baseRecord.js +91 -26
- package/lib/server/controllerMixins/validations.js +1 -1
- package/package.json +1 -1
- package/test/activeRecord/afterCommitHooks.test.js +326 -0
- package/test/activeRecord/baseRecord.test.js +312 -14
- package/test/activeRecord/helpers/afterCommitHooks.js +47 -0
- package/test/activeRecord/helpers/baseRecordMocks.js +2 -0
|
@@ -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
|
-
-
|
|
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.
|
|
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(
|
|
203
|
-
|
|
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.
|
|
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.
|
|
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
|
|
517
|
+
static async _afterCommitCacheHook(record, options) {
|
|
452
518
|
await record.clearSelfCache();
|
|
453
519
|
await record.updateCache(options);
|
|
454
520
|
}
|
|
455
521
|
|
|
456
522
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
@@ -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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
995
|
-
expect(
|
|
1090
|
+
|
|
1091
|
+
expect(addSpy).toHaveBeenCalledWith('afterCommit', Cacheable._afterCommitCacheHook);
|
|
996
1092
|
});
|
|
997
1093
|
|
|
998
|
-
it ('is not
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|