@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.
@@ -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,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
- return this.model.update(id, item, this.mergeUpdate, context);
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
- let mergeItem = false;
581
- let context = {};
582
- if (!param4) {
583
- context = param3;
584
- }
585
- else if (typeof param3 === 'boolean') {
586
- mergeItem = param3;
587
- context = param4;
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;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextage/nx-frame-be",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
4
4
  "description": "",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",