@kollors/deep-json-server 1.0.0-alpha.1 → 1.0.0-alpha.10
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/README.md +522 -179
- package/README.ru.md +525 -180
- package/dist/bin/deep-json-server.js +1 -1
- package/dist/bin/deep-json-server.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/src/auth/contract.d.ts +31 -0
- package/dist/src/auth/contract.js +12 -0
- package/dist/src/auth/contract.js.map +1 -0
- package/dist/src/auth/input.d.ts +18 -0
- package/dist/src/auth/input.js +34 -0
- package/dist/src/auth/input.js.map +1 -0
- package/dist/src/auth/password.d.ts +17 -0
- package/dist/src/auth/password.js +41 -0
- package/dist/src/auth/password.js.map +1 -0
- package/dist/src/auth/public.d.ts +3 -0
- package/dist/src/auth/public.js +5 -0
- package/dist/src/auth/public.js.map +1 -0
- package/dist/src/auth/routes.d.ts +6 -0
- package/dist/src/auth/routes.js +18 -0
- package/dist/src/auth/routes.js.map +1 -0
- package/dist/src/auth/service.d.ts +78 -0
- package/dist/src/auth/service.js +218 -0
- package/dist/src/auth/service.js.map +1 -0
- package/dist/src/auth/store.d.ts +26 -0
- package/dist/src/auth/store.js +82 -0
- package/dist/src/auth/store.js.map +1 -0
- package/dist/src/cli/index.d.ts +7 -0
- package/dist/src/cli/index.js +122 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/core/config-values.d.ts +28 -0
- package/dist/src/core/config-values.js +53 -0
- package/dist/src/core/config-values.js.map +1 -0
- package/dist/src/{constants.d.ts → core/constants.d.ts} +1 -0
- package/dist/src/{constants.js → core/constants.js} +1 -0
- package/dist/src/core/constants.js.map +1 -0
- package/dist/src/core/database.d.ts +48 -0
- package/dist/src/{database.js → core/database.js} +36 -14
- package/dist/src/core/database.js.map +1 -0
- package/dist/src/core/engine.d.ts +65 -0
- package/dist/src/core/engine.js +312 -0
- package/dist/src/core/engine.js.map +1 -0
- package/dist/src/core/errors.d.ts +9 -0
- package/dist/src/core/errors.js +12 -0
- package/dist/src/core/errors.js.map +1 -0
- package/dist/src/core/http-errors.d.ts +9 -0
- package/dist/src/core/http-errors.js +15 -0
- package/dist/src/core/http-errors.js.map +1 -0
- package/dist/src/core/lifecycle/mutation.d.ts +49 -0
- package/dist/src/core/lifecycle/mutation.js +170 -0
- package/dist/src/core/lifecycle/mutation.js.map +1 -0
- package/dist/src/core/lifecycle/options.d.ts +21 -0
- package/dist/src/core/lifecycle/options.js +19 -0
- package/dist/src/core/lifecycle/options.js.map +1 -0
- package/dist/src/core/model.d.ts +145 -0
- package/dist/src/{model.js → core/model.js} +224 -33
- package/dist/src/core/model.js.map +1 -0
- package/dist/src/core/mutations/write.d.ts +26 -0
- package/dist/src/core/mutations/write.js +284 -0
- package/dist/src/core/mutations/write.js.map +1 -0
- package/dist/src/core/operations.d.ts +33 -0
- package/dist/src/core/operations.js +10 -0
- package/dist/src/core/operations.js.map +1 -0
- package/dist/src/core/pagination.d.ts +10 -0
- package/dist/src/core/pagination.js +13 -0
- package/dist/src/core/pagination.js.map +1 -0
- package/dist/src/core/paths.d.ts +16 -0
- package/dist/src/core/paths.js +64 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/query/contract.d.ts +6 -0
- package/dist/src/core/query/contract.js +22 -0
- package/dist/src/core/query/contract.js.map +1 -0
- package/dist/src/core/query/filter.d.ts +6 -0
- package/dist/src/core/query/filter.js +100 -0
- package/dist/src/core/query/filter.js.map +1 -0
- package/dist/src/core/query/options.d.ts +30 -0
- package/dist/src/core/query/options.js +44 -0
- package/dist/src/core/query/options.js.map +1 -0
- package/dist/src/core/records.d.ts +46 -0
- package/dist/src/core/records.js +69 -0
- package/dist/src/core/records.js.map +1 -0
- package/dist/src/core/relation-metadata.d.ts +15 -0
- package/dist/src/{relation-metadata.js → core/relation-metadata.js} +7 -2
- package/dist/src/core/relation-metadata.js.map +1 -0
- package/dist/src/core/storage.d.ts +2 -0
- package/dist/src/core/storage.js +2 -0
- package/dist/src/core/storage.js.map +1 -0
- package/dist/src/core/types.d.ts +10 -0
- package/dist/src/{types.js.map → core/types.js.map} +1 -1
- package/dist/src/core/utils.d.ts +56 -0
- package/dist/src/core/utils.js +102 -0
- package/dist/src/core/utils.js.map +1 -0
- package/dist/src/files/contract.d.ts +32 -30
- package/dist/src/files/contract.js +29 -48
- package/dist/src/files/contract.js.map +1 -1
- package/dist/src/files/disk-store.d.ts +5 -1
- package/dist/src/files/disk-store.js +49 -32
- package/dist/src/files/disk-store.js.map +1 -1
- package/dist/src/files/http.d.ts +36 -0
- package/dist/src/files/http.js +53 -0
- package/dist/src/files/http.js.map +1 -0
- package/dist/src/files/index.d.ts +5 -4
- package/dist/src/files/index.js +7 -2
- package/dist/src/files/index.js.map +1 -1
- package/dist/src/files/memory-store.d.ts +4 -1
- package/dist/src/files/memory-store.js +29 -22
- package/dist/src/files/memory-store.js.map +1 -1
- package/dist/src/files/routes.d.ts +3 -0
- package/dist/src/files/routes.js +27 -2
- package/dist/src/files/routes.js.map +1 -1
- package/dist/src/files/streams.d.ts +5 -0
- package/dist/src/files/streams.js +20 -0
- package/dist/src/files/streams.js.map +1 -0
- package/dist/src/graphql/entry.d.ts +12 -0
- package/dist/src/graphql/entry.js +13 -0
- package/dist/src/graphql/entry.js.map +1 -0
- package/dist/src/graphql/generate.d.ts +12 -0
- package/dist/src/graphql/generate.js +19 -0
- package/dist/src/graphql/generate.js.map +1 -0
- package/dist/src/graphql/preflight.d.ts +6 -0
- package/dist/src/graphql/preflight.js +42 -0
- package/dist/src/graphql/preflight.js.map +1 -0
- package/dist/src/graphql/resolvers.d.ts +17 -0
- package/dist/src/graphql/resolvers.js +44 -0
- package/dist/src/graphql/resolvers.js.map +1 -0
- package/dist/src/graphql/routes.d.ts +8 -0
- package/dist/src/graphql/routes.js +30 -0
- package/dist/src/graphql/routes.js.map +1 -0
- package/dist/src/graphql/schema.d.ts +6 -0
- package/dist/src/{graphql.js → graphql/schema.js} +57 -50
- package/dist/src/graphql/schema.js.map +1 -0
- package/dist/src/graphql/write.d.ts +4 -0
- package/dist/src/graphql/write.js +10 -0
- package/dist/src/graphql/write.js.map +1 -0
- package/dist/src/openapi/auth.d.ts +13 -0
- package/dist/src/openapi/auth.js +139 -0
- package/dist/src/openapi/auth.js.map +1 -0
- package/dist/src/openapi/document.d.ts +7 -3
- package/dist/src/openapi/document.js +147 -92
- package/dist/src/openapi/document.js.map +1 -1
- package/dist/src/openapi/entry.d.ts +14 -0
- package/dist/src/openapi/entry.js +13 -0
- package/dist/src/openapi/entry.js.map +1 -0
- package/dist/src/openapi/files.d.ts +6 -0
- package/dist/src/{files/openapi.js → openapi/files.js} +6 -5
- package/dist/src/openapi/files.js.map +1 -0
- package/dist/src/openapi/generate.d.ts +19 -0
- package/dist/src/openapi/generate.js +41 -0
- package/dist/src/openapi/generate.js.map +1 -0
- package/dist/src/openapi/helpers.d.ts +26 -0
- package/dist/src/openapi/helpers.js +13 -0
- package/dist/src/openapi/helpers.js.map +1 -0
- package/dist/src/openapi/options.d.ts +18 -0
- package/dist/src/openapi/options.js +13 -0
- package/dist/src/openapi/options.js.map +1 -0
- package/dist/src/{types.d.ts → openapi/types.d.ts} +2 -10
- package/dist/src/openapi/types.js +2 -0
- package/dist/src/openapi/types.js.map +1 -0
- package/dist/src/openapi/write.d.ts +5 -0
- package/dist/src/openapi/write.js +12 -0
- package/dist/src/openapi/write.js.map +1 -0
- package/dist/src/rest/options.d.ts +22 -0
- package/dist/src/rest/options.js +65 -0
- package/dist/src/rest/options.js.map +1 -0
- package/dist/src/rest/projection.d.ts +13 -0
- package/dist/src/rest/projection.js +61 -0
- package/dist/src/rest/projection.js.map +1 -0
- package/dist/src/rest/routes.d.ts +7 -0
- package/dist/src/rest/routes.js +49 -0
- package/dist/src/rest/routes.js.map +1 -0
- package/dist/src/server/config.d.ts +74 -0
- package/dist/src/server/config.js +142 -0
- package/dist/src/server/config.js.map +1 -0
- package/dist/src/server/create.d.ts +16 -0
- package/dist/src/server/create.js +133 -0
- package/dist/src/server/create.js.map +1 -0
- package/dist/src/server/features.d.ts +4 -0
- package/dist/src/server/features.js +11 -0
- package/dist/src/server/features.js.map +1 -0
- package/dist/src/server/model.d.ts +6 -0
- package/dist/src/server/model.js +14 -0
- package/dist/src/server/model.js.map +1 -0
- package/dist/src/server/public.d.ts +2 -0
- package/dist/src/server/public.js +2 -0
- package/dist/src/server/public.js.map +1 -0
- package/package.json +17 -1
- package/dist/src/cli.d.ts +0 -4
- package/dist/src/cli.js +0 -67
- package/dist/src/cli.js.map +0 -1
- package/dist/src/config.d.ts +0 -68
- package/dist/src/config.js +0 -161
- package/dist/src/config.js.map +0 -1
- package/dist/src/constants.js.map +0 -1
- package/dist/src/database.d.ts +0 -21
- package/dist/src/database.js.map +0 -1
- package/dist/src/engine.d.ts +0 -47
- package/dist/src/engine.js +0 -438
- package/dist/src/engine.js.map +0 -1
- package/dist/src/files/openapi.d.ts +0 -3
- package/dist/src/files/openapi.js.map +0 -1
- package/dist/src/graphql.d.ts +0 -4
- package/dist/src/graphql.js.map +0 -1
- package/dist/src/model.d.ts +0 -67
- package/dist/src/model.js.map +0 -1
- package/dist/src/openapi/index.d.ts +0 -7
- package/dist/src/openapi/index.js +0 -26
- package/dist/src/openapi/index.js.map +0 -1
- package/dist/src/query/filter.d.ts +0 -1
- package/dist/src/query/filter.js +0 -84
- package/dist/src/query/filter.js.map +0 -1
- package/dist/src/query/options.d.ts +0 -31
- package/dist/src/query/options.js +0 -135
- package/dist/src/query/options.js.map +0 -1
- package/dist/src/relation-metadata.d.ts +0 -10
- package/dist/src/relation-metadata.js.map +0 -1
- package/dist/src/server.d.ts +0 -14
- package/dist/src/server.js +0 -166
- package/dist/src/server.js.map +0 -1
- package/dist/src/utils.d.ts +0 -19
- package/dist/src/utils.js +0 -62
- package/dist/src/utils.js.map +0 -1
- /package/dist/src/{types.js → core/types.js} +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface RecordOptions {
|
|
2
|
+
timestamps?: boolean;
|
|
3
|
+
softDelete?: boolean;
|
|
4
|
+
auth?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface Actor {
|
|
7
|
+
id: string;
|
|
8
|
+
isAdmin?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type ActorSource = Actor | (() => Actor);
|
|
11
|
+
export type Authenticate = (authorization: unknown) => Actor;
|
|
12
|
+
export declare const DELETION_META = "djsDeletion";
|
|
13
|
+
export declare const AUDIT_FIELDS: readonly ['createdAt', 'updatedAt', 'deletedAt', 'createdById', 'updatedById', 'deletedById'];
|
|
14
|
+
/** Проверяет логические флаги и заменяет отсутствующие значения на false.
|
|
15
|
+
* @example recordOptions({ softDelete: true }) → { timestamps: false, softDelete: true, auth: false }.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recordOptions(options?: RecordOptions): Required<RecordOptions>;
|
|
18
|
+
/** Ищет условие deletedAt на текущем уровне и в логических ветках, не заходя в поля связанных объектов.
|
|
19
|
+
* @example mentionsDeletedAt({ or: [{ deletedAt: { ne: null } }] }) → true; { user: { deletedAt: {} } } → false.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mentionsDeletedAt(where: unknown): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getBoolean } from '../config-values.js';
|
|
2
|
+
import { isObject } from '../utils.js';
|
|
3
|
+
export const DELETION_META = 'djsDeletion';
|
|
4
|
+
export const AUDIT_FIELDS = ['createdAt', 'updatedAt', 'deletedAt', 'createdById', 'updatedById', 'deletedById'];
|
|
5
|
+
/** Проверяет логические флаги и заменяет отсутствующие значения на false.
|
|
6
|
+
* @example recordOptions({ softDelete: true }) → { timestamps: false, softDelete: true, auth: false }.
|
|
7
|
+
*/
|
|
8
|
+
export function recordOptions(options = {}) {
|
|
9
|
+
return { timestamps: getBoolean(options.timestamps, 'timestamps') ?? false, softDelete: getBoolean(options.softDelete, 'softDelete') ?? false, auth: getBoolean(options.auth, 'auth') ?? false };
|
|
10
|
+
}
|
|
11
|
+
/** Ищет условие deletedAt на текущем уровне и в логических ветках, не заходя в поля связанных объектов.
|
|
12
|
+
* @example mentionsDeletedAt({ or: [{ deletedAt: { ne: null } }] }) → true; { user: { deletedAt: {} } } → false.
|
|
13
|
+
*/
|
|
14
|
+
export function mentionsDeletedAt(where) {
|
|
15
|
+
if (!isObject(where))
|
|
16
|
+
return false;
|
|
17
|
+
return Object.hasOwn(where, 'deletedAt') || ['and', 'or'].some((key) => Array.isArray(where[key]) && where[key].some(mentionsDeletedAt)) || mentionsDeletedAt(where.not);
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=options.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"options.js","sourceRoot":"","sources":["../../../../src/core/lifecycle/options.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAavC,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAAC;AAC3C,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,aAAa,CAAU,CAAC;AAC1H;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,OAAO,GAAkB,EAAE;IACvD,OAAO,EAAE,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,UAAU,EAAE,YAAY,CAAC,IAAI,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,UAAU,EAAE,YAAY,CAAC,IAAI,KAAK,EAAE,IAAI,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;AACnM,CAAC;AACD;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,IAAI,iBAAiB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AAC3K,CAAC"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type RecordOptions } from './lifecycle/options.js';
|
|
2
|
+
import type { WriteMode } from './operations.js';
|
|
3
|
+
import type { DatabaseData, JsonValue } from './types.js';
|
|
4
|
+
export interface Field {
|
|
5
|
+
type: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
example?: JsonValue;
|
|
8
|
+
required?: boolean;
|
|
9
|
+
nullable?: boolean;
|
|
10
|
+
default?: JsonValue;
|
|
11
|
+
enum?: JsonValue[];
|
|
12
|
+
primary?: boolean;
|
|
13
|
+
generated?: 'uuid' | 'increment';
|
|
14
|
+
readOnly?: boolean;
|
|
15
|
+
writeOnly?: boolean;
|
|
16
|
+
minLength?: number;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
pattern?: string;
|
|
19
|
+
format?: 'date' | 'date-time' | 'email' | 'uri' | 'uuid';
|
|
20
|
+
minimum?: number;
|
|
21
|
+
maximum?: number;
|
|
22
|
+
source?: string;
|
|
23
|
+
target?: string;
|
|
24
|
+
onDelete?: 'restrict' | 'cascade';
|
|
25
|
+
}
|
|
26
|
+
export interface EntityDefinition {
|
|
27
|
+
timestamps?: boolean;
|
|
28
|
+
softDelete?: boolean;
|
|
29
|
+
collection: string;
|
|
30
|
+
api?: ('openapi' | 'graphql')[];
|
|
31
|
+
fields: Record<string, Field>;
|
|
32
|
+
}
|
|
33
|
+
export type ApiFormat = 'openapi' | 'graphql';
|
|
34
|
+
export interface ModelSchema {
|
|
35
|
+
models: Record<string, EntityDefinition>;
|
|
36
|
+
api?: ApiFormat[];
|
|
37
|
+
timestamps?: boolean;
|
|
38
|
+
softDelete?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface ModelOptions {
|
|
41
|
+
auth?: boolean;
|
|
42
|
+
api?: ApiFormat[];
|
|
43
|
+
}
|
|
44
|
+
/** Проверяет список форматов и возвращает его независимую копию.
|
|
45
|
+
* @example apiFormats(['graphql']) → ['graphql']; ['unknown'] → ошибка.
|
|
46
|
+
*/
|
|
47
|
+
export declare function apiFormats(value: unknown, fallback: ApiFormat[], label: string): ApiFormat[];
|
|
48
|
+
export interface Node extends Field {
|
|
49
|
+
system?: boolean;
|
|
50
|
+
internal?: boolean;
|
|
51
|
+
softDelete?: boolean;
|
|
52
|
+
path: string;
|
|
53
|
+
base: string;
|
|
54
|
+
many: boolean;
|
|
55
|
+
children: Record<string, Node>;
|
|
56
|
+
relation?: Entity;
|
|
57
|
+
implicit?: boolean;
|
|
58
|
+
mixed?: boolean;
|
|
59
|
+
relationKey?: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface Entity {
|
|
62
|
+
timestamps: boolean;
|
|
63
|
+
softDelete: boolean;
|
|
64
|
+
name: string;
|
|
65
|
+
collection: string;
|
|
66
|
+
api: ('openapi' | 'graphql')[];
|
|
67
|
+
primary: string;
|
|
68
|
+
fields: Record<string, Node>;
|
|
69
|
+
root: Node;
|
|
70
|
+
}
|
|
71
|
+
export interface Model {
|
|
72
|
+
options: Required<RecordOptions>;
|
|
73
|
+
entities: Entity[];
|
|
74
|
+
byName: Map<string, Entity>;
|
|
75
|
+
byCollection: Map<string, Entity>;
|
|
76
|
+
explicit: boolean;
|
|
77
|
+
}
|
|
78
|
+
export type ValidationSchema = Record<string, unknown>;
|
|
79
|
+
/** Разбивает путь по точкам и проверяет допустимость каждого имени.
|
|
80
|
+
* @example pathParts('profile.name') → ['profile', 'name']; pathParts('a..b') → ошибка.
|
|
81
|
+
*/
|
|
82
|
+
export declare const pathParts: (path: string) => string[];
|
|
83
|
+
/** Находит самый длинный префикс пути среди ключей объекта, учитывая границы сегментов.
|
|
84
|
+
* @example bindingFor({ a: {}, 'a.b': {} }, 'a.b.id') → 'a.b'; bindingFor({ a: {} }, 'abc.id') → undefined.
|
|
85
|
+
*/
|
|
86
|
+
export declare function bindingFor(bindings: Record<string, unknown>, path: string): string | undefined;
|
|
87
|
+
/** Проверяет, что все сегменты пути объявлены и доступны для записи, включая родительские объекты.
|
|
88
|
+
* @example Для поля name без ограничений canWriteKey(entity, 'name') → true; для первичного ключа → false.
|
|
89
|
+
*/
|
|
90
|
+
export declare function canWriteKey(entity: Entity, path: string): boolean;
|
|
91
|
+
/** Распознаёт обратную связь: источник совпадает с первичным ключом, а целевой путь отличается от первичного ключа цели.
|
|
92
|
+
* @example При source = 'id', target = 'authorId' и первичных ключах id → true.
|
|
93
|
+
*/
|
|
94
|
+
export declare function isReverseRelation(entity: Entity, node: Node): boolean;
|
|
95
|
+
/** Собирает значения по вложенному пути, проходя через массивы; отсутствующие значения и null пропускает.
|
|
96
|
+
* @example readPath({ rows: [{ id: 1 }, { id: 2 }] }, 'rows.id') → [1, 2].
|
|
97
|
+
*/
|
|
98
|
+
export declare const readPath: (value: unknown, path: string | string[]) => unknown[];
|
|
99
|
+
/** Возвращает последний сегмент пути поля.
|
|
100
|
+
* @example childName({ path: 'profile.name', … }) → 'name'.
|
|
101
|
+
*/
|
|
102
|
+
export declare const childName: (node: Node) => string;
|
|
103
|
+
/** Для связи возвращает целевую сущность и её корень; обычный узел сохраняет без изменений.
|
|
104
|
+
* @example canonicalNode(author, { relation: book }) → [book, book.root].
|
|
105
|
+
* @example canonicalNode(author, author.root) → [author, author.root].
|
|
106
|
+
*/
|
|
107
|
+
export declare const canonicalNode: (entity: Entity, node: Node) => [Entity, Node];
|
|
108
|
+
/** Соединяет имя сущности и путь поля, заменяя точки подчёркиваниями.
|
|
109
|
+
* @example nodeName({ name: 'User', … }, { path: 'profile.name', … }) → 'User_profile_name'.
|
|
110
|
+
*/
|
|
111
|
+
export declare const nodeName: (entity: Entity, node: Node) => string;
|
|
112
|
+
/** Переводит первую букву имени сущности в нижний регистр.
|
|
113
|
+
* @example operationName({ name: 'UserProfile', … }) → 'userProfile'.
|
|
114
|
+
*/
|
|
115
|
+
export declare const operationName: (entity: Entity) => string;
|
|
116
|
+
/** Загружает описание из объекта или JSON-файла, проверяет поля и сопоставляет связи.
|
|
117
|
+
* @example loadModel(undefined) → Promise<undefined>; корректное описание → Promise<Model>.
|
|
118
|
+
*/
|
|
119
|
+
export declare function loadModel(source: unknown, settings?: ModelOptions): Promise<Model | undefined>;
|
|
120
|
+
/** Определяет доступность поля для выбранного режима записи, учитывая защищённые дочерние поля.
|
|
121
|
+
* @example writable({ readOnly: true, … }, 'update') → false.
|
|
122
|
+
*/
|
|
123
|
+
export declare function writable(node: Node, mode: InputMode, relations?: boolean): boolean;
|
|
124
|
+
/** Проверяет наличие явной модели и доступность выбранного формата для её связей.
|
|
125
|
+
* @example assertApi(undefined, 'graphql') → ошибка; согласованная модель → undefined.
|
|
126
|
+
*/
|
|
127
|
+
export declare function assertApi(model: Model | undefined, api: 'graphql' | 'openapi'): asserts model is Model;
|
|
128
|
+
/** Строит схему проверки значения с ограничениями, массивом и допустимостью null.
|
|
129
|
+
* @example Для строкового узла без ограничений valueSchema(node) → { type: 'string' }.
|
|
130
|
+
*/
|
|
131
|
+
export declare function valueSchema(node: Node): ValidationSchema;
|
|
132
|
+
export type InputMode = 'stored' | WriteMode;
|
|
133
|
+
/** Строит схему объекта для выбранного режима; при частичном обновлении корневые поля необязательны.
|
|
134
|
+
* @example Для пустого узла → { type: 'object', properties: {}, additionalProperties: false }.
|
|
135
|
+
*/
|
|
136
|
+
export declare function objectSchema(node: Node, mode: InputMode, root?: boolean, relationSchema?: (node: Node) => ValidationSchema): ValidationSchema;
|
|
137
|
+
export declare function relationInputSchema(node: Node, object?: ValidationSchema): ValidationSchema;
|
|
138
|
+
/** Проверяет запись по схеме выбранного режима и выбрасывает исключение при несоответствии.
|
|
139
|
+
* @example Обязательное строковое name: { name: 'Анна' } → undefined; { name: 2 } → ошибка.
|
|
140
|
+
*/
|
|
141
|
+
export declare function validateRecord(entity: Entity, value: unknown, mode: InputMode, relations?: boolean): void;
|
|
142
|
+
/** Выводит поля и связи из значений коллекций, не меняя переданные записи.
|
|
143
|
+
* @example inferModel({ users: [{ id: '1', name: 'Анна' }] }) → модель со строковыми id и name.
|
|
144
|
+
*/
|
|
145
|
+
export declare function inferModel(database: DatabaseData, settings?: RecordOptions): Model;
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { Ajv } from 'ajv';
|
|
3
3
|
import addFormats from 'ajv-formats';
|
|
4
|
+
import { domainError } from './errors.js';
|
|
5
|
+
import { AUDIT_FIELDS, DELETION_META, recordOptions } from './lifecycle/options.js';
|
|
4
6
|
import { getRelationMetadata } from './relation-metadata.js';
|
|
5
|
-
import { assertKnownKeys,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { assertKnownKeys, isObject, isSafeKey, singularize, toPascalCase } from './utils.js';
|
|
8
|
+
/** Проверяет список форматов и возвращает его независимую копию.
|
|
9
|
+
* @example apiFormats(['graphql']) → ['graphql']; ['unknown'] → ошибка.
|
|
10
|
+
*/
|
|
11
|
+
export function apiFormats(value, fallback, label) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return [...fallback];
|
|
14
|
+
if (!Array.isArray(value) || value.some((format) => !['openapi', 'graphql'].includes(format)) || new Set(value).size !== value.length)
|
|
15
|
+
throw new Error(`Invalid api for ${label}`);
|
|
16
|
+
return [...value];
|
|
17
|
+
}
|
|
18
|
+
const createValidator = () => {
|
|
19
|
+
const ajv = new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, ownProperties: true });
|
|
20
|
+
addFormats.default(ajv);
|
|
21
|
+
return ajv;
|
|
22
|
+
};
|
|
23
|
+
const entityValidators = new WeakMap();
|
|
8
24
|
const PRIMITIVES = new Set(['string', 'number', 'boolean', 'object']);
|
|
9
25
|
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
10
26
|
const FIELD_KEYS = new Set([
|
|
@@ -29,12 +45,44 @@ const FIELD_KEYS = new Set([
|
|
|
29
45
|
'target',
|
|
30
46
|
'onDelete',
|
|
31
47
|
]);
|
|
48
|
+
/** Разбивает путь по точкам и проверяет допустимость каждого имени.
|
|
49
|
+
* @example pathParts('profile.name') → ['profile', 'name']; pathParts('a..b') → ошибка.
|
|
50
|
+
*/
|
|
32
51
|
export const pathParts = (path) => {
|
|
33
52
|
const parts = path.split('.');
|
|
34
53
|
if (parts.some((p) => !NAME.test(p) || !isSafeKey(p)))
|
|
35
|
-
throw
|
|
54
|
+
throw domainError('INVALID_INPUT', `Invalid field path: ${path}`);
|
|
36
55
|
return parts;
|
|
37
56
|
};
|
|
57
|
+
/** Находит самый длинный префикс пути среди ключей объекта, учитывая границы сегментов.
|
|
58
|
+
* @example bindingFor({ a: {}, 'a.b': {} }, 'a.b.id') → 'a.b'; bindingFor({ a: {} }, 'abc.id') → undefined.
|
|
59
|
+
*/
|
|
60
|
+
export function bindingFor(bindings, path) {
|
|
61
|
+
let nearest;
|
|
62
|
+
for (const prefix of Object.keys(bindings))
|
|
63
|
+
if ((path === prefix || path.startsWith(`${prefix}.`)) && (!nearest || prefix.length > nearest.length))
|
|
64
|
+
nearest = prefix;
|
|
65
|
+
return nearest;
|
|
66
|
+
}
|
|
67
|
+
/** Проверяет, что все сегменты пути объявлены и доступны для записи, включая родительские объекты.
|
|
68
|
+
* @example Для поля name без ограничений canWriteKey(entity, 'name') → true; для первичного ключа → false.
|
|
69
|
+
*/
|
|
70
|
+
export function canWriteKey(entity, path) {
|
|
71
|
+
const parts = pathParts(path);
|
|
72
|
+
return parts.every((_, index) => {
|
|
73
|
+
const field = entity.fields[parts.slice(0, index + 1).join('.')];
|
|
74
|
+
return field && !field.primary && !field.generated && !field.readOnly;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Распознаёт обратную связь: источник совпадает с первичным ключом, а целевой путь отличается от первичного ключа цели.
|
|
78
|
+
* @example При source = 'id', target = 'authorId' и первичных ключах id → true.
|
|
79
|
+
*/
|
|
80
|
+
export function isReverseRelation(entity, node) {
|
|
81
|
+
return node.source === entity.primary || (!canWriteKey(entity, node.source) && canWriteKey(node.relation, node.target));
|
|
82
|
+
}
|
|
83
|
+
/** Собирает значения по вложенному пути, проходя через массивы; отсутствующие значения и null пропускает.
|
|
84
|
+
* @example readPath({ rows: [{ id: 1 }, { id: 2 }] }, 'rows.id') → [1, 2].
|
|
85
|
+
*/
|
|
38
86
|
export const readPath = (value, path) => {
|
|
39
87
|
const parts = typeof path === 'string' ? pathParts(path) : path;
|
|
40
88
|
if (!parts.length)
|
|
@@ -43,8 +91,22 @@ export const readPath = (value, path) => {
|
|
|
43
91
|
return value.flatMap((v) => readPath(v, parts));
|
|
44
92
|
return isObject(value) && Object.hasOwn(value, parts[0]) ? readPath(value[parts[0]], parts.slice(1)) : [];
|
|
45
93
|
};
|
|
94
|
+
/** Возвращает последний сегмент пути поля.
|
|
95
|
+
* @example childName({ path: 'profile.name', … }) → 'name'.
|
|
96
|
+
*/
|
|
46
97
|
export const childName = (node) => node.path.split('.').at(-1);
|
|
98
|
+
/** Для связи возвращает целевую сущность и её корень; обычный узел сохраняет без изменений.
|
|
99
|
+
* @example canonicalNode(author, { relation: book }) → [book, book.root].
|
|
100
|
+
* @example canonicalNode(author, author.root) → [author, author.root].
|
|
101
|
+
*/
|
|
102
|
+
export const canonicalNode = (entity, node) => (node.relation ? [node.relation, node.relation.root] : [entity, node]);
|
|
103
|
+
/** Соединяет имя сущности и путь поля, заменяя точки подчёркиваниями.
|
|
104
|
+
* @example nodeName({ name: 'User', … }, { path: 'profile.name', … }) → 'User_profile_name'.
|
|
105
|
+
*/
|
|
47
106
|
export const nodeName = (entity, node) => (node.path ? `${entity.name}_${node.path.replaceAll('.', '_')}` : entity.name);
|
|
107
|
+
/** Переводит первую букву имени сущности в нижний регистр.
|
|
108
|
+
* @example operationName({ name: 'UserProfile', … }) → 'userProfile'.
|
|
109
|
+
*/
|
|
48
110
|
export const operationName = (entity) => entity.name[0].toLowerCase() + entity.name.slice(1);
|
|
49
111
|
const newNode = (path, field) => ({ ...field, path, base: field.type.replace(/\[\]$/, ''), many: field.type.endsWith('[]'), children: Object.create(null) });
|
|
50
112
|
function addField(entity, path, field, implicit = false) {
|
|
@@ -63,11 +125,30 @@ function addField(entity, path, field, implicit = false) {
|
|
|
63
125
|
const key = parts.at(-1);
|
|
64
126
|
const node = newNode(path, field);
|
|
65
127
|
node.implicit = implicit;
|
|
66
|
-
node.children = parent.children[key]?.children ??
|
|
128
|
+
node.children = parent.children[key]?.children ?? Object.create(null);
|
|
67
129
|
parent.children[key] = node;
|
|
68
130
|
entity.fields[path] = node;
|
|
69
131
|
return node;
|
|
70
132
|
}
|
|
133
|
+
function systemFields(entity, options) {
|
|
134
|
+
const names = [
|
|
135
|
+
...(entity.timestamps ? ['createdAt', 'updatedAt'] : []),
|
|
136
|
+
DELETION_META,
|
|
137
|
+
...(entity.softDelete ? ['deletedAt'] : []),
|
|
138
|
+
...(options.auth ? ['createdById', 'updatedById', ...(entity.softDelete ? ['deletedById'] : [])] : []),
|
|
139
|
+
];
|
|
140
|
+
entity.root.softDelete = entity.softDelete;
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (entity.fields[name])
|
|
143
|
+
throw new Error(`Reserved system field ${entity.name}.${name}`);
|
|
144
|
+
const node = addField(entity, name, { type: 'string', readOnly: true, nullable: true, ...(name.endsWith('At') ? { format: 'date-time' } : {}) });
|
|
145
|
+
node.system = true;
|
|
146
|
+
if (name === DELETION_META) {
|
|
147
|
+
node.internal = true;
|
|
148
|
+
node.writeOnly = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
71
152
|
function checkField(path, field) {
|
|
72
153
|
if (!isObject(field))
|
|
73
154
|
throw new Error(`Field ${path} must be an object`);
|
|
@@ -103,6 +184,8 @@ function checkField(path, field) {
|
|
|
103
184
|
if (field.format !== undefined && !['date', 'date-time', 'email', 'uri', 'uuid'].includes(String(field.format)))
|
|
104
185
|
throw new Error(`${path}: unknown format`);
|
|
105
186
|
const base = field.type.replace(/\[\]$/, '');
|
|
187
|
+
if (base === 'object' && field.enum !== undefined)
|
|
188
|
+
throw new Error(`${path}: enum is supported only for primitive fields`);
|
|
106
189
|
if (['minLength', 'maxLength', 'pattern', 'format'].some((k) => field[k] !== undefined) && base !== 'string')
|
|
107
190
|
throw new Error(`${path}: string constraint on non-string field`);
|
|
108
191
|
if (['minimum', 'maximum'].some((k) => field[k] !== undefined) && base !== 'number')
|
|
@@ -114,29 +197,44 @@ function checkField(path, field) {
|
|
|
114
197
|
if (typeof field.minLength === 'number' && typeof field.maxLength === 'number' && field.minLength > field.maxLength)
|
|
115
198
|
throw new Error(`${path}: minLength exceeds maxLength`);
|
|
116
199
|
}
|
|
117
|
-
|
|
200
|
+
/** Загружает описание из объекта или JSON-файла, проверяет поля и сопоставляет связи.
|
|
201
|
+
* @example loadModel(undefined) → Promise<undefined>; корректное описание → Promise<Model>.
|
|
202
|
+
*/
|
|
203
|
+
export async function loadModel(source, settings = {}) {
|
|
118
204
|
if (source === undefined)
|
|
119
205
|
return undefined;
|
|
120
|
-
const schema = typeof source === 'string' ? JSON.parse(await readFile(source, 'utf8')) : source;
|
|
206
|
+
const schema = typeof source === 'string' ? JSON.parse(await readFile(source, 'utf8')) : structuredClone(source);
|
|
207
|
+
const ajv = createValidator();
|
|
121
208
|
if (!isObject(schema) || !Object.keys(schema).length)
|
|
122
209
|
throw new Error('Model schema must be a nonempty object');
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
210
|
+
assertKnownKeys(schema, new Set(['models', 'api', 'timestamps', 'softDelete']), 'schema');
|
|
211
|
+
if (!isObject(schema.models) || !Object.keys(schema.models).length)
|
|
212
|
+
throw new Error('schema.models must be a nonempty object');
|
|
213
|
+
const options = recordOptions({ auth: settings.auth, timestamps: schema.timestamps, softDelete: schema.softDelete });
|
|
214
|
+
const defaults = apiFormats(schema.api, apiFormats(settings.api, ['openapi', 'graphql'], 'defaults'), 'schema');
|
|
215
|
+
const model = { entities: [], byName: new Map(), byCollection: new Map(), explicit: true, options };
|
|
216
|
+
for (const [name, definition] of Object.entries(schema.models)) {
|
|
127
217
|
if (!NAME.test(name) || !isSafeKey(name) || PRIMITIVES.has(name) || !isObject(definition))
|
|
128
218
|
throw new Error(`Invalid model ${name}`);
|
|
129
|
-
assertKnownKeys(definition, new Set(['collection', 'api', 'fields']), name);
|
|
219
|
+
assertKnownKeys(definition, new Set(['collection', 'api', 'fields', 'timestamps', 'softDelete']), name);
|
|
130
220
|
if (typeof definition.collection !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(definition.collection) || !isSafeKey(definition.collection))
|
|
131
221
|
throw new Error(`Invalid collection for ${name}`);
|
|
132
222
|
if (model.byCollection.has(definition.collection))
|
|
133
223
|
throw new Error(`Duplicate collection ${definition.collection}`);
|
|
134
|
-
const api = definition.api
|
|
135
|
-
if (!Array.isArray(api) || api.some((v) => !['openapi', 'graphql'].includes(v)) || new Set(api).size !== api.length)
|
|
136
|
-
throw new Error(`Invalid api for ${name}`);
|
|
224
|
+
const api = apiFormats(definition.api, defaults, name);
|
|
137
225
|
if (!isObject(definition.fields))
|
|
138
226
|
throw new Error(`${name}.fields must be an object`);
|
|
139
|
-
const
|
|
227
|
+
const flags = recordOptions({ timestamps: definition.timestamps, softDelete: definition.softDelete });
|
|
228
|
+
const entity = {
|
|
229
|
+
timestamps: definition.timestamps === undefined ? options.timestamps : flags.timestamps,
|
|
230
|
+
softDelete: definition.softDelete === undefined ? options.softDelete : flags.softDelete,
|
|
231
|
+
name,
|
|
232
|
+
collection: definition.collection,
|
|
233
|
+
api,
|
|
234
|
+
primary: '',
|
|
235
|
+
fields: Object.create(null),
|
|
236
|
+
root: newNode('', { type: 'object' }),
|
|
237
|
+
};
|
|
140
238
|
for (const [path, field] of Object.entries(definition.fields).sort(([a], [b]) => a.split('.').length - b.split('.').length)) {
|
|
141
239
|
checkField(path, field);
|
|
142
240
|
addField(entity, path, field);
|
|
@@ -149,6 +247,8 @@ export async function loadModel(source) {
|
|
|
149
247
|
}
|
|
150
248
|
if (!entity.primary)
|
|
151
249
|
throw new Error(`${name}: primary key is required`);
|
|
250
|
+
systemFields(entity, options);
|
|
251
|
+
entityValidators.set(entity, ajv);
|
|
152
252
|
model.entities.push(entity);
|
|
153
253
|
model.byName.set(name, entity);
|
|
154
254
|
model.byCollection.set(entity.collection, entity);
|
|
@@ -200,18 +300,47 @@ export async function loadModel(source) {
|
|
|
200
300
|
if (node.relation) {
|
|
201
301
|
const sourceField = entity.fields[node.source];
|
|
202
302
|
const targetField = node.relation.fields[node.target];
|
|
303
|
+
if (!sourceField.primary)
|
|
304
|
+
sourceField.relationKey = true;
|
|
305
|
+
if (!targetField.primary)
|
|
306
|
+
targetField.relationKey = true;
|
|
203
307
|
if (sourceField.relation || targetField.relation || !['string', 'number'].includes(sourceField.base) || sourceField.base !== targetField.base)
|
|
204
308
|
throw new Error(`Incompatible relation keys: ${entity.name}.${node.path}`);
|
|
205
309
|
}
|
|
206
310
|
else {
|
|
207
|
-
const
|
|
311
|
+
const schema = valueSchema(node);
|
|
312
|
+
const validate = ajv.compile(schema);
|
|
208
313
|
for (const key of ['default', 'example'])
|
|
209
314
|
if (node[key] !== undefined && !validate(structuredClone(node[key])))
|
|
210
315
|
throw new Error(`Invalid ${key}: ${entity.name}.${node.path}`);
|
|
316
|
+
ajv.removeSchema(schema);
|
|
211
317
|
}
|
|
212
318
|
}
|
|
319
|
+
for (const entity of model.entities) {
|
|
320
|
+
const check = (node, inArray = false, protectedParent = false) => {
|
|
321
|
+
if (inArray && node.readOnly && !protectedParent)
|
|
322
|
+
throw new Error(`Read-only fields inside arrays require a readOnly parent: ${entity.name}.${node.path}`);
|
|
323
|
+
for (const child of Object.values(node.children))
|
|
324
|
+
check(child, inArray || node.many, protectedParent || !!node.readOnly);
|
|
325
|
+
};
|
|
326
|
+
check(entity.root);
|
|
327
|
+
}
|
|
213
328
|
return model;
|
|
214
329
|
}
|
|
330
|
+
/** Определяет доступность поля для выбранного режима записи, учитывая защищённые дочерние поля.
|
|
331
|
+
* @example writable({ readOnly: true, … }, 'update') → false.
|
|
332
|
+
*/
|
|
333
|
+
export function writable(node, mode, relations = false) {
|
|
334
|
+
if (node.relation)
|
|
335
|
+
return relations;
|
|
336
|
+
if (node.generated || node.readOnly || (node.primary && mode !== 'create'))
|
|
337
|
+
return false;
|
|
338
|
+
const children = Object.values(node.children);
|
|
339
|
+
return node.base !== 'object' || children.length === 0 || children.some((child) => writable(child, mode, relations));
|
|
340
|
+
}
|
|
341
|
+
/** Проверяет наличие явной модели и доступность выбранного формата для её связей.
|
|
342
|
+
* @example assertApi(undefined, 'graphql') → ошибка; согласованная модель → undefined.
|
|
343
|
+
*/
|
|
215
344
|
export function assertApi(model, api) {
|
|
216
345
|
if (!model?.explicit)
|
|
217
346
|
throw new Error(`${api} requires an explicit model schema`);
|
|
@@ -220,6 +349,9 @@ export function assertApi(model, api) {
|
|
|
220
349
|
if (node.relation && !node.relation.api.includes(api))
|
|
221
350
|
throw new Error(`${entity.name}.${node.path}: ${node.relation.name} does not enable ${api}`);
|
|
222
351
|
}
|
|
352
|
+
/** Строит схему проверки значения с ограничениями, массивом и допустимостью null.
|
|
353
|
+
* @example Для строкового узла без ограничений valueSchema(node) → { type: 'string' }.
|
|
354
|
+
*/
|
|
223
355
|
export function valueSchema(node) {
|
|
224
356
|
let schema = node.base === 'object' ? objectSchema(node, 'stored') : { type: node.base };
|
|
225
357
|
for (const key of ['minLength', 'maxLength', 'pattern', 'format', 'minimum', 'maximum', 'enum'])
|
|
@@ -231,53 +363,110 @@ export function valueSchema(node) {
|
|
|
231
363
|
schema = { anyOf: [schema, { type: 'null' }] };
|
|
232
364
|
return schema;
|
|
233
365
|
}
|
|
234
|
-
|
|
366
|
+
/** Строит схему объекта для выбранного режима; при частичном обновлении корневые поля необязательны.
|
|
367
|
+
* @example Для пустого узла → { type: 'object', properties: {}, additionalProperties: false }.
|
|
368
|
+
*/
|
|
369
|
+
export function objectSchema(node, mode, root = false, relationSchema) {
|
|
235
370
|
const properties = {};
|
|
236
371
|
const required = [];
|
|
237
372
|
for (const [key, child] of Object.entries(node.children)) {
|
|
238
|
-
if (child.relation
|
|
373
|
+
if (child.relation) {
|
|
374
|
+
if (mode !== 'stored' && relationSchema)
|
|
375
|
+
properties[key] = relationSchema(child);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (mode !== 'stored' && !writable(child, mode, !!relationSchema))
|
|
239
379
|
continue;
|
|
240
380
|
properties[key] =
|
|
241
381
|
child.base === 'object'
|
|
242
382
|
? (() => {
|
|
243
|
-
let s = objectSchema(child, mode);
|
|
383
|
+
let s = objectSchema(child, mode, false, relationSchema);
|
|
244
384
|
if (child.many)
|
|
245
385
|
s = { type: 'array', items: s };
|
|
246
386
|
return child.nullable ? { anyOf: [s, { type: 'null' }] } : s;
|
|
247
387
|
})()
|
|
248
388
|
: valueSchema(child);
|
|
249
|
-
if (child.required && !(root && mode === 'update') && !(mode !== 'stored' && child.default !== undefined))
|
|
389
|
+
if (child.required && !(root && mode === 'update') && !(mode !== 'stored' && child.default !== undefined) && !(relationSchema && child.relationKey))
|
|
250
390
|
required.push(key);
|
|
251
391
|
}
|
|
392
|
+
// Сохраняем ранее записанные даты и авторов при отключении соответствующих флагов.
|
|
393
|
+
if (root && mode === 'stored')
|
|
394
|
+
for (const name of AUDIT_FIELDS)
|
|
395
|
+
properties[name] ??= { type: ['string', 'null'], ...(name.endsWith('At') ? { format: 'date-time' } : {}) };
|
|
252
396
|
return { type: 'object', properties, additionalProperties: false, ...(required.length ? { required } : {}) };
|
|
253
397
|
}
|
|
254
|
-
export
|
|
255
|
-
|
|
398
|
+
export function relationInputSchema(node, object = { type: 'object' }) {
|
|
399
|
+
const target = node.relation;
|
|
400
|
+
let schema = { anyOf: [valueSchema(target.fields[target.primary]), object] };
|
|
401
|
+
if (node.many)
|
|
402
|
+
schema = { type: 'array', items: schema };
|
|
403
|
+
else if (node.nullable)
|
|
404
|
+
schema = { anyOf: [schema, { type: 'null' }] };
|
|
405
|
+
return schema;
|
|
406
|
+
}
|
|
407
|
+
const validators = new WeakMap();
|
|
408
|
+
/** Проверяет запись по схеме выбранного режима и выбрасывает исключение при несоответствии.
|
|
409
|
+
* @example Обязательное строковое name: { name: 'Анна' } → undefined; { name: 2 } → ошибка.
|
|
410
|
+
*/
|
|
411
|
+
export function validateRecord(entity, value, mode, relations = false) {
|
|
412
|
+
const ajv = entityValidators.get(entity);
|
|
256
413
|
let cache = validators.get(entity);
|
|
257
414
|
if (!cache) {
|
|
258
415
|
cache = new Map();
|
|
259
416
|
validators.set(entity, cache);
|
|
260
417
|
}
|
|
261
|
-
|
|
418
|
+
const key = `${mode}:${relations}`;
|
|
419
|
+
let validate = cache.get(key);
|
|
262
420
|
if (!validate) {
|
|
263
|
-
validate = ajv.compile(objectSchema(entity.root, mode, true));
|
|
264
|
-
cache.set(
|
|
421
|
+
validate = ajv.compile(objectSchema(entity.root, mode, true, relations ? (node) => relationInputSchema(node) : undefined));
|
|
422
|
+
cache.set(key, validate);
|
|
265
423
|
}
|
|
266
424
|
if (!validate(value))
|
|
267
|
-
throw
|
|
425
|
+
throw domainError('INVALID_INPUT', `${entity.name}: ${ajv.errorsText(validate.errors)}`);
|
|
268
426
|
}
|
|
269
|
-
|
|
270
|
-
|
|
427
|
+
/** Выводит поля и связи из значений коллекций, не меняя переданные записи.
|
|
428
|
+
* @example inferModel({ users: [{ id: '1', name: 'Анна' }] }) → модель со строковыми id и name.
|
|
429
|
+
*/
|
|
430
|
+
export function inferModel(database, settings = {}) {
|
|
431
|
+
const options = recordOptions(settings);
|
|
432
|
+
const valueType = (value) => (isObject(value) ? 'object' : ['string', 'number', 'boolean'].includes(typeof value) ? typeof value : 'string');
|
|
433
|
+
const model = { entities: [], byName: new Map(), byCollection: new Map(), explicit: false, options };
|
|
271
434
|
for (const [collection, records] of Object.entries(database)) {
|
|
272
435
|
const name = toPascalCase(singularize(collection));
|
|
273
|
-
const entity = {
|
|
436
|
+
const entity = {
|
|
437
|
+
timestamps: options.timestamps,
|
|
438
|
+
softDelete: options.softDelete,
|
|
439
|
+
name,
|
|
440
|
+
collection,
|
|
441
|
+
api: [],
|
|
442
|
+
primary: 'id',
|
|
443
|
+
fields: Object.create(null),
|
|
444
|
+
root: newNode('', { type: 'object' }),
|
|
445
|
+
};
|
|
446
|
+
systemFields(entity, options);
|
|
447
|
+
const observed = new Map();
|
|
274
448
|
const scan = (record, prefix = '') => {
|
|
275
449
|
for (const [key, value] of Object.entries(record)) {
|
|
450
|
+
if (!NAME.test(key) || !isSafeKey(key) || (!prefix && entity.fields[key]?.system))
|
|
451
|
+
continue;
|
|
276
452
|
const path = prefix + key;
|
|
277
453
|
const sample = Array.isArray(value) ? value.find((v) => v !== null) : value;
|
|
278
|
-
const base =
|
|
279
|
-
|
|
280
|
-
|
|
454
|
+
const base = valueType(sample);
|
|
455
|
+
const type = base + (Array.isArray(value) ? '[]' : '');
|
|
456
|
+
const types = observed.get(path) ?? new Set();
|
|
457
|
+
for (const element of Array.isArray(value) ? value : [value]) {
|
|
458
|
+
if (element == null)
|
|
459
|
+
continue;
|
|
460
|
+
types.add(valueType(element) + (Array.isArray(value) ? '[]' : ''));
|
|
461
|
+
}
|
|
462
|
+
observed.set(path, types);
|
|
463
|
+
const node = entity.fields[path] ?? addField(entity, path, { type });
|
|
464
|
+
if (types.size) {
|
|
465
|
+
node.mixed = node.mixed || types.size > 1 || (Array.isArray(value) && value.some(isObject) && value.some((item) => !isObject(item)));
|
|
466
|
+
node.base = [...types].some((type) => type.startsWith('object')) ? 'object' : [...types].sort()[0].replace(/\[\]$/, '');
|
|
467
|
+
node.many = [...types].every((type) => type.endsWith('[]'));
|
|
468
|
+
node.type = node.base + (node.many ? '[]' : '');
|
|
469
|
+
}
|
|
281
470
|
if (isObject(value))
|
|
282
471
|
scan(value, `${path}.`);
|
|
283
472
|
if (Array.isArray(value))
|
|
@@ -299,6 +488,8 @@ export function inferModel(database) {
|
|
|
299
488
|
const resources = Object.keys(database);
|
|
300
489
|
for (const entity of model.entities)
|
|
301
490
|
for (const field of Object.values(entity.fields)) {
|
|
491
|
+
if (field.mixed || field.system)
|
|
492
|
+
continue;
|
|
302
493
|
const relation = getRelationMetadata(childName(field), resources, entity.collection);
|
|
303
494
|
if (!relation)
|
|
304
495
|
continue;
|