@kollors/deep-json-server 0.3.2 → 0.5.0

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/src/config.js ADDED
@@ -0,0 +1,97 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { isObject } from './utils.js';
4
+
5
+ const CONFIG_KEYS = new Set(['database', 'files', 'openapi', 'server']);
6
+ const DATABASE_KEYS = new Set(['path', 'schema']);
7
+ const FILES_KEYS = new Set(['directory', 'metadata']);
8
+ const OPENAPI_KEYS = new Set(['path']);
9
+ const SERVER_KEYS = new Set(['host', 'port']);
10
+ let configImportIndex = 0;
11
+
12
+ const assertKnownKeys = (value, keys, path) => {
13
+ const unknownKey = Object.keys(value).find((key) => !keys.has(key));
14
+
15
+ if (unknownKey != null) {
16
+ throw new Error(`Неизвестный ключ ${path}.${unknownKey}`);
17
+ }
18
+ };
19
+
20
+ const getObject = (value, path) => {
21
+ if (value == null) {
22
+ return {};
23
+ }
24
+
25
+ if (!isObject(value)) {
26
+ throw new Error(`Ключ ${path} должен быть JSON-объектом`);
27
+ }
28
+
29
+ return value;
30
+ };
31
+
32
+ const getString = (value, path, required = false) => {
33
+ if (value == null && !required) {
34
+ return undefined;
35
+ }
36
+
37
+ if (typeof value !== 'string' || value.trim() === '') {
38
+ throw new Error(`Ключ ${path} должен содержать непустую строку`);
39
+ }
40
+
41
+ return value;
42
+ };
43
+
44
+ const resolveConfigPath = (value, directoryPath) => (value == null ? undefined : resolve(directoryPath, value));
45
+
46
+ export async function readServerConfig(configPath) {
47
+ const resolvedConfigPath = resolve(getString(configPath, 'config', true));
48
+ let config;
49
+
50
+ try {
51
+ const configUrl = pathToFileURL(resolvedConfigPath);
52
+
53
+ configUrl.searchParams.set('deep-json-server-import', String(configImportIndex++));
54
+ config = (await import(configUrl.href)).default;
55
+ } catch (error) {
56
+ throw new Error(`Не удалось загрузить конфигурацию ${resolvedConfigPath}: ${error.message}`, { cause: error });
57
+ }
58
+
59
+ if (!isObject(config)) {
60
+ throw new Error('Конфигурация сервера должна экспортировать JSON-объект через export default');
61
+ }
62
+
63
+ assertKnownKeys(config, CONFIG_KEYS, 'config');
64
+
65
+ const database = getObject(config.database, 'config.database');
66
+ const files = getObject(config.files, 'config.files');
67
+ const openapi = getObject(config.openapi, 'config.openapi');
68
+ const server = getObject(config.server, 'config.server');
69
+
70
+ assertKnownKeys(database, DATABASE_KEYS, 'config.database');
71
+ assertKnownKeys(files, FILES_KEYS, 'config.files');
72
+ assertKnownKeys(openapi, OPENAPI_KEYS, 'config.openapi');
73
+ assertKnownKeys(server, SERVER_KEYS, 'config.server');
74
+
75
+ const directoryPath = dirname(resolvedConfigPath);
76
+ const databasePath = getString(database.path, 'config.database.path', true);
77
+ const schemaPath = getString(database.schema, 'config.database.schema');
78
+ const openapiPath = getString(openapi.path, 'config.openapi.path');
79
+ const filesDirectory = getString(files.directory, 'config.files.directory');
80
+ const filesMetadata = getString(files.metadata, 'config.files.metadata');
81
+ const host = getString(server.host, 'config.server.host');
82
+
83
+ if (server.port != null && (!Number.isInteger(server.port) || server.port < 0 || server.port > 65_535)) {
84
+ throw new Error('Ключ config.server.port должен быть целым числом от 0 до 65535');
85
+ }
86
+
87
+ return {
88
+ configPath: resolvedConfigPath,
89
+ databasePath: resolveConfigPath(databasePath, directoryPath),
90
+ filesDirectoryPath: resolveConfigPath(filesDirectory, directoryPath),
91
+ filesMetadataPath: resolveConfigPath(filesMetadata, directoryPath),
92
+ host,
93
+ openapiPath: resolveConfigPath(openapiPath, directoryPath),
94
+ port: server.port,
95
+ schemaPath: resolveConfigPath(schemaPath, directoryPath),
96
+ };
97
+ }
@@ -0,0 +1,5 @@
1
+ export const DEFAULT_HOST = '127.0.0.1';
2
+ export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
3
+ export const DEFAULT_PAGE_SIZE = 10;
4
+ export const DEFAULT_PORT = 4001;
5
+ export const MAX_PAGE_SIZE = 1000;
@@ -0,0 +1,128 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { JSONFilePreset } from 'lowdb/node';
5
+ import { createHttpError, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
6
+
7
+ const RESOURCE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
8
+
9
+ export const validateDatabase = (data) => {
10
+ if (!isObject(data)) {
11
+ throw new Error('База данных должна содержать JSON-объект');
12
+ }
13
+
14
+ Object.entries(data).forEach(([resource, records]) => {
15
+ if (!RESOURCE_NAME_PATTERN.test(resource) || !isSafeKey(resource)) {
16
+ throw new Error(`Недопустимое имя ресурса «${resource}»`);
17
+ }
18
+
19
+ if (!Array.isArray(records)) {
20
+ throw new Error(`Ресурс «${resource}» должен содержать JSON-массив`);
21
+ }
22
+
23
+ const ids = new Set();
24
+
25
+ records.forEach((record, index) => {
26
+ if (!isObject(record)) {
27
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать JSON-объект`);
28
+ }
29
+
30
+ if (typeof record.id !== 'string' && !(typeof record.id === 'number' && Number.isFinite(record.id))) {
31
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать строковый или числовой id`);
32
+ }
33
+
34
+ if (String(record.id) === '') {
35
+ throw new Error(`Запись ${index} ресурса «${resource}» должна содержать непустой id`);
36
+ }
37
+
38
+ const id = String(record.id);
39
+
40
+ if (ids.has(id)) {
41
+ throw new Error(`Ресурс «${resource}» содержит повторяющийся id «${id}»`);
42
+ }
43
+
44
+ ids.add(id);
45
+ });
46
+ });
47
+
48
+ return data;
49
+ };
50
+
51
+ export const readJsonObject = async (path, label) => {
52
+ let source;
53
+
54
+ try {
55
+ source = await readFile(resolve(path), 'utf8');
56
+ } catch (error) {
57
+ if (error?.code === 'ENOENT') {
58
+ throw new Error(`${label} не найден: ${resolve(path)}`);
59
+ }
60
+
61
+ throw error;
62
+ }
63
+
64
+ const value = JSON.parse(source);
65
+
66
+ if (!isObject(value)) {
67
+ throw new Error(`${label} должен содержать JSON-объект`);
68
+ }
69
+
70
+ return value;
71
+ };
72
+
73
+ export const readDatabaseFile = async (databasePath) => validateDatabase(await readJsonObject(databasePath, 'Файл базы данных'));
74
+
75
+ /** @returns {Promise<any>} Internal LowDB-backed store. */
76
+ export const createDatabaseStore = async (databasePath) => {
77
+ const resolvedDatabasePath = resolveDatabasePath(databasePath);
78
+ const initialData = await readDatabaseFile(resolvedDatabasePath);
79
+ const database = await JSONFilePreset(resolvedDatabasePath, initialData);
80
+ let writeQueue = Promise.resolve();
81
+
82
+ const read = async () => {
83
+ database.data = await readDatabaseFile(resolvedDatabasePath);
84
+
85
+ return database.data;
86
+ };
87
+
88
+ const update = (operation) => {
89
+ const pendingOperation = writeQueue.then(async () => {
90
+ await read();
91
+
92
+ const result = operation(database);
93
+
94
+ validateDatabase(database.data);
95
+ await database.write();
96
+
97
+ return result;
98
+ });
99
+
100
+ writeQueue = pendingOperation.catch(() => undefined);
101
+
102
+ return pendingOperation;
103
+ };
104
+
105
+ return { database, path: resolvedDatabasePath, read, update };
106
+ };
107
+
108
+ export const getCollection = (database, resource) => {
109
+ const collection = isSafeKey(resource) ? database.data[resource] : undefined;
110
+
111
+ if (!Array.isArray(collection)) {
112
+ throw createHttpError(404, 'Ресурс не найден');
113
+ }
114
+
115
+ return collection;
116
+ };
117
+
118
+ export const findItem = (collection, id) => collection.find((item) => String(item.id) === String(id));
119
+
120
+ export const createId = (collection) => {
121
+ let id;
122
+
123
+ do {
124
+ id = randomBytes(8).toString('base64url');
125
+ } while (findItem(collection, id) != null);
126
+
127
+ return id;
128
+ };
package/src/files.js ADDED
@@ -0,0 +1,278 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { createHttpError, isObject } from './utils.js';
8
+
9
+ const createFileId = (files) => {
10
+ let id;
11
+
12
+ do {
13
+ id = randomBytes(8).toString('base64url');
14
+ } while (files.some((file) => file.id === id));
15
+
16
+ return id;
17
+ };
18
+
19
+ const validateFileMetadata = (files, metadataPath) => {
20
+ if (!Array.isArray(files)) {
21
+ throw new Error(`Файл метаданных ${metadataPath} должен содержать JSON-массив`);
22
+ }
23
+
24
+ const ids = new Set();
25
+
26
+ files.forEach((file, index) => {
27
+ if (
28
+ !isObject(file) ||
29
+ typeof file.id !== 'string' ||
30
+ file.id === '' ||
31
+ typeof file.mimeType !== 'string' ||
32
+ file.mimeType === '' ||
33
+ typeof file.name !== 'string' ||
34
+ file.name === '' ||
35
+ !Number.isInteger(file.size) ||
36
+ file.size < 0 ||
37
+ file.url !== `/_files/${file.id}`
38
+ ) {
39
+ throw new Error(`Некорректная запись ${index} в файле метаданных ${metadataPath}`);
40
+ }
41
+
42
+ if (ids.has(file.id)) {
43
+ throw new Error(`Файл метаданных ${metadataPath} содержит повторяющийся id «${file.id}»`);
44
+ }
45
+
46
+ ids.add(file.id);
47
+ });
48
+
49
+ return files;
50
+ };
51
+
52
+ const readMetadata = async (metadataPath) => {
53
+ try {
54
+ return validateFileMetadata(JSON.parse(await readFile(metadataPath, 'utf8')), metadataPath);
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') {
57
+ return [];
58
+ }
59
+
60
+ throw error;
61
+ }
62
+ };
63
+
64
+ const writeMetadata = async (metadataPath, files) => {
65
+ const temporaryPath = `${metadataPath}.${randomBytes(6).toString('hex')}.tmp`;
66
+
67
+ try {
68
+ await writeFile(temporaryPath, JSON.stringify(files, null, 2), { encoding: 'utf8', flag: 'wx' });
69
+ await rename(temporaryPath, metadataPath);
70
+ } catch (error) {
71
+ await rm(temporaryPath, { force: true });
72
+ throw error;
73
+ }
74
+ };
75
+
76
+ const getContentName = (value) => {
77
+ if (typeof value !== 'string' || value === '') {
78
+ throw createHttpError(400, 'Заголовок Content-Name обязателен');
79
+ }
80
+
81
+ let name;
82
+
83
+ try {
84
+ name = decodeURIComponent(value).replaceAll('\\', '/');
85
+ } catch {
86
+ throw createHttpError(400, 'Заголовок Content-Name содержит некорректное значение');
87
+ }
88
+
89
+ const parts = name.split('/');
90
+
91
+ if (name.startsWith('/') || parts.some((part) => part === '' || part === '.' || part === '..' || part.includes('\0'))) {
92
+ throw createHttpError(400, 'Заголовок Content-Name должен содержать безопасный относительный путь');
93
+ }
94
+
95
+ return name;
96
+ };
97
+
98
+ const getMimeType = (value) => {
99
+ const mimeType = typeof value === 'string' ? value.split(';', 1)[0].trim().toLowerCase() : '';
100
+
101
+ if (mimeType === '' || !/^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/.test(mimeType)) {
102
+ throw createHttpError(400, 'Заголовок Content-Type должен содержать корректный MIME-тип');
103
+ }
104
+
105
+ return mimeType;
106
+ };
107
+
108
+ const createSizeLimiter = (maxFileSize, onSize) => {
109
+ let size = 0;
110
+
111
+ return new Transform({
112
+ transform(chunk, _encoding, callback) {
113
+ size += chunk.length;
114
+
115
+ if (size > maxFileSize) {
116
+ callback(createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`));
117
+ return;
118
+ }
119
+
120
+ onSize(size);
121
+ callback(null, chunk);
122
+ },
123
+ });
124
+ };
125
+
126
+ const createFileStore = async ({ directoryPath: sourceDirectoryPath, metadataPath: sourceMetadataPath }) => {
127
+ if (typeof sourceDirectoryPath !== 'string' || sourceDirectoryPath.trim() === '') {
128
+ throw new Error('Путь к директории файлов не должен быть пустым');
129
+ }
130
+
131
+ if (typeof sourceMetadataPath !== 'string' || sourceMetadataPath.trim() === '') {
132
+ throw new Error('Путь к файлу метаданных не должен быть пустым');
133
+ }
134
+
135
+ const directoryPath = resolve(sourceDirectoryPath);
136
+ const metadataPath = resolve(sourceMetadataPath);
137
+ let operationQueue = Promise.resolve();
138
+
139
+ await Promise.all([mkdir(directoryPath, { recursive: true }), mkdir(dirname(metadataPath), { recursive: true })]);
140
+ await readMetadata(metadataPath);
141
+
142
+ const schedule = (operation) => {
143
+ const pendingOperation = operationQueue.then(operation);
144
+
145
+ operationQueue = pendingOperation.catch(() => undefined);
146
+
147
+ return pendingOperation;
148
+ };
149
+
150
+ const get = (id) =>
151
+ schedule(async () => {
152
+ const files = await readMetadata(metadataPath);
153
+ const file = files.find((item) => item.id === id);
154
+
155
+ if (file == null) {
156
+ throw createHttpError(404, 'Файл не найден');
157
+ }
158
+
159
+ const path = join(directoryPath, file.id);
160
+
161
+ try {
162
+ await access(path);
163
+ } catch (error) {
164
+ if (error?.code === 'ENOENT') {
165
+ throw createHttpError(404, 'Файл не найден');
166
+ }
167
+
168
+ throw error;
169
+ }
170
+
171
+ return { file, path };
172
+ });
173
+
174
+ const upload = ({ maxFileSize, mimeType, name, stream }) =>
175
+ schedule(async () => {
176
+ const files = await readMetadata(metadataPath);
177
+ const id = createFileId(files);
178
+ const path = join(directoryPath, id);
179
+ const temporaryPath = `${path}.upload`;
180
+ let size = 0;
181
+
182
+ try {
183
+ await pipeline(
184
+ stream,
185
+ createSizeLimiter(maxFileSize, (value) => (size = value)),
186
+ createWriteStream(temporaryPath, { flags: 'wx' }),
187
+ );
188
+ await rename(temporaryPath, path);
189
+
190
+ const file = { id, mimeType, name, size, url: `/_files/${id}` };
191
+
192
+ files.push(file);
193
+ await writeMetadata(metadataPath, files);
194
+
195
+ return file;
196
+ } catch (error) {
197
+ await Promise.all([rm(temporaryPath, { force: true }), rm(path, { force: true })]);
198
+ throw error;
199
+ }
200
+ });
201
+
202
+ const remove = (id) =>
203
+ schedule(async () => {
204
+ const files = await readMetadata(metadataPath);
205
+ const index = files.findIndex((file) => file.id === id);
206
+
207
+ if (index === -1) {
208
+ throw createHttpError(404, 'Файл не найден');
209
+ }
210
+
211
+ const [file] = files.splice(index, 1);
212
+ const path = join(directoryPath, file.id);
213
+ const temporaryPath = `${path}.${randomBytes(6).toString('hex')}.delete`;
214
+
215
+ try {
216
+ await rename(path, temporaryPath);
217
+ } catch (error) {
218
+ if (error?.code === 'ENOENT') {
219
+ throw createHttpError(404, 'Файл не найден');
220
+ }
221
+
222
+ throw error;
223
+ }
224
+
225
+ try {
226
+ await writeMetadata(metadataPath, files);
227
+ } catch (error) {
228
+ await rename(temporaryPath, path);
229
+ throw error;
230
+ }
231
+
232
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
233
+
234
+ return file;
235
+ });
236
+
237
+ return { get, remove, upload };
238
+ };
239
+
240
+ const getDownloadName = (name) => encodeURIComponent(basename(name)).replaceAll("'", '%27');
241
+
242
+ export const registerFileRoutes = async (server, { directoryPath, maxFileSize, metadataPath }) => {
243
+ const store = await createFileStore({ directoryPath, metadataPath });
244
+
245
+ server.register((fileServer, _options, done) => {
246
+ fileServer.removeAllContentTypeParsers();
247
+ fileServer.addContentTypeParser('*', (_request, payload, parserDone) => parserDone(null, payload));
248
+
249
+ fileServer.post('/_files', async (request, reply) => {
250
+ const contentLength = Number(request.headers['content-length']);
251
+
252
+ if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
253
+ throw createHttpError(413, `Размер файла не должен превышать ${maxFileSize} байт`);
254
+ }
255
+
256
+ const file = await store.upload({
257
+ maxFileSize,
258
+ mimeType: getMimeType(request.headers['content-type']),
259
+ name: getContentName(request.headers['content-name']),
260
+ stream: request.body,
261
+ });
262
+
263
+ return reply.code(201).send(file);
264
+ });
265
+
266
+ fileServer.get('/_files/:id', async (request, reply) => {
267
+ const { file, path } = await store.get(request.params.id);
268
+
269
+ reply.header('Content-Disposition', `inline; filename*=UTF-8''${getDownloadName(file.name)}`);
270
+ reply.type(file.mimeType);
271
+
272
+ return reply.send(createReadStream(path));
273
+ });
274
+
275
+ fileServer.delete('/_files/:id', async (request) => store.remove(request.params.id));
276
+ done();
277
+ });
278
+ };
@@ -0,0 +1,110 @@
1
+ import { readJsonObject } from '../database.js';
2
+ import { isObject } from '../utils.js';
3
+ import { applyRequiredFields, getSchemasAtPath, updateSchemasAtPath } from './inference.js';
4
+
5
+ const validateSchemaOverride = (schema, path) => {
6
+ if (!isObject(schema)) {
7
+ throw new Error(`OpenAPI-схема свойства «${path}» должна содержать JSON-объект`);
8
+ }
9
+
10
+ if (schema.properties != null) {
11
+ if (!isObject(schema.properties)) {
12
+ throw new Error(`properties свойства «${path}» должен содержать JSON-объект`);
13
+ }
14
+
15
+ Object.entries(schema.properties).forEach(([key, value]) => {
16
+ validateSchemaOverride(value, `${path}.${key}`);
17
+ });
18
+ }
19
+
20
+ if (schema.items != null) {
21
+ validateSchemaOverride(schema.items, `${path}[]`);
22
+ }
23
+
24
+ if (schema.oneOf != null) {
25
+ if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
26
+ throw new Error(`oneOf свойства «${path}» должен содержать непустой массив`);
27
+ }
28
+
29
+ schema.oneOf.forEach((value, index) => {
30
+ validateSchemaOverride(value, `${path}.oneOf[${index}]`);
31
+ });
32
+ }
33
+ };
34
+
35
+ export const validateSchemaConfig = (schemaConfig, resources) => {
36
+ if (
37
+ Object.hasOwn(schemaConfig, '$info') &&
38
+ (!isObject(schemaConfig.$info) || !['title', 'version'].every((key) => typeof schemaConfig.$info[key] === 'string' && schemaConfig.$info[key].trim() !== ''))
39
+ ) {
40
+ throw new Error('$info должен содержать непустые строковые поля title и version');
41
+ }
42
+
43
+ if (Object.hasOwn(schemaConfig, '$schema') && !isObject(schemaConfig.$schema)) {
44
+ throw new Error('$schema должен содержать JSON-объект');
45
+ }
46
+
47
+ const resourceConfigs = schemaConfig.$schema ?? {};
48
+
49
+ Object.entries(resourceConfigs).forEach(([resource, resourceConfig]) => {
50
+ if (!resources.includes(resource)) {
51
+ throw new Error(`В $schema указан неизвестный ресурс «${resource}»`);
52
+ }
53
+
54
+ if (!isObject(resourceConfig)) {
55
+ throw new Error(`Настройки ресурса «${resource}» должны содержать JSON-объект`);
56
+ }
57
+
58
+ if (resourceConfig.name != null && (typeof resourceConfig.name !== 'string' || resourceConfig.name.trim() === '')) {
59
+ throw new Error(`$schema.${resource}.name должен содержать непустую строку`);
60
+ }
61
+
62
+ if (resourceConfig.required != null && (!Array.isArray(resourceConfig.required) || resourceConfig.required.some((path) => typeof path !== 'string' || path === ''))) {
63
+ throw new Error(`$schema.${resource}.required должен содержать массив непустых строк`);
64
+ }
65
+
66
+ if (
67
+ resourceConfig.formats != null &&
68
+ (!isObject(resourceConfig.formats) || Object.entries(resourceConfig.formats).some(([path, format]) => path === '' || typeof format !== 'string' || format === ''))
69
+ ) {
70
+ throw new Error(`$schema.${resource}.formats должен содержать JSON-объект с непустыми строковыми путями и форматами`);
71
+ }
72
+
73
+ if (resourceConfig.properties != null) {
74
+ if (!isObject(resourceConfig.properties)) {
75
+ throw new Error(`$schema.${resource}.properties должен содержать JSON-объект`);
76
+ }
77
+
78
+ Object.entries(resourceConfig.properties).forEach(([key, value]) => {
79
+ validateSchemaOverride(value, `${resource}.${key}`);
80
+ });
81
+ }
82
+ });
83
+
84
+ return resourceConfigs;
85
+ };
86
+
87
+ export const applyConfiguredFields = (schema, resource, resourceConfig) => {
88
+ const requiredFields = Array.isArray(resourceConfig.required) ? resourceConfig.required : [];
89
+ const formats = isObject(resourceConfig.formats) ? resourceConfig.formats : {};
90
+
91
+ [...requiredFields, ...Object.keys(formats)].forEach((path) => {
92
+ if (getSchemasAtPath(schema, path.split('.')).length === 0) {
93
+ throw new Error(`Путь «${path}» из настроек ресурса «${resource}» отсутствует в итоговой схеме`);
94
+ }
95
+ });
96
+
97
+ const schemaWithFormats = Object.entries(formats).reduce((result, [path, format]) => {
98
+ const schemas = getSchemasAtPath(result, path.split('.'));
99
+
100
+ if (!schemas.some((nestedSchema) => nestedSchema.type === 'string')) {
101
+ throw new Error(`Формат «${format}» для пути «${resource}.${path}» можно применить только к строковому полю`);
102
+ }
103
+
104
+ return updateSchemasAtPath(result, path.split('.'), (nestedSchema) => (nestedSchema.type === 'string' ? { ...nestedSchema, format } : nestedSchema));
105
+ }, schema);
106
+
107
+ return applyRequiredFields(schemaWithFormats, '', new Set(requiredFields));
108
+ };
109
+
110
+ export const readSchemaConfig = async (schemaPath) => (schemaPath == null ? {} : readJsonObject(schemaPath, 'Файл схемы базы данных'));