@cepharum/concrete-db 0.1.1-alpha.1 → 0.2.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/lib/shaper.mjs CHANGED
@@ -1,38 +1,12 @@
1
- /**
2
- * (c) 2021 cepharum GmbH, Berlin, http://cepharum.de
3
- *
4
- * The MIT License (MIT)
5
- *
6
- * Copyright (c) 2021 cepharum GmbH
7
- *
8
- * Permission is hereby granted, free of charge, to any person obtaining a copy
9
- * of this software and associated documentation files (the "Software"), to deal
10
- * in the Software without restriction, including without limitation the rights
11
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
- * copies of the Software, and to permit persons to whom the Software is
13
- * furnished to do so, subject to the following conditions:
14
- *
15
- * The above copyright notice and this permission notice shall be included in all
16
- * copies or substantial portions of the Software.
17
- *
18
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
- * SOFTWARE.
25
- *
26
- * @author: cepharum
27
- */
1
+ // noinspection ExceptionCaughtLocallyJS
28
2
 
29
3
  import EventEmitter from "events";
30
4
 
31
5
  import { Compiler, Functions } from "simple-terms";
32
6
  import merge from "lodash.merge";
33
7
 
34
- import { DefaultShaperOptions } from "./defaults.mjs";
35
- import { augmentedDataSpace, deepFreeze } from "./helper.mjs";
8
+ import { DefaultShape, DefaultShaperOptions } from "./defaults.mjs";
9
+ import { augmentDataSpace } from "./helper.mjs";
36
10
  import * as TermFunctions from "./term-functions.mjs";
37
11
 
38
12
 
@@ -55,16 +29,17 @@ export class Shaper extends EventEmitter {
55
29
  constructor( options ) {
56
30
  super();
57
31
 
32
+ const compiledOptions = { ...DefaultShaperOptions, ...options };
33
+
58
34
  Object.defineProperties( this, {
59
- options: { value: { ...DefaultShaperOptions, ...options }, enumerable: true },
35
+ options: { value: compiledOptions, enumerable: true },
60
36
 
61
37
  termsCache: { value: new Map() },
62
38
 
63
- termFunctions: { value: { ...Functions, ...TermFunctions } },
64
- } );
39
+ // SECURITY: prevent custom library functions from replacing regular ones by intention
40
+ termFunctions: { value: { ...compiledOptions.library, ...Functions, ...TermFunctions } },
65
41
 
66
- Object.defineProperties( this, {
67
- prefix: { value: normalizePathname( this.options.prefix, true ) },
42
+ prefix: { value: normalizePathname( compiledOptions.prefix, true ) },
68
43
  } );
69
44
  }
70
45
 
@@ -72,13 +47,19 @@ export class Shaper extends EventEmitter {
72
47
  * Creates database from provided collection of records.
73
48
  *
74
49
  * @param {Map<string,CollectedRecord[]>} recordsCollection collection of records to transform
50
+ * @param {Shape[]} localShapes local shapes of folders records have been collected from
75
51
  * @returns {Database} resulting database records
76
52
  */
77
- transform( recordsCollection ) {
78
- const models = this.groupByModel( recordsCollection );
53
+ transform( recordsCollection, localShapes ) {
54
+ if ( recordsCollection.size > 0 && !localShapes?.length ) {
55
+ throw new Error( "invalid use of Shaper: missing list of local shapes per folder records have been collected from" );
56
+ }
57
+
58
+ const mergedLocalShapes = merge( {}, DefaultShape, ...localShapes );
59
+ const models = this.handleContributions( this.groupByModel( recordsCollection ), mergedLocalShapes );
79
60
  const endpoints = {};
80
61
 
81
- this.populateModels( endpoints, models );
62
+ this.populateModels( endpoints, models, mergedLocalShapes );
82
63
 
83
64
  return { prefix: this.prefix, endpoints };
84
65
  }
@@ -86,62 +67,587 @@ export class Shaper extends EventEmitter {
86
67
  /**
87
68
  * Reorganizes records to by grouped by model.
88
69
  *
89
- * @param {Map<string,CollectedRecord[]>} collection collection of records to transform
90
- * @returns {object<string,object<string, CollectedRecord[]>>} provided records grouped by model and unique item ID
70
+ * @param {Map<string,CollectedRecord[]>} collectedRecordsByName collection of records to transform
71
+ * @returns {Object<string,Object<string, ModelItem>>} provided records grouped by model and unique item ID
91
72
  */
92
- groupByModel( collection ) {
93
- const models = {};
94
- const modelShapes = {};
95
-
96
- for ( const records of collection.values() ) {
97
- for ( const record of records ) {
98
- const { segments, shape, data } = record;
99
- const metaProperties = {
100
- $segments: segments,
101
- };
102
- const wrapped = augmentedDataSpace( data, metaProperties );
103
-
104
- const modelName = this.detectModel( wrapped, shape );
105
- metaProperties.$model = modelName;
106
-
107
- if ( !modelShapes.hasOwnProperty( modelName ) ) {
108
- modelShapes[modelName] = deepFreeze( merge( {}, shape?.common, shape?.models?.[modelName] ) );
73
+ groupByModel( collectedRecordsByName ) {
74
+ const globalItemsPerModel = {};
75
+ let index = 0;
76
+
77
+ if ( this.options.verbose ) {
78
+ console.error( `shaping records ...` );
79
+ }
80
+
81
+ for ( const records of collectedRecordsByName.values() ) {
82
+ if ( this.options.verbose ) {
83
+ process.stderr.write( `\r[${++index}/${collectedRecordsByName.size}]` );
84
+ }
85
+
86
+ this.compileRecords( globalItemsPerModel, records );
87
+ }
88
+
89
+ return globalItemsPerModel;
90
+ }
91
+
92
+ /**
93
+ * Implements separate stage for computing contributions per model item
94
+ * based on either model's compiled shape.
95
+ *
96
+ * @param {Object<string,Object<string, CollectedRecord[]>>} globalItemsPerModel collections of items per model
97
+ * @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
98
+ * @returns {Object<string,Object<string, CollectedRecord[]>>} collections of items per model as provided
99
+ */
100
+ handleContributions( globalItemsPerModel, mergedLocalShapes ) {
101
+ const postProcessingQueue = new Set();
102
+
103
+ if ( this.options.verbose ) {
104
+ console.error( `\nprocessing contributions ...` );
105
+ }
106
+
107
+ this.processContributions( globalItemsPerModel, postProcessingQueue, mergedLocalShapes );
108
+
109
+ if ( this.options.verbose ) {
110
+ console.error( `post-processing ${postProcessingQueue.size} contributions` );
111
+ }
112
+
113
+ this.recompileContributionTargets( globalItemsPerModel, postProcessingQueue );
114
+
115
+ return globalItemsPerModel;
116
+ }
117
+
118
+ /**
119
+ * Compiles all items according to collected records as provided and collect
120
+ * them grouped by described items' models.
121
+ *
122
+ * @param {Object<string,Object<string, ModelItem>>} itemsPerModel collections of items per model to be populated
123
+ * @param {CollectedRecord[]} records list of records collected from a source
124
+ * @returns {Object<string,Object<string,ModelItem>>} compiled items grouped by model, addressable by either item's ID
125
+ */
126
+ compileRecords( itemsPerModel, records ) {
127
+ const shapesPerModelCache = {};
128
+
129
+ for ( let read = 0, numRecords = records.length; read < numRecords; read++ ) {
130
+ const item = this.compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, records[read] );
131
+ const modelPool = itemsPerModel[item.$model];
132
+
133
+ if ( item.$variantId == null ) {
134
+ modelPool[item.$id] = this.mergeModelItem( modelPool[item.$id], item );
135
+ } else {
136
+ const target = modelPool[item.$id];
137
+
138
+ if ( !target.$variants ) {
139
+ target.$variants = {};
109
140
  }
110
141
 
111
- const modelShape = modelShapes[modelName];
112
- metaProperties.$shape = modelShape;
142
+ target.$variants[item.$variantId] = this.mergeModelItem( target.$variants[item.$variantId], item );
143
+ }
144
+ }
113
145
 
114
- const id = this.identifyRecord( wrapped, modelShape );
115
- metaProperties.$id = id;
146
+ return itemsPerModel;
147
+ }
116
148
 
117
- let model = models[modelName];
118
- if ( !model ) {
119
- model = models[modelName] = {};
149
+ /**
150
+ * Compiles item of model from a provided source record. This includes
151
+ * detecting model and selected ID of resulting item from provided record
152
+ * based on record's shape.
153
+ *
154
+ * @param {Object<string,ShapeModel>} shapesPerModelCache caches per-model shapes compiled before
155
+ * @param {Object<string,Object<string,Object>>} itemsPerModel collection of previously compiled items per model
156
+ * @param {CollectedRecord} record augmented data collected from a source to be compiled
157
+ * @returns {object} instance of model with hidden meta information injected
158
+ */
159
+ compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, record ) {
160
+ const { segments, shape, data } = record;
120
161
 
121
- Object.defineProperties( model, {
122
- $shape: { value: modelShape },
123
- $model: { value: modelName },
124
- } );
162
+ const metaProperties = {
163
+ $segments: segments
164
+ };
165
+
166
+ const augmentedData = augmentDataSpace( data, metaProperties );
167
+
168
+ metaProperties.$model = this.detectModel( augmentedData, shape );
169
+ metaProperties.$shape = this.getShapeOfModel( {}, shape, metaProperties.$model );
170
+ metaProperties.$id = this.identifyRecord( augmentedData, metaProperties.$shape );
171
+
172
+ if ( !itemsPerModel.hasOwnProperty( metaProperties.$model ) ) {
173
+ // eslint-disable-next-line no-param-reassign
174
+ itemsPerModel[metaProperties.$model] = {};
175
+ }
176
+
177
+ const model = itemsPerModel[metaProperties.$model];
178
+
179
+ const { properties, defaults } = metaProperties.$shape;
180
+ const item = this.compileItem( augmentedData, properties, model[metaProperties.$id] ? {} : defaults );
181
+
182
+ Object.defineProperties( item, {
183
+ $segments: { value: segments },
184
+ $original: { value: data, configurable: true },
185
+ $shape: { value: metaProperties.$shape, configurable: true },
186
+ $model: { value: metaProperties.$model },
187
+ $id: { value: metaProperties.$id },
188
+ $variantId: { value: this.computeVariantIdOfItem( metaProperties.$shape.properties.$variant, augmentedData ) },
189
+ } );
190
+
191
+ return item;
192
+ }
193
+
194
+ /**
195
+ * Re-compiles provided item this time based on item's properties'
196
+ * themselves instead of some record originally collected from a source.
197
+ *
198
+ * Re-compiling is used to compute data contributed by other models.
199
+ *
200
+ * @param {ModelItem} item previously compiled item to re-compile
201
+ * @returns {ModelItem} re-compiled item
202
+ */
203
+ recompileItem( item ) {
204
+ const data = augmentDataSpace( merge( {}, item.$original, item ), { ...item, $post: true } );
205
+ const updatedItem = this.compileItem( data, item.$shape.properties, {} );
206
+
207
+ Object.defineProperties( updatedItem, {
208
+ $segments: { value: item.$segments },
209
+ $original: { value: item.$original, configurable: true },
210
+ $shape: { value: item.$shape, configurable: true },
211
+ $model: { value: item.$model },
212
+ $id: { value: item.$id },
213
+ } );
214
+
215
+ return updatedItem;
216
+ }
217
+
218
+ /**
219
+ * Computes ID of variant provided item is describing. If nullish, the item
220
+ * isn't describing any variant but the base version of the item.
221
+ *
222
+ * @param {string?} term term expressing computation of variant ID
223
+ * @param {ModelItem} item an item to get variant ID for
224
+ * @returns {null|any} computed variant ID of provided item, nullish if item is not a variant
225
+ */
226
+ computeVariantIdOfItem( term, item ) {
227
+ if ( term == null ) {
228
+ return null;
229
+ }
230
+
231
+ // compile data space based on item's properties overlaying the item's originating record
232
+ const data = augmentDataSpace( merge( {}, item.$original, item ), item );
233
+
234
+ try {
235
+ const value = Compiler.compile( String( term ), this.termFunctions, this.termsCache )( data );
236
+
237
+ // support processed term requesting to fail computation of variant ID
238
+ if ( value instanceof Error ) {
239
+ throw value;
240
+ }
241
+
242
+ return value;
243
+ } catch ( cause ) {
244
+ throw new Error( `computing variant ID of ${item.$model}#${item.$id} failed: ${cause.message} in ${term}` );
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Fetches compiled shape of named model using provided cache.
250
+ *
251
+ * @param {Object<string,ShapeModel>} shapesPerModelCache cache to use on fetching shape for same model multiple times
252
+ * @param {Shape} localShape shape including desired definition of selected model's shape
253
+ * @param {string} modelName name of model
254
+ * @returns {ShapeModel} compiled shape of model incorporating common definitions for all models
255
+ */
256
+ getShapeOfModel( shapesPerModelCache, localShape, modelName ) {
257
+ if ( !shapesPerModelCache.hasOwnProperty( modelName ) ) {
258
+ // eslint-disable-next-line no-param-reassign
259
+ shapesPerModelCache[modelName] = merge( { defaults: DefaultShape.common.defaults }, localShape?.common, localShape?.models?.[modelName] );
260
+ }
261
+
262
+ return shapesPerModelCache[modelName];
263
+ }
264
+
265
+ /**
266
+ * Computes dependencies of models on contributions by provided items
267
+ * grouped by model.
268
+ *
269
+ * @param {Object<string,Object<string,Object>>} itemsPerModel compiled items grouped by model
270
+ * @returns {string[]} sorted list of names of models in provided pool starting with model depending on other models' contributions the least
271
+ */
272
+ listContributingModels( itemsPerModel ) {
273
+ const contributorsPerModel = {};
274
+
275
+ // create map of immediate relations between every two models involved
276
+ // in a contribution
277
+ for ( const model of Object.keys( itemsPerModel ) ) {
278
+ for ( const item of Object.values( itemsPerModel[model] ) ) {
279
+ for ( const contributedModel of Object.keys( item.$shape.contributions || {} ) ) {
280
+ if ( !contributorsPerModel.hasOwnProperty( contributedModel ) ) {
281
+ contributorsPerModel[contributedModel] = {};
282
+ }
283
+
284
+ contributorsPerModel[contributedModel][item.$model] = 1;
125
285
  }
286
+ }
287
+ }
288
+
289
+ // surface mediate dependencies and collect all contributing models' names
290
+ const contributingModelNames = {};
291
+
292
+ for ( const surfaceModel of Object.keys( contributorsPerModel ) ) {
293
+ descend( surfaceModel, surfaceModel, [] );
294
+ }
126
295
 
127
- const isVariant = model.hasOwnProperty( id );
128
- const item = this.compileItem( wrapped, modelShape, !isVariant );
296
+ // sort names of contributing models from models depending on other
297
+ // contributing models the least to those doing it the most
298
+ const names = Object.keys( contributingModelNames );
129
299
 
130
- Object.defineProperties( item, {
131
- $shape: { value: modelShape },
132
- $model: { value: modelName },
133
- $id: { value: id },
134
- } );
300
+ names.sort( ( left, right ) => {
301
+ if ( contributorsPerModel.hasOwnProperty( left ) && contributorsPerModel[left].hasOwnProperty( right ) ) {
302
+ // left model depends on contributions by right model
303
+ return 1;
304
+ }
305
+
306
+ if ( contributorsPerModel.hasOwnProperty( right ) && contributorsPerModel[right].hasOwnProperty( left ) ) {
307
+ // right model depends on contributions by left model
308
+ return -1;
309
+ }
135
310
 
136
- if ( isVariant ) {
137
- // FIXME implement variants or overlays or whatever there is to do with multiple records for the same item
311
+ return 0;
312
+ } );
313
+
314
+ return names;
315
+
316
+ /**
317
+ * Surfaces dependencies of a named model.
318
+ *
319
+ * @param {string} surfaceModel name of model triggering current processing
320
+ * @param {string} currentModel name of currently processed model
321
+ * @param {string[]} breadcrumb names of models processed while descending into dependencies
322
+ * @returns {void}
323
+ */
324
+ function descend( surfaceModel, currentModel, breadcrumb ) {
325
+ if ( breadcrumb.indexOf( currentModel ) > -1 ) {
326
+ throw new Error( `invalid circular dependency on contributing to model ${surfaceModel}: [${breadcrumb}]` );
327
+ }
328
+
329
+ if ( contributorsPerModel.hasOwnProperty( currentModel ) ) {
330
+ const current = contributorsPerModel[currentModel];
331
+ const surface = contributorsPerModel[surfaceModel];
332
+
333
+ for ( const contributingModel of Object.keys( current ) ) {
334
+ contributingModelNames[contributingModel] = true;
335
+
336
+ if ( !surface.hasOwnProperty( contributingModel ) ) {
337
+ surface[contributingModel] = -1;
338
+ } else if ( current[contributingModel] > 0 ) {
339
+ breadcrumb.push( currentModel );
340
+ descend( surfaceModel, contributingModel, breadcrumb );
341
+ breadcrumb.pop();
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Processes contributions of models in provided collection of items
350
+ * augmenting that collection.
351
+ *
352
+ * @param {Object<string,Object<string,Object>>} itemsPerModel map of model names into either model's map of item IDs into related item
353
+ * @param {Set<string>} contributedItems tracks IDs of items or their variants that have been contributed to
354
+ * @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
355
+ * @returns {void}
356
+ */
357
+ processContributions( itemsPerModel, contributedItems, mergedLocalShapes ) {
358
+ const contributingModels = this.listContributingModels( itemsPerModel );
359
+ const shapePerModelCache = {};
360
+
361
+ for ( const modelName of contributingModels ) {
362
+ const items = itemsPerModel[modelName];
363
+
364
+ for ( const item of Object.values( items ) ) {
365
+ const { contributions } = item.$shape;
366
+ if ( !contributions ) {
367
+ continue;
368
+ }
369
+
370
+ for ( const contributedModelName of Object.keys( contributions ) ) {
371
+ const isOptionalContribution = contributedModelName.trim().endsWith( "?" );
372
+ const targetModelName = contributedModelName.replace( /\s*\?\s*$/, "" );
373
+
374
+ const targetModelShape = this.getShapeOfModel( shapePerModelCache, mergedLocalShapes, targetModelName );
375
+
376
+
377
+ // extract contributions to actually declared properties of target model
378
+ const contributionTerms = {};
379
+ const contributionModes = {};
380
+ let warn = false;
381
+ let numActualProperties = 0;
382
+
383
+ for ( const termTargetName of Object.keys( contributions[contributedModelName] ) ) {
384
+ const match = /^\s*(\S.+?)\s*(\[\])?\s*(\.\.\.|\u2026)?\s*$/u.exec( termTargetName );
385
+ const propertyName = match ? match[1] : termTargetName;
386
+
387
+ if ( match && ( match[2] || match[3] ) && propertyName.startsWith( "$" ) ) {
388
+ throw new Error( `invalid collecting or distributed contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
389
+ }
390
+
391
+ if ( propertyName === "$variant" || propertyName === "$id" || Object.prototype.hasOwnProperty.call( targetModelShape.properties, propertyName ) ) {
392
+ if ( contributionTerms.hasOwnProperty( propertyName ) ) {
393
+ throw new Error( `double contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
394
+ }
395
+
396
+ if ( !propertyName.startsWith( "$" ) ) {
397
+ numActualProperties++;
398
+ }
399
+
400
+ contributionTerms[propertyName] = contributions[contributedModelName][termTargetName];
401
+ contributionModes[propertyName] = {
402
+ $isCollecting: Boolean( match && match[2] ),
403
+ $isDistributed: Boolean( match && match[3] ),
404
+ };
405
+ } else {
406
+ warn = true;
407
+ }
408
+ }
409
+
410
+ if ( numActualProperties < 1 && !isOptionalContribution ) {
411
+ console.warn( `ignoring eventually empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
412
+ continue;
413
+ }
414
+
415
+ if ( warn ) {
416
+ console.warn( `ignoring undeclared properties in contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` ); // eslint-disable-line max-len
417
+ }
418
+
419
+
420
+ // pre-compute ID(s) of item(s) to contribute to
421
+ const contributionMeta = {
422
+ $segments: item.$segments,
423
+ $original: item.$original,
424
+ $shape: item.$shape,
425
+ $model: item.$model,
426
+ $id: item.$id,
427
+ };
428
+
429
+ const augmentedData = augmentDataSpace( item, contributionMeta );
430
+ const targetId = this.computeId( contributionTerms.$id, augmentedData );
431
+
432
+ if ( !isOptionalContribution && ( !targetId || ( Array.isArray( targetId ) && !targetId.length ) ) ) {
433
+ console.warn( `ignoring contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName} due to lack of target ID` ); // eslint-disable-line max-len
434
+ continue;
435
+ }
436
+
437
+ const targetIds = Array.isArray( targetId ) ? targetId : [targetId];
438
+
439
+
440
+ for ( let index = 0, count = targetIds.length; index < count; index++ ) {
441
+ const id = targetIds[index];
442
+
443
+ // compile contribution per target item
444
+ contributionMeta.$targetid = id;
445
+ contributionMeta.$targetindex = index;
446
+
447
+
448
+ // try computing variant ID based on contributing item
449
+ const variantId = this.computeVariantIdOfItem( contributionTerms.$variant, {
450
+ ...item,
451
+ $segments: item.$segments,
452
+ $original: item.$original,
453
+ $shape: targetModelShape,
454
+ $model: targetModelName,
455
+ $id: targetId,
456
+ } );
457
+
458
+ // track contributions to selected target item or its variant
459
+ contributedItems.add( JSON.stringify( [ targetModelName, id, variantId ] ) );
460
+
461
+
462
+ // gain access on selected target item, create its containers as necessary
463
+ let target;
464
+
465
+ if ( !itemsPerModel.hasOwnProperty( targetModelName ) ) {
466
+ // eslint-disable-next-line no-param-reassign
467
+ itemsPerModel[targetModelName] = {};
468
+ }
469
+
470
+ const model = itemsPerModel[targetModelName];
471
+ const targetItemExists = model.hasOwnProperty( id );
472
+
473
+ if ( !targetItemExists ) {
474
+ model[id] = Object.defineProperties( {}, {
475
+ $segments: { value: item.$segments },
476
+ $original: { value: {}, configurable: true },
477
+ $shape: { value: targetModelShape, configurable: true },
478
+ $model: { value: targetModelName },
479
+ $id: { value: id },
480
+ } );
481
+ }
482
+
483
+ const targetItem = model[id];
484
+
485
+ if ( variantId == null ) {
486
+ target = targetItem;
487
+ } else {
488
+ if ( !targetItem.$variants ) {
489
+ targetItem.$variants = {};
490
+ }
491
+
492
+ if ( !targetItem.$variants.hasOwnProperty( variantId ) ) {
493
+ targetItem.$variants[variantId] = {};
494
+ }
495
+
496
+ target = targetItem.$variants[variantId];
497
+ }
498
+
499
+
500
+ // compile actual properties of contribution
501
+ const contributionDefaults = targetItemExists || variantId != null ? {} : targetModelShape.defaults;
502
+ const contribution = this.compileItem( augmentedData, contributionTerms, contributionDefaults );
503
+
504
+
505
+ // merge this item's contribution with existing
506
+ // properties of selected target using reduced version
507
+ // of merge strategy found in mergeModelItem()
508
+ for ( const name of Object.keys( contribution ) ) {
509
+ if ( String( name ).startsWith( "$" ) ) {
510
+ continue;
511
+ }
512
+
513
+ const { $isCollecting, $isDistributed } = contributionModes[name] || {};
514
+ let value = contribution[name];
515
+
516
+ if ( $isDistributed ) {
517
+ // contribution is providing another value for every target item
518
+ if ( Array.isArray( value ) && value.length === targetIds.length ) { // eslint-disable-line max-depth
519
+ value = value[index];
520
+ }
521
+ }
522
+
523
+ if ( $isCollecting ) {
524
+ // contribution has been declared with one or more suffices, e.g. []
525
+ // -> collect value in target
526
+ if ( Array.isArray( target[name] ) ) { // eslint-disable-line no-lonely-if,max-depth
527
+ target[name].push( value );
528
+ } else if ( target[name] == null ) {
529
+ target[name] = [value];
530
+ } else {
531
+ target[name] = [ target[name], value ];
532
+ }
533
+ } else {
534
+ // contribution has been declared without any suffix
535
+ // -> do not collect, but replace value in target
536
+ target[name] = value;
537
+ }
538
+ }
539
+ }
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Deeply merges provided source item into given target item including their
547
+ * attached model shape and original data.
548
+ *
549
+ * @param {ModelItem} target model item to adjust by merging
550
+ * @param {ModelItem} source model item to merge into provided target
551
+ * @returns {ModelItem} provided target
552
+ */
553
+ mergeModelItem( target, source ) {
554
+ const _to = target || {};
555
+ const oldNames = target ? Object.keys( _to.$shape.properties ) : [];
556
+ const newNames = Object.keys( source.$shape.properties );
557
+
558
+ // merge properties of items
559
+ for ( const name of Object.keys( source ) ) {
560
+ if ( !String( name ).startsWith( "$" ) ) {
561
+ const wasCollecting = oldNames.some( n => n.replace( /\s*\[]$/, "" ).trim() === name );
562
+ const isCollecting = newNames.some( n => n.replace( /\s*\[]$/, "" ).trim() === name );
563
+
564
+ if ( _to.hasOwnProperty( name ) ) {
565
+ if ( isCollecting ) {
566
+ if ( wasCollecting && Array.isArray( _to[name] ) ) {
567
+ _to[name].splice( _to[name].length, 0, ...source[name] );
568
+ } else if ( _to[name] == null ) {
569
+ _to[name] = source[name];
570
+ } else {
571
+ _to[name] = [ _to[name], ...source[name] ];
572
+ }
573
+ } else {
574
+ _to[name] = source[name];
575
+ }
138
576
  } else {
139
- model[id] = item;
577
+ _to[name] = source[name];
140
578
  }
141
579
  }
142
580
  }
143
581
 
144
- return models;
582
+ // assure some freshly created target has all common meta properties
583
+ if ( !target ) {
584
+ Object.defineProperties( _to, {
585
+ $segments: { value: source.$segments },
586
+ $model: { value: source.$model },
587
+ $id: { value: source.$id },
588
+ } );
589
+ }
590
+
591
+ // merge some complex meta properties
592
+ Object.defineProperties( _to, {
593
+ $shape: {
594
+ value: merge( _to.$shape || {}, source.$shape ),
595
+ configurable: true,
596
+ },
597
+ $original: {
598
+ value: merge( _to.$original || {}, source.$original ),
599
+ configurable: true,
600
+ },
601
+ } );
602
+
603
+ return _to;
604
+ }
605
+
606
+ /**
607
+ * Re-compiles all items in provided pool that have been contributed to.
608
+ *
609
+ * @param {Object<string,Object<string,ModelItem>>} itemsPerModel pool of items per model to update
610
+ * @param {Set<string>} contributions lists IDs of items or their variants that have been contributed to
611
+ * @returns {Object<string,Object<string,ModelItem>>} pool of items per model as provided
612
+ */
613
+ recompileContributionTargets( itemsPerModel, contributions ) {
614
+ for ( const track of contributions.values() ) {
615
+ const [ model, id, variantId ] = JSON.parse( track );
616
+
617
+ if ( !itemsPerModel.hasOwnProperty( model ) ) {
618
+ console.warn( `inconsistency: missing items pool of model ${model} though contributions have been tracked` );
619
+ continue;
620
+ }
621
+
622
+ const modelPool = itemsPerModel[model];
623
+
624
+ if ( !modelPool.hasOwnProperty( id ) ) {
625
+ console.warn( `inconsistency: missing item ${id} of model ${model} though contributions have been tracked` );
626
+ continue;
627
+ }
628
+
629
+ const item = modelPool[id];
630
+
631
+ if ( variantId == null ) {
632
+ modelPool[id] = this.recompileItem( item );
633
+ } else {
634
+ if ( !item.$variants ) {
635
+ console.warn( `inconsistency: missing variants of item ${id} of model ${model} though contributions have been tracked` );
636
+ continue;
637
+ }
638
+
639
+ const variants = item.$variants;
640
+
641
+ if ( !variants.hasOwnProperty( variantId ) ) {
642
+ console.warn( `inconsistency: missing variant #${variantId} of item #${id} of model ${model} though contributions have been tracked` );
643
+ continue;
644
+ }
645
+
646
+ variants[variantId] = this.recompileItem( variants[variantId] );
647
+ }
648
+ }
649
+
650
+ return itemsPerModel;
145
651
  }
146
652
 
147
653
  /**
@@ -160,6 +666,11 @@ export class Shaper extends EventEmitter {
160
666
 
161
667
  const name = Compiler.compile( selector, this.termFunctions, this.termsCache )( data );
162
668
 
669
+ // support processed term requesting to fail model detection
670
+ if ( name instanceof Error ) {
671
+ throw new Error( `detecting model of record failed: ${name.message} in context of ${JSON.stringify( data )}` );
672
+ }
673
+
163
674
  if ( !name ) {
164
675
  throw new Error( `detecting model of record failed: ${JSON.stringify( data )}` );
165
676
  }
@@ -175,13 +686,7 @@ export class Shaper extends EventEmitter {
175
686
  * @returns {string} ID of record
176
687
  */
177
688
  identifyRecord( data, modelShape ) {
178
- const selector = modelShape?.properties?.$id;
179
-
180
- if ( !selector || typeof selector !== "string" ) {
181
- throw new Error( `missing or invalid definition of $id in shape of ${data.$segments.join( "/" )}` );
182
- }
183
-
184
- const id = Compiler.compile( selector )( data );
689
+ const id = this.computeId( modelShape?.properties?.$id, data );
185
690
 
186
691
  if ( !id ) {
187
692
  throw new Error( `detecting ID of item related to record ${data.$segments.join( "/" )} failed` );
@@ -193,66 +698,133 @@ export class Shaper extends EventEmitter {
193
698
  /**
194
699
  * Compiles resulting item by evaluating properties declared in model shape.
195
700
  *
196
- * @param {object} data source of record
197
- * @param {ShapeModel} modelShape shape of particular model
198
- * @param {boolean} useDefaults set true to include defaults configured in shape
701
+ * @param {object} data (augmented) source of record
702
+ * @param {ShapeProperties} properties definition of resulting item's properties
703
+ * @param {ShapePropertyDefaults} defaults definition of default values for resulting item's properties
704
+ * @param {string[]} accept list names of properties to accept even when starting with a dollar sign
199
705
  * @returns {object} compiled item
200
706
  */
201
- compileItem( data, modelShape, useDefaults = true ) {
202
- const properties = modelShape?.properties || {};
203
- const defaults = modelShape?.defaults || {};
707
+ compileItem( data, properties = {}, defaults = {}, accept = [] ) {
204
708
  const item = {};
205
709
 
206
710
  for ( const property of Object.keys( properties ) ) {
207
- if ( !String( property ).startsWith( "$" ) ) {
208
- const selector = properties[property];
209
- if ( typeof selector !== "string" || selector.trim().length === 0 ) {
210
- throw new Error( `invalid or missing term for computing value of property ${data.$model}#${property}` );
711
+ if ( accept.indexOf( property ) > -1 || !String( property ).startsWith( "$" ) ) {
712
+ const propertyName = property.replace( /\s*\[]$/, "" );
713
+ const isCollecting = property !== propertyName;
714
+
715
+ const term = properties[property];
716
+ if ( typeof term !== "string" || term.trim().length === 0 ) {
717
+ throw new Error( `invalid or missing term for computing value of property ${propertyName} of ${data.$model}` );
718
+ }
719
+
720
+ if ( isCollecting && properties.hasOwnProperty( propertyName ) ) {
721
+ throw new Error( `double definition: property ${propertyName} of ${data.$model} must be either regular or collecting property` );
211
722
  }
212
723
 
213
724
  let value;
214
725
 
215
726
  try {
216
- value = Compiler.compile( selector, this.termFunctions, this.termsCache )( data );
727
+ value = Compiler.compile( term, this.termFunctions, this.termsCache )( data );
728
+
729
+ // support processed term requesting to fail computation of property's value
730
+ if ( value instanceof Error ) {
731
+ throw value;
732
+ }
217
733
  } catch ( cause ) {
218
- throw new Error( `fetching value of property ${data.$model}#${property} failed: ${cause.message} in ${selector}` );
734
+ throw new Error( `fetching value of property ${propertyName} of ${data.$model} failed: ${cause.message} in ${term}` );
219
735
  }
220
736
 
221
737
  if ( value == null ) {
222
- if ( useDefaults && defaults.hasOwnProperty( property ) ) {
223
- const defaultValue = defaults[property];
738
+ if ( defaults.hasOwnProperty( propertyName ) || defaults.hasOwnProperty( property ) ) {
739
+ const defaultValue = defaults.hasOwnProperty( propertyName ) ? defaults[propertyName] : defaults[property];
224
740
  const trimmed = typeof defaultValue === "string" ? defaultValue.trim() : "";
225
741
 
226
742
  try {
227
743
  const code = trimmed.startsWith( "=" ) ? trimmed.slice( 1 ) : undefined;
228
744
  value = code ? Compiler.compile( code, this.termFunctions, this.termsCache )( data ) : defaultValue;
745
+
746
+ // support processed term requesting to fail computation of default value
747
+ if ( value instanceof Error ) {
748
+ throw value;
749
+ }
229
750
  } catch ( cause ) {
230
- throw new Error( `fetching default of property ${data.$model}#${property} failed: "${cause.message}" in "${defaultValue}"` );
751
+ throw new Error( `fetching default of property ${propertyName} of ${data.$model} failed: "${cause.message}" in "${defaultValue}"` );
231
752
  }
232
753
  }
233
754
  }
234
755
 
235
756
  if ( value != null ) {
236
- item[property] = value;
757
+ if ( isCollecting ) {
758
+ if ( Array.isArray( item[propertyName] ) ) { // eslint-disable-line no-lonely-if
759
+ item[propertyName].push( value );
760
+ } else if ( item[propertyName] == null ) {
761
+ item[propertyName] = [value];
762
+ } else {
763
+ item[propertyName] = [ item[propertyName], value ];
764
+ }
765
+ } else {
766
+ item[propertyName] = value;
767
+ }
237
768
  }
238
769
  }
239
770
  }
240
771
 
241
- return item;
772
+ // deeply merge fresh object with compiled item to get rid of deep data
773
+ // exposed to terms as Proxy
774
+ return merge( {}, item );
775
+ }
776
+
777
+ /**
778
+ * Computes ID using provided term in context of given data space.
779
+ *
780
+ * @param {string} term term to process
781
+ * @param {object} data data space available to term processing
782
+ * @returns {any} processed term's result
783
+ */
784
+ computeId( term, data ) {
785
+ if ( !term || typeof term !== "string" ) {
786
+ throw new Error( `missing or invalid term for computing ID of ${data.$segments.join( "/" )}` );
787
+ }
788
+
789
+ try {
790
+ const value = Compiler.compile( term, this.termFunctions, this.termsCache )( data );
791
+
792
+ // support processed term requesting to fail computation of ID
793
+ if ( value instanceof Error ) {
794
+ throw value;
795
+ }
796
+
797
+ return value;
798
+ } catch ( cause ) {
799
+ throw new Error( `computing ID failed: ${cause.message} in ${term}` );
800
+ }
242
801
  }
243
802
 
244
803
  /**
245
804
  * Populates pool of endpoints with collected records of discovered models.
246
805
  *
247
806
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
248
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
807
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
808
+ * @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
249
809
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
250
810
  */
251
- populateModels( endpoints, models ) {
252
- for ( const modelName of Object.keys( models ) ) {
811
+ populateModels( endpoints, models, mergedLocalShapes ) {
812
+ const modelNames = Object.keys( models );
813
+ let index = 0;
814
+
815
+ for ( const modelName of modelNames ) {
253
816
  const model = models[modelName];
817
+ const itemIds = Object.keys( model );
254
818
 
255
- for ( const itemId of Object.keys( model ) ) {
819
+ if ( this.options.verbose ) {
820
+ console.error( `creating endpoints for model ${modelName} [${++index}/${modelNames.length}] ...` );
821
+ }
822
+
823
+ if ( this.options.verbose ) {
824
+ console.error( ` - ${itemIds.length} items ...` );
825
+ }
826
+
827
+ for ( const itemId of itemIds ) {
256
828
  const normalized = normalizePathname( `${this.prefix}${modelName}/item/${itemId}` );
257
829
  if ( normalized.endsWith( "/" ) ) {
258
830
  throw new Error( `item ID "${itemId}" of model "${modelName}" results in invalid route for endpoint exposing it` );
@@ -261,7 +833,7 @@ export class Shaper extends EventEmitter {
261
833
  endpoints[normalized] = model[itemId]; // eslint-disable-line no-param-reassign
262
834
  }
263
835
 
264
- this.populateModelCollections( endpoints, models, modelName );
836
+ this.populateModelCollections( endpoints, models, modelName, mergedLocalShapes );
265
837
  }
266
838
 
267
839
  return endpoints;
@@ -272,13 +844,14 @@ export class Shaper extends EventEmitter {
272
844
  * a model.
273
845
  *
274
846
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
275
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
847
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
276
848
  * @param {string} modelName name of model to process
849
+ * @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
277
850
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
278
851
  */
279
- populateModelCollections( endpoints, models, modelName ) {
280
- const model = models[modelName];
281
- const collections = model.$shape?.collections || {};
852
+ populateModelCollections( endpoints, models, modelName, mergedLocalShapes ) {
853
+ const finalModelShape = merge( {}, mergedLocalShapes.common, mergedLocalShapes.models[modelName] );
854
+ const collections = finalModelShape.collections || {};
282
855
  const cache = {};
283
856
 
284
857
  // prepare to process collection instances prior to collection references
@@ -297,9 +870,15 @@ export class Shaper extends EventEmitter {
297
870
  } );
298
871
 
299
872
  // process collection by collection
873
+ let index = 0;
874
+
300
875
  for ( const route of keys ) {
301
876
  const normalized = normalizePathname( route );
302
877
 
878
+ if ( this.options.verbose ) {
879
+ console.error( ` - collection /${normalized} [${++index}/${keys.length}] ...` );
880
+ }
881
+
303
882
  if ( String( normalized ).startsWith( "item/" ) || /(^|\/)\.\.(\/|$)|[\\%]/.test( normalized ) ) {
304
883
  throw new Error( `invalid custom route "${normalized}" for collection of model "${modelName}"` );
305
884
  }
@@ -339,14 +918,14 @@ export class Shaper extends EventEmitter {
339
918
 
340
919
  for ( const key in collection ) {
341
920
  if ( collection.hasOwnProperty( key ) ) {
342
- this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName );
921
+ this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName, definition );
343
922
  }
344
923
  }
345
924
  } else {
346
925
  throw new Error( `invalid use of {key} in route "${normalized}" of regular collection of model "${modelName}"` );
347
926
  }
348
927
  } else {
349
- this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName );
928
+ this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName, definition );
350
929
  }
351
930
  }
352
931
  }
@@ -361,12 +940,13 @@ export class Shaper extends EventEmitter {
361
940
  *
362
941
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
363
942
  * @param {string} route route of current collection
364
- * @param {{items: Array<object>}|object<string, Array<object>>} collection collection of items to expose
943
+ * @param {{items: Array<object>}|Object<string, Array<object>>} collection collection of items to expose
365
944
  * @param {boolean} isKeyed indicates if collection lists item per key or all items in a single list
366
945
  * @param {string} modelName name of model this collection is defined for
946
+ * @param {object} definition provided collection's definition in shape
367
947
  * @returns {void}
368
948
  */
369
- populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName ) {
949
+ populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName, definition ) {
370
950
  ptnOffset.lastIndex = 0;
371
951
  const match = ptnOffset.exec( route );
372
952
 
@@ -387,22 +967,30 @@ export class Shaper extends EventEmitter {
387
967
  const normalized = normalizePathname( prefix + route.replace( ptnOffset, offset ) );
388
968
  endpoints[normalized] = { count: items.length, items: items.slice( offset, offset + limit ) }; // eslint-disable-line no-param-reassign
389
969
  }
390
- } else {
970
+ } else if ( isKeyed ) {
391
971
  const normalized = normalizePathname( this.prefix + modelName + "/" + route );
972
+ const base = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
392
973
 
393
- if ( isKeyed ) {
394
- const qualified = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
974
+ for ( const key of Object.keys( collection ) ) {
975
+ const items = collection[key].items;
395
976
 
396
- for ( const key of Object.keys( collection ) ) {
397
- const items = collection[key].items;
977
+ base[key] = { count: items.length };
398
978
 
399
- qualified[key] = { count: items.length, items };
400
- }
401
- } else {
402
- const items = collection.items;
979
+ if ( definition.reduce ) {
980
+ // generate single endpoint listing all items of collection grouped by key
981
+ base[key].items = items;
982
+ } else {
983
+ // expose separate endpoint per group of items
984
+ const subNormalized = normalizePathname( this.prefix + modelName + "/" + route + "/" + key );
403
985
 
404
- endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
986
+ endpoints[subNormalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
987
+ }
405
988
  }
989
+ } else {
990
+ const normalized = normalizePathname( this.prefix + modelName + "/" + route );
991
+ const items = collection.items;
992
+
993
+ endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
406
994
  }
407
995
  }
408
996
 
@@ -410,15 +998,15 @@ export class Shaper extends EventEmitter {
410
998
  * Creates managed data space for evaluating terms in context of a set of
411
999
  * items.
412
1000
  *
413
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
1001
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
414
1002
  * @param {string} modelName name of model to process
415
1003
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
416
- * @returns {{handler: {get: ((function(*, *=): (*))|*)}, context: {$models, $model, $collection}}} handler and extensible context for term evaluation
1004
+ * @returns {{handler: {get: ((function(*, *=): (*))|*)}, context: {$database, $model, $collection}}} handler and extensible context for term evaluation
417
1005
  */
418
1006
  createTermContextCollection( models, modelName, collectionDefinition ) {
419
1007
  const context = {
420
1008
  $collection: collectionDefinition,
421
- $models: models,
1009
+ $database: models,
422
1010
  $model: modelName,
423
1011
  };
424
1012
 
@@ -438,7 +1026,7 @@ export class Shaper extends EventEmitter {
438
1026
  /**
439
1027
  * Compiles collection according to its definition in provided shape.
440
1028
  *
441
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
1029
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
442
1030
  * @param {string} modelName name of model to process
443
1031
  * @param {ShapeCollection} definition rules controlling compilation of collection
444
1032
  * @param {string} route route for resulting collection
@@ -451,7 +1039,7 @@ export class Shaper extends EventEmitter {
451
1039
  const collection = isKeyed ? {} : { items: [] };
452
1040
 
453
1041
 
454
- const { handler, context } = this.createTermContextCollection();
1042
+ const { handler, context } = this.createTermContextCollection( models, modelName, definition );
455
1043
 
456
1044
 
457
1045
  // prepare terms used in compiling collection
@@ -461,12 +1049,22 @@ export class Shaper extends EventEmitter {
461
1049
 
462
1050
  try {
463
1051
  keyFn = isKeyed ? Compiler.compile( $key, this.termFunctions, this.termsCache ) : undefined;
1052
+
1053
+ // support processed term requesting to fail
1054
+ if ( keyFn instanceof Error ) {
1055
+ throw keyFn;
1056
+ }
464
1057
  } catch ( cause ) {
465
1058
  throw new Error( `invalid rule for defining key of collection at "${route}" in ${modelName}: ${cause.message}` );
466
1059
  }
467
1060
 
468
1061
  try {
469
1062
  filterFn = $filter && typeof $filter === "string" ? Compiler.compile( $filter, this.termFunctions, this.termsCache ) : undefined;
1063
+
1064
+ // support processed term requesting to fail
1065
+ if ( filterFn instanceof Error ) {
1066
+ throw filterFn;
1067
+ }
470
1068
  } catch ( cause ) {
471
1069
  throw new Error( `invalid rule for defining filter of collection at "${route}" in ${modelName}: ${cause.message}` );
472
1070
  }
@@ -478,6 +1076,11 @@ export class Shaper extends EventEmitter {
478
1076
  if ( code && typeof code === "string" ) {
479
1077
  const fn = Compiler.compile( code, this.termFunctions, this.termsCache );
480
1078
 
1079
+ // support processed term requesting to fail computed definition of collection
1080
+ if ( fn instanceof Error ) {
1081
+ throw fn;
1082
+ }
1083
+
481
1084
  fn.target = target;
482
1085
 
483
1086
  return fn;
@@ -492,7 +1095,7 @@ export class Shaper extends EventEmitter {
492
1095
 
493
1096
  // process all items of model
494
1097
  for ( const id in model ) {
495
- if ( model.hasOwnProperty( id ) && typeof id === "string" && id[0] !== "$" ) {
1098
+ if ( model[id] && typeof id === "string" && id[0] !== "$" ) {
496
1099
  const item = model[id];
497
1100
  const data = new Proxy( item, handler );
498
1101
  let extracted;
@@ -548,7 +1151,7 @@ export class Shaper extends EventEmitter {
548
1151
  /**
549
1152
  * Sorts provided collection either in-place or as a copy.
550
1153
  *
551
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
1154
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
552
1155
  * @param {string} modelName name of model to process
553
1156
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
554
1157
  * @param {string} route route for resulting collection
@@ -562,6 +1165,11 @@ export class Shaper extends EventEmitter {
562
1165
 
563
1166
  try {
564
1167
  $sort = $sort && typeof $sort === "string" ? Compiler.compile( $sort, this.termFunctions, this.termsCache ) : $sort?.property ? $sort : undefined;
1168
+
1169
+ // support processed term requesting to fail
1170
+ if ( $sort instanceof Error ) {
1171
+ throw $sort;
1172
+ }
565
1173
  } catch ( cause ) {
566
1174
  throw new Error( `invalid rule for defining sorting of collection at "${route}" in ${modelName}: ${cause.message}` );
567
1175
  }