e22m4u 1 неделя назад
Сommit
22b5a33b5f

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 6 - 0
.husky/pre-commit

@@ -0,0 +1,6 @@
+npm run lint:fix
+npm run format
+npm run test
+npm run build:cjs
+
+git add -A

+ 4 - 0
.mocharc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  extension: ['js'],
+  spec: 'src/**/*.spec.js',
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2025 Mikhail Evstropov <e22m4u@yandex.ru>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 282 - 0
README.md

@@ -0,0 +1,282 @@
+## @e22m4u/js-data-projector
+
+JavaScript модуль для работы с проекцией данных.
+
+Модуль использует декларативные схемы для определения правил видимости полей
+данных. Поддерживается вложенность, именные схемы, области проекции
+и строгий режим.
+
+## Содержание
+
+- [Установка](#установка)
+- [Использование](#использование)
+  - [Функция `projectData`](#функция-projectdata)
+  - [Класс `DataProjector`](#класс-dataprojector)
+- [Тесты](#тесты)
+- [Лицензия](#лицензия)
+
+## Установка
+
+```bash
+npm install @e22m4u/js-data-projector
+```
+
+## Использование
+
+Модуль экспортирует функцию `projectData` и класс `DataProjector`.
+Оба инструмента реализуют одинаковый функционал создания проекций,
+за исключением возможности регистрации именных схем, которая
+доступна только экземпляру класса.
+
+### Функция `projectData`
+
+Создание проекции с помощью схемы.
+
+```js
+import {projectData} from '@e22m4u/js-data-projector';
+
+const schema = {
+  name: true,
+  password: false,
+}
+
+const data = {
+  name: 'Fedor',       // будет доступно, явное правило
+  password: 'pass123', // будет исключено, явное правило
+  extra: 10            // будет доступно в режиме по умолчанию
+}
+
+const result = projectData(schema, data);
+console.log(result);
+// {
+//   name: 'Fedor',
+//   extra: 10
+// }
+```
+
+Создание проекции каждого элемента массива.
+
+```js
+import {projectData} from '@e22m4u/js-data-projector';
+
+const schema = {
+  id: true,
+  secret: false
+};
+
+const data = [
+  {id: 1, secret: 'A'},
+  {id: 2, secret: 'B'},
+];
+
+const result = projectData(schema, data);
+console.log(result);
+// [
+//   {id: 1},
+//   {id: 2}
+// ]
+```
+
+Создание проекции в строгом режиме.
+
+```js
+import {projectData} from '@e22m4u/js-data-projector';
+
+const schema = {
+  name: true,
+  password: false,
+}
+
+const data = {
+  name: 'Fedor',       // будет доступно, явное правило
+  password: 'pass123', // будет исключено, явное правило
+  extra: 10            // будет исключено в строгом режиме
+}
+
+const result = projectData(schema, data, {
+  strict: true, // <= строгий режим
+});
+console.log(result);
+// {
+//   name: 'Fedor'
+// }
+```
+
+Создание проекции с помощью вложенной схемы.
+
+```js
+import {projectData} from '@e22m4u/js-data-projector';
+
+const schema = {
+  id: false,
+  name: true,
+  city: {
+    select: true, // правило видимости поля city
+    schema: {     // вложенная схема
+      id: false,
+      name: true,
+    }
+  }
+}
+
+const data = {
+  id: 10,             // будет скрыто, явное правило
+  name: 'Fedor',
+  city: {
+    id: 20,           // будет скрыто, явное правило
+    name: 'Moscow',
+  }
+}
+
+const result = projectData(schema, data);
+console.log(result);
+// {
+//   name: 'Fedor',
+//   city: {
+//     name: 'Moscow',
+//   }
+// }
+```
+
+Создание проекции для указанной области.
+
+```js
+import {projectData} from '@e22m4u/js-data-projector';
+
+const schema = {
+  name: true,
+  password: {
+    scopes: {
+      input: true,   // правило для области 'input'
+      output: false, // правило для области 'output'
+    },
+  },
+}
+
+const data = {
+  name: 'Fedor',       // будет доступно, явное правило
+  password: 'pass123', // будет доступно в зависимости от области
+}
+
+const inputData = projectData(schema, data, {
+  scope: 'input' // <= область проекции
+});
+console.log(inputData);
+// {
+//   name: 'Fedor',
+//   password: 'pass123'
+// }
+
+const outputData = projectData(schema, data, {
+  scope: 'output' // <= область проекции
+});
+console.log(outputData);
+// {
+//   name: 'Fedor'
+// }
+```
+
+### Класс `DataProjector`
+
+Применение схемы проекции с помощью класса-сервиса.
+
+```js
+import {DataProjector} from '@e22m4u/js-data-projector';
+
+const projector = new DataProjector();
+
+const schema = {
+  name: true,
+  password: false,
+}
+
+const data = {
+  name: 'Fedor',
+  password: 'pass123', // будет скрыто
+  extra: 10
+}
+
+const result = projector.project(schema, data);
+console.log(result);
+// {
+//   name: 'Fedor',
+//   extra: 10
+// }
+```
+
+Создание проекции с помощью именной схемы.
+
+```js
+import {DataProjector} from '@e22m4u/js-data-projector';
+
+const projector = new DataProjector();
+
+// регистрация именной схемы
+projector.defineSchema('user', {
+  id: true,
+  name: true,
+  password: false,
+});
+
+const data = {
+  id: 10,
+  name: 'Fedor',
+  password: 'pass123',
+};
+
+const result = projector.project('user', data);
+console.log(result);
+// {
+//   id: 10,
+//   name: 'Fedor'
+// }
+```
+
+Комбинирование именных схем.
+
+```js
+import {DataProjector} from '@e22m4u/js-data-projector';
+
+const projector = new DataProjector();
+
+// регистрация схемы адреса
+projector.defineSchema('address', {
+  city: true,
+  zip: false
+});
+
+// регистрация схемы пользователя
+projector.defineSchema('user', {
+  name: true,
+  location: {
+    schema: 'address' // ссылка на именную схему
+  }
+});
+
+const data = {
+  name: 'John',
+  location: {
+    city: 'Moscow',
+    zip: '101000'
+  }
+};
+
+const result = projector.project('user', data);
+console.log(result);
+// {
+//   name: 'John',
+//   location: {
+//     city: 'Moscow'
+//   }
+// }
+```
+
+## Тесты
+
+```bash
+npm run test
+```
+
+## Лицензия
+
+MIT

+ 16 - 0
build-cjs.js

@@ -0,0 +1,16 @@
+import * as esbuild from 'esbuild';
+import packageJson from './package.json' with {type: 'json'};
+
+await esbuild.build({
+  entryPoints: ['src/index.js'],
+  outfile: 'dist/cjs/index.cjs',
+  format: 'cjs',
+  platform: 'node',
+  target: ['node12'],
+  bundle: true,
+  keepNames: true,
+  external: [
+    ...Object.keys(packageJson.peerDependencies || {}),
+    ...Object.keys(packageJson.dependencies || {}),
+  ],
+});

+ 390 - 0
dist/cjs/index.cjs

@@ -0,0 +1,390 @@
+var __defProp = Object.defineProperty;
+var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+var __export = (target, all) => {
+  for (var name in all)
+    __defProp(target, name, { get: all[name], enumerable: true });
+};
+var __copyProps = (to, from, except, desc) => {
+  if (from && typeof from === "object" || typeof from === "function") {
+    for (let key of __getOwnPropNames(from))
+      if (!__hasOwnProp.call(to, key) && key !== except)
+        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+  }
+  return to;
+};
+var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+// src/index.js
+var index_exports = {};
+__export(index_exports, {
+  DataProjector: () => DataProjector,
+  ProjectionSchemaRegistry: () => ProjectionSchemaRegistry,
+  ProjectionScope: () => ProjectionScope,
+  projectData: () => projectData,
+  validateProjectionSchema: () => validateProjectionSchema
+});
+module.exports = __toCommonJS(index_exports);
+
+// src/project-data.js
+var import_js_format2 = require("@e22m4u/js-format");
+
+// src/validate-projection-schema.js
+var import_js_format = require("@e22m4u/js-format");
+function validateProjectionSchema(schema, shallowMode = false) {
+  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
+    throw new import_js_format.InvalidArgumentError(
+      "Projection schema must be an Object, but %v was given.",
+      schema
+    );
+  }
+  if (typeof shallowMode !== "boolean") {
+    throw new import_js_format.InvalidArgumentError(
+      'The parameter "shallowMode" should be a Boolean, but %v was given.',
+      shallowMode
+    );
+  }
+  Object.keys(schema).forEach((propName) => {
+    const options = schema[propName];
+    if (options === void 0) {
+      return;
+    }
+    if (options === null || typeof options !== "boolean" && typeof options !== "object" || Array.isArray(options)) {
+      throw new import_js_format.InvalidArgumentError(
+        "Property options must be a Boolean or an Object, but %v was given.",
+        options
+      );
+    }
+    if (typeof options === "boolean") {
+      return;
+    }
+    if (options.select !== void 0 && typeof options.select !== "boolean") {
+      throw new import_js_format.InvalidArgumentError(
+        'Property option "select" must be a Boolean, but %v was given.',
+        options.select
+      );
+    }
+    if (options.schema !== void 0) {
+      if (!options.schema || typeof options.schema !== "string" && typeof options.schema !== "object" || Array.isArray(options.schema)) {
+        throw new import_js_format.InvalidArgumentError(
+          "Embedded schema must be an Object or a non-empty String that represents a schema name, but %v was given.",
+          options.schema
+        );
+      }
+      if (!shallowMode && typeof options.schema === "object") {
+        validateProjectionSchema(options.schema, shallowMode);
+      }
+    }
+    if (options.scopes !== void 0) {
+      if (!options.scopes || typeof options.scopes !== "object" || Array.isArray(options.scopes)) {
+        throw new import_js_format.InvalidArgumentError(
+          'Property option "scopes" must be an Object, but %v was given.',
+          options.scopes
+        );
+      }
+      Object.keys(options.scopes).forEach((scopeName) => {
+        const scopeOptions = options.scopes[scopeName];
+        if (scopeOptions === void 0) {
+          return;
+        }
+        if (scopeOptions === null || typeof scopeOptions !== "boolean" && typeof scopeOptions !== "object" || Array.isArray(scopeOptions)) {
+          throw new import_js_format.InvalidArgumentError(
+            "Scope options must be a Boolean or an Object, but %v was given.",
+            scopeOptions
+          );
+        }
+        if (typeof scopeOptions === "boolean") {
+          return;
+        }
+        if (scopeOptions.select !== void 0) {
+          if (typeof scopeOptions.select !== "boolean") {
+            throw new import_js_format.InvalidArgumentError(
+              'Scope option "select" must be a Boolean, but %v was given.',
+              scopeOptions.select
+            );
+          }
+        }
+      });
+    }
+  });
+}
+__name(validateProjectionSchema, "validateProjectionSchema");
+
+// src/project-data.js
+function projectData(schemaOrName, data, options = void 0) {
+  if (!schemaOrName || typeof schemaOrName !== "string" && typeof schemaOrName !== "object" || Array.isArray(schemaOrName)) {
+    throw new import_js_format2.InvalidArgumentError(
+      "Projection schema must be an Object or a non-empty String that represents a schema name, but %v was given.",
+      schemaOrName
+    );
+  }
+  if (options !== void 0) {
+    if (!options || typeof options !== "object" || Array.isArray(options)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options
+      );
+    }
+    if (options.strict !== void 0 && typeof options.strict !== "boolean") {
+      throw new import_js_format2.InvalidArgumentError(
+        'Option "strict" must be a Boolean, but %v was given.',
+        options.strict
+      );
+    }
+    if (options.scope !== void 0 && (!options.scope || typeof options.scope !== "string")) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Option "scope" must be a non-empty String, but %v was given.',
+        options.scope
+      );
+    }
+    if (options.resolver !== void 0 && (!options.resolver || typeof options.resolver !== "function")) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Option "resolver" must be a Function, but %v was given.',
+        options.resolver
+      );
+    }
+  }
+  const strict = Boolean(options && options.strict);
+  const scope = options && options.scope || void 0;
+  const resolver = options && options.resolver || void 0;
+  let schema = schemaOrName;
+  if (typeof schemaOrName === "string") {
+    if (!resolver) {
+      throw new import_js_format2.InvalidArgumentError(
+        "Unable to resolve the named schema %v without a specified projection schema resolver.",
+        schemaOrName
+      );
+    }
+    schema = resolver(schemaOrName);
+    if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
+      throw new import_js_format2.InvalidArgumentError(
+        "Projection schema resolver must return an Object, but %v was given.",
+        schema
+      );
+    }
+  }
+  validateProjectionSchema(schema, true);
+  if (data === null || typeof data !== "object") {
+    return data;
+  }
+  if (Array.isArray(data)) {
+    return data.map(
+      (item) => projectData(schema, item, { strict, scope, resolver })
+    );
+  }
+  const result = {};
+  const keys = Object.keys(strict ? schema : data);
+  for (const key of keys) {
+    if (!(key in data)) continue;
+    const propOptionsOrBoolean = schema[key];
+    if (_shouldSelect(propOptionsOrBoolean, strict, scope)) {
+      const value = data[key];
+      if (propOptionsOrBoolean && typeof propOptionsOrBoolean === "object" && propOptionsOrBoolean.schema) {
+        result[key] = projectData(propOptionsOrBoolean.schema, value, {
+          strict,
+          scope,
+          resolver
+        });
+      } else {
+        result[key] = value;
+      }
+    }
+  }
+  return result;
+}
+__name(projectData, "projectData");
+function _shouldSelect(propOptionsOrBoolean, strict, scope) {
+  if (typeof propOptionsOrBoolean === "boolean") {
+    return propOptionsOrBoolean;
+  }
+  if (typeof propOptionsOrBoolean === "object") {
+    const propOptions = propOptionsOrBoolean;
+    if (scope && propOptions.scopes && typeof propOptions.scopes === "object" && propOptions.scopes[scope] != null) {
+      const scopeOptionsOrBoolean = propOptions.scopes[scope];
+      if (typeof scopeOptionsOrBoolean === "boolean") {
+        return scopeOptionsOrBoolean;
+      }
+      if (scopeOptionsOrBoolean && typeof scopeOptionsOrBoolean === "object" && typeof scopeOptionsOrBoolean.select === "boolean") {
+        return scopeOptionsOrBoolean.select;
+      }
+    }
+    if (typeof propOptionsOrBoolean.select === "boolean") {
+      return propOptionsOrBoolean.select;
+    }
+  }
+  return !strict;
+}
+__name(_shouldSelect, "_shouldSelect");
+
+// src/data-projector.js
+var import_js_service2 = require("@e22m4u/js-service");
+
+// src/projection-scope.js
+var ProjectionScope = {
+  INPUT: "input",
+  OUTPUT: "output"
+};
+
+// src/data-projector.js
+var import_js_format4 = require("@e22m4u/js-format");
+
+// src/projection-schema-registry.js
+var import_js_service = require("@e22m4u/js-service");
+var import_js_format3 = require("@e22m4u/js-format");
+var _ProjectionSchemaRegistry = class _ProjectionSchemaRegistry extends import_js_service.Service {
+  /**
+   * Schema map.
+   */
+  _schemas = /* @__PURE__ */ new Map();
+  /**
+   * Define schema.
+   *
+   * @param {string} name
+   * @param {object} schema
+   * @returns {this}
+   */
+  defineSchema(name, schema) {
+    if (!name || typeof name !== "string") {
+      throw new import_js_format3.InvalidArgumentError(
+        "Schema name must be a non-empty String, but %v was given.",
+        name
+      );
+    }
+    if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
+      throw new import_js_format3.InvalidArgumentError(
+        "Projection schema must be an Object, but %v was given.",
+        schema
+      );
+    }
+    if (this._schemas.has(name)) {
+      throw new import_js_format3.InvalidArgumentError(
+        "Projection schema %v is already registered.",
+        name
+      );
+    }
+    validateProjectionSchema(schema);
+    this._schemas.set(name, schema);
+    return this;
+  }
+  /**
+   * Get schema.
+   *
+   * @param {string} name
+   * @returns {object}
+   */
+  getSchema(name) {
+    if (!name || typeof name !== "string") {
+      throw new import_js_format3.InvalidArgumentError(
+        "Schema name must be a non-empty String, but %v was given.",
+        name
+      );
+    }
+    const schema = this._schemas.get(name);
+    if (!schema) {
+      throw new import_js_format3.InvalidArgumentError(
+        "Projection schema %v is not found.",
+        name
+      );
+    }
+    return schema;
+  }
+};
+__name(_ProjectionSchemaRegistry, "ProjectionSchemaRegistry");
+var ProjectionSchemaRegistry = _ProjectionSchemaRegistry;
+
+// src/data-projector.js
+var _DataProjector = class _DataProjector extends import_js_service2.Service {
+  /**
+   * Define schema.
+   *
+   * @param {string} name
+   * @param {object} schema
+   * @returns {this}
+   */
+  defineSchema(name, schema) {
+    this.getService(ProjectionSchemaRegistry).defineSchema(name, schema);
+    return this;
+  }
+  /**
+   * Project.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  project(schemaOrName, data, options = void 0) {
+    if (!schemaOrName || typeof schemaOrName !== "string" && typeof schemaOrName !== "object" || Array.isArray(schemaOrName)) {
+      throw new import_js_format4.InvalidArgumentError(
+        "Projection schema must be an Object or a non-empty String that represents a schema name, but %v was given.",
+        schemaOrName
+      );
+    }
+    if (options !== void 0) {
+      if (!options || typeof options !== "object" || Array.isArray(options)) {
+        throw new import_js_format4.InvalidArgumentError(
+          'Parameter "options" must be an Object, but %v was given.',
+          options
+        );
+      }
+      if (options.strict !== void 0 && typeof options.strict !== "boolean") {
+        throw new import_js_format4.InvalidArgumentError(
+          'Option "strict" must be a Boolean, but %v was given.',
+          options.strict
+        );
+      }
+      if (options.scope !== void 0 && (!options.scope || typeof options.scope !== "string")) {
+        throw new import_js_format4.InvalidArgumentError(
+          'Option "scope" must be a non-empty String, but %v was given.',
+          options.scope
+        );
+      }
+      if (options.resolver !== void 0) {
+        throw new import_js_format4.InvalidArgumentError(
+          'Option "resolver" is not supported for the DataProjector.'
+        );
+      }
+    }
+    const registry = this.getService(ProjectionSchemaRegistry);
+    return projectData(schemaOrName, data, {
+      ...options,
+      resolver: /* @__PURE__ */ __name((name) => registry.getSchema(name), "resolver")
+    });
+  }
+  /**
+   * Project with "input" scope.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  projectInput(schemaOrName, data, options = void 0) {
+    options = { ...options, scope: ProjectionScope.INPUT };
+    return this.project(schemaOrName, data, options);
+  }
+  /**
+   * Project with "output" scope.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  projectOutput(schemaOrName, data, options = void 0) {
+    options = { ...options, scope: ProjectionScope.OUTPUT };
+    return this.project(schemaOrName, data, options);
+  }
+};
+__name(_DataProjector, "DataProjector");
+var DataProjector = _DataProjector;
+// Annotate the CommonJS export names for ESM import in node:
+0 && (module.exports = {
+  DataProjector,
+  ProjectionSchemaRegistry,
+  ProjectionScope,
+  projectData,
+  validateProjectionSchema
+});

+ 36 - 0
eslint.config.js

@@ -0,0 +1,36 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'jsdoc': eslintJsdocPlugin,
+    'mocha': eslintMochaPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintMochaPlugin.configs.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
+    'no-duplicate-imports': 'error',
+    'jsdoc/reject-any-type': 0,
+    'jsdoc/reject-function-type': 0,
+    'jsdoc/require-param-description': 0,
+    'jsdoc/require-returns-description': 0,
+    'jsdoc/require-property-description': 0,
+    'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 65 - 0
package.json

@@ -0,0 +1,65 @@
+{
+  "name": "@e22m4u/js-data-projector",
+  "version": "0.0.0",
+  "description": "JavaScript модуль для работы с проекцией данных",
+  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "data",
+    "input",
+    "output",
+    "projection",
+    "filtering"
+  ],
+  "homepage": "https://github.com/e22m4u/js-data-projector",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/e22m4u/js-data-projector.git"
+  },
+  "type": "module",
+  "types": "./src/index.d.ts",
+  "module": "./src/index.js",
+  "main": "./dist/cjs/index.cjs",
+  "exports": {
+    "types": "./src/index.d.ts",
+    "import": "./src/index.js",
+    "require": "./dist/cjs/index.cjs"
+  },
+  "engines": {
+    "node": ">=12"
+  },
+  "scripts": {
+    "lint": "tsc && eslint ./src",
+    "lint:fix": "tsc && eslint ./src --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha --bail",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha --bail",
+    "build:cjs": "rimraf ./dist/cjs && node --no-warnings=ExperimentalWarning build-cjs.js",
+    "prepare": "husky"
+  },
+  "dependencies": {
+    "@e22m4u/js-format": "~0.2.1",
+    "@e22m4u/js-service": "~0.4.6"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~20.1.0",
+    "@commitlint/config-conventional": "~20.0.0",
+    "@eslint/js": "~9.39.1",
+    "@types/chai": "^5.2.3",
+    "@types/mocha": "~10.0.10",
+    "c8": "~10.1.3",
+    "chai": "~6.2.1",
+    "esbuild": "~0.27.0",
+    "eslint": "~9.39.1",
+    "eslint-config-prettier": "~10.1.8",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-jsdoc": "~61.4.1",
+    "eslint-plugin-mocha": "~11.2.0",
+    "globals": "~16.5.0",
+    "husky": "~9.1.7",
+    "mocha": "~11.7.5",
+    "prettier": "~3.7.1",
+    "rimraf": "~6.1.2",
+    "typescript": "~5.9.3"
+  }
+}

+ 55 - 0
src/data-projector.d.ts

@@ -0,0 +1,55 @@
+import {Service} from '@e22m4u/js-service';
+import {ProjectDataOptions} from './project-data.js';
+import {ProjectionSchema} from './projection-schema.js';
+
+/**
+ * Data projector.
+ */
+export declare class DataProjector extends Service {
+  /**
+   * Define schema.
+   *
+   * @param name
+   * @param schema
+   */
+  defineSchema(name: string, schema: ProjectionSchema): this;
+
+  /**
+   * Project.
+   *
+   * @param schemaOrName
+   * @param data
+   * @param options
+   */
+  project<T>(
+    schemaOrName: string | ProjectionSchema,
+    data: T,
+    options?: Omit<ProjectDataOptions, 'resolver'>,
+  ): T;
+
+  /**
+   * Project with "input" scope.
+   *
+   * @param schemaOrName
+   * @param data
+   * @param options
+   */
+  projectInput<T>(
+    schemaOrName: string | ProjectionSchema,
+    data: T,
+    options?: Omit<ProjectDataOptions, 'resolver' | 'scope'>,
+  ): T;
+
+  /**
+   * Project with "output" scope.
+   *
+   * @param schemaOrName
+   * @param data
+   * @param options
+   */
+  projectOutput<T>(
+    schemaOrName: string | ProjectionSchema,
+    data: T,
+    options?: Omit<ProjectDataOptions, 'resolver' | 'scope'>,
+  ): T;
+}

+ 108 - 0
src/data-projector.js

@@ -0,0 +1,108 @@
+import {Service} from '@e22m4u/js-service';
+import {projectData} from './project-data.js';
+import {ProjectionScope} from './projection-scope.js';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {ProjectionSchemaRegistry} from './projection-schema-registry.js';
+
+/**
+ * Data projector.
+ */
+export class DataProjector extends Service {
+  /**
+   * Define schema.
+   *
+   * @param {string} name
+   * @param {object} schema
+   * @returns {this}
+   */
+  defineSchema(name, schema) {
+    this.getService(ProjectionSchemaRegistry).defineSchema(name, schema);
+    return this;
+  }
+
+  /**
+   * Project.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  project(schemaOrName, data, options = undefined) {
+    // schemaOrName
+    if (
+      !schemaOrName ||
+      (typeof schemaOrName !== 'string' && typeof schemaOrName !== 'object') ||
+      Array.isArray(schemaOrName)
+    ) {
+      throw new InvalidArgumentError(
+        'Projection schema must be an Object or a non-empty String ' +
+          'that represents a schema name, but %v was given.',
+        schemaOrName,
+      );
+    }
+    // options
+    if (options !== undefined) {
+      if (!options || typeof options !== 'object' || Array.isArray(options)) {
+        throw new InvalidArgumentError(
+          'Parameter "options" must be an Object, but %v was given.',
+          options,
+        );
+      }
+      // options.strict
+      if (options.strict !== undefined && typeof options.strict !== 'boolean') {
+        throw new InvalidArgumentError(
+          'Option "strict" must be a Boolean, but %v was given.',
+          options.strict,
+        );
+      }
+      // options.scope
+      if (
+        options.scope !== undefined &&
+        (!options.scope || typeof options.scope !== 'string')
+      ) {
+        throw new InvalidArgumentError(
+          'Option "scope" must be a non-empty String, but %v was given.',
+          options.scope,
+        );
+      }
+      // options.resolver
+      if (options.resolver !== undefined) {
+        throw new InvalidArgumentError(
+          'Option "resolver" is not supported for the DataProjector.',
+        );
+      }
+    }
+    const registry = this.getService(ProjectionSchemaRegistry);
+    return projectData(schemaOrName, data, {
+      ...options,
+      resolver: name => registry.getSchema(name),
+    });
+  }
+
+  /**
+   * Project with "input" scope.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  projectInput(schemaOrName, data, options = undefined) {
+    options = {...options, scope: ProjectionScope.INPUT};
+    return this.project(schemaOrName, data, options);
+  }
+
+  /**
+   * Project with "output" scope.
+   *
+   * @param {object|string} schemaOrName
+   * @param {object} data
+   * @param {object|undefined} options
+   * @returns {*}
+   */
+  projectOutput(schemaOrName, data, options = undefined) {
+    options = {...options, scope: ProjectionScope.OUTPUT};
+    return this.project(schemaOrName, data, options);
+  }
+}

+ 380 - 0
src/data-projector.spec.js

@@ -0,0 +1,380 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {DataProjector} from './data-projector.js';
+import {ProjectionSchemaRegistry} from './projection-schema-registry.js';
+
+describe('DataProjector', function () {
+  describe('defineSchema', function () {
+    it('should require the name parameter to be a non-empty string', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.defineSchema(v, {});
+      const error = s =>
+        format('Schema name must be a non-empty String, but %s was given.', s);
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('mySchema')();
+    });
+
+    it('should require the schema parameter to be an object', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.defineSchema('mySchema', v);
+      const error = s =>
+        format('Projection schema must be an Object, but %s was given.', s);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable({})();
+    });
+
+    it('should throw an error if the name is already registered', function () {
+      const S = new DataProjector();
+      S.defineSchema('mySchema', {});
+      const throwable = () => S.defineSchema('mySchema', {});
+      expect(throwable).to.throw(
+        'Projection schema "mySchema" is already registered.',
+      );
+    });
+
+    it('should register the given schema', function () {
+      const S = new DataProjector();
+      const registry = S.getService(ProjectionSchemaRegistry);
+      const schema = {foo: true, bar: false};
+      S.defineSchema('mySchema', schema);
+      expect(registry.getSchema('mySchema')).to.be.eql(schema);
+    });
+
+    it('should return this', function () {
+      const S = new DataProjector();
+      const res = S.defineSchema('mySchema', {});
+      expect(res).to.be.eq(S);
+    });
+  });
+
+  describe('project', function () {
+    it('should require the parameter "schemaOrName" to be a non-empty string or an object', function () {
+      const S = new DataProjector();
+      S.defineSchema('mySchema', {});
+      const throwable = v => () => S.project(v, {});
+      const error = s =>
+        format(
+          'Projection schema must be an Object or a non-empty String ' +
+            'that represents a schema name, but %s was given.',
+          s,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('mySchema')();
+      throwable({})();
+    });
+
+    it('should require the parameter "options" to be an object', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.project({}, {}, v);
+      const error = s =>
+        format('Parameter "options" must be an Object, but %s was given.', s);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(null)).to.throw(error('null'));
+      throwable({})();
+      throwable(undefined)();
+    });
+
+    it('should require the option "strict" to be a boolean', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.project({}, {}, {strict: v});
+      const error = s =>
+        format('Option "strict" must be a Boolean, but %s was given.', s);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable(null)).to.throw(error('null'));
+      throwable(true)();
+      throwable(false)();
+      throwable(undefined)();
+    });
+
+    it('should require the option "scope" to be a non-empty string', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.project({}, {}, {scope: v});
+      const error = s =>
+        format(
+          'Option "scope" must be a non-empty String, but %s was given.',
+          s,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable(null)).to.throw(error('null'));
+      throwable('str')();
+      throwable(undefined)();
+    });
+
+    it('should throw an error when the resolver option is provided', function () {
+      const S = new DataProjector();
+      const throwable = v => () => S.project({}, {}, {resolver: v});
+      const error = 'Option "resolver" is not supported for the DataProjector.';
+      expect(throwable('str')).to.throw(error);
+      expect(throwable('')).to.throw(error);
+      expect(throwable(10)).to.throw(error);
+      expect(throwable(0)).to.throw(error);
+      expect(throwable(true)).to.throw(error);
+      expect(throwable(false)).to.throw(error);
+      expect(throwable([])).to.throw(error);
+      expect(throwable({})).to.throw(error);
+      expect(throwable(null)).to.throw(error);
+      throwable(undefined)();
+    });
+
+    it('should validate the given schema object', function () {
+      const S = new DataProjector();
+      const throwable = () => S.project({foo: 10}, {foo: 'bar'});
+      expect(throwable).to.throw(
+        'Property options must be a Boolean or an Object, but 10 was given.',
+      );
+    });
+
+    it('should throw an error if the schema name is not registered', function () {
+      const S = new DataProjector();
+      const throwable = () => S.project('unknown', {});
+      expect(throwable).to.throw('Projection schema "unknown" is not found.');
+    });
+
+    it('should return non-object values as is', function () {
+      const S = new DataProjector();
+      const schema = {foo: {select: true}};
+      expect(S.project(schema, 'str')).to.be.eq('str');
+      expect(S.project(schema, '')).to.be.eq('');
+      expect(S.project(schema, 10)).to.be.eq(10);
+      expect(S.project(schema, 0)).to.be.eq(0);
+      expect(S.project(schema, true)).to.be.eq(true);
+      expect(S.project(schema, false)).to.be.eq(false);
+      expect(S.project(schema, undefined)).to.be.eq(undefined);
+      expect(S.project(schema, null)).to.be.eq(null);
+    });
+
+    it('should add properties without rules by default', function () {
+      const S = new DataProjector();
+      expect(S.project({}, {foo: 10, bar: 20})).to.be.eql({
+        foo: 10,
+        bar: 20,
+      });
+    });
+
+    it('should project fields by a boolean value', function () {
+      const S = new DataProjector();
+      const schema = {foo: true, bar: false};
+      const data = {foo: 10, bar: 20, baz: 30};
+      expect(S.project(schema, data)).to.be.eql({foo: 10, baz: 30});
+    });
+
+    it('should project fields by the select option', function () {
+      const S = new DataProjector();
+      const schema = {foo: {select: true}, bar: {select: false}};
+      const data = {foo: 10, bar: 20};
+      expect(S.project(schema, data)).to.be.eql({foo: 10});
+    });
+
+    it('should project fields by the schema name', function () {
+      const S = new DataProjector();
+      S.defineSchema('user', {id: true, email: false});
+      const data = {id: 1, email: 'test@example.com', name: 'John'};
+      expect(S.project('user', data)).to.be.eql({id: 1, name: 'John'});
+    });
+
+    it('should apply projection to an array of items', function () {
+      const S = new DataProjector();
+      const schema = {id: {select: true}, secret: {select: false}};
+      const data = [
+        {id: 1, secret: 'A'},
+        {id: 2, secret: 'B'},
+      ];
+      expect(S.project(schema, data)).to.be.eql([{id: 1}, {id: 2}]);
+    });
+
+    describe('strict mode', function () {
+      it('should ignore properties not present in schema when strict mode is enabled', function () {
+        const S = new DataProjector();
+        const schema = {id: {select: true}};
+        const data = {id: 1, other: 'value'};
+        expect(S.project(schema, data, {strict: true})).to.be.eql({id: 1});
+      });
+
+      it('should project fields by a boolean value', function () {
+        const S = new DataProjector();
+        const schema = {foo: true, bar: false};
+        const data = {foo: 1, bar: 2, baz: 3};
+        expect(S.project(schema, data, {strict: true})).to.be.eql({foo: 1});
+      });
+
+      it('should default to hidden in strict mode if no rules are provided', function () {
+        const S = new DataProjector();
+        const schema = {id: {}};
+        const data = {id: 1};
+        expect(S.project(schema, data, {strict: true})).to.be.eql({});
+      });
+
+      it('should skip properties present in schema but missing in data', function () {
+        const S = new DataProjector();
+        const schema = {
+          existing: {select: true},
+          missing: {select: true},
+        };
+        const data = {existing: 1};
+        expect(S.project(schema, data, {strict: true})).to.be.eql({
+          existing: 1,
+        });
+      });
+    });
+
+    describe('projection scopes', function () {
+      it('should apply scope-specific selection rules', function () {
+        const S = new DataProjector();
+        const schema = {
+          foo: {
+            select: false,
+            scopes: {
+              input: {select: true},
+            },
+          },
+          bar: {select: true},
+        };
+        const data = {foo: 10, bar: 20};
+        expect(S.project(schema, data)).to.be.eql({bar: 20});
+        expect(S.project(schema, data, {scope: 'input'})).to.be.eql({
+          foo: 10,
+          bar: 20,
+        });
+      });
+
+      it('should fallback to general rule if scope rule is missing', function () {
+        const S = new DataProjector();
+        const schema = {
+          foo: {
+            select: true,
+            scopes: {
+              output: {select: false},
+            },
+          },
+        };
+        const data = {foo: 10};
+        expect(S.project(schema, data, {scope: 'input'})).to.be.eql({
+          foo: 10,
+        });
+      });
+
+      it('should fallback to the general rule if the scope options exists but lacks the select option', function () {
+        const S = new DataProjector();
+        const schema = {
+          foo: {
+            select: true,
+            scopes: {
+              input: {},
+            },
+          },
+          bar: {
+            select: false,
+            scopes: {
+              input: {},
+            },
+          },
+        };
+        const data = {foo: 10, bar: 20};
+        const result = S.project(schema, data, {scope: 'input'});
+        expect(result).to.be.eql({foo: 10});
+      });
+    });
+
+    describe('nested schema', function () {
+      it('should apply nested schema recursively', function () {
+        const S = new DataProjector();
+        const schema = {user: {schema: {password: false}}};
+        const data = {user: {id: 1, password: '123'}};
+        expect(S.project(schema, data)).to.be.eql({user: {id: 1}});
+      });
+
+      it('should apply nested registered schema by a name', function () {
+        const S = new DataProjector();
+        S.defineSchema('address', {zip: {select: false}});
+        const schema = {location: {schema: 'address'}};
+        const data = {location: {city: 'City', zip: '12345'}};
+        expect(S.project(schema, data)).to.be.eql({location: {city: 'City'}});
+      });
+
+      it('should apply nested schema to array of objects', function () {
+        const S = new DataProjector();
+        const schema = {items: {schema: {hidden: false}}};
+        const data = {
+          items: [
+            {id: 1, hidden: 'x'},
+            {id: 2, hidden: 'y'},
+          ],
+        };
+        expect(S.project(schema, data)).to.be.eql({items: [{id: 1}, {id: 2}]});
+      });
+
+      it('should handle null or undefined in nested data', function () {
+        const S = new DataProjector();
+        const schema = {nested: {schema: {foo: true}}};
+        expect(S.project(schema, {nested: null})).to.be.eql({nested: null});
+        expect(S.project(schema, {nested: undefined})).to.be.eql({
+          nested: undefined,
+        });
+      });
+    });
+  });
+
+  describe('projectInput', function () {
+    it('should apply the given schema with the input scope', function () {
+      const S = new DataProjector();
+      const schema = {
+        foo: {scopes: {input: true, output: false}},
+        bar: {scopes: {input: false, output: true}},
+      };
+      const res = S.projectInput(schema, {foo: 10, bar: 20});
+      expect(res).to.be.eql({foo: 10});
+    });
+  });
+
+  describe('projectOutput', function () {
+    it('should apply the given schema with the output scope', function () {
+      const S = new DataProjector();
+      const schema = {
+        foo: {scopes: {input: true, output: false}},
+        bar: {scopes: {input: false, output: true}},
+      };
+      const res = S.projectOutput(schema, {foo: 10, bar: 20});
+      expect(res).to.be.eql({bar: 20});
+    });
+  });
+});

+ 6 - 0
src/index.d.ts

@@ -0,0 +1,6 @@
+export * from './project-data.js';
+export * from './data-projector.js';
+export * from './projection-scope.js';
+export * from './projection-schema.js';
+export * from './projection-schema-registry.js';
+export * from './validate-projection-schema.js';

+ 6 - 0
src/index.js

@@ -0,0 +1,6 @@
+export * from './project-data.js';
+export * from './data-projector.js';
+export * from './projection-scope.js';
+export * from './projection-schema.js';
+export * from './projection-schema-registry.js';
+export * from './validate-projection-schema.js';

+ 30 - 0
src/project-data.d.ts

@@ -0,0 +1,30 @@
+import {ProjectionSchema} from './projection-schema.js';
+
+/**
+ * Projection schema resolver.
+ */
+export type ProjectionSchemaResolver = (
+  schemaName: string,
+) => ProjectionSchema;
+
+/**
+ * Project data options.
+ */
+export type ProjectDataOptions = {
+  strict?: boolean;
+  scope?: string;
+  resolver?: ProjectionSchemaResolver;
+};
+
+/**
+ * Project data.
+ *
+ * @param schemaOrName
+ * @param data
+ * @param options
+ */
+export declare function projectData<T>(
+  schemaOrName: string | ProjectionSchema,
+  data: T,
+  options?: ProjectDataOptions,
+): T;

+ 194 - 0
src/project-data.js

@@ -0,0 +1,194 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {validateProjectionSchema} from './validate-projection-schema.js';
+
+/**
+ * Project data.
+ *
+ * @param {object|string} schemaOrName
+ * @param {object} data
+ * @param {object|undefined} options
+ * @returns {*}
+ */
+export function projectData(schemaOrName, data, options = undefined) {
+  // schemaOrName
+  if (
+    !schemaOrName ||
+    (typeof schemaOrName !== 'string' && typeof schemaOrName !== 'object') ||
+    Array.isArray(schemaOrName)
+  ) {
+    throw new InvalidArgumentError(
+      'Projection schema must be an Object or a non-empty String ' +
+        'that represents a schema name, but %v was given.',
+      schemaOrName,
+    );
+  }
+  // options
+  if (options !== undefined) {
+    if (!options || typeof options !== 'object' || Array.isArray(options)) {
+      throw new InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options,
+      );
+    }
+    // options.strict
+    if (options.strict !== undefined && typeof options.strict !== 'boolean') {
+      throw new InvalidArgumentError(
+        'Option "strict" must be a Boolean, but %v was given.',
+        options.strict,
+      );
+    }
+    // options.scope
+    if (
+      options.scope !== undefined &&
+      (!options.scope || typeof options.scope !== 'string')
+    ) {
+      throw new InvalidArgumentError(
+        'Option "scope" must be a non-empty String, but %v was given.',
+        options.scope,
+      );
+    }
+    // options.resolver
+    if (
+      options.resolver !== undefined &&
+      (!options.resolver || typeof options.resolver !== 'function')
+    ) {
+      throw new InvalidArgumentError(
+        'Option "resolver" must be a Function, but %v was given.',
+        options.resolver,
+      );
+    }
+  }
+  const strict = Boolean(options && options.strict);
+  const scope = (options && options.scope) || undefined;
+  const resolver = (options && options.resolver) || undefined;
+  // если вместо схемы передана строка,
+  // то строка используется как название
+  // зарегистрированной схемы проекции
+  let schema = schemaOrName;
+  if (typeof schemaOrName === 'string') {
+    if (!resolver) {
+      throw new InvalidArgumentError(
+        'Unable to resolve the named schema %v without ' +
+          'a specified projection schema resolver.',
+        schemaOrName,
+      );
+    }
+    schema = resolver(schemaOrName);
+    // если не удалось извлечь схему проекции
+    // по имени, то выбрасывается ошибка
+    if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+      throw new InvalidArgumentError(
+        'Projection schema resolver must return an Object, but %v was given.',
+        schema,
+      );
+    }
+  }
+  // валидация полученной схемы проекции
+  // без проверки вложенных схем (shallowMode)
+  validateProjectionSchema(schema, true);
+  // если данные не являются объектом (null, undefined, примитив),
+  // то значение возвращается без изменений
+  if (data === null || typeof data !== 'object') {
+    return data;
+  }
+  // если данные являются массивом, то проекция
+  // применяется к каждому элементу
+  if (Array.isArray(data)) {
+    return data.map(item =>
+      projectData(schema, item, {strict, scope, resolver}),
+    );
+  }
+  // если данные являются объектом,
+  // то создается проекция согласно схеме
+  const result = {};
+  // в обычном режиме итерация выполняется по ключам исходного
+  // объекта, а в строгом режиме по ключам, описанным в схеме
+  // (исключая ключи прототипа Object.keys(x))
+  const keys = Object.keys(strict ? schema : data);
+  for (const key of keys) {
+    // если свойство отсутствует в исходных
+    // данных, то свойство игнорируется
+    if (!(key in data)) continue;
+    const propOptionsOrBoolean = schema[key];
+    // проверка доступности свойства для данной
+    // области проекции (если определена)
+    if (_shouldSelect(propOptionsOrBoolean, strict, scope)) {
+      const value = data[key];
+      // если определена вложенная схема,
+      // то проекция применяется рекурсивно
+      if (
+        propOptionsOrBoolean &&
+        typeof propOptionsOrBoolean === 'object' &&
+        propOptionsOrBoolean.schema
+      ) {
+        result[key] = projectData(propOptionsOrBoolean.schema, value, {
+          strict,
+          scope,
+          resolver,
+        });
+      }
+      // иначе значение присваивается
+      // свойству без изменений
+      else {
+        result[key] = value;
+      }
+    }
+  }
+  return result;
+}
+
+/**
+ * Should select (internal).
+ *
+ * Определяет, следует ли включать свойство в результат.
+ * Приоритет: правило для области -> общее правило -> по умолчанию true.
+ *
+ * @param {object|boolean|undefined} propOptionsOrBoolean
+ * @param {boolean|undefined} strict
+ * @param {string|undefined} scope
+ * @returns {boolean}
+ */
+function _shouldSelect(propOptionsOrBoolean, strict, scope) {
+  // если настройки свойства являются логическим значением,
+  // то значение используется как индикатор видимости
+  if (typeof propOptionsOrBoolean === 'boolean') {
+    return propOptionsOrBoolean;
+  }
+  // если настройки свойства являются объектом,
+  // то проверяется правило области и общее правило
+  if (typeof propOptionsOrBoolean === 'object') {
+    const propOptions = propOptionsOrBoolean;
+    // если определена область проекции,
+    // то выполняется проверка правила области
+    if (
+      scope &&
+      propOptions.scopes &&
+      typeof propOptions.scopes === 'object' &&
+      propOptions.scopes[scope] != null
+    ) {
+      const scopeOptionsOrBoolean = propOptions.scopes[scope];
+      // если настройки области являются логическим значением,
+      // то значение используется как индикатор видимости
+      if (typeof scopeOptionsOrBoolean === 'boolean') {
+        return scopeOptionsOrBoolean;
+      }
+      // если настройки области являются объектом,
+      // то используется опция select
+      if (
+        scopeOptionsOrBoolean &&
+        typeof scopeOptionsOrBoolean === 'object' &&
+        typeof scopeOptionsOrBoolean.select === 'boolean'
+      ) {
+        return scopeOptionsOrBoolean.select;
+      }
+    }
+    // если область проекции не указана,
+    // то проверяется общее правило
+    if (typeof propOptionsOrBoolean.select === 'boolean') {
+      return propOptionsOrBoolean.select;
+    }
+  }
+  // если для свойства нет правил, то свойство
+  // по умолчанию доступно (недоступно в режиме strict)
+  return !strict;
+}

+ 316 - 0
src/project-data.spec.js

@@ -0,0 +1,316 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {projectData} from './project-data.js';
+
+describe('projectData', function () {
+  it('should require the parameter "schemaOrName" to be a non-empty string or an object', function () {
+    const resolver = () => ({});
+    const throwable = v => () => projectData(v, {}, {resolver});
+    const error = s =>
+      format(
+        'Projection schema must be an Object or a non-empty String ' +
+          'that represents a schema name, but %s was given.',
+        s,
+      );
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable('mySchema')();
+    throwable({})();
+  });
+
+  it('should require the parameter "options" to be an object', function () {
+    const throwable = v => () => projectData({}, {}, v);
+    const error = s =>
+      format('Parameter "options" must be an Object, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable({})();
+    throwable(undefined)();
+  });
+
+  it('should require the option "strict" to be a boolean', function () {
+    const throwable = v => () => projectData({}, {}, {strict: v});
+    const error = s =>
+      format('Option "strict" must be a Boolean, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+  });
+
+  it('should require the option "scope" to be a non-empty string', function () {
+    const throwable = v => () => projectData({}, {}, {scope: v});
+    const error = s =>
+      format('Option "scope" must be a non-empty String, but %s was given.', s);
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable('str')();
+    throwable(undefined)();
+  });
+
+  it('should require the option "resolver" to be a function', function () {
+    const throwable = v => () => projectData({}, {}, {resolver: v});
+    const error = s =>
+      format('Option "resolver" must be a Function, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable(() => undefined)();
+    throwable(undefined)();
+  });
+
+  it('should throw an error if no resolver specified when a schema name is provided', function () {
+    expect(() => projectData('mySchema', {})).to.throw(
+      'Unable to resolve the named schema "mySchema" without ' +
+        'a specified projection schema resolver.',
+    );
+  });
+
+  it('should throw an error if the schema resolver returns an invalid value', function () {
+    const throwable = v => () =>
+      projectData('mySchema', {}, {resolver: () => v});
+    const error = s =>
+      format(
+        'Projection schema resolver must return an Object, but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable({})();
+  });
+
+  it('should return a non-object and non-array data as is', function () {
+    const fn = v => projectData({foo: true}, v);
+    expect(fn('str')).to.be.eql('str');
+    expect(fn('')).to.be.eql('');
+    expect(fn(10)).to.be.eql(10);
+    expect(fn(0)).to.be.eql(0);
+    expect(fn(true)).to.be.eql(true);
+    expect(fn(false)).to.be.eql(false);
+    expect(fn(null)).to.be.eql(null);
+    expect(fn(undefined)).to.be.eql(undefined);
+  });
+
+  it('should apply projection for each array element', function () {
+    const schema = {foo: true, bar: false};
+    const data = [
+      {foo: 10, bar: 20, baz: 30},
+      {foo: 40, bar: 50, baz: 60},
+    ];
+    const res = projectData(schema, data);
+    expect(res).to.be.eql([
+      {foo: 10, baz: 30},
+      {foo: 40, baz: 60},
+    ]);
+  });
+
+  it('should add fields without rules by default', function () {
+    const res = projectData({}, {foo: 10, bar: 20});
+    expect(res).to.be.eql({foo: 10, bar: 20});
+  });
+
+  it('should project fields by a boolean value', function () {
+    const res = projectData({foo: true, bar: false}, {foo: 10, bar: 20});
+    expect(res).to.be.eql({foo: 10});
+  });
+
+  it('should project fields by the select option', function () {
+    const res = projectData(
+      {foo: {select: true}, bar: {select: false}},
+      {foo: 10, bar: 20},
+    );
+    expect(res).to.be.eql({foo: 10});
+  });
+
+  it('should ignore scope-related rules by default', function () {
+    const res = projectData(
+      {foo: {scopes: {input: false, output: false}}},
+      {foo: 10},
+    );
+    expect(res).to.be.eql({foo: 10});
+  });
+
+  it('should create nested projection by the schema option', function () {
+    const res = projectData(
+      {foo: true, bar: false, qux: {schema: {abc: true, def: false}}},
+      {foo: 10, bar: 20, qux: {abc: 30, def: 40}},
+    );
+    expect(res).to.be.eql({foo: 10, qux: {abc: 30}});
+  });
+
+  describe('schema name', function () {
+    it('should pass the schema name to the schema resolver', function () {
+      let invoked = 0;
+      const resolver = name => {
+        expect(name).to.be.eq('mySchema');
+        invoked++;
+        return {foo: true, bar: false};
+      };
+      const res = projectData('mySchema', {foo: 10, bar: 20}, {resolver});
+      expect(res).to.be.eql({foo: 10});
+      expect(invoked).to.be.eq(1);
+    });
+
+    it('should use the schema resolver in the nested schema', function () {
+      let invoked = 0;
+      const resolver = name => {
+        expect(name).to.be.eq('mySchema');
+        invoked++;
+        return {baz: true, qux: false};
+      };
+      const res = projectData(
+        {foo: true, bar: {schema: 'mySchema'}},
+        {foo: 10, bar: {baz: 20, qux: 30}},
+        {resolver},
+      );
+      expect(res).to.be.eql({foo: 10, bar: {baz: 20}});
+      expect(invoked).to.be.eq(1);
+    });
+  });
+
+  describe('strict mode', function () {
+    it('should preserve fields not defined in the schema when the strict option is false', function () {
+      const res = projectData({}, {foo: 10});
+      expect(res).to.be.eql({foo: 10});
+    });
+
+    it('should remove fields without rules when the strict mode is enabled', function () {
+      const res = projectData({}, {foo: 10}, {strict: true});
+      expect(res).to.be.eql({});
+    });
+
+    it('should project fields by a boolean value', function () {
+      const res = projectData(
+        {foo: true, bar: false},
+        {foo: 10, bar: 20},
+        {strict: true},
+      );
+      expect(res).to.be.eql({foo: 10});
+    });
+
+    it('should project fields by the select option', function () {
+      const res = projectData(
+        {foo: {select: true}, bar: {select: false}},
+        {foo: 10, bar: 20},
+        {strict: true},
+      );
+      expect(res).to.be.eql({foo: 10});
+    });
+
+    it('should propagate the strict mode to nested schema', function () {
+      const res = projectData(
+        {foo: false, bar: {select: true, schema: {baz: true}}},
+        {foo: 10, bar: {baz: 20, qux: 30}},
+        {strict: true},
+      );
+      expect(res).to.be.eql({bar: {baz: 20}});
+    });
+  });
+
+  describe('projection scope', function () {
+    it('should apply scope-specific selection rule by a boolean value', function () {
+      const schema = {
+        foo: {
+          select: false,
+          scopes: {
+            input: true,
+          },
+        },
+        bar: true,
+      };
+      const data = {foo: 10, bar: 20};
+      const res1 = projectData(schema, data);
+      const res2 = projectData(schema, data, {scope: 'input'});
+      expect(res1).to.be.eql({bar: 20});
+      expect(res2).to.be.eql({foo: 10, bar: 20});
+    });
+
+    it('should apply scope-specific selection rule by the select option', function () {
+      const schema = {
+        foo: {
+          select: false,
+          scopes: {
+            input: {select: true},
+          },
+        },
+        bar: {select: true},
+      };
+      const data = {foo: 10, bar: 20};
+      const res1 = projectData(schema, data);
+      const res2 = projectData(schema, data, {scope: 'input'});
+      expect(res1).to.be.eql({bar: 20});
+      expect(res2).to.be.eql({foo: 10, bar: 20});
+    });
+
+    it('should fallback to general rule if scope rule is missing', function () {
+      const schema = {
+        foo: {
+          select: true,
+          scopes: {
+            output: {select: false},
+          },
+        },
+      };
+      const data = {foo: 10};
+      const res = projectData(schema, data, {scope: 'input'});
+      expect(res).to.be.eql({foo: 10});
+    });
+
+    it('should fallback to the general rule if the scope options exists but lacks the select option', function () {
+      const schema = {
+        foo: {
+          select: true,
+          scopes: {
+            input: {},
+          },
+        },
+        bar: {
+          select: false,
+          scopes: {
+            input: {},
+          },
+        },
+      };
+      const data = {foo: 10, bar: 20};
+      const res = projectData(schema, data, {scope: 'input'});
+      expect(res).to.be.eql({foo: 10});
+    });
+  });
+});

+ 22 - 0
src/projection-schema-registry.d.ts

@@ -0,0 +1,22 @@
+import {Service} from '@e22m4u/js-service';
+import {ProjectionSchema} from './projection-schema.js';
+
+/**
+ * Projection schema registry.
+ */
+export declare class ProjectionSchemaRegistry extends Service {
+  /**
+   * Define schema.
+   *
+   * @param name
+   * @param schema
+   */
+  defineSchema(name: string, schema: ProjectionSchema): this;
+
+  /**
+   * Get schema.
+   *
+   * @param name
+   */
+  getSchema(name: string): ProjectionSchema;
+}

+ 67 - 0
src/projection-schema-registry.js

@@ -0,0 +1,67 @@
+import {Service} from '@e22m4u/js-service';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {validateProjectionSchema} from './validate-projection-schema.js';
+
+/**
+ * Projection schema registry.
+ */
+export class ProjectionSchemaRegistry extends Service {
+  /**
+   * Schema map.
+   */
+  _schemas = new Map();
+
+  /**
+   * Define schema.
+   *
+   * @param {string} name
+   * @param {object} schema
+   * @returns {this}
+   */
+  defineSchema(name, schema) {
+    if (!name || typeof name !== 'string') {
+      throw new InvalidArgumentError(
+        'Schema name must be a non-empty String, but %v was given.',
+        name,
+      );
+    }
+    if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+      throw new InvalidArgumentError(
+        'Projection schema must be an Object, but %v was given.',
+        schema,
+      );
+    }
+    if (this._schemas.has(name)) {
+      throw new InvalidArgumentError(
+        'Projection schema %v is already registered.',
+        name,
+      );
+    }
+    validateProjectionSchema(schema);
+    this._schemas.set(name, schema);
+    return this;
+  }
+
+  /**
+   * Get schema.
+   *
+   * @param {string} name
+   * @returns {object}
+   */
+  getSchema(name) {
+    if (!name || typeof name !== 'string') {
+      throw new InvalidArgumentError(
+        'Schema name must be a non-empty String, but %v was given.',
+        name,
+      );
+    }
+    const schema = this._schemas.get(name);
+    if (!schema) {
+      throw new InvalidArgumentError(
+        'Projection schema %v is not found.',
+        name,
+      );
+    }
+    return schema;
+  }
+}

+ 96 - 0
src/projection-schema-registry.spec.js

@@ -0,0 +1,96 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {ProjectionSchemaRegistry} from './projection-schema-registry.js';
+
+describe('ProjectionSchemaRegistry', function () {
+  describe('defineSchema', function () {
+    it('should require the name parameter to be a non-empty string', function () {
+      const S = new ProjectionSchemaRegistry();
+      const throwable = v => () => S.defineSchema(v, {});
+      const error = s =>
+        format('Schema name must be a non-empty String, but %s was given.', s);
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('mySchema')();
+    });
+
+    it('should require the schema parameter to be an object', function () {
+      const S = new ProjectionSchemaRegistry();
+      const throwable = v => () => S.defineSchema('mySchema', v);
+      const error = s =>
+        format('Projection schema must be an Object, but %s was given.', s);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable({})();
+    });
+
+    it('should throw an error if the name is already registered', function () {
+      const S = new ProjectionSchemaRegistry();
+      S.defineSchema('mySchema', {});
+      const throwable = () => S.defineSchema('mySchema', {});
+      expect(throwable).to.throw(
+        'Projection schema "mySchema" is already registered.',
+      );
+    });
+
+    it('should register the given schema', function () {
+      const S = new ProjectionSchemaRegistry();
+      const schema = {foo: true, bar: false};
+      S.defineSchema('mySchema', schema);
+      expect(S._schemas.get('mySchema')).to.be.eql(schema);
+    });
+
+    it('should return this', function () {
+      const S = new ProjectionSchemaRegistry();
+      const res = S.defineSchema('mySchema', {});
+      expect(res).to.be.eq(S);
+    });
+  });
+
+  describe('getSchema', function () {
+    it('should require the name parameter to be a non-empty string', function () {
+      const S = new ProjectionSchemaRegistry();
+      S.defineSchema('mySchema', {});
+      const throwable = v => () => S.getSchema(v);
+      const error = s =>
+        format('Schema name must be a non-empty String, but %s was given.', s);
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('mySchema')();
+    });
+
+    it('should throw an error if the name is not registered', function () {
+      const S = new ProjectionSchemaRegistry();
+      S.defineSchema('mySchema', {});
+      const throwable = () => S.getSchema('unknown');
+      expect(throwable).to.throw('Projection schema "unknown" is not found.');
+    });
+
+    it('should return the registered schema', function () {
+      const S = new ProjectionSchemaRegistry();
+      const schema = {foo: true, bar: false};
+      S.defineSchema('mySchema', schema);
+      const res = S.getSchema('mySchema');
+      expect(res).to.be.eql(schema);
+    });
+  });
+});

+ 29 - 0
src/projection-schema.d.ts

@@ -0,0 +1,29 @@
+/**
+ * Projection schema.
+ */
+export type ProjectionSchema = {
+  [property: string]: boolean | ProjectionSchemaPropertyOptions | undefined;
+}
+
+/**
+ * Projection schema property options.
+ */
+export type ProjectionSchemaPropertyOptions = {
+  select?: boolean;
+  scopes?: ProjectionSchemaScopes;
+  schema?: string | ProjectionSchema;
+}
+
+/**
+ * Projection schema scopes.
+ */
+export type ProjectionSchemaScopes = {
+  [scope: string]: boolean | ProjectionSchemaScopeOptions | undefined;
+}
+
+/**
+ * Projection schema scope options.
+ */
+export type ProjectionSchemaScopeOptions = {
+  select?: boolean;
+}

+ 1 - 0
src/projection-schema.js

@@ -0,0 +1 @@
+export {};

+ 13 - 0
src/projection-scope.d.ts

@@ -0,0 +1,13 @@
+/**
+ * Projection scope.
+ */
+export declare const ProjectionScope: {
+  INPUT: 'input',
+  OUTPUT: 'output',
+};
+
+/**
+ * Projection scope.
+ */
+export type ProjectionScope =
+  (typeof ProjectionScope)[keyof typeof ProjectionScope];

+ 9 - 0
src/projection-scope.js

@@ -0,0 +1,9 @@
+/**
+ * Projection scope.
+ *
+ * @type {{INPUT: string, OUTPUT: string}}
+ */
+export const ProjectionScope = {
+  INPUT: 'input',
+  OUTPUT: 'output',
+};

+ 10 - 0
src/validate-projection-schema.d.ts

@@ -0,0 +1,10 @@
+import {ProjectionSchema} from './projection-schema.js';
+
+/**
+ * Validate projection schema.
+ *
+ * @param schema
+ */
+export declare function validateProjectionSchema(
+  schema: ProjectionSchema,
+): void;

+ 113 - 0
src/validate-projection-schema.js

@@ -0,0 +1,113 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Validate projection schema.
+ *
+ * @param {object} schema
+ * @param {boolean} shallowMode
+ * @returns {undefined}
+ */
+export function validateProjectionSchema(schema, shallowMode = false) {
+  // schema
+  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+    throw new InvalidArgumentError(
+      'Projection schema must be an Object, but %v was given.',
+      schema,
+    );
+  }
+  // shallowMode
+  if (typeof shallowMode !== 'boolean') {
+    throw new InvalidArgumentError(
+      'The parameter "shallowMode" should be a Boolean, but %v was given.',
+      shallowMode,
+    );
+  }
+  Object.keys(schema).forEach(propName => {
+    // schema[k]
+    const options = schema[propName];
+    if (options === undefined) {
+      return;
+    }
+    if (
+      options === null ||
+      (typeof options !== 'boolean' && typeof options !== 'object') ||
+      Array.isArray(options)
+    ) {
+      throw new InvalidArgumentError(
+        'Property options must be a Boolean or an Object, but %v was given.',
+        options,
+      );
+    }
+    if (typeof options === 'boolean') {
+      return;
+    }
+    // schema[k].select
+    if (options.select !== undefined && typeof options.select !== 'boolean') {
+      throw new InvalidArgumentError(
+        'Property option "select" must be a Boolean, but %v was given.',
+        options.select,
+      );
+    }
+    // schema[k].schema
+    if (options.schema !== undefined) {
+      if (
+        !options.schema ||
+        (typeof options.schema !== 'string' &&
+          typeof options.schema !== 'object') ||
+        Array.isArray(options.schema)
+      ) {
+        throw new InvalidArgumentError(
+          'Embedded schema must be an Object or a non-empty String ' +
+            'that represents a schema name, but %v was given.',
+          options.schema,
+        );
+      }
+      if (!shallowMode && typeof options.schema === 'object') {
+        validateProjectionSchema(options.schema, shallowMode);
+      }
+    }
+    // schema[k].scopes
+    if (options.scopes !== undefined) {
+      if (
+        !options.scopes ||
+        typeof options.scopes !== 'object' ||
+        Array.isArray(options.scopes)
+      ) {
+        throw new InvalidArgumentError(
+          'Property option "scopes" must be an Object, but %v was given.',
+          options.scopes,
+        );
+      }
+      Object.keys(options.scopes).forEach(scopeName => {
+        // schema[k].scopes[k]
+        const scopeOptions = options.scopes[scopeName];
+        if (scopeOptions === undefined) {
+          return;
+        }
+        if (
+          scopeOptions === null ||
+          (typeof scopeOptions !== 'boolean' &&
+            typeof scopeOptions !== 'object') ||
+          Array.isArray(scopeOptions)
+        ) {
+          throw new InvalidArgumentError(
+            'Scope options must be a Boolean or an Object, but %v was given.',
+            scopeOptions,
+          );
+        }
+        if (typeof scopeOptions === 'boolean') {
+          return;
+        }
+        // schema[k].scopes[k].select
+        if (scopeOptions.select !== undefined) {
+          if (typeof scopeOptions.select !== 'boolean') {
+            throw new InvalidArgumentError(
+              'Scope option "select" must be a Boolean, but %v was given.',
+              scopeOptions.select,
+            );
+          }
+        }
+      });
+    }
+  });
+}

+ 161 - 0
src/validate-projection-schema.spec.js

@@ -0,0 +1,161 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {validateProjectionSchema} from './validate-projection-schema.js';
+
+describe('validateProjectionSchema', function () {
+  it('should require an object', function () {
+    const throwable = v => () => validateProjectionSchema(v);
+    const error = s =>
+      format('Projection schema must be an Object, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable({})();
+  });
+
+  it('should require schema properties to be a boolean or an object', function () {
+    const throwable = v => () => validateProjectionSchema({foo: v});
+    const error = s =>
+      format(
+        'Property options must be a Boolean or an Object, but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable({})();
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+  });
+
+  it('should require the property option "select" to be a boolean', function () {
+    const throwable = v => () => validateProjectionSchema({foo: {select: v}});
+    const error = s =>
+      format(
+        'Property option "select" must be a Boolean, but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+  });
+
+  it('should require the property option "schema" to be an object or a non-empty string', function () {
+    const throwable = v => () => validateProjectionSchema({foo: {schema: v}});
+    const error = s =>
+      format(
+        'Embedded schema must be an Object or a non-empty String ' +
+          'that represents a schema name, but %s was given.',
+        s,
+      );
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable('str')();
+    throwable({})();
+    throwable(undefined)();
+  });
+
+  it('should require the property option "scopes" to be an object', function () {
+    const throwable = v => () => validateProjectionSchema({foo: {scopes: v}});
+    const error = s =>
+      format(
+        'Property option "scopes" must be an Object, but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable({})();
+    throwable(undefined)();
+  });
+
+  it('should require scope options to be a boolean or an object', function () {
+    const throwable = v => () =>
+      validateProjectionSchema({foo: {scopes: {input: v}}});
+    const error = s =>
+      format(
+        'Scope options must be a Boolean or an Object, but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable({})();
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+  });
+
+  it('should require the scope option "select" to be a boolean', function () {
+    const throwable = v => () =>
+      validateProjectionSchema({foo: {scopes: {input: {select: v}}}});
+    const error = s =>
+      format('Scope option "select" must be a Boolean, but %s was given.', s);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+  });
+
+  it('should allow nested schema', function () {
+    validateProjectionSchema({
+      foo: {
+        select: true,
+        schema: {
+          bar: {
+            select: true,
+          },
+        },
+      },
+      baz: {
+        select: false,
+        schema: {
+          qux: {
+            select: false,
+          },
+        },
+      },
+    });
+  });
+
+  it('should allow a projection name in the "schema" option', function () {
+    validateProjectionSchema({foo: {schema: 'mySchema'}});
+    validateProjectionSchema({foo: {schema: {bar: {schema: 'mySchema'}}}});
+  });
+});

+ 9 - 0
tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "rootDir": "src",
+    "noEmit": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  }
+}