@nextage/nx-frame-be 1.0.50 → 1.0.52
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/build/common/base/__test__/base.controller.deferred-events.test.d.ts +1 -0
- package/build/common/base/__test__/base.controller.deferred-events.test.js +145 -0
- package/build/common/base/base.controller.d.ts +25 -0
- package/build/common/base/base.controller.js +62 -3
- package/build/common/base/base.model.d.ts +15 -6
- package/build/common/base/base.model.js +22 -8
- package/build/common/interfaces.d.ts +20 -0
- package/build/common/manager/pubsub-manager.d.ts +13 -2
- package/build/common/manager/pubsub-manager.js +33 -5
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const constants_1 = require("../../constants");
|
|
13
|
+
const constants_2 = require("../../constants");
|
|
14
|
+
const base_controller_1 = require("../base.controller");
|
|
15
|
+
const pubsub_manager_1 = require("../../manager/pubsub-manager");
|
|
16
|
+
const enums_1 = require("../../enums");
|
|
17
|
+
const coded_entity_model_1 = require("../../models/coded-entity.model");
|
|
18
|
+
/**
|
|
19
|
+
* DEFERRED CRUD EMISSIONS - what a transaction is allowed to announce, and when.
|
|
20
|
+
*
|
|
21
|
+
* The harness runs a STANDALONE mongodb-memory-server, so the real transactional path cannot be
|
|
22
|
+
* reached here: `supportsTransactions()` answers false and `withTransaction` degrades. These are
|
|
23
|
+
* therefore UNIT tests of the queue itself - detection and the session are stubbed, and what is
|
|
24
|
+
* asserted is the contract the transactional path must keep:
|
|
25
|
+
*
|
|
26
|
+
* nothing leaves before the commit · everything leaves after it · an abort leaves nothing ·
|
|
27
|
+
* a retried transaction emits once · the subscriber never receives the session.
|
|
28
|
+
*
|
|
29
|
+
* The end-to-end proof, against a REAL replica set, lives in the consumer (D1).
|
|
30
|
+
*/
|
|
31
|
+
class CeController extends base_controller_1.BaseController {
|
|
32
|
+
}
|
|
33
|
+
const ctrl = new CeController({ model: coded_entity_model_1.ceModel });
|
|
34
|
+
/** A session that records what was asked of it and never talks to a server. */
|
|
35
|
+
function fakeSession(behaviour) {
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
return {
|
|
38
|
+
ended: false,
|
|
39
|
+
attempts: () => attempts,
|
|
40
|
+
withTransaction: (fn) => __awaiter(this, void 0, void 0, function* () {
|
|
41
|
+
attempts++;
|
|
42
|
+
if (behaviour === 'abort') {
|
|
43
|
+
yield fn();
|
|
44
|
+
throw new Error('deliberate abort');
|
|
45
|
+
}
|
|
46
|
+
if (behaviour === 'retry-once' && attempts === 1) {
|
|
47
|
+
yield fn(); // first attempt does its work...
|
|
48
|
+
attempts++;
|
|
49
|
+
return fn(); // ...and is then re-run, as a transient error would cause
|
|
50
|
+
}
|
|
51
|
+
return fn();
|
|
52
|
+
}),
|
|
53
|
+
endSession: function () {
|
|
54
|
+
return __awaiter(this, void 0, void 0, function* () { this.ended = true; });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
describe('withTransaction - emissions wait for the commit', () => {
|
|
59
|
+
let published;
|
|
60
|
+
let session;
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
published = [];
|
|
63
|
+
constants_1.APP.nxPubSub = {
|
|
64
|
+
publish: (key, data, context) => { published.push({ key, data, context }); }
|
|
65
|
+
};
|
|
66
|
+
jest.spyOn(ctrl, 'supportsTransactions').mockResolvedValue(true);
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => jest.restoreAllMocks());
|
|
69
|
+
const startWith = (behaviour) => {
|
|
70
|
+
session = fakeSession(behaviour);
|
|
71
|
+
jest.spyOn(coded_entity_model_1.ceModel.mgModel, 'startSession').mockResolvedValue(session);
|
|
72
|
+
};
|
|
73
|
+
it('holds an emission back until the transaction has committed', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
74
|
+
startWith('commit');
|
|
75
|
+
let seenDuring = -1;
|
|
76
|
+
yield ctrl.withTransaction({ i18n: 'en' }, (ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
77
|
+
coded_entity_model_1.ceModel.publishCRUDEvent({ type: enums_1.ModelCrudEventType.create, data: { uid: 'x1', model: 'ce' } }, ctx);
|
|
78
|
+
seenDuring = published.length;
|
|
79
|
+
}));
|
|
80
|
+
expect(seenDuring).toBe(0); // nothing announced while the write is uncommitted
|
|
81
|
+
expect(published).toHaveLength(1); // and exactly one announcement after the commit
|
|
82
|
+
expect(published[0].key).toBe(constants_2.MODEL_CRUD_EVENT);
|
|
83
|
+
expect(published[0].data.data.uid).toBe('x1');
|
|
84
|
+
}));
|
|
85
|
+
it('hands the subscriber a context without the session and without the queue', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
startWith('commit');
|
|
87
|
+
yield ctrl.withTransaction({ i18n: 'en' }, (ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88
|
+
expect(ctx.session).toBeDefined(); // the WRITE is in the transaction...
|
|
89
|
+
expect(ctx.deferredEvents).toBeDefined();
|
|
90
|
+
coded_entity_model_1.ceModel.publishCRUDEvent({ type: enums_1.ModelCrudEventType.create, data: { uid: 'x2', model: 'ce' } }, ctx);
|
|
91
|
+
}));
|
|
92
|
+
// ...but the SUBSCRIBER is not: it would be joining a transaction it cannot see the end of.
|
|
93
|
+
expect(published[0].context.session).toBeUndefined();
|
|
94
|
+
expect(published[0].context.deferredEvents).toBeUndefined();
|
|
95
|
+
expect(published[0].context.i18n).toBe('en'); // the rest of the context survives
|
|
96
|
+
}));
|
|
97
|
+
it('announces nothing when the transaction aborts', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
98
|
+
startWith('abort');
|
|
99
|
+
yield expect(ctrl.withTransaction({}, (ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
100
|
+
coded_entity_model_1.ceModel.publishCRUDEvent({ type: enums_1.ModelCrudEventType.create, data: { uid: 'x3', model: 'ce' } }, ctx);
|
|
101
|
+
}))).rejects.toThrow('deliberate abort');
|
|
102
|
+
expect(published).toHaveLength(0); // the write never happened: announcing it would be a lie
|
|
103
|
+
}));
|
|
104
|
+
it('announces once when the transaction is re-run on a transient error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
105
|
+
startWith('retry-once');
|
|
106
|
+
yield ctrl.withTransaction({}, (ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
107
|
+
coded_entity_model_1.ceModel.publishCRUDEvent({ type: enums_1.ModelCrudEventType.create, data: { uid: 'x4', model: 'ce' } }, ctx);
|
|
108
|
+
}));
|
|
109
|
+
expect(session.attempts()).toBeGreaterThan(1); // the callback really did run twice
|
|
110
|
+
expect(published).toHaveLength(1); // and the queue kept only the last attempt
|
|
111
|
+
}));
|
|
112
|
+
it('publishes immediately - and without the session - when there is no transaction', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
113
|
+
jest.spyOn(ctrl, 'supportsTransactions').mockResolvedValue(false);
|
|
114
|
+
const handMadeSession = { id: 'by-hand' };
|
|
115
|
+
coded_entity_model_1.ceModel.publishCRUDEvent({ type: enums_1.ModelCrudEventType.create, data: { uid: 'x5', model: 'ce' } }, { i18n: 'it', session: handMadeSession });
|
|
116
|
+
expect(published).toHaveLength(1);
|
|
117
|
+
expect(published[0].context.session).toBeUndefined();
|
|
118
|
+
expect(published[0].context.i18n).toBe('it');
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
describe('PubSubManager - a failing subscriber cannot take the process down', () => {
|
|
122
|
+
it('handles the rejection of an async subscriber instead of leaving it unhandled', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
123
|
+
const pubSub = new pubsub_manager_1.PubSubManager();
|
|
124
|
+
const seen = [];
|
|
125
|
+
const onUnhandled = (err) => seen.push(err);
|
|
126
|
+
process.on('unhandledRejection', onUnhandled);
|
|
127
|
+
const id = pubSub.subscribe('probe.key', () => __awaiter(void 0, void 0, void 0, function* () { throw new Error('subscriber failed'); }));
|
|
128
|
+
pubSub.publish('probe.key', { any: 'payload' }, {});
|
|
129
|
+
// Let the microtask queue run: an unhandled rejection would be reported by now.
|
|
130
|
+
yield new Promise(resolve => setImmediate(resolve));
|
|
131
|
+
yield new Promise(resolve => setImmediate(resolve));
|
|
132
|
+
process.off('unhandledRejection', onUnhandled);
|
|
133
|
+
expect(seen).toHaveLength(0);
|
|
134
|
+
pubSub.unsubscribe(id);
|
|
135
|
+
}));
|
|
136
|
+
it('really detaches the subscriber it registered', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
137
|
+
const pubSub = new pubsub_manager_1.PubSubManager();
|
|
138
|
+
let calls = 0;
|
|
139
|
+
const id = pubSub.subscribe('probe.detach', () => { calls++; });
|
|
140
|
+
pubSub.publish('probe.detach', {}, {});
|
|
141
|
+
pubSub.unsubscribe(id);
|
|
142
|
+
pubSub.publish('probe.detach', {}, {});
|
|
143
|
+
expect(calls).toBe(1); // the second publish must not reach a detached subscriber
|
|
144
|
+
}));
|
|
145
|
+
});
|
|
@@ -146,8 +146,33 @@ export declare abstract class BaseController<TAttrs, TDoc extends BaseMongoDoc,
|
|
|
146
146
|
* - `session.withTransaction` may re-run `fn` on transient errors, so `fn` MUST
|
|
147
147
|
* keep non-DB side effects (e.g. publishing) OUTSIDE the transaction.
|
|
148
148
|
*
|
|
149
|
+
* DEFERRED EMISSIONS. A CRUD publication asked for INSIDE the transaction is held back and
|
|
150
|
+
* released only after the commit. Without this the subscribers were handed a context carrying
|
|
151
|
+
* the live session, their work was never awaited by the caller, and whatever they had in
|
|
152
|
+
* flight met that session the moment `withTransaction` committed and `endSession` closed it -
|
|
153
|
+
* a rejection nobody was listening for, which is an uncaught rejection and, with the usual
|
|
154
|
+
* process handler, a shutdown. Queueing also makes the emission mean what it says: an event
|
|
155
|
+
* announcing a write that the database has not yet accepted is a lie the subscribers cannot
|
|
156
|
+
* detect, and on an abort it would announce a write that never happened.
|
|
157
|
+
*
|
|
149
158
|
* @param context caller context (may already carry a session)
|
|
150
159
|
* @param fn work to run; receives the context to pass down to model ops
|
|
151
160
|
*/
|
|
152
161
|
withTransaction<R>(context: NxContext, fn: (ctx: NxContext) => Promise<R>): Promise<R>;
|
|
162
|
+
/**
|
|
163
|
+
* Releases the emissions a committed transaction held back.
|
|
164
|
+
*
|
|
165
|
+
* Two properties, both deliberate:
|
|
166
|
+
*
|
|
167
|
+
* - the subscribers are handed a context WITHOUT the session (and without the queue). The
|
|
168
|
+
* transaction is over; a subscriber that joined it would be writing into a session that is
|
|
169
|
+
* about to be closed, which is the failure this whole mechanism removes;
|
|
170
|
+
* - a subscriber that throws SYNCHRONOUSLY cannot stop the flush or fail the caller's write,
|
|
171
|
+
* which has already committed. It is logged and the remaining emissions still go out. An
|
|
172
|
+
* asynchronous rejection is guarded one level down, where the subscriber is invoked
|
|
173
|
+
* (`PubSubManager.subscribe`), because that is the only place that can see the promise.
|
|
174
|
+
*
|
|
175
|
+
* @param deferred the queue opened for the transaction that has just committed
|
|
176
|
+
*/
|
|
177
|
+
private flushDeferredEvents;
|
|
153
178
|
}
|
|
@@ -8,8 +8,20 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
12
|
+
var t = {};
|
|
13
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
14
|
+
t[p] = s[p];
|
|
15
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
16
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
17
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
18
|
+
t[p[i]] = s[p[i]];
|
|
19
|
+
}
|
|
20
|
+
return t;
|
|
21
|
+
};
|
|
11
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
23
|
exports.BaseController = void 0;
|
|
24
|
+
const constants_1 = require("../constants");
|
|
13
25
|
/**
|
|
14
26
|
* Merges the options the PARSE decided with the ones the CALLER asked for, leaving both untouched.
|
|
15
27
|
*
|
|
@@ -226,23 +238,42 @@ class BaseController {
|
|
|
226
238
|
* - `session.withTransaction` may re-run `fn` on transient errors, so `fn` MUST
|
|
227
239
|
* keep non-DB side effects (e.g. publishing) OUTSIDE the transaction.
|
|
228
240
|
*
|
|
241
|
+
* DEFERRED EMISSIONS. A CRUD publication asked for INSIDE the transaction is held back and
|
|
242
|
+
* released only after the commit. Without this the subscribers were handed a context carrying
|
|
243
|
+
* the live session, their work was never awaited by the caller, and whatever they had in
|
|
244
|
+
* flight met that session the moment `withTransaction` committed and `endSession` closed it -
|
|
245
|
+
* a rejection nobody was listening for, which is an uncaught rejection and, with the usual
|
|
246
|
+
* process handler, a shutdown. Queueing also makes the emission mean what it says: an event
|
|
247
|
+
* announcing a write that the database has not yet accepted is a lie the subscribers cannot
|
|
248
|
+
* detect, and on an abort it would announce a write that never happened.
|
|
249
|
+
*
|
|
229
250
|
* @param context caller context (may already carry a session)
|
|
230
251
|
* @param fn work to run; receives the context to pass down to model ops
|
|
231
252
|
*/
|
|
232
253
|
withTransaction(context, fn) {
|
|
233
254
|
return __awaiter(this, void 0, void 0, function* () {
|
|
234
|
-
// already inside a transaction → reuse it (nested call, no new session)
|
|
255
|
+
// already inside a transaction → reuse it (nested call, no new session). The queue of the
|
|
256
|
+
// OUTER call travels with the context, so an inner emission is released by the outer commit.
|
|
235
257
|
if (context === null || context === void 0 ? void 0 : context.session)
|
|
236
258
|
return fn(context);
|
|
237
|
-
// standalone deployment → no atomicity available, run as today
|
|
259
|
+
// standalone deployment → no atomicity available, run as today. Nothing to wait for either:
|
|
260
|
+
// with no transaction there is no commit to defer to, so emissions leave immediately.
|
|
238
261
|
if (!(yield this.supportsTransactions()))
|
|
239
262
|
return fn(context);
|
|
240
263
|
const session = yield this.model.mgModel.startSession();
|
|
264
|
+
const deferred = [];
|
|
241
265
|
try {
|
|
242
266
|
let result;
|
|
243
267
|
yield session.withTransaction(() => __awaiter(this, void 0, void 0, function* () {
|
|
244
|
-
|
|
268
|
+
// `withTransaction` MAY RE-RUN this callback on a transient error. Anything queued by a
|
|
269
|
+
// previous attempt belongs to a transaction that did not commit, so it is dropped here
|
|
270
|
+
// rather than emitted twice.
|
|
271
|
+
deferred.length = 0;
|
|
272
|
+
result = yield fn(Object.assign(Object.assign({}, context), { session, deferredEvents: deferred }));
|
|
245
273
|
}));
|
|
274
|
+
// Committed, and only now. An abort never reaches this line: the throw propagates and the
|
|
275
|
+
// queue is discarded with the stack, which is exactly the intended behaviour.
|
|
276
|
+
this.flushDeferredEvents(deferred);
|
|
246
277
|
return result;
|
|
247
278
|
}
|
|
248
279
|
finally {
|
|
@@ -250,5 +281,33 @@ class BaseController {
|
|
|
250
281
|
}
|
|
251
282
|
});
|
|
252
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Releases the emissions a committed transaction held back.
|
|
286
|
+
*
|
|
287
|
+
* Two properties, both deliberate:
|
|
288
|
+
*
|
|
289
|
+
* - the subscribers are handed a context WITHOUT the session (and without the queue). The
|
|
290
|
+
* transaction is over; a subscriber that joined it would be writing into a session that is
|
|
291
|
+
* about to be closed, which is the failure this whole mechanism removes;
|
|
292
|
+
* - a subscriber that throws SYNCHRONOUSLY cannot stop the flush or fail the caller's write,
|
|
293
|
+
* which has already committed. It is logged and the remaining emissions still go out. An
|
|
294
|
+
* asynchronous rejection is guarded one level down, where the subscriber is invoked
|
|
295
|
+
* (`PubSubManager.subscribe`), because that is the only place that can see the promise.
|
|
296
|
+
*
|
|
297
|
+
* @param deferred the queue opened for the transaction that has just committed
|
|
298
|
+
*/
|
|
299
|
+
flushDeferredEvents(deferred) {
|
|
300
|
+
var _a, _b;
|
|
301
|
+
for (const ev of deferred) {
|
|
302
|
+
const _c = ev.context, { session, deferredEvents } = _c, ctx = __rest(_c, ["session", "deferredEvents"]);
|
|
303
|
+
try {
|
|
304
|
+
(_a = constants_1.APP.nxPubSub) === null || _a === void 0 ? void 0 : _a.publish(ev.key, ev.data, ctx);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
(_b = constants_1.APP.logger) === null || _b === void 0 ? void 0 : _b.error(`Deferred event [${ev.key}] failed to publish after commit: ${err}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
deferred.length = 0;
|
|
311
|
+
}
|
|
253
312
|
}
|
|
254
313
|
exports.BaseController = BaseController;
|
|
@@ -85,11 +85,10 @@ export declare class BaseModel<TAttrs, TDoc extends BaseMongoDoc, TMongoModel ex
|
|
|
85
85
|
aggregateNativeFiltered(pipeline: mongoose.PipelineStage[], params: FilterParams | undefined, context: NxContext): Promise<mongoose.mongo.AggregationCursor<any> | null>;
|
|
86
86
|
/**
|
|
87
87
|
*
|
|
88
|
-
* @param
|
|
89
|
-
* @param
|
|
90
|
-
* @param {*} context
|
|
88
|
+
* @param pipeline
|
|
89
|
+
* @param options
|
|
91
90
|
*/
|
|
92
|
-
aggregateNative(pipeline: mongoose.PipelineStage[]): mongoose.mongo.AggregationCursor<any>;
|
|
91
|
+
aggregateNative(pipeline: mongoose.PipelineStage[], options?: mongoose.mongo.AggregateOptions): mongoose.mongo.AggregationCursor<any>;
|
|
93
92
|
/**
|
|
94
93
|
* Simple Find wrapper
|
|
95
94
|
* @param {*} params
|
|
@@ -442,9 +441,19 @@ export declare class BaseModel<TAttrs, TDoc extends BaseMongoDoc, TMongoModel ex
|
|
|
442
441
|
*/
|
|
443
442
|
bulkRemove(ids: string, context: NxContext): Promise<TDoc[]>;
|
|
444
443
|
/**
|
|
444
|
+
* Announce a CRUD write to the in-process subscribers.
|
|
445
445
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
446
|
+
* WHEN A TRANSACTION IS OPEN THE EMISSION IS QUEUED, not published: the context carries the
|
|
447
|
+
* queue that `BaseController.withTransaction` opened, and the flush happens after the commit.
|
|
448
|
+
* Publishing here would announce a write the database has not accepted yet, and would hand the
|
|
449
|
+
* subscribers a live session that the caller is about to close.
|
|
450
|
+
*
|
|
451
|
+
* OUTSIDE a transaction the emission goes out immediately, as it always has - but the session,
|
|
452
|
+
* if the caller put one on the context by hand, is REMOVED first. A subscriber must never join
|
|
453
|
+
* a transaction whose lifetime it does not control and whose end it cannot see.
|
|
454
|
+
*
|
|
455
|
+
* @param evt the CRUD event to announce
|
|
456
|
+
* @param context the context of the write; decides whether this waits for a commit
|
|
448
457
|
*/
|
|
449
458
|
publishCRUDEvent(evt: CrudItemEvent, context?: NxContext): void;
|
|
450
459
|
/**
|
|
@@ -175,12 +175,11 @@ class BaseModel extends events_1.EventEmitter {
|
|
|
175
175
|
}
|
|
176
176
|
/**
|
|
177
177
|
*
|
|
178
|
-
* @param
|
|
179
|
-
* @param
|
|
180
|
-
* @param {*} context
|
|
178
|
+
* @param pipeline
|
|
179
|
+
* @param options
|
|
181
180
|
*/
|
|
182
|
-
aggregateNative(pipeline) {
|
|
183
|
-
return this.mgModel.collection.aggregate(pipeline, { allowDiskUse: true });
|
|
181
|
+
aggregateNative(pipeline, options = {}) {
|
|
182
|
+
return this.mgModel.collection.aggregate(pipeline, Object.assign({ allowDiskUse: true }, options));
|
|
184
183
|
}
|
|
185
184
|
/**
|
|
186
185
|
* Simple Find wrapper
|
|
@@ -928,9 +927,19 @@ class BaseModel extends events_1.EventEmitter {
|
|
|
928
927
|
return Promise.all(promises);
|
|
929
928
|
}
|
|
930
929
|
/**
|
|
930
|
+
* Announce a CRUD write to the in-process subscribers.
|
|
931
931
|
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
932
|
+
* WHEN A TRANSACTION IS OPEN THE EMISSION IS QUEUED, not published: the context carries the
|
|
933
|
+
* queue that `BaseController.withTransaction` opened, and the flush happens after the commit.
|
|
934
|
+
* Publishing here would announce a write the database has not accepted yet, and would hand the
|
|
935
|
+
* subscribers a live session that the caller is about to close.
|
|
936
|
+
*
|
|
937
|
+
* OUTSIDE a transaction the emission goes out immediately, as it always has - but the session,
|
|
938
|
+
* if the caller put one on the context by hand, is REMOVED first. A subscriber must never join
|
|
939
|
+
* a transaction whose lifetime it does not control and whose end it cannot see.
|
|
940
|
+
*
|
|
941
|
+
* @param evt the CRUD event to announce
|
|
942
|
+
* @param context the context of the write; decides whether this waits for a commit
|
|
934
943
|
*/
|
|
935
944
|
publishCRUDEvent(evt, context = {}) {
|
|
936
945
|
var _a;
|
|
@@ -938,7 +947,12 @@ class BaseModel extends events_1.EventEmitter {
|
|
|
938
947
|
return;
|
|
939
948
|
if (context === null || context === void 0 ? void 0 : context.evtType)
|
|
940
949
|
evt.type = context.evtType;
|
|
941
|
-
(
|
|
950
|
+
if (context === null || context === void 0 ? void 0 : context.deferredEvents) {
|
|
951
|
+
context.deferredEvents.push({ key: constants_1.MODEL_CRUD_EVENT, data: evt, context });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const { session, deferredEvents } = context, ctx = __rest(context, ["session", "deferredEvents"]);
|
|
955
|
+
(_a = constants_1.APP.nxPubSub) === null || _a === void 0 ? void 0 : _a.publish(constants_1.MODEL_CRUD_EVENT, evt, ctx);
|
|
942
956
|
}
|
|
943
957
|
/**
|
|
944
958
|
*
|
|
@@ -12,6 +12,26 @@ export interface NxContext {
|
|
|
12
12
|
res?: Response;
|
|
13
13
|
i18n?: string;
|
|
14
14
|
session?: ClientSession;
|
|
15
|
+
/**
|
|
16
|
+
* Emissions held back until the transaction that produced them has COMMITTED.
|
|
17
|
+
*
|
|
18
|
+
* Opened by `BaseController.withTransaction` on the child context and, from there, carried
|
|
19
|
+
* down to every model op. Its presence is the signal that a publication must wait: see
|
|
20
|
+
* `BaseModel.publishCRUDEvent`. It is transport-agnostic bookkeeping, not application data,
|
|
21
|
+
* and it never reaches a subscriber - the flush strips it along with the session.
|
|
22
|
+
*/
|
|
23
|
+
deferredEvents?: DeferredEvent[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One publication, captured at the moment it was asked for and released after the commit.
|
|
27
|
+
*
|
|
28
|
+
* The context is kept as it was so the subscriber still sees the user, the language and the
|
|
29
|
+
* rest of it; only `session` and `deferredEvents` are removed on the way out.
|
|
30
|
+
*/
|
|
31
|
+
export interface DeferredEvent {
|
|
32
|
+
key: string;
|
|
33
|
+
data: any;
|
|
34
|
+
context: NxContext;
|
|
15
35
|
}
|
|
16
36
|
export interface NxJwtData extends NxObject {
|
|
17
37
|
rls?: string[];
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { NxContext } from '../interfaces';
|
|
2
2
|
export declare class PubSubManager {
|
|
3
3
|
/**
|
|
4
|
+
* Register `method` for `key`.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* THE SUBSCRIBER IS WRAPPED, for one reason: `EventEmitter.emit` calls a listener and throws
|
|
7
|
+
* its return value away. An async subscriber therefore leaves a promise nobody holds, and if
|
|
8
|
+
* it rejects the process sees an unhandled rejection - which, with the usual process-level
|
|
9
|
+
* handler, takes the service down over a failure in one listener. The wrapper is the only
|
|
10
|
+
* place that can see that promise, so it is the only place the rejection can be handled.
|
|
11
|
+
*
|
|
12
|
+
* A failing subscriber is logged and the publication continues: a publisher never learns that
|
|
13
|
+
* a listener failed, and must not, because the write it announced has already happened.
|
|
14
|
+
*
|
|
15
|
+
* @param key the event name
|
|
16
|
+
* @param method the subscriber; ignored when it is not a function
|
|
17
|
+
* @returns the subscription id, to be passed back to `unsubscribe`
|
|
7
18
|
*/
|
|
8
19
|
subscribe(key: string, method: Function): string;
|
|
9
20
|
/**
|
|
@@ -2,21 +2,49 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PubSubManager = void 0;
|
|
4
4
|
const events_1 = require("events");
|
|
5
|
+
const constants_1 = require("../constants");
|
|
5
6
|
const utils_1 = require("../utils");
|
|
6
7
|
const subs = {};
|
|
7
8
|
const eventsEmitter = new events_1.EventEmitter();
|
|
8
9
|
class PubSubManager {
|
|
9
10
|
/**
|
|
11
|
+
* Register `method` for `key`.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* THE SUBSCRIBER IS WRAPPED, for one reason: `EventEmitter.emit` calls a listener and throws
|
|
14
|
+
* its return value away. An async subscriber therefore leaves a promise nobody holds, and if
|
|
15
|
+
* it rejects the process sees an unhandled rejection - which, with the usual process-level
|
|
16
|
+
* handler, takes the service down over a failure in one listener. The wrapper is the only
|
|
17
|
+
* place that can see that promise, so it is the only place the rejection can be handled.
|
|
18
|
+
*
|
|
19
|
+
* A failing subscriber is logged and the publication continues: a publisher never learns that
|
|
20
|
+
* a listener failed, and must not, because the write it announced has already happened.
|
|
21
|
+
*
|
|
22
|
+
* @param key the event name
|
|
23
|
+
* @param method the subscriber; ignored when it is not a function
|
|
24
|
+
* @returns the subscription id, to be passed back to `unsubscribe`
|
|
13
25
|
*/
|
|
14
26
|
subscribe(key, method) {
|
|
15
27
|
if (!(0, utils_1.isFunction)(method))
|
|
16
28
|
return '';
|
|
17
29
|
const id = (0, utils_1.uuid)();
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
const guarded = (data, context) => {
|
|
31
|
+
var _a;
|
|
32
|
+
try {
|
|
33
|
+
const result = method(data, context);
|
|
34
|
+
if (result && typeof result.then === 'function')
|
|
35
|
+
result.catch((err) => {
|
|
36
|
+
var _a;
|
|
37
|
+
(_a = constants_1.APP.logger) === null || _a === void 0 ? void 0 : _a.error(`Subscriber of [${key}] rejected: ${err}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
(_a = constants_1.APP.logger) === null || _a === void 0 ? void 0 : _a.error(`Subscriber of [${key}] threw: ${err}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
// The GUARDED function is what gets registered, so it is also what `unsubscribe` must
|
|
45
|
+
// remove: `off` compares by reference and would not find the original `method`.
|
|
46
|
+
subs[id] = { key, method, guarded };
|
|
47
|
+
eventsEmitter.on(key, guarded);
|
|
20
48
|
return id;
|
|
21
49
|
}
|
|
22
50
|
/**
|
|
@@ -32,7 +60,7 @@ class PubSubManager {
|
|
|
32
60
|
* @param {*} id
|
|
33
61
|
*/
|
|
34
62
|
unsubscribe(id) {
|
|
35
|
-
eventsEmitter.off(subs[id].key, subs[id].
|
|
63
|
+
eventsEmitter.off(subs[id].key, subs[id].guarded);
|
|
36
64
|
}
|
|
37
65
|
}
|
|
38
66
|
exports.PubSubManager = PubSubManager;
|