@nextage/nx-frame-be 1.0.47 → 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/graphql/__test__/generator.overrides.test.d.ts +1 -0
- package/build/common/graphql/__test__/generator.overrides.test.js +116 -0
- package/build/common/graphql/generator.js +62 -21
- package/build/common/graphql/interfaces.d.ts +24 -0
- package/build/common/index.d.ts +1 -0
- package/package.json +4 -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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* -------------------------------------------------------------------------- */
|
|
3
|
+
/* Per-operation @auth role overrides */
|
|
4
|
+
/* */
|
|
5
|
+
/* These tests assert the SDL emitted by the generator, not a running server: */
|
|
6
|
+
/* the point is WHICH directive is attached to WHICH generated operation. */
|
|
7
|
+
/* Mounting Apollo here is impossible anyway, since registering the same */
|
|
8
|
+
/* controller twice collides on `type Mutation`. */
|
|
9
|
+
/* -------------------------------------------------------------------------- */
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
const coded_entity_controller_1 = require("../../models/coded-entity.controller");
|
|
12
|
+
const generator_1 = require("../generator");
|
|
13
|
+
const loader_1 = require("../loader");
|
|
14
|
+
const READ = 'READ_ROLE';
|
|
15
|
+
const WRITE = 'WRITE_ROLE';
|
|
16
|
+
const NARROW = 'CROSS_TENANT_ROLE';
|
|
17
|
+
/**
|
|
18
|
+
* `loadSchema` APPENDS to the process-wide `mainSchema`, so every scenario has to
|
|
19
|
+
* start from an empty accumulator or it would read the previous scenario's SDL.
|
|
20
|
+
*/
|
|
21
|
+
function generate(roles) {
|
|
22
|
+
loader_1.mainSchema.typeDefs.query = '';
|
|
23
|
+
loader_1.mainSchema.typeDefs.mutation = '';
|
|
24
|
+
loader_1.mainSchema.typeDefs.subscription = '';
|
|
25
|
+
loader_1.mainSchema.typeDefs.type = '';
|
|
26
|
+
loader_1.mainSchema.resolvers = {};
|
|
27
|
+
(0, generator_1.generateSchema)(coded_entity_controller_1.ceCtrl, roles ? { roles } : {});
|
|
28
|
+
return {
|
|
29
|
+
query: loader_1.mainSchema.typeDefs.query,
|
|
30
|
+
mutation: loader_1.mainSchema.typeDefs.mutation,
|
|
31
|
+
subscription: loader_1.mainSchema.typeDefs.subscription
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Return the `@auth` directive argument attached to one generated operation, or
|
|
36
|
+
* `null` when the operation is absent from the SDL.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT: the match is anchored on the FIELD (`name(`), never on the bare name:
|
|
39
|
+
* `codedEntity` is a prefix of `codedEntityList`, and asserting on the bare name
|
|
40
|
+
* would silently read the wrong line.
|
|
41
|
+
*/
|
|
42
|
+
function authOf(sdl, operation) {
|
|
43
|
+
var _a;
|
|
44
|
+
const line = sdl.split('\n').find(l => l.trim().startsWith(`${operation}(`));
|
|
45
|
+
if (!line)
|
|
46
|
+
return null;
|
|
47
|
+
const match = line.match(/@auth(\(requires: "[^"]*"\))?/);
|
|
48
|
+
return match ? ((_a = match[1]) !== null && _a !== void 0 ? _a : '') : null;
|
|
49
|
+
}
|
|
50
|
+
const requires = (roles) => `(requires: "${roles}")`;
|
|
51
|
+
/* -------------------------------------------------------------------------- */
|
|
52
|
+
/* Default behaviour (non-regression) */
|
|
53
|
+
/* -------------------------------------------------------------------------- */
|
|
54
|
+
it('keeps read on queries and write on mutations when no override is given', () => {
|
|
55
|
+
const sdl = generate({ read: READ, write: WRITE });
|
|
56
|
+
expect(authOf(sdl.query, 'codedEntityCount')).toEqual(requires(READ));
|
|
57
|
+
expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(READ));
|
|
58
|
+
expect(authOf(sdl.query, 'codedEntity')).toEqual(requires(READ));
|
|
59
|
+
expect(authOf(sdl.query, 'findOneCodedEntity')).toEqual(requires(READ));
|
|
60
|
+
expect(authOf(sdl.mutation, 'createCodedEntity')).toEqual(requires(WRITE));
|
|
61
|
+
expect(authOf(sdl.mutation, 'updateCodedEntity')).toEqual(requires(WRITE));
|
|
62
|
+
expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
|
|
63
|
+
expect(authOf(sdl.mutation, 'copyCodedEntity')).toEqual(requires(WRITE));
|
|
64
|
+
expect(authOf(sdl.mutation, 'bulkRemoveCodedEntity')).toEqual(requires(WRITE));
|
|
65
|
+
});
|
|
66
|
+
it('emits a bare @auth when a role list is not configured', () => {
|
|
67
|
+
const sdl = generate({ write: WRITE });
|
|
68
|
+
expect(authOf(sdl.query, 'codedEntityList')).toEqual('');
|
|
69
|
+
expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
|
|
70
|
+
});
|
|
71
|
+
it('still accepts a plain role list as read and write', () => {
|
|
72
|
+
const sdl = generate([READ, WRITE]);
|
|
73
|
+
expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(`${READ},${WRITE}`));
|
|
74
|
+
expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(`${READ},${WRITE}`));
|
|
75
|
+
});
|
|
76
|
+
/* -------------------------------------------------------------------------- */
|
|
77
|
+
/* Overrides */
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
it('narrows a single mutation and leaves its siblings on write', () => {
|
|
80
|
+
const sdl = generate({ read: READ, write: WRITE, overrides: { remove: NARROW } });
|
|
81
|
+
expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(NARROW));
|
|
82
|
+
expect(authOf(sdl.mutation, 'createCodedEntity')).toEqual(requires(WRITE));
|
|
83
|
+
expect(authOf(sdl.mutation, 'updateCodedEntity')).toEqual(requires(WRITE));
|
|
84
|
+
expect(authOf(sdl.mutation, 'copyCodedEntity')).toEqual(requires(WRITE));
|
|
85
|
+
expect(authOf(sdl.mutation, 'bulkRemoveCodedEntity')).toEqual(requires(WRITE));
|
|
86
|
+
expect(authOf(sdl.mutation, 'removeCodedEntityArrayItem')).toEqual(requires(WRITE));
|
|
87
|
+
});
|
|
88
|
+
it('narrows a single query and leaves its siblings on read', () => {
|
|
89
|
+
const sdl = generate({ read: READ, write: WRITE, overrides: { list: NARROW } });
|
|
90
|
+
expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(NARROW));
|
|
91
|
+
expect(authOf(sdl.query, 'codedEntityCount')).toEqual(requires(READ));
|
|
92
|
+
expect(authOf(sdl.query, 'codedEntity')).toEqual(requires(READ));
|
|
93
|
+
expect(authOf(sdl.query, 'findOneCodedEntity')).toEqual(requires(READ));
|
|
94
|
+
});
|
|
95
|
+
it('joins a multi-role override into a single requires argument', () => {
|
|
96
|
+
const sdl = generate({ read: READ, write: WRITE, overrides: { remove: [NARROW, 'ADMIN'] } });
|
|
97
|
+
expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(`${NARROW},ADMIN`));
|
|
98
|
+
});
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
/* Fail-safe */
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
it('falls back to the default instead of publishing the operation when an override is empty', () => {
|
|
103
|
+
const empty = generate({ read: READ, write: WRITE, overrides: { remove: [], list: '' } });
|
|
104
|
+
expect(authOf(empty.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
|
|
105
|
+
expect(authOf(empty.query, 'codedEntityList')).toEqual(requires(READ));
|
|
106
|
+
const missing = generate({ read: READ, write: WRITE, overrides: { remove: undefined } });
|
|
107
|
+
expect(authOf(missing.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
|
|
108
|
+
});
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
/* Subscriptions */
|
|
111
|
+
/* -------------------------------------------------------------------------- */
|
|
112
|
+
it('does not let an override reach the subscriptions, which always follow read', () => {
|
|
113
|
+
const sdl = generate({ read: READ, write: WRITE, overrides: { remove: NARROW } });
|
|
114
|
+
expect(sdl.subscription.match(new RegExp(`@auth\\(requires: "${READ}"\\)`, 'g'))).toHaveLength(3);
|
|
115
|
+
expect(sdl.subscription).not.toContain(NARROW);
|
|
116
|
+
});
|
|
@@ -79,6 +79,19 @@ exports.foldedEntityFields = {
|
|
|
79
79
|
`
|
|
80
80
|
};
|
|
81
81
|
exports.mainSubChannel = 'mainCH';
|
|
82
|
+
/**
|
|
83
|
+
* Every base operation generated by generateTypeDefs, in declaration order.
|
|
84
|
+
*/
|
|
85
|
+
const baseMethodNames = [
|
|
86
|
+
'count', 'list', 'get', 'findOne',
|
|
87
|
+
'create', 'update', 'remove', 'copy',
|
|
88
|
+
'addArrayItem', 'updateArrayItem', 'removeArrayItem',
|
|
89
|
+
'bulkUpdate', 'bulkRemove'
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Base operations that default to the `read` roles; every other one defaults to `write`.
|
|
93
|
+
*/
|
|
94
|
+
const readMethodNames = ['count', 'list', 'get', 'findOne'];
|
|
82
95
|
/**
|
|
83
96
|
*
|
|
84
97
|
* @param modelName
|
|
@@ -119,24 +132,52 @@ function getSubscriptionNames(modelName) {
|
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/**
|
|
135
|
+
* Formats a role list as an `@auth` directive argument list.
|
|
136
|
+
*
|
|
137
|
+
* @param roles
|
|
138
|
+
* @returns `(requires: "ROLE_A,ROLE_B")`, or an empty string when no role is required
|
|
139
|
+
*/
|
|
140
|
+
function toAuthDirective(roles) {
|
|
141
|
+
if (!roles)
|
|
142
|
+
return '';
|
|
143
|
+
return `(requires: "${Array.isArray(roles) ? roles.join(',') : roles}")`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolves the `@auth` directive of every generated operation.
|
|
147
|
+
*
|
|
148
|
+
* Read operations default to `roles.read` and the remaining ones to `roles.write`; a per-operation
|
|
149
|
+
* entry in `roles.overrides` replaces that default for that operation only.
|
|
150
|
+
*
|
|
151
|
+
* IMPORTANT: an override is applied only when it carries roles. An entry whose value is empty or
|
|
152
|
+
* undefined is treated as ABSENT and the default stands, because the alternative fails OPEN: a
|
|
153
|
+
* mistyped constant, or one that did not resolve, would silently strip the directive and publish
|
|
154
|
+
* the operation to everyone. An operation meant to require no role is expressed by leaving the
|
|
155
|
+
* module's `roles` out, not by an empty override.
|
|
122
156
|
*
|
|
123
157
|
* @param extension
|
|
124
|
-
* @returns
|
|
158
|
+
* @returns the directive suffix of each base operation, empty string when unconstrained
|
|
125
159
|
*/
|
|
126
160
|
function getAuth(extension = {}) {
|
|
127
|
-
|
|
161
|
+
var _a;
|
|
162
|
+
const methods = {};
|
|
163
|
+
const auth = { read: '', write: '', methods };
|
|
164
|
+
for (const method of baseMethodNames)
|
|
165
|
+
methods[method] = '';
|
|
128
166
|
if (!extension.roles)
|
|
129
|
-
return
|
|
167
|
+
return auth;
|
|
130
168
|
if ((0, utils_1.isString)(extension.roles))
|
|
131
169
|
extension.roles = extension.roles.split(',');
|
|
132
170
|
if (Array.isArray(extension.roles))
|
|
133
171
|
extension.roles = { read: extension.roles, write: extension.roles };
|
|
134
172
|
const sr = extension.roles;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
auth.read = toAuthDirective(sr.read);
|
|
174
|
+
auth.write = toAuthDirective(sr.write);
|
|
175
|
+
for (const method of baseMethodNames) {
|
|
176
|
+
const override = (_a = sr.overrides) === null || _a === void 0 ? void 0 : _a[method];
|
|
177
|
+
const fallback = readMethodNames.includes(method) ? auth.read : auth.write;
|
|
178
|
+
methods[method] = (override === null || override === void 0 ? void 0 : override.length) ? toAuthDirective(override) : fallback;
|
|
179
|
+
}
|
|
180
|
+
return auth;
|
|
140
181
|
}
|
|
141
182
|
/**
|
|
142
183
|
*
|
|
@@ -163,23 +204,23 @@ function generateTypeDefs(ctrl, schemaExtension = {}, mergeCodedEntity, readOnly
|
|
|
163
204
|
dataFields = exports.codedEntityFields;
|
|
164
205
|
const baseTypeDefs = {
|
|
165
206
|
query: `
|
|
166
|
-
${methods.count}(params: ${modelName}Query) : Int! @auth${roles.
|
|
167
|
-
${methods.list}(params: ${modelName}Query, pagination: OffsetPagination): [${modelName}]! @auth${roles.
|
|
168
|
-
${methods.get}(id: ID!) : ${modelName}! @auth${roles.
|
|
169
|
-
${methods.findOne}(params: ${modelName}Query) : ${modelName} @auth${roles.
|
|
207
|
+
${methods.count}(params: ${modelName}Query) : Int! @auth${roles.methods.count}
|
|
208
|
+
${methods.list}(params: ${modelName}Query, pagination: OffsetPagination): [${modelName}]! @auth${roles.methods.list}
|
|
209
|
+
${methods.get}(id: ID!) : ${modelName}! @auth${roles.methods.get}
|
|
210
|
+
${methods.findOne}(params: ${modelName}Query) : ${modelName} @auth${roles.methods.findOne}
|
|
170
211
|
`,
|
|
171
212
|
mutation: `
|
|
172
|
-
${methods.create}(item: ${modelName}Input!) : ${modelName}! @auth${roles.
|
|
173
|
-
${methods.update}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.
|
|
174
|
-
${methods.remove}(id: ID!) : ${modelName}! @auth${roles.
|
|
175
|
-
${methods.copy}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.
|
|
213
|
+
${methods.create}(item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.create}
|
|
214
|
+
${methods.update}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.update}
|
|
215
|
+
${methods.remove}(id: ID!) : ${modelName}! @auth${roles.methods.remove}
|
|
216
|
+
${methods.copy}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.copy}
|
|
176
217
|
|
|
177
|
-
${methods.addArrayItem}(id: ID!, item: AddArrayElemItem!) : ${modelName}! @auth${roles.
|
|
178
|
-
${methods.updateArrayItem}(id: ID!, item: UpdateArrayElemItem!) : ${modelName}! @auth${roles.
|
|
179
|
-
${methods.removeArrayItem}(id: ID!, item: RemoveArrayElemItem!) : ${modelName}! @auth${roles.
|
|
218
|
+
${methods.addArrayItem}(id: ID!, item: AddArrayElemItem!) : ${modelName}! @auth${roles.methods.addArrayItem}
|
|
219
|
+
${methods.updateArrayItem}(id: ID!, item: UpdateArrayElemItem!) : ${modelName}! @auth${roles.methods.updateArrayItem}
|
|
220
|
+
${methods.removeArrayItem}(id: ID!, item: RemoveArrayElemItem!) : ${modelName}! @auth${roles.methods.removeArrayItem}
|
|
180
221
|
|
|
181
|
-
${methods.bulkUpdate}(ids: [ID]!, items: [${modelName}Input]!) : [${modelName}]! @auth${roles.
|
|
182
|
-
${methods.bulkRemove}(ids: [ID]!) : [${modelName}]! @auth${roles.
|
|
222
|
+
${methods.bulkUpdate}(ids: [ID]!, items: [${modelName}Input]!) : [${modelName}]! @auth${roles.methods.bulkUpdate}
|
|
223
|
+
${methods.bulkRemove}(ids: [ID]!) : [${modelName}]! @auth${roles.methods.bulkRemove}
|
|
183
224
|
`,
|
|
184
225
|
subscription: `
|
|
185
226
|
${subscriptions.create.id}(channelId: String!, filter: ${modelName}Query): ${modelName}! @auth${roles.read}
|
|
@@ -64,4 +64,28 @@ export interface NxGraphQLSchemaExtension extends NxGraphQLSchemaData {
|
|
|
64
64
|
export interface NxGraphQLSchemaRole {
|
|
65
65
|
read?: string | string[];
|
|
66
66
|
write?: string | string[];
|
|
67
|
+
overrides?: NxGraphQLSchemaRoleOverrides;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Per-operation role overrides.
|
|
71
|
+
*
|
|
72
|
+
* Each key is one of the generated base methods (see NxGraphQLBaseMethodNames) and its value
|
|
73
|
+
* replaces the roles required by that single operation. Operations left out keep the default
|
|
74
|
+
* behaviour: `read` for count/list/get/findOne, `write` for every other operation.
|
|
75
|
+
*
|
|
76
|
+
* IMPORTANT: an empty or undefined value does NOT remove the role constraint, it falls back to
|
|
77
|
+
* the default. Publishing an operation without any constraint must stay an explicit choice made
|
|
78
|
+
* by omitting `read`/`write`, never an accident of a half-filled override map.
|
|
79
|
+
*
|
|
80
|
+
* Subscriptions are not overridable: they always follow `read`.
|
|
81
|
+
*/
|
|
82
|
+
export type NxGraphQLSchemaRoleOverrides = Partial<Record<keyof NxGraphQLBaseMethodNames, string | string[]>>;
|
|
83
|
+
/**
|
|
84
|
+
* Resolved `@auth` directive suffixes, ready to be interpolated into the generated SDL.
|
|
85
|
+
* Each value is either an empty string (no role constraint) or `(requires: "ROLE_A,ROLE_B")`.
|
|
86
|
+
*/
|
|
87
|
+
export interface NxGraphQLAuthDirectives {
|
|
88
|
+
read: string;
|
|
89
|
+
write: string;
|
|
90
|
+
methods: Record<keyof NxGraphQLBaseMethodNames, string>;
|
|
67
91
|
}
|
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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextage/nx-frame-be",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.49",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"jest": {
|
|
17
17
|
"preset": "ts-jest",
|
|
18
18
|
"testEnvironment": "node",
|
|
19
|
+
"testPathIgnorePatterns": [
|
|
20
|
+
"<rootDir>/build/"
|
|
21
|
+
],
|
|
19
22
|
"setupFilesAfterEnv": [
|
|
20
23
|
"./src/test/setup.ts"
|
|
21
24
|
]
|