@travetto/schema 6.0.1 → 7.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ import type { RegistryAdapter } from '@travetto/registry';
2
+ import { AppError, castKey, castTo, Class, describeFunction, safeAssign } from '@travetto/runtime';
3
+
4
+ import {
5
+ SchemaClassConfig, SchemaMethodConfig, SchemaFieldConfig,
6
+ SchemaParameterConfig, SchemaInputConfig, SchemaFieldMap, SchemaCoreConfig,
7
+ CONSTRUCTOR_PROPERTY
8
+ } from './types';
9
+
10
+ const classToDiscriminatedType = (cls: Class): string => cls.name
11
+ .replace(/([A-Z])([A-Z][a-z])/g, (all, l, r) => `${l}_${r.toLowerCase()}`)
12
+ .replace(/([a-z]|\b)([A-Z])/g, (all, l, r) => l ? `${l}_${r.toLowerCase()}` : r.toLowerCase())
13
+ .toLowerCase();
14
+
15
+ function assignMetadata<T>(key: symbol, base: SchemaCoreConfig, data: Partial<T>[]): T {
16
+ const md = base.metadata ??= {};
17
+ const out = md[key] ??= {};
18
+ for (const d of data) {
19
+ safeAssign(out, d);
20
+ }
21
+ return castTo(out);
22
+ }
23
+
24
+ function combineCore<T extends SchemaCoreConfig>(base: T, config: Partial<T>): T {
25
+ return safeAssign(base, {
26
+ ...config.metadata ? { metadata: { ...base.metadata, ...config.metadata } } : {},
27
+ ...config.private ? { private: config.private ?? base.private } : {},
28
+ ...config.description ? { description: config.description || base.description } : {},
29
+ ...config.examples ? { examples: [...(base.examples ?? []), ...(config.examples ?? [])] } : {},
30
+ });
31
+ }
32
+
33
+ function combineInputs<T extends SchemaInputConfig>(base: T, configs: Partial<T>[]): T {
34
+ for (const config of configs) {
35
+ safeAssign(base, {
36
+ ...config,
37
+ ...config.aliases ? { aliases: [...base.aliases ?? [], ...config.aliases ?? []] } : {},
38
+ ...config.specifiers ? { specifiers: [...base.specifiers ?? [], ...config.specifiers ?? []] } : {},
39
+ ...config.enum ? {
40
+ enum: {
41
+ message: config.enum?.message ?? base.enum?.message,
42
+ values: (config.enum?.values ?? base.enum?.values ?? []).toSorted()
43
+ }
44
+ } : {},
45
+ });
46
+ combineCore(base, config);
47
+ }
48
+ return base;
49
+ }
50
+
51
+ function combineMethods<T extends SchemaMethodConfig>(base: T, configs: Partial<T>[]): T {
52
+ for (const config of configs) {
53
+ safeAssign(base, {
54
+ ...config,
55
+ parameters: config.parameters ?? base.parameters,
56
+ validators: [...base.validators, ...(config.validators ?? [])],
57
+ });
58
+ combineCore(base, config);
59
+ if (config.parameters) {
60
+ for (const param of config.parameters) {
61
+ safeAssign(base.parameters[param.index], param);
62
+ }
63
+ }
64
+ }
65
+ return base;
66
+ }
67
+
68
+ function getConstructorConfig<T extends SchemaClassConfig>(base: Partial<T>, parent?: Partial<T>): SchemaMethodConfig {
69
+ const parentCons = parent?.methods?.[CONSTRUCTOR_PROPERTY];
70
+ const baseCons = base.methods?.[CONSTRUCTOR_PROPERTY];
71
+ return {
72
+ parameters: [],
73
+ validators: [],
74
+ ...parentCons,
75
+ ...baseCons,
76
+ returnType: { type: base.class! }
77
+ };
78
+ }
79
+
80
+ function combineClassWithParent<T extends SchemaClassConfig>(base: T, parent: T): T {
81
+ safeAssign(base, {
82
+ ...base.views ? { views: { ...parent.views, ...base.views } } : {},
83
+ ...base.validators ? { validators: [...parent.validators, ...base.validators] } : {},
84
+ ...base.metadata ? { metadata: { ...parent.metadata, ...base.metadata } } : {},
85
+ interfaces: [...parent.interfaces, ...base.interfaces],
86
+ methods: { ...parent.methods, ...base.methods },
87
+ description: base.description || parent.description,
88
+ examples: [...(parent.examples ?? []), ...(base.examples ?? [])],
89
+ discriminatedField: base.discriminatedField ?? parent.discriminatedField,
90
+ });
91
+ switch (base.mappedOperation) {
92
+ case 'Required':
93
+ case 'Partial': {
94
+ base.fields = Object.fromEntries(
95
+ Object.entries(parent.fields).map(([k, v]) => [k, {
96
+ ...v,
97
+ required: {
98
+ active: base.mappedOperation === 'Required'
99
+ }
100
+ }])
101
+ );
102
+ break;
103
+ }
104
+ case 'Pick':
105
+ case 'Omit': {
106
+ const keys = new Set<string>(base.mappedFields ?? []);
107
+ base.fields = Object.fromEntries(
108
+ Object.entries(parent.fields).filter(([k]) =>
109
+ base.mappedOperation === 'Pick' ? keys.has(k) : !keys.has(k)
110
+ )
111
+ );
112
+ break;
113
+ }
114
+ default: {
115
+ base.fields = { ...parent.fields, ...base.fields };
116
+ }
117
+ }
118
+ return base;
119
+ }
120
+
121
+ function combineClasses<T extends SchemaClassConfig>(base: T, configs: Partial<T>[]): T {
122
+ for (const config of configs) {
123
+ Object.assign(base, {
124
+ ...config,
125
+ ...config.views ? { views: { ...base.views, ...config.views } } : {},
126
+ ...config.validators ? { validators: [...base.validators, ...config.validators] } : {},
127
+ interfaces: [...base.interfaces, ...(config.interfaces ?? [])],
128
+ methods: { ...base.methods, ...config.methods },
129
+ fields: { ...base.fields, ...config.fields },
130
+ });
131
+ combineCore(base, config);
132
+ }
133
+ return base;
134
+ }
135
+
136
+ export class SchemaRegistryAdapter implements RegistryAdapter<SchemaClassConfig> {
137
+
138
+ #cls: Class;
139
+ #config: SchemaClassConfig;
140
+ #views: Map<string, SchemaFieldMap> = new Map();
141
+ #accessorDescriptors: Map<string, PropertyDescriptor> = new Map();
142
+
143
+ constructor(cls: Class) {
144
+ this.#cls = cls;
145
+ }
146
+
147
+ register(...data: Partial<SchemaClassConfig>[]): SchemaClassConfig {
148
+ const cfg = this.#config ??= {
149
+ methods: {},
150
+ class: this.#cls,
151
+ views: {},
152
+ validators: [],
153
+ interfaces: [],
154
+ fields: {},
155
+ };
156
+ return combineClasses(cfg, data);
157
+ }
158
+
159
+ registerMetadata<T>(key: symbol, ...data: Partial<T>[]): T {
160
+ const cfg = this.register({});
161
+ return assignMetadata(key, cfg, data);
162
+ }
163
+
164
+ getMetadata<T>(key: symbol): T | undefined {
165
+ const md = this.#config?.metadata;
166
+ return castTo<T>(md?.[key]);
167
+ }
168
+
169
+ registerField(field: string | symbol, ...data: Partial<SchemaFieldConfig>[]): SchemaFieldConfig {
170
+ const config = this.register({});
171
+ const cfg = config.fields[field] ??= { name: field, owner: this.#cls, type: null! };
172
+ const combined = combineInputs(cfg, data);
173
+ return combined;
174
+ }
175
+
176
+ registerFieldMetadata<T>(field: string | symbol, key: symbol, ...data: Partial<T>[]): T {
177
+ const cfg = this.registerField(field);
178
+ return assignMetadata(key, cfg, data);
179
+ }
180
+
181
+ getFieldMetadata<T>(field: string | symbol, key: symbol): T | undefined {
182
+ const md = this.#config?.fields[field]?.metadata;
183
+ return castTo<T>(md?.[key]);
184
+ }
185
+
186
+ registerClass({ methods, ...cfg }: Partial<SchemaClassConfig> = {}): SchemaClassConfig {
187
+ this.register({ ...cfg });
188
+ if (methods?.[CONSTRUCTOR_PROPERTY]) {
189
+ const { parameters, ...rest } = methods[CONSTRUCTOR_PROPERTY];
190
+ this.registerMethod(CONSTRUCTOR_PROPERTY, rest);
191
+ for (const param of parameters ?? []) {
192
+ this.registerParameter(CONSTRUCTOR_PROPERTY, param.index!, param);
193
+ }
194
+ }
195
+ return this.#config;
196
+ }
197
+
198
+ registerMethod(method: string | symbol, ...data: Partial<SchemaMethodConfig>[]): SchemaMethodConfig {
199
+ const config = this.register();
200
+ const cfg = config.methods[method] ??= { parameters: [], validators: [] };
201
+ return combineMethods(cfg, data);
202
+ }
203
+
204
+ registerMethodMetadata<T>(method: string | symbol, key: symbol, ...data: Partial<T>[]): T {
205
+ const cfg = this.registerMethod(method);
206
+ return assignMetadata(key, cfg, data);
207
+ }
208
+
209
+ getMethodMetadata<T>(method: string | symbol, key: symbol): T | undefined {
210
+ const md = this.#config?.methods[method]?.metadata;
211
+ return castTo<T>(md?.[key]);
212
+ }
213
+
214
+ registerParameter(method: string | symbol, idx: number, ...data: Partial<SchemaParameterConfig>[]): SchemaParameterConfig {
215
+ const params = this.registerMethod(method, {}).parameters;
216
+ const cfg = params[idx] ??= { method, index: idx, owner: this.#cls, array: false, type: null! };
217
+ return combineInputs(cfg, data);
218
+ }
219
+
220
+ registerParameterMetadata<T>(method: string | symbol, idx: number, key: symbol, ...data: Partial<T>[]): T {
221
+ const cfg = this.registerParameter(method, idx);
222
+ return assignMetadata(key, cfg, data);
223
+ }
224
+
225
+ getParameterMetadata<T>(method: string | symbol, idx: number, key: symbol): T | undefined {
226
+ const md = this.#config?.methods[method]?.parameters[idx]?.metadata;
227
+ return castTo<T>(md?.[key]);
228
+ }
229
+
230
+ finalize(parent?: SchemaClassConfig): void {
231
+ const config = this.#config;
232
+
233
+ if (parent) {
234
+ combineClassWithParent(config, parent);
235
+ }
236
+
237
+ if (config.discriminatedField && !config.discriminatedType && !describeFunction(this.#cls).abstract) {
238
+ config.discriminatedType = classToDiscriminatedType(this.#cls);
239
+ }
240
+
241
+ if (config.discriminatedField && config.discriminatedType) {
242
+ config.fields[config.discriminatedField] = {
243
+ ...config.fields[config.discriminatedField], // Make a full copy
244
+ required: {
245
+ active: false
246
+ },
247
+ enum: {
248
+ values: [config.discriminatedType],
249
+ message: `${config.discriminatedField} can only be '${config.discriminatedType}'`,
250
+ },
251
+ };
252
+ }
253
+
254
+ // Compute views on install
255
+ for (const view of Object.keys(config.views)) {
256
+ const fields = config.views[view];
257
+ const withoutSet = 'without' in fields ? new Set<string>(fields.without) : undefined;
258
+ const fieldList = withoutSet ?
259
+ Object.keys(config.fields).filter(x => !withoutSet.has(x)) :
260
+ ('with' in fields ? fields.with : []);
261
+
262
+ this.#views.set(view,
263
+ fieldList.reduce<SchemaFieldMap>((acc, v) => {
264
+ acc[v] = config.fields[v];
265
+ return acc;
266
+ }, {})
267
+ );
268
+ }
269
+
270
+ config.methods[CONSTRUCTOR_PROPERTY] = getConstructorConfig(config, parent);
271
+
272
+ for (const method of Object.values(config.methods)) {
273
+ method.parameters = method.parameters.toSorted((a, b) => (a.index! - b.index!));
274
+ }
275
+ }
276
+
277
+ get(): SchemaClassConfig {
278
+ return this.#config;
279
+ }
280
+
281
+ getField(field: string | symbol): SchemaFieldConfig {
282
+ return this.#config.fields[field];
283
+ }
284
+
285
+ getMethod(method: string | symbol): SchemaMethodConfig {
286
+ const res = this.#config.methods[method];
287
+ if (!res) {
288
+ throw new AppError(`Unknown method ${String(method)} on class ${this.#cls.Ⲑid}`);
289
+ }
290
+ return res;
291
+ }
292
+
293
+ getMethodReturnType(method: string | symbol): Class {
294
+ return this.getMethod(method).returnType!.type;
295
+ }
296
+
297
+ getFields(view?: string): SchemaFieldMap {
298
+ if (!view) {
299
+ return this.#config.fields;
300
+ }
301
+ if (!this.#views.has(view)) {
302
+ throw new AppError(`Unknown view ${view} for class ${this.#cls.Ⲑid}`);
303
+ }
304
+ return this.#views.get(view)!;
305
+ }
306
+
307
+ /**
308
+ * Provides the prototype-derived descriptor for a property
309
+ */
310
+ getAccessorDescriptor(field: string): PropertyDescriptor {
311
+ if (!this.#accessorDescriptors.has(field)) {
312
+ let proto = this.#cls.prototype;
313
+ while (proto && !Object.hasOwn(proto, field)) {
314
+ proto = proto.prototype;
315
+ }
316
+ this.#accessorDescriptors.set(field, Object.getOwnPropertyDescriptor(proto, field)!);
317
+ }
318
+ return this.#accessorDescriptors.get(field)!;
319
+ }
320
+
321
+ /**
322
+ * Ensure type is set properly
323
+ */
324
+ ensureInstanceTypeField<T>(o: T): T {
325
+ const config = this.getDiscriminatedConfig();
326
+ if (config) {
327
+ const typeField = castKey<T>(config.discriminatedField);
328
+ o[typeField] ??= castTo(config.discriminatedType); // Assign if missing
329
+ }
330
+ return o;
331
+ }
332
+
333
+ getDiscriminatedConfig(): Required<Pick<SchemaClassConfig, 'discriminatedType' | 'discriminatedField' | 'discriminatedBase'>> | undefined {
334
+ const { discriminatedField, discriminatedType, discriminatedBase } = this.#config;
335
+ if (discriminatedType && discriminatedField) {
336
+ return { discriminatedType, discriminatedField, discriminatedBase: !!discriminatedBase };
337
+ }
338
+ return undefined;
339
+ }
340
+ }
@@ -0,0 +1,230 @@
1
+ import { ChangeEvent, RegistrationMethods, RegistryIndex, RegistryIndexStore, Registry } from '@travetto/registry';
2
+ import { AppError, castKey, castTo, Class, classConstruct, getParentClass, Util } from '@travetto/runtime';
3
+
4
+ import { SchemaFieldConfig, SchemaClassConfig } from './types.ts';
5
+ import { SchemaRegistryAdapter } from './registry-adapter.ts';
6
+ import { SchemaChangeListener } from './changes.ts';
7
+
8
+ /**
9
+ * Schema registry index for managing schema configurations across classes
10
+ */
11
+ export class SchemaRegistryIndex implements RegistryIndex {
12
+
13
+ static #instance = Registry.registerIndex(SchemaRegistryIndex);
14
+
15
+ static getForRegister(cls: Class, allowFinalized = false): SchemaRegistryAdapter {
16
+ return this.#instance.store.getForRegister(cls, allowFinalized);
17
+ }
18
+
19
+ static getConfig(cls: Class): SchemaClassConfig {
20
+ return this.#instance.store.get(cls).get();
21
+ }
22
+
23
+ static getDiscriminatedConfig<T>(cls: Class<T>): Required<Pick<SchemaClassConfig, 'discriminatedType' | 'discriminatedField' | 'discriminatedBase'>> | undefined {
24
+ return this.#instance.store.get(cls).getDiscriminatedConfig();
25
+ }
26
+
27
+ static has(cls: Class): boolean {
28
+ return this.#instance.store.has(cls);
29
+ }
30
+
31
+ static getClassById(classId: string): Class {
32
+ return this.#instance.store.getClassById(classId);
33
+ }
34
+
35
+ static getDiscriminatedTypes(cls: Class): string[] | undefined {
36
+ return this.#instance.getDiscriminatedTypes(cls);
37
+ }
38
+
39
+ static resolveInstanceType<T>(cls: Class<T>, o: T): Class {
40
+ return this.#instance.resolveInstanceType(cls, o);
41
+ }
42
+
43
+ static visitFields<T>(cls: Class<T>, onField: (field: SchemaFieldConfig, path: SchemaFieldConfig[]) => void): void {
44
+ return this.#instance.visitFields(cls, onField);
45
+ }
46
+
47
+ static getDiscriminatedClasses(cls: Class): Class[] {
48
+ return this.#instance.getDiscriminatedClasses(cls);
49
+ }
50
+
51
+ static getBaseClass(cls: Class): Class {
52
+ return this.#instance.getBaseClass(cls);
53
+ }
54
+
55
+ static get(cls: Class): Omit<SchemaRegistryAdapter, RegistrationMethods> {
56
+ return this.#instance.store.get(cls);
57
+ }
58
+
59
+ static getOptional(cls: Class): Omit<SchemaRegistryAdapter, RegistrationMethods> | undefined {
60
+ return this.#instance.store.getOptional(cls);
61
+ }
62
+
63
+ static getClasses(): Class[] {
64
+ return this.#instance.store.getClasses();
65
+ }
66
+
67
+ store = new RegistryIndexStore(SchemaRegistryAdapter);
68
+ #baseSchema = new Map<Class, Class>();
69
+ #byDiscriminatedTypes = new Map<Class, Map<string, Class>>();
70
+
71
+ /**
72
+ * Register discriminated types for a class
73
+ */
74
+ #registerDiscriminatedTypes(cls: Class): void {
75
+ // Mark as subtype
76
+ const config = this.getClassConfig(cls);
77
+ if (!config.discriminatedType) {
78
+ return;
79
+ }
80
+ const base = this.getBaseClass(cls);
81
+ if (!this.#byDiscriminatedTypes.has(base)) {
82
+ this.#byDiscriminatedTypes.set(base, new Map());
83
+ }
84
+ this.#byDiscriminatedTypes.get(base)!.set(config.discriminatedType, cls);
85
+ }
86
+
87
+ #onChanged(event: ChangeEvent<Class> & { type: 'changed' }): void {
88
+ Util.queueMacroTask().then(() => {
89
+ SchemaChangeListener.emitFieldChanges({
90
+ type: 'changed',
91
+ curr: this.getClassConfig(event.curr),
92
+ prev: this.getClassConfig(event.prev)
93
+ });
94
+ });
95
+ }
96
+
97
+ #onRemoving(event: ChangeEvent<Class> & { type: 'removing' }): void {
98
+ SchemaChangeListener.clearSchemaDependency(event.prev);
99
+ }
100
+
101
+ #onAdded(event: ChangeEvent<Class> & { type: 'added' }): void {
102
+ Util.queueMacroTask().then(() => {
103
+ SchemaChangeListener.emitFieldChanges({
104
+ type: 'added',
105
+ curr: this.getClassConfig(event.curr)
106
+ });
107
+ });
108
+ }
109
+
110
+ process(events: ChangeEvent<Class>[]): void {
111
+ for (const event of events) {
112
+ if (event.type === 'changed') {
113
+ this.#onChanged(event);
114
+ } else if (event.type === 'removing') {
115
+ this.#onRemoving(event);
116
+ } else if (event.type === 'added') {
117
+ this.#onAdded(event);
118
+ }
119
+ }
120
+
121
+ // Rebuild indices after every "process" batch
122
+ this.#byDiscriminatedTypes.clear();
123
+ for (const el of this.store.getClasses()) {
124
+ this.#registerDiscriminatedTypes(el);
125
+ }
126
+ }
127
+
128
+ getClassConfig(cls: Class): SchemaClassConfig {
129
+ return this.store.get(cls).get();
130
+ }
131
+
132
+ /**
133
+ * Find base schema class for a given class
134
+ */
135
+ getBaseClass(cls: Class): Class {
136
+ if (!this.#baseSchema.has(cls)) {
137
+ let conf = this.getClassConfig(cls);
138
+ let parent: Class | undefined = cls;
139
+ while (parent && conf.discriminatedType && !conf.discriminatedBase) {
140
+ parent = getParentClass(parent);
141
+ if (parent) {
142
+ conf = this.store.getOptional(parent)?.get() ?? conf;
143
+ }
144
+ }
145
+ this.#baseSchema.set(cls, conf.class);
146
+ }
147
+ return this.#baseSchema.get(cls)!;
148
+ }
149
+
150
+ /**
151
+ * Find the resolved type for a given instance
152
+ * @param cls Class for instance
153
+ * @param o Actual instance
154
+ */
155
+ resolveInstanceType<T>(cls: Class<T>, o: T): Class {
156
+ const { discriminatedField, discriminatedType, class: targetClass } = this.store.get(cls).get();
157
+ if (!discriminatedField) {
158
+ return targetClass;
159
+ } else {
160
+ const base = this.getBaseClass(targetClass);
161
+ const map = this.#byDiscriminatedTypes.get(base);
162
+ const type = castTo<string>(o[castKey<T>(discriminatedField)]) ?? discriminatedType;
163
+ if (!type) {
164
+ throw new AppError(`Unable to resolve discriminated type for class ${base.name} without a type`);
165
+ }
166
+ if (!map?.has(type)) {
167
+ throw new AppError(`Unable to resolve discriminated type '${type}' for class ${base.name}`);
168
+ }
169
+ const requested = map.get(type)!;
170
+ if (!(classConstruct(requested) instanceof targetClass)) {
171
+ throw new AppError(`Resolved discriminated type '${type}' for class ${base.name} is not an instance of requested type ${targetClass.name}`);
172
+ }
173
+ return requested;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Track changes to schemas, and track the dependent changes
179
+ * @param cls The root class of the hierarchy
180
+ * @param curr The new class
181
+ * @param path The path within the object hierarchy
182
+ */
183
+ trackSchemaDependencies(cls: Class, curr: Class = cls, path: SchemaFieldConfig[] = []): void {
184
+ const config = this.getClassConfig(curr);
185
+
186
+ SchemaChangeListener.trackSchemaDependency(curr, cls, path, this.getClassConfig(cls));
187
+
188
+ // Read children
189
+ for (const field of Object.values(config.fields)) {
190
+ if (SchemaRegistryIndex.has(field.type) && field.type !== cls) {
191
+ this.trackSchemaDependencies(cls, field.type, [...path, field]);
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Visit fields recursively
198
+ */
199
+ visitFields<T>(cls: Class<T>, onField: (field: SchemaFieldConfig, path: SchemaFieldConfig[]) => void, _path: SchemaFieldConfig[] = [], root = cls): void {
200
+ const fields = SchemaRegistryIndex.has(cls) ?
201
+ Object.values(this.getClassConfig(cls).fields) :
202
+ [];
203
+ for (const field of fields) {
204
+ if (SchemaRegistryIndex.has(field.type)) {
205
+ this.visitFields(field.type, onField, [..._path, field], root);
206
+ } else {
207
+ onField(field, _path);
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Return all subtypes by discriminator for a given class
214
+ * @param cls The base class to resolve from
215
+ */
216
+ getDiscriminatedClasses(cls: Class): Class[] {
217
+ return [...this.#byDiscriminatedTypes.get(cls)?.values() ?? []];
218
+ }
219
+
220
+ /**
221
+ * Get all discriminated types for a given class
222
+ */
223
+ getDiscriminatedTypes(cls: Class): string[] | undefined {
224
+ const map = this.#byDiscriminatedTypes.get(cls);
225
+ if (map) {
226
+ return [...map.keys()];
227
+ }
228
+ return undefined;
229
+ }
230
+ }