@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.
@@ -1,501 +0,0 @@
1
- import { Class, AppError, describeFunction, castTo, classConstruct, asFull, castKey } from '@travetto/runtime';
2
- import { MetadataRegistry, RootRegistry, ChangeEvent } from '@travetto/registry';
3
-
4
- import { ClassList, FieldConfig, ClassConfig, SchemaConfig, ViewFieldsConfig, ViewConfig, SchemaMethodConfig } from './types.ts';
5
- import { SchemaChangeListener } from './changes.ts';
6
- import { MethodValidatorFn } from '../validate/types.ts';
7
-
8
- const classToSubTypeName = (cls: Class): string => cls.name
9
- .replace(/([A-Z])([A-Z][a-z])/g, (all, l, r) => `${l}_${r.toLowerCase()}`)
10
- .replace(/([a-z]|\b)([A-Z])/g, (all, l, r) => l ? `${l}_${r.toLowerCase()}` : r.toLowerCase())
11
- .toLowerCase();
12
-
13
- /**
14
- * Schema registry for listening to changes
15
- */
16
- class $SchemaRegistry extends MetadataRegistry<ClassConfig, FieldConfig> {
17
-
18
- #accessorDescriptors = new Map<Class, Map<string, PropertyDescriptor>>();
19
- #subTypes = new Map<Class, Map<string, Class>>();
20
- #pendingViews = new Map<Class, Map<string, ViewFieldsConfig<unknown>>>();
21
- #baseSchema = new Map<Class, Class>();
22
-
23
- constructor() {
24
- super(RootRegistry);
25
- }
26
-
27
- /**
28
- * Find base schema class for a given class
29
- */
30
- getBaseSchema(cls: Class): Class {
31
- if (!this.#baseSchema.has(cls)) {
32
- let conf = this.get(cls) ?? this.getOrCreatePending(cls);
33
- let parent = cls;
34
-
35
- while (conf && !conf.baseType) {
36
- parent = this.getParentClass(parent)!;
37
- conf = this.get(parent) ?? this.pending.get(MetadataRegistry.id(parent));
38
- }
39
-
40
- this.#baseSchema.set(cls, conf ? parent : cls);
41
- }
42
- return this.#baseSchema.get(cls)!;
43
- }
44
-
45
- /**
46
- * Retrieve class level metadata
47
- * @param cls
48
- * @param prop
49
- * @param key
50
- * @returns
51
- */
52
- getMetadata<K>(cls: Class, key: symbol): K | undefined {
53
- const cfg = this.get(cls);
54
- return castTo(cfg.metadata?.[key]);
55
- }
56
-
57
- /**
58
- * Retrieve pending class level metadata, or create if needed
59
- * @param cls
60
- * @param prop
61
- * @param key
62
- * @returns
63
- */
64
- getOrCreatePendingMetadata<K>(cls: Class, key: symbol, value: K): K {
65
- const cfg = this.getOrCreatePending(cls);
66
- return castTo((cfg.metadata ??= {})[key] ??= value);
67
- }
68
-
69
- /**
70
- * Ensure type is set properly
71
- */
72
- ensureInstanceTypeField<T>(cls: Class, o: T): void {
73
- const schema = this.get(cls);
74
- const typeField = castKey<T>(schema.subTypeField);
75
- if (schema.subTypeName && typeField in schema.totalView.schema && !o[typeField]) { // Do we have a type field defined
76
- o[typeField] = castTo(schema.subTypeName); // Assign if missing
77
- }
78
- }
79
-
80
- /**
81
- * Provides the prototype-derived descriptor for a property
82
- */
83
- getAccessorDescriptor(cls: Class, field: string): PropertyDescriptor {
84
- if (!this.#accessorDescriptors.has(cls)) {
85
- this.#accessorDescriptors.set(cls, new Map());
86
- }
87
- const map = this.#accessorDescriptors.get(cls)!;
88
- if (!map.has(field)) {
89
- let proto = cls.prototype;
90
- while (proto && !Object.hasOwn(proto, field)) {
91
- proto = proto.prototype;
92
- }
93
- map.set(field, Object.getOwnPropertyDescriptor(proto, field)!);
94
- }
95
- return map.get(field)!;
96
- }
97
-
98
- /**
99
- * Find the resolved type for a given instance
100
- * @param cls Class for instance
101
- * @param o Actual instance
102
- */
103
- resolveInstanceType<T>(cls: Class<T>, o: T): Class {
104
- cls = this.get(cls.Ⲑid).class; // Resolve by id to handle any stale references
105
-
106
- const base = this.getBaseSchema(cls);
107
- const clsSchema = this.get(cls);
108
- const baseSchema = this.get(base);
109
-
110
- if (clsSchema.subTypeName || baseSchema.baseType) { // We have a sub type
111
- const type = castTo<string>(o[castKey<T>(baseSchema.subTypeField)]) ?? clsSchema.subTypeName ?? baseSchema.subTypeName;
112
- const subType = this.#subTypes.get(base)!.get(type)!;
113
- if (subType && !(classConstruct(subType) instanceof cls)) {
114
- throw new AppError(`Resolved class ${subType.name} is not assignable to ${cls.name}`);
115
- }
116
- return subType;
117
- } else {
118
- return cls;
119
- }
120
- }
121
-
122
- /**
123
- * Return all subtypes by discriminator for a given class
124
- * @param cls The base class to resolve from
125
- */
126
- getSubTypesForClass(cls: Class): Class[] | undefined {
127
- const res = this.#subTypes.get(cls)?.values();
128
- return res ? [...res] : undefined;
129
- }
130
-
131
- /**
132
- * Register sub types for a class
133
- * @param cls The class to register against
134
- * @param name The subtype name
135
- */
136
- registerSubTypes(cls: Class, name?: string): void {
137
- // Mark as subtype
138
- const config = (this.get(cls) ?? this.getOrCreatePending(cls));
139
- let base: Class | undefined = this.getBaseSchema(cls);
140
-
141
- if (!this.#subTypes.has(base)) {
142
- this.#subTypes.set(base, new Map());
143
- }
144
-
145
- if (base !== cls || config.baseType) {
146
- config.subTypeField = (this.get(base) ?? this.getOrCreatePending(base)).subTypeField;
147
- config.subTypeName = name ?? config.subTypeName ?? classToSubTypeName(cls);
148
- this.#subTypes.get(base)!.set(config.subTypeName!, cls);
149
- }
150
- if (base !== cls) {
151
- while (base && base.Ⲑid) {
152
- this.#subTypes.get(base)!.set(config.subTypeName!, cls);
153
- const parent = this.getParentClass(base);
154
- base = parent ? this.getBaseSchema(parent) : undefined;
155
- }
156
- }
157
- }
158
-
159
- /**
160
- * Track changes to schemas, and track the dependent changes
161
- * @param cls The root class of the hierarchy
162
- * @param curr The new class
163
- * @param path The path within the object hierarchy
164
- */
165
- trackSchemaDependencies(cls: Class, curr: Class = cls, path: FieldConfig[] = []): void {
166
- const config = this.get(curr);
167
-
168
- SchemaChangeListener.trackSchemaDependency(curr, cls, path, this.get(cls));
169
-
170
- // Read children
171
- const view = config.totalView;
172
- for (const k of view.fields) {
173
- if (this.has(view.schema[k].type) && view.schema[k].type !== cls) {
174
- this.trackSchemaDependencies(cls, view.schema[k].type, [...path, view.schema[k]]);
175
- }
176
- }
177
- }
178
-
179
- createPending(cls: Class): ClassConfig {
180
- return {
181
- class: cls,
182
- validators: [],
183
- subTypeField: 'type',
184
- baseType: describeFunction(cls)?.abstract,
185
- metadata: {},
186
- methods: {},
187
- totalView: {
188
- schema: {},
189
- fields: [],
190
- },
191
- views: {}
192
- };
193
- }
194
-
195
- /**
196
- * Get schema for a given view
197
- * @param cls The class to retrieve the schema for
198
- * @param view The view name
199
- */
200
- getViewSchema<T>(cls: Class<T>, view?: string): ViewConfig {
201
- const schema = this.get(cls)!;
202
- if (!schema) {
203
- throw new Error(`Unknown schema class ${cls.name}`);
204
- }
205
- let res = schema.totalView;
206
- if (view) {
207
- res = schema.views[view];
208
- if (!res) {
209
- throw new Error(`Unknown view ${view.toString()} for ${cls.name}`);
210
- }
211
- }
212
- return res;
213
- }
214
-
215
- /**
216
- * Get schema for a method invocation
217
- * @param cls
218
- * @param method
219
- */
220
- getMethodSchema<T>(cls: Class<T>, method: string): FieldConfig[] {
221
- return (this.get(cls)?.methods?.[method] ?? {}).fields?.filter(x => !!x).toSorted((a, b) => a.index! - b.index!) ?? [];
222
- }
223
-
224
- /**
225
- * Get method validators
226
- * @param cls
227
- * @param method
228
- */
229
- getMethodValidators<T>(cls: Class<T>, method: string): MethodValidatorFn<unknown[]>[] {
230
- return (this.get(cls)?.methods?.[method] ?? {}).validators ?? [];
231
- }
232
-
233
- /**
234
- * Register a view
235
- * @param target The target class
236
- * @param view View name
237
- * @param fields Fields to register
238
- */
239
- registerPendingView<T>(target: Class<T>, view: string, fields: ViewFieldsConfig<T>): void {
240
- if (!this.#pendingViews.has(target)) {
241
- this.#pendingViews.set(target, new Map());
242
- }
243
- const generalConfig: ViewFieldsConfig<unknown> = castTo(fields);
244
- this.#pendingViews.get(target)!.set(view, generalConfig);
245
- }
246
-
247
- /**
248
- * Register pending method, and establish a method config
249
- * @param target
250
- * @param method
251
- */
252
- registerPendingMethod(target: Class, method: string): SchemaMethodConfig {
253
- const methods = this.getOrCreatePending(target)!.methods!;
254
- return (methods[method] ??= { fields: [], validators: [] });
255
- }
256
-
257
- /**
258
- * Register a partial config for a pending method param
259
- * @param target The class to target
260
- * @param prop The method name
261
- * @param idx The param index
262
- * @param config The config to register
263
- */
264
- registerPendingParamFacet(target: Class, method: string, idx: number, config: Partial<FieldConfig>): Class {
265
- const params = this.registerPendingMethod(target, method).fields;
266
- if (config.name === '') {
267
- delete config.name;
268
- }
269
-
270
- if (config.aliases) {
271
- config.aliases = [...params[idx]?.aliases ?? [], ...config.aliases];
272
- }
273
- if (config.specifiers) {
274
- config.specifiers = [...params[idx]?.specifiers ?? [], ...config.specifiers];
275
- }
276
- if (config.enum?.values) {
277
- config.enum.values = config.enum.values.toSorted();
278
- }
279
-
280
- params[idx] = {
281
- // @ts-expect-error
282
- name: `${method}.${idx}`,
283
- ...params[idx] ?? {},
284
- owner: target,
285
- index: idx,
286
- ...config,
287
- };
288
- return target;
289
- }
290
-
291
- /**
292
- * Register a partial config for a pending field
293
- * @param target The class to target
294
- * @param prop The property name
295
- * @param config The config to register
296
- */
297
- registerPendingFieldFacet(target: Class, prop: string, config: Partial<FieldConfig>): Class {
298
- if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') {
299
- throw new AppError('Invalid property name');
300
- }
301
-
302
- const totalViewConf = this.getOrCreatePending(target).totalView!;
303
-
304
- if (!totalViewConf.schema[prop]) {
305
- totalViewConf.fields.push(prop);
306
- // Partial config while building
307
- totalViewConf.schema[prop] = asFull<FieldConfig>({});
308
- }
309
- if (config.aliases) {
310
- config.aliases = [...totalViewConf.schema[prop].aliases ?? [], ...config.aliases];
311
- }
312
- if (config.specifiers) {
313
- config.specifiers = [...totalViewConf.schema[prop].specifiers ?? [], ...config.specifiers];
314
- }
315
- if (config.enum?.values) {
316
- config.enum.values = config.enum.values.toSorted();
317
- }
318
-
319
- Object.assign(totalViewConf.schema[prop], config);
320
-
321
- return target;
322
- }
323
-
324
- /**
325
- * Register pending field configuration
326
- * @param target Target class
327
- * @param method Method name
328
- * @param idx Param index
329
- * @param type List of types
330
- * @param conf Extra config
331
- */
332
- registerPendingParamConfig(target: Class, method: string, idx: number, type: ClassList, conf?: Partial<FieldConfig>): Class {
333
- return this.registerPendingParamFacet(target, method, idx, {
334
- ...conf,
335
- array: Array.isArray(type),
336
- type: Array.isArray(type) ? type[0] : type,
337
- });
338
- }
339
-
340
- /**
341
- * Register pending field configuration
342
- * @param target Target class
343
- * @param prop Property name
344
- * @param type List of types
345
- * @param conf Extra config
346
- */
347
- registerPendingFieldConfig(target: Class, prop: string, type: ClassList, conf?: Partial<FieldConfig>): Class {
348
- const fieldConf: FieldConfig = {
349
- owner: target,
350
- name: prop,
351
- array: Array.isArray(type),
352
- type: Array.isArray(type) ? type[0] : type,
353
- ...(conf ?? {})
354
- };
355
-
356
- return this.registerPendingFieldFacet(target, prop, fieldConf);
357
- }
358
-
359
- /**
360
- * Merge two class configs
361
- * @param dest Target config
362
- * @param src Source config
363
- */
364
- mergeConfigs(dest: ClassConfig, src: Partial<ClassConfig>, inherited = false): ClassConfig {
365
- dest.totalView = {
366
- schema: { ...dest.totalView.schema, ...src.totalView?.schema },
367
- fields: [...dest.totalView.fields, ...src.totalView?.fields ?? []]
368
- };
369
- if (!inherited) {
370
- dest.baseType = src.baseType ?? dest.baseType;
371
- dest.subTypeName = src.subTypeName ?? dest.subTypeName;
372
- }
373
- dest.methods = { ...src.methods ?? {}, ...dest.methods ?? {} };
374
- dest.metadata = { ...src.metadata ?? {}, ...dest.metadata ?? {} };
375
- dest.subTypeField = src.subTypeField ?? dest.subTypeField;
376
- dest.title = src.title || dest.title;
377
- dest.validators = [...src.validators ?? [], ...dest.validators];
378
- return dest;
379
- }
380
-
381
- /**
382
- * Project all pending views into a final state
383
- * @param target The target class
384
- * @param conf The class config
385
- */
386
- finalizeViews<T>(target: Class<T>, conf: ClassConfig): ClassConfig {
387
- const totalViewConf = conf.totalView;
388
- const pending = this.#pendingViews.get(target) ?? new Map<string, ViewFieldsConfig<string>>();
389
- this.#pendingViews.delete(target);
390
-
391
- for (const [view, fields] of pending.entries()) {
392
- const withoutSet = 'without' in fields ? new Set<string>(fields.without) : undefined;
393
- const fieldList = withoutSet ?
394
- totalViewConf.fields.filter(x => !withoutSet.has(x)) :
395
- ('with' in fields ? fields.with : []);
396
-
397
- conf.views![view] = {
398
- fields: fieldList,
399
- schema: fieldList.reduce<SchemaConfig>((acc, v) => {
400
- acc[v] = totalViewConf.schema[v];
401
- return acc;
402
- }, {})
403
- };
404
- }
405
-
406
- return conf;
407
- }
408
-
409
- onInstallFinalize(cls: Class): ClassConfig {
410
-
411
- let config: ClassConfig = this.createPending(cls);
412
-
413
- // Merge parent
414
- const parent = this.getParentClass(cls);
415
- if (parent) {
416
- const parentConfig = this.get(parent);
417
- if (parentConfig) {
418
- config = this.mergeConfigs(config, parentConfig, true);
419
- }
420
- }
421
-
422
- this.registerSubTypes(cls);
423
-
424
- // Merge pending, back on top, to allow child to have higher precedence
425
- const pending = this.getOrCreatePending(cls);
426
- if (pending) {
427
- config = this.mergeConfigs(config, pending);
428
- }
429
-
430
- // Write views out
431
- config = this.finalizeViews(cls, config);
432
-
433
- if (config.subTypeName && config.subTypeField in config.totalView.schema) {
434
- const field = config.totalView.schema[config.subTypeField];
435
- config.totalView.schema[config.subTypeField] = {
436
- ...field,
437
- enum: {
438
- values: [config.subTypeName],
439
- message: `${config.subTypeField} can only be '${config.subTypeName}'`,
440
- }
441
- };
442
- }
443
-
444
- return config;
445
- }
446
-
447
- override onInstall(cls: Class, e: ChangeEvent<Class>): void {
448
- super.onInstall(cls, e);
449
-
450
- if (this.has(cls)) { // Track dependencies of schemas
451
- this.trackSchemaDependencies(cls);
452
- }
453
- }
454
-
455
- override onUninstall<T>(cls: Class<T>, e: ChangeEvent<Class>): void {
456
- super.onUninstall(cls, e);
457
- if (e.type === 'removing' && this.hasExpired(cls)) {
458
- // Recompute subtypes
459
- this.#subTypes.clear();
460
- this.#baseSchema.delete(cls);
461
- this.#accessorDescriptors.delete(cls);
462
-
463
- // Recompute subtype mappings
464
- for (const el of this.entries.keys()) {
465
- const clz = this.entries.get(el)!.class;
466
- this.registerSubTypes(clz);
467
- }
468
-
469
- SchemaChangeListener.clearSchemaDependency(cls);
470
- }
471
- }
472
-
473
- override emit(ev: ChangeEvent<Class>): void {
474
- super.emit(ev);
475
- if (ev.type === 'changed') {
476
- SchemaChangeListener.emitFieldChanges({
477
- type: 'changed',
478
- curr: this.get(ev.curr!),
479
- prev: this.getExpired(ev.curr!)
480
- });
481
- }
482
- }
483
-
484
- /**
485
- * Visit fields recursively
486
- */
487
- visitFields<T>(cls: Class<T>, onField: (field: FieldConfig, path: FieldConfig[]) => void, _path: FieldConfig[] = [], root = cls): void {
488
- const fields = this.has(cls) ?
489
- Object.values(this.getViewSchema(cls).schema) :
490
- [];
491
- for (const field of fields) {
492
- if (this.has(field.type)) {
493
- this.visitFields(field.type, onField, [..._path, field], root);
494
- } else {
495
- onField(field, _path);
496
- }
497
- }
498
- }
499
- }
500
-
501
- export const SchemaRegistry = new $SchemaRegistry();