@nextage/nx-frame-be 1.0.48 → 1.0.49
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.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 +2 -1
- package/build/common/base/base.controller.js +4 -2
- 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,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;
|
|
@@ -77,7 +78,7 @@ export declare abstract class BaseController<TAttrs, TDoc extends BaseMongoDoc,
|
|
|
77
78
|
* @param context
|
|
78
79
|
* @returns
|
|
79
80
|
*/
|
|
80
|
-
update(id: string, item: TAttrs, context: NxContext): Promise<TDoc>;
|
|
81
|
+
update(id: string, item: TAttrs, context: NxContext, options?: UpdateOptions): Promise<TDoc>;
|
|
81
82
|
/**
|
|
82
83
|
*
|
|
83
84
|
* @param id
|
|
@@ -112,10 +112,12 @@ class BaseController {
|
|
|
112
112
|
* @param context
|
|
113
113
|
* @returns
|
|
114
114
|
*/
|
|
115
|
-
update(id, item, context) {
|
|
115
|
+
update(id, item, context, options) {
|
|
116
116
|
return __awaiter(this, void 0, void 0, function* () {
|
|
117
117
|
yield this.parseUpdateItem(id, item, context);
|
|
118
|
-
|
|
118
|
+
// The options belong to THIS update and are handed over untouched: the controller does not
|
|
119
|
+
// read them and does not interpret them.
|
|
120
|
+
return this.model.update(id, item, this.mergeUpdate, context, options);
|
|
119
121
|
});
|
|
120
122
|
}
|
|
121
123
|
/**
|
|
@@ -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';
|