@nextage/nx-frame-be 1.0.48 → 1.0.50
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.update-options.test.d.ts +1 -0
- package/build/common/base/__test__/base.controller.update-options.test.js +112 -0
- package/build/common/base/__test__/base.model.unset-paths.test.d.ts +1 -0
- package/build/common/base/__test__/base.model.unset-paths.test.js +149 -0
- package/build/common/base/base.controller.d.ts +9 -2
- package/build/common/base/base.controller.js +25 -3
- package/build/common/base/base.interfaces.d.ts +24 -0
- package/build/common/base/base.model.d.ts +3 -2
- package/build/common/base/base.model.js +16 -10
- package/build/common/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* BaseController.update - the options a PARSE can decide
|
|
4
|
+
*
|
|
5
|
+
* An update payload cannot say everything about the write it wants. The parse step, which is
|
|
6
|
+
* where a subclass runs its own derivation, may conclude something the payload has no way to
|
|
7
|
+
* express - typically that a field must be REMOVED rather than left unwritten. `parseUpdateItem`
|
|
8
|
+
* can therefore return the options it decided, and `update` carries them to the model together
|
|
9
|
+
* with the caller's.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THESE TESTS GUARD is mostly what does NOT change: an override that returns nothing - every
|
|
12
|
+
* one that exists today - must reach the model with the caller's own options object, by identity.
|
|
13
|
+
* A merge that quietly rebuilt that object would be invisible here and would break the callers
|
|
14
|
+
* that hand the same options around, so identity is asserted and not just deep equality.
|
|
15
|
+
*
|
|
16
|
+
* The model is a fake recording its arguments: this is about the controller, and there is nothing
|
|
17
|
+
* to persist.
|
|
18
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
19
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
20
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
21
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
22
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
23
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
24
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
25
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
const base_controller_1 = require("../base.controller");
|
|
30
|
+
const doc = (over) => (Object.assign(Object.assign({}, over), { toObject: () => (Object.assign({}, over)) }));
|
|
31
|
+
/** Records what `update` hands to the model. */
|
|
32
|
+
const fakeModel = () => ({
|
|
33
|
+
get: jest.fn((id) => __awaiter(void 0, void 0, void 0, function* () { return doc({ id }); })),
|
|
34
|
+
update: jest.fn((id, item) => __awaiter(void 0, void 0, void 0, function* () { return doc(Object.assign({ id }, item)); })),
|
|
35
|
+
});
|
|
36
|
+
/** LEGACY: the shape of every override that exists today - declared `Promise<void>`. */
|
|
37
|
+
class LegacyCtrl extends base_controller_1.BaseController {
|
|
38
|
+
parseUpdateItem(id, item) {
|
|
39
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
40
|
+
item.parsed = true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** A parse that decides a removal of its own. */
|
|
45
|
+
class DecidingCtrl extends base_controller_1.BaseController {
|
|
46
|
+
constructor(model, decided) {
|
|
47
|
+
super({ model, mergeUpdate: true });
|
|
48
|
+
this.decided = decided;
|
|
49
|
+
}
|
|
50
|
+
parseUpdateItem() {
|
|
51
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
return this.decided;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The options the model was handed, i.e. the fifth argument of `model.update`. */
|
|
57
|
+
const optionsSeenBy = (model) => model.update.mock.calls[0][4];
|
|
58
|
+
describe('BaseController.update - a parse that decides nothing changes nothing', () => {
|
|
59
|
+
it('hands the CALLER options to the model by identity', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
60
|
+
const model = fakeModel();
|
|
61
|
+
const ctrl = new LegacyCtrl({ model, mergeUpdate: true });
|
|
62
|
+
const options = { unsetPaths: ['a'] };
|
|
63
|
+
yield ctrl.update('id-1', { name: 'N' }, {}, options);
|
|
64
|
+
expect(optionsSeenBy(model)).toBe(options); // the same object, not a copy
|
|
65
|
+
}));
|
|
66
|
+
it('hands over undefined when the caller passed nothing', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
67
|
+
const model = fakeModel();
|
|
68
|
+
const ctrl = new LegacyCtrl({ model, mergeUpdate: true });
|
|
69
|
+
yield ctrl.update('id-1', { name: 'N' }, {});
|
|
70
|
+
expect(optionsSeenBy(model)).toBeUndefined();
|
|
71
|
+
}));
|
|
72
|
+
it('still runs the parse: a legacy override keeps mutating the item', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
73
|
+
const model = fakeModel();
|
|
74
|
+
const ctrl = new LegacyCtrl({ model, mergeUpdate: true });
|
|
75
|
+
const item = { name: 'N' };
|
|
76
|
+
yield ctrl.update('id-1', item, {});
|
|
77
|
+
expect(item.parsed).toBe(true);
|
|
78
|
+
}));
|
|
79
|
+
});
|
|
80
|
+
describe('BaseController.update - a parse that returns options', () => {
|
|
81
|
+
it('carries them to the model when the caller asked for nothing', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
const model = fakeModel();
|
|
83
|
+
const ctrl = new DecidingCtrl(model, { unsetPaths: ['confidentiality'] });
|
|
84
|
+
yield ctrl.update('id-1', { name: 'N' }, {});
|
|
85
|
+
expect(optionsSeenBy(model)).toEqual({ unsetPaths: ['confidentiality'] });
|
|
86
|
+
}));
|
|
87
|
+
it('unions the paths of the caller and of the parse', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
88
|
+
const model = fakeModel();
|
|
89
|
+
const ctrl = new DecidingCtrl(model, { unsetPaths: ['confidentiality'] });
|
|
90
|
+
yield ctrl.update('id-1', { name: 'N' }, {}, { unsetPaths: ['legacyField'] });
|
|
91
|
+
expect(optionsSeenBy(model).unsetPaths).toEqual(['legacyField', 'confidentiality']);
|
|
92
|
+
}));
|
|
93
|
+
it('removes duplicates when both ask for the same path', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
+
const model = fakeModel();
|
|
95
|
+
const ctrl = new DecidingCtrl(model, { unsetPaths: ['confidentiality', 'other'] });
|
|
96
|
+
yield ctrl.update('id-1', { name: 'N' }, {}, { unsetPaths: ['confidentiality'] });
|
|
97
|
+
expect(optionsSeenBy(model).unsetPaths).toEqual(['confidentiality', 'other']);
|
|
98
|
+
}));
|
|
99
|
+
it('leaves the two originals untouched, arrays included', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
100
|
+
const model = fakeModel();
|
|
101
|
+
const decided = { unsetPaths: ['confidentiality'] };
|
|
102
|
+
const callerOptions = { unsetPaths: ['legacyField'] };
|
|
103
|
+
const ctrl = new DecidingCtrl(model, decided);
|
|
104
|
+
yield ctrl.update('id-1', { name: 'N' }, {}, callerOptions);
|
|
105
|
+
expect(callerOptions).toEqual({ unsetPaths: ['legacyField'] });
|
|
106
|
+
expect(decided).toEqual({ unsetPaths: ['confidentiality'] });
|
|
107
|
+
// and the merged one is a new object, not either of them
|
|
108
|
+
const seen = optionsSeenBy(model);
|
|
109
|
+
expect(seen).not.toBe(callerOptions);
|
|
110
|
+
expect(seen).not.toBe(decided);
|
|
111
|
+
}));
|
|
112
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* BaseModel.update - the `unsetPaths` option
|
|
4
|
+
*
|
|
5
|
+
* An update payload can say two things about a property: absent means PRESERVE, and present
|
|
6
|
+
* means set it. It cannot say REMOVE IT - a property set to `undefined` does not survive the
|
|
7
|
+
* merge, because lodash skips undefined source values, and an absent key is preserved by
|
|
8
|
+
* design. `unsetPaths` is the third statement.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THESE TESTS GUARD, beyond the option itself: that adding an optional argument did not
|
|
11
|
+
* disturb the two call shapes that already existed. The overloads are told apart by the TYPE
|
|
12
|
+
* of the third argument, and a check on the PRESENCE of the fourth - which is what the code
|
|
13
|
+
* did before - would leave the context empty as soon as options are passed, turning a scoped
|
|
14
|
+
* read and write into an unscoped one without any error.
|
|
15
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
16
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
17
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
18
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
19
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
20
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
21
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
22
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
const mongoose_1 = __importDefault(require("mongoose"));
|
|
30
|
+
const base_model_1 = require("../base.model");
|
|
31
|
+
const mongo_utils_1 = require("../mongo-utils");
|
|
32
|
+
class UnsetEntity extends base_model_1.BaseModel {
|
|
33
|
+
}
|
|
34
|
+
const schema = {
|
|
35
|
+
code: { type: String, required: true },
|
|
36
|
+
name: { type: String, required: true },
|
|
37
|
+
note: { type: String }, // optional scalar: the unset target
|
|
38
|
+
data: { type: mongoose_1.default.SchemaTypes.Mixed }, // holds the dotted scalar path
|
|
39
|
+
// Declared because the audit only stamps what the schema declares: it is what lets the third
|
|
40
|
+
// case below prove that the CONTEXT was read as such and not mistaken for the options.
|
|
41
|
+
updatedBy: { type: String },
|
|
42
|
+
updatedAt: { type: Date }
|
|
43
|
+
};
|
|
44
|
+
const model = (0, mongo_utils_1.createModel)({
|
|
45
|
+
name: 'UnsetEntity',
|
|
46
|
+
modelName: 'UnsetEntity',
|
|
47
|
+
collection: 'unsetEntity',
|
|
48
|
+
classDef: UnsetEntity,
|
|
49
|
+
mergeSchema: false,
|
|
50
|
+
modelParams: { sort: { code: 1 } },
|
|
51
|
+
schema
|
|
52
|
+
}, false);
|
|
53
|
+
/** The document as MONGO holds it: the only place where "absent" and "null" tell apart. */
|
|
54
|
+
const raw = (id) => mongoose_1.default.connection.db.collection('unsetEntity').findOne({ _id: new mongoose_1.default.Types.ObjectId(id) });
|
|
55
|
+
const seed = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (attrs = {}) { return model.create(Object.assign({ code: 'C1', name: 'N1', note: 'to-remove', data: { keep: 'k', drop: 'd' } }, attrs), {}); });
|
|
56
|
+
describe('BaseModel.update - legacy call shapes stay untouched', () => {
|
|
57
|
+
it('1. (id, item, context) keeps working and applies the payload', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
58
|
+
const doc = yield seed();
|
|
59
|
+
const updated = yield model.update(doc.id, { name: 'N2' }, {});
|
|
60
|
+
expect(updated.name).toEqual('N2');
|
|
61
|
+
expect(updated.code).toEqual('C1');
|
|
62
|
+
expect(updated.version).toEqual(doc.version + 1);
|
|
63
|
+
}));
|
|
64
|
+
it('2. (id, item, mergeItem, context) keeps working, and the merge still preserves', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
65
|
+
const doc = yield seed();
|
|
66
|
+
const updated = yield model.update(doc.id, { name: 'N2' }, true, {});
|
|
67
|
+
expect(updated.name).toEqual('N2');
|
|
68
|
+
expect(updated.note).toEqual('to-remove'); // absent from the payload: preserved
|
|
69
|
+
}));
|
|
70
|
+
});
|
|
71
|
+
describe('BaseModel.update - the context is not lost when options are passed', () => {
|
|
72
|
+
it('3. (id, item, context, options) reads the third argument as the CONTEXT', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
73
|
+
const doc = yield seed();
|
|
74
|
+
// The context carries a user here: were the third argument misread as options - the trap the
|
|
75
|
+
// dispatch had to avoid - the audit stamp would silently go missing.
|
|
76
|
+
const ctx = { user: { id: 'u-1' } };
|
|
77
|
+
const updated = yield model.update(doc.id, { name: 'N2' }, ctx, { unsetPaths: ['note'] });
|
|
78
|
+
expect(updated.name).toEqual('N2');
|
|
79
|
+
expect(updated.updatedBy).toEqual('u-1');
|
|
80
|
+
expect(yield raw(doc.id)).not.toHaveProperty('note');
|
|
81
|
+
}));
|
|
82
|
+
it('4. (id, item, mergeItem, context, options) works in the five-argument shape', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
83
|
+
const doc = yield seed();
|
|
84
|
+
const updated = yield model.update(doc.id, { name: 'N2' }, true, {}, { unsetPaths: ['note'] });
|
|
85
|
+
expect(updated.name).toEqual('N2');
|
|
86
|
+
expect(yield raw(doc.id)).not.toHaveProperty('note');
|
|
87
|
+
}));
|
|
88
|
+
});
|
|
89
|
+
describe('BaseModel.update - unsetPaths', () => {
|
|
90
|
+
it('5. mergeUpdate with an absent property preserves the persisted value', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
91
|
+
const doc = yield seed();
|
|
92
|
+
yield model.update(doc.id, { name: 'N2' }, true, {});
|
|
93
|
+
const after = yield raw(doc.id);
|
|
94
|
+
expect(after.note).toEqual('to-remove');
|
|
95
|
+
}));
|
|
96
|
+
it('6. mergeUpdate + unsetPaths REMOVES the key, it does not null it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
97
|
+
const doc = yield seed();
|
|
98
|
+
yield model.update(doc.id, { name: 'N2' }, true, {}, { unsetPaths: ['note'] });
|
|
99
|
+
const after = yield raw(doc.id);
|
|
100
|
+
expect(after).not.toHaveProperty('note'); // the key is gone
|
|
101
|
+
expect(after.note).toBeUndefined(); // and not sitting there as null
|
|
102
|
+
expect(after.name).toEqual('N2');
|
|
103
|
+
}));
|
|
104
|
+
it('7. unsetting a path that is already absent is a no-op', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
105
|
+
const doc = yield seed({ note: undefined });
|
|
106
|
+
const before = yield raw(doc.id);
|
|
107
|
+
expect(before).not.toHaveProperty('note');
|
|
108
|
+
const updated = yield model.update(doc.id, {}, true, {}, { unsetPaths: ['note'] });
|
|
109
|
+
expect(yield raw(doc.id)).not.toHaveProperty('note');
|
|
110
|
+
expect(updated.code).toEqual('C1');
|
|
111
|
+
}));
|
|
112
|
+
it('8. sets one field and unsets another in the SAME update, with one version step', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
113
|
+
const doc = yield seed();
|
|
114
|
+
const updated = yield model.update(doc.id, { name: 'N2' }, true, {}, { unsetPaths: ['note'] });
|
|
115
|
+
const after = yield raw(doc.id);
|
|
116
|
+
expect(after.name).toEqual('N2');
|
|
117
|
+
expect(after).not.toHaveProperty('note');
|
|
118
|
+
expect(updated.version).toEqual(doc.version + 1); // one save, one version
|
|
119
|
+
}));
|
|
120
|
+
it('9. removes a DOTTED path to a scalar, leaving its siblings alone', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
121
|
+
const doc = yield seed();
|
|
122
|
+
yield model.update(doc.id, {}, true, {}, { unsetPaths: ['data.drop'] });
|
|
123
|
+
const after = yield raw(doc.id);
|
|
124
|
+
expect(after.data).toEqual({ keep: 'k' });
|
|
125
|
+
}));
|
|
126
|
+
it('10. re-applies the removal on the VersionError retry, without bypassing it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
127
|
+
const doc = yield seed();
|
|
128
|
+
// A concurrent writer bumps the version between the read and the save of the FIRST attempt:
|
|
129
|
+
// mongoose raises a VersionError, the loop re-reads and retries. The removal must reach the
|
|
130
|
+
// document read by the retry too - which is why it is applied inside the loop.
|
|
131
|
+
const original = model.get.bind(model);
|
|
132
|
+
let raced = false;
|
|
133
|
+
const spy = jest.spyOn(model, 'get').mockImplementation((...args) => __awaiter(void 0, void 0, void 0, function* () {
|
|
134
|
+
const found = yield original(...args);
|
|
135
|
+
if (!raced) {
|
|
136
|
+
raced = true;
|
|
137
|
+
yield mongoose_1.default.connection.db.collection('unsetEntity')
|
|
138
|
+
.updateOne({ _id: new mongoose_1.default.Types.ObjectId(doc.id) }, { $inc: { version: 1 } });
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}));
|
|
142
|
+
yield model.update(doc.id, { name: 'N2' }, true, {}, { unsetPaths: ['note'] });
|
|
143
|
+
expect(spy).toHaveBeenCalledTimes(2); // first attempt + retry
|
|
144
|
+
const after = yield raw(doc.id);
|
|
145
|
+
expect(after).not.toHaveProperty('note');
|
|
146
|
+
expect(after.name).toEqual('N2');
|
|
147
|
+
spy.mockRestore();
|
|
148
|
+
}));
|
|
149
|
+
});
|
|
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
|
|
|
2
2
|
import { NxContext, NxObject } from '../interfaces';
|
|
3
3
|
import { BaseModel } from './base.model';
|
|
4
4
|
import { ArrayItemParams, BaseMongoDoc } from './base.interfaces';
|
|
5
|
+
import { UpdateOptions } from './base.interfaces';
|
|
5
6
|
import { FilterParams, QueryPagination } from './base.interfaces';
|
|
6
7
|
export declare abstract class BaseController<TAttrs, TDoc extends BaseMongoDoc, T extends BaseModel<TAttrs, TDoc, any>> {
|
|
7
8
|
model: T;
|
|
@@ -47,8 +48,14 @@ export declare abstract class BaseController<TAttrs, TDoc extends BaseMongoDoc,
|
|
|
47
48
|
* @param id
|
|
48
49
|
* @param item
|
|
49
50
|
* @param context
|
|
51
|
+
* @returns the options this parse decided for THIS update, or nothing.
|
|
52
|
+
*
|
|
53
|
+
* A parse may conclude something that the payload cannot express - typically that a field must
|
|
54
|
+
* be REMOVED and not merely left unwritten. Returning it here is how that conclusion reaches the
|
|
55
|
+
* write, without a second call and without borrowing the context, which belongs to the operation
|
|
56
|
+
* and not to the mutation. Returning nothing is the ordinary case and changes nothing.
|
|
50
57
|
*/
|
|
51
|
-
protected parseUpdateItem(id: string, item: TAttrs, context: NxContext): Promise<void>;
|
|
58
|
+
protected parseUpdateItem(id: string, item: TAttrs, context: NxContext): Promise<UpdateOptions | void>;
|
|
52
59
|
/**
|
|
53
60
|
*
|
|
54
61
|
* @param item
|
|
@@ -77,7 +84,7 @@ export declare abstract class BaseController<TAttrs, TDoc extends BaseMongoDoc,
|
|
|
77
84
|
* @param context
|
|
78
85
|
* @returns
|
|
79
86
|
*/
|
|
80
|
-
update(id: string, item: TAttrs, context: NxContext): Promise<TDoc>;
|
|
87
|
+
update(id: string, item: TAttrs, context: NxContext, options?: UpdateOptions): Promise<TDoc>;
|
|
81
88
|
/**
|
|
82
89
|
*
|
|
83
90
|
* @param id
|
|
@@ -10,6 +10,19 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.BaseController = void 0;
|
|
13
|
+
/**
|
|
14
|
+
* Merges the options the PARSE decided with the ones the CALLER asked for, leaving both untouched.
|
|
15
|
+
*
|
|
16
|
+
* Neither side owns the other: the caller states what it wants removed, the parse states what the
|
|
17
|
+
* derivation concluded must go, and an update carries the union. `unsetPaths` is unioned without
|
|
18
|
+
* duplicates - unsetting the same path twice is harmless, but a list that grows on every pass is
|
|
19
|
+
* noise nobody should have to read.
|
|
20
|
+
*/
|
|
21
|
+
const mergeUpdateOptions = (caller, parsed) => {
|
|
22
|
+
var _a, _b;
|
|
23
|
+
const unsetPaths = [...new Set([...((_a = caller === null || caller === void 0 ? void 0 : caller.unsetPaths) !== null && _a !== void 0 ? _a : []), ...((_b = parsed.unsetPaths) !== null && _b !== void 0 ? _b : [])])];
|
|
24
|
+
return Object.assign(Object.assign(Object.assign({}, caller), parsed), { unsetPaths });
|
|
25
|
+
};
|
|
13
26
|
class BaseController {
|
|
14
27
|
constructor({ model, mergeUpdate }) {
|
|
15
28
|
this.mergeUpdate = false;
|
|
@@ -67,6 +80,12 @@ class BaseController {
|
|
|
67
80
|
* @param id
|
|
68
81
|
* @param item
|
|
69
82
|
* @param context
|
|
83
|
+
* @returns the options this parse decided for THIS update, or nothing.
|
|
84
|
+
*
|
|
85
|
+
* A parse may conclude something that the payload cannot express - typically that a field must
|
|
86
|
+
* be REMOVED and not merely left unwritten. Returning it here is how that conclusion reaches the
|
|
87
|
+
* write, without a second call and without borrowing the context, which belongs to the operation
|
|
88
|
+
* and not to the mutation. Returning nothing is the ordinary case and changes nothing.
|
|
70
89
|
*/
|
|
71
90
|
parseUpdateItem(id, item, context) {
|
|
72
91
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -112,10 +131,13 @@ class BaseController {
|
|
|
112
131
|
* @param context
|
|
113
132
|
* @returns
|
|
114
133
|
*/
|
|
115
|
-
update(id, item, context) {
|
|
134
|
+
update(id, item, context, options) {
|
|
116
135
|
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
-
yield this.parseUpdateItem(id, item, context);
|
|
118
|
-
|
|
136
|
+
const parsed = yield this.parseUpdateItem(id, item, context);
|
|
137
|
+
// The options belong to THIS update. Those of the caller are handed over untouched - the very
|
|
138
|
+
// same object when the parse decided nothing, which is the ordinary case; when it did decide
|
|
139
|
+
// something, the two are merged into a NEW object and neither original is modified.
|
|
140
|
+
return this.model.update(id, item, this.mergeUpdate, context, parsed ? mergeUpdateOptions(options, parsed) : options);
|
|
119
141
|
});
|
|
120
142
|
}
|
|
121
143
|
/**
|
|
@@ -33,6 +33,30 @@ export interface QueryPagination {
|
|
|
33
33
|
limit?: number;
|
|
34
34
|
sort?: NxObject;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Options of a SINGLE update, describing the mutation and not the operation context - which is
|
|
38
|
+
* why they travel as an argument and never inside `NxContext`: a context is reused across calls,
|
|
39
|
+
* and an option that outlived its own update would silently apply to the next one.
|
|
40
|
+
*/
|
|
41
|
+
export interface UpdateOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Paths to REMOVE from the document.
|
|
44
|
+
*
|
|
45
|
+
* WHY IT CANNOT BE EXPRESSED BY THE PAYLOAD. An absent property means PRESERVE, and a property
|
|
46
|
+
* set to `undefined` does not survive the merge - lodash skips undefined source values - so
|
|
47
|
+
* neither of the two can ask for a removal. This option is the third statement: remove it.
|
|
48
|
+
*
|
|
49
|
+
* The paths are applied AFTER the merge, on the same hydrated document and inside the SAME
|
|
50
|
+
* `save()`: same session, same versioning, same VersionError retry, and no second write.
|
|
51
|
+
*
|
|
52
|
+
* CONTRACT, deliberately conservative in this first form: TOP-LEVEL paths and DOTTED paths to
|
|
53
|
+
* SCALAR fields. Arrays and positional paths are out of scope and untested.
|
|
54
|
+
*
|
|
55
|
+
* A path that is already absent is a no-op, and a path the schema declares `required` fails
|
|
56
|
+
* the ordinary mongoose validation on save: no bypass is introduced here.
|
|
57
|
+
*/
|
|
58
|
+
unsetPaths?: string[];
|
|
59
|
+
}
|
|
36
60
|
export interface ArrayItemParams {
|
|
37
61
|
arrayPath: string;
|
|
38
62
|
item: NxObject;
|
|
@@ -5,6 +5,7 @@ import { BaseMongoDoc, BaseMongoModel } from './base.interfaces';
|
|
|
5
5
|
import { ArrayItemParams, ChangeStreamData } from './base.interfaces';
|
|
6
6
|
import { CrudItemEvent, FilterParams } from './base.interfaces';
|
|
7
7
|
import { QueryPagination } from './base.interfaces';
|
|
8
|
+
import { UpdateOptions } from './base.interfaces';
|
|
8
9
|
import { ModelData } from './mongo.types';
|
|
9
10
|
export declare class BaseModel<TAttrs, TDoc extends BaseMongoDoc, TMongoModel extends BaseMongoModel<TAttrs, TDoc>> extends EventEmitter {
|
|
10
11
|
name: string;
|
|
@@ -288,8 +289,8 @@ export declare class BaseModel<TAttrs, TDoc extends BaseMongoDoc, TMongoModel ex
|
|
|
288
289
|
* @param {*} callback
|
|
289
290
|
*/
|
|
290
291
|
insertManyNative(items: mongoose.mongo.OptionalId<Document>[], options: mongoose.mongo.BulkWriteOptions): Promise<mongoose.mongo.InsertManyResult<mongoose.mongo.BSON.Document>>;
|
|
291
|
-
update(id: string, item: TAttrs, context: NxContext): Promise<TDoc>;
|
|
292
|
-
update(id: string, item: TAttrs, mergeItem: boolean, context: NxContext): Promise<TDoc>;
|
|
292
|
+
update(id: string, item: TAttrs, context: NxContext, options?: UpdateOptions): Promise<TDoc>;
|
|
293
|
+
update(id: string, item: TAttrs, mergeItem: boolean, context: NxContext, options?: UpdateOptions): Promise<TDoc>;
|
|
293
294
|
/**
|
|
294
295
|
* Update document using mongodb generic method
|
|
295
296
|
*
|
|
@@ -574,18 +574,18 @@ class BaseModel extends events_1.EventEmitter {
|
|
|
574
574
|
*
|
|
575
575
|
* @param {*} id
|
|
576
576
|
* @param {*} item
|
|
577
|
+
* @param {*} options optional per-update options, currently `unsetPaths`
|
|
577
578
|
*/
|
|
578
|
-
update(id, item, param3, param4) {
|
|
579
|
+
update(id, item, param3, param4, param5) {
|
|
579
580
|
return __awaiter(this, void 0, void 0, function* () {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
581
|
+
var _a, _b, _c;
|
|
582
|
+
// The overloads are told apart by the TYPE of the third argument, never by the presence of the
|
|
583
|
+
// fourth: the optional options object makes the fourth argument present in both forms, and a
|
|
584
|
+
// check on its presence would leave `context` empty - an unscoped read and write, in silence.
|
|
585
|
+
const byMerge = typeof param3 === 'boolean';
|
|
586
|
+
const mergeItem = byMerge ? param3 : false;
|
|
587
|
+
const context = ((_a = (byMerge ? param4 : param3)) !== null && _a !== void 0 ? _a : {});
|
|
588
|
+
const options = ((_b = (byMerge ? param5 : param4)) !== null && _b !== void 0 ? _b : {});
|
|
589
589
|
item = yield this.beforeUpdate(item, context);
|
|
590
590
|
//const res = await this.mgModel.findByIdAndUpdate(id, { $set: item }, { new: true });
|
|
591
591
|
//const itemDoc = this.mgModel.build(Object.assign({ id, item }));
|
|
@@ -611,6 +611,12 @@ class BaseModel extends events_1.EventEmitter {
|
|
|
611
611
|
});
|
|
612
612
|
}
|
|
613
613
|
itemDoc.set(toApply);
|
|
614
|
+
// Explicit removals, AFTER the merge and on the same hydrated document: mongoose turns a
|
|
615
|
+
// path set to `undefined` into a real removal at save time, so this needs no second write
|
|
616
|
+
// and keeps the session, the versioning and the retry of this very loop. Applied INSIDE the
|
|
617
|
+
// loop on purpose: a retry re-reads the document, and the removals must reach that one too.
|
|
618
|
+
for (const path of (_c = options.unsetPaths) !== null && _c !== void 0 ? _c : [])
|
|
619
|
+
itemDoc.set(path, undefined);
|
|
614
620
|
try {
|
|
615
621
|
res = yield itemDoc.save({ session: context === null || context === void 0 ? void 0 : context.session });
|
|
616
622
|
break;
|
package/build/common/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './interfaces';
|
|
|
6
6
|
export * from './types';
|
|
7
7
|
export * from './manager';
|
|
8
8
|
export * from './utils';
|
|
9
|
+
export type { UpdateOptions } from './base/base.interfaces';
|
|
9
10
|
export * from './base/tenancy';
|
|
10
11
|
export * from './base/audit';
|
|
11
12
|
export * from './express';
|