@cepharum/concrete-db 0.1.2-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,9 +1,11 @@
1
+ // noinspection ExceptionCaughtLocallyJS
2
+
1
3
  import EventEmitter from "events";
2
4
 
3
5
  import { Compiler, Functions } from "simple-terms";
4
6
  import merge from "lodash.merge";
5
7
 
6
- import { DefaultShaperOptions } from "./defaults.mjs";
8
+ import { DefaultShape, DefaultShaperOptions } from "./defaults.mjs";
7
9
  import { augmentDataSpace } from "./helper.mjs";
8
10
  import * as TermFunctions from "./term-functions.mjs";
9
11
 
@@ -27,16 +29,17 @@ export class Shaper extends EventEmitter {
27
29
  constructor( options ) {
28
30
  super();
29
31
 
32
+ const compiledOptions = { ...DefaultShaperOptions, ...options };
33
+
30
34
  Object.defineProperties( this, {
31
- options: { value: { ...DefaultShaperOptions, ...options }, enumerable: true },
35
+ options: { value: compiledOptions, enumerable: true },
32
36
 
33
37
  termsCache: { value: new Map() },
34
38
 
35
- termFunctions: { value: { ...Functions, ...TermFunctions } },
36
- } );
39
+ // SECURITY: prevent custom library functions from replacing regular ones by intention
40
+ termFunctions: { value: { ...compiledOptions.library, ...Functions, ...TermFunctions } },
37
41
 
38
- Object.defineProperties( this, {
39
- prefix: { value: normalizePathname( this.options.prefix, true ) },
42
+ prefix: { value: normalizePathname( compiledOptions.prefix, true ) },
40
43
  } );
41
44
  }
42
45
 
@@ -44,13 +47,19 @@ export class Shaper extends EventEmitter {
44
47
  * Creates database from provided collection of records.
45
48
  *
46
49
  * @param {Map<string,CollectedRecord[]>} recordsCollection collection of records to transform
50
+ * @param {Shape[]} localShapes local shapes of folders records have been collected from
47
51
  * @returns {Database} resulting database records
48
52
  */
49
- transform( recordsCollection ) {
50
- 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 );
51
60
  const endpoints = {};
52
61
 
53
- this.populateModels( endpoints, models );
62
+ this.populateModels( endpoints, models, mergedLocalShapes );
54
63
 
55
64
  return { prefix: this.prefix, endpoints };
56
65
  }
@@ -58,44 +67,71 @@ export class Shaper extends EventEmitter {
58
67
  /**
59
68
  * Reorganizes records to by grouped by model.
60
69
  *
61
- * @param {Map<string,CollectedRecord[]>} collection collection of records to transform
62
- * @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
63
72
  */
64
- groupByModel( collection ) {
73
+ groupByModel( collectedRecordsByName ) {
65
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 ) {
66
101
  const postProcessingQueue = new Set();
67
102
 
68
- for ( const source of collection.values() ) {
69
- const localItemsPerModel = this.compileModelsOfSource( source );
103
+ if ( this.options.verbose ) {
104
+ console.error( `\nprocessing contributions ...` );
105
+ }
70
106
 
71
- this.processContributions( localItemsPerModel, postProcessingQueue );
107
+ this.processContributions( globalItemsPerModel, postProcessingQueue, mergedLocalShapes );
72
108
 
73
- this.mergeItemsPerModel( globalItemsPerModel, localItemsPerModel );
109
+ if ( this.options.verbose ) {
110
+ console.error( `post-processing ${postProcessingQueue.size} contributions` );
74
111
  }
75
112
 
76
- this.postProcessContributions( globalItemsPerModel, postProcessingQueue );
113
+ this.recompileContributionTargets( globalItemsPerModel, postProcessingQueue );
77
114
 
78
115
  return globalItemsPerModel;
79
116
  }
80
117
 
81
118
  /**
82
- * Compiles all items according to collected records of provided source and
83
- * collect them grouped by items' model.
119
+ * Compiles all items according to collected records as provided and collect
120
+ * them grouped by described items' models.
84
121
  *
85
- * @param {CollectedRecord[]} source list of records collected from a source
86
- * @returns {Object<string,Object<string,Object>>} compiled items grouped by model, addressable by either item's ID
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
87
125
  */
88
- compileModelsOfSource( source ) {
126
+ compileRecords( itemsPerModel, records ) {
89
127
  const shapesPerModelCache = {};
90
- const itemsPerModel = {};
91
128
 
92
- for ( let read = 0, numRecords = source.length; read < numRecords; read++ ) {
93
- const item = this.compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, source[read] );
129
+ for ( let read = 0, numRecords = records.length; read < numRecords; read++ ) {
130
+ const item = this.compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, records[read] );
94
131
  const modelPool = itemsPerModel[item.$model];
95
132
 
96
- const variantId = this.computeVariantIdOfItem( item );
97
- if ( variantId == null ) {
98
- modelPool[item.$id] = modelPool[item.$id] ? merge( modelPool[item.$id], item ) : item;
133
+ if ( item.$variantId == null ) {
134
+ modelPool[item.$id] = this.mergeModelItem( modelPool[item.$id], item );
99
135
  } else {
100
136
  const target = modelPool[item.$id];
101
137
 
@@ -103,7 +139,7 @@ export class Shaper extends EventEmitter {
103
139
  target.$variants = {};
104
140
  }
105
141
 
106
- target.$variants[variantId] = target.$variants[variantId] ? merge( target.$variants[variantId], item ) : item;
142
+ target.$variants[item.$variantId] = this.mergeModelItem( target.$variants[item.$variantId], item );
107
143
  }
108
144
  }
109
145
 
@@ -130,15 +166,12 @@ export class Shaper extends EventEmitter {
130
166
  const augmentedData = augmentDataSpace( data, metaProperties );
131
167
 
132
168
  metaProperties.$model = this.detectModel( augmentedData, shape );
133
- metaProperties.$shape = this.getShapeOfModel( shapesPerModelCache, shape, metaProperties.$model );
169
+ metaProperties.$shape = this.getShapeOfModel( {}, shape, metaProperties.$model );
134
170
  metaProperties.$id = this.identifyRecord( augmentedData, metaProperties.$shape );
135
171
 
136
172
  if ( !itemsPerModel.hasOwnProperty( metaProperties.$model ) ) {
137
173
  // eslint-disable-next-line no-param-reassign
138
- itemsPerModel[metaProperties.$model] = Object.defineProperties( {}, {
139
- $model: { value: metaProperties.$model },
140
- $shape: { value: metaProperties.$shape },
141
- } );
174
+ itemsPerModel[metaProperties.$model] = {};
142
175
  }
143
176
 
144
177
  const model = itemsPerModel[metaProperties.$model];
@@ -147,37 +180,36 @@ export class Shaper extends EventEmitter {
147
180
  const item = this.compileItem( augmentedData, properties, model[metaProperties.$id] ? {} : defaults );
148
181
 
149
182
  Object.defineProperties( item, {
150
- $original: { value: data },
151
- $shape: { value: metaProperties.$shape },
183
+ $segments: { value: segments },
184
+ $original: { value: data, configurable: true },
185
+ $shape: { value: metaProperties.$shape, configurable: true },
152
186
  $model: { value: metaProperties.$model },
153
187
  $id: { value: metaProperties.$id },
154
- $segments: { value: segments },
155
- $localShape: { value: shape },
188
+ $variantId: { value: this.computeVariantIdOfItem( metaProperties.$shape.properties.$variant, augmentedData ) },
156
189
  } );
157
190
 
158
191
  return item;
159
192
  }
160
193
 
161
194
  /**
162
- * Re-compiles provided item this time based item's properties' themselves
163
- * instead of some record originally collected from a source.
195
+ * Re-compiles provided item this time based on item's properties'
196
+ * themselves instead of some record originally collected from a source.
164
197
  *
165
198
  * Re-compiling is used to compute data contributed by other models.
166
199
  *
167
- * @param {CollectedItem} item previously compiled item to re-compile
168
- * @returns {CollectedItem} re-compiled item
200
+ * @param {ModelItem} item previously compiled item to re-compile
201
+ * @returns {ModelItem} re-compiled item
169
202
  */
170
203
  recompileItem( item ) {
171
- const data = augmentDataSpace( merge( {}, item.$original, item ), { ...item, $postcontribution: true } );
204
+ const data = augmentDataSpace( merge( {}, item.$original, item ), { ...item, $post: true } );
172
205
  const updatedItem = this.compileItem( data, item.$shape.properties, {} );
173
206
 
174
207
  Object.defineProperties( updatedItem, {
175
- $original: { value: item.$original },
176
- $shape: { value: item.$shape },
208
+ $segments: { value: item.$segments },
209
+ $original: { value: item.$original, configurable: true },
210
+ $shape: { value: item.$shape, configurable: true },
177
211
  $model: { value: item.$model },
178
212
  $id: { value: item.$id },
179
- $segments: { value: item.$segments },
180
- $localShape: { value: item.$localShape },
181
213
  } );
182
214
 
183
215
  return updatedItem;
@@ -187,12 +219,11 @@ export class Shaper extends EventEmitter {
187
219
  * Computes ID of variant provided item is describing. If nullish, the item
188
220
  * isn't describing any variant but the base version of the item.
189
221
  *
190
- * @param {CollectedItem} item an item to get variant ID for
222
+ * @param {string?} term term expressing computation of variant ID
223
+ * @param {ModelItem} item an item to get variant ID for
191
224
  * @returns {null|any} computed variant ID of provided item, nullish if item is not a variant
192
225
  */
193
- computeVariantIdOfItem( item ) {
194
- const term = item.$shape.variant;
195
-
226
+ computeVariantIdOfItem( term, item ) {
196
227
  if ( term == null ) {
197
228
  return null;
198
229
  }
@@ -201,7 +232,14 @@ export class Shaper extends EventEmitter {
201
232
  const data = augmentDataSpace( merge( {}, item.$original, item ), item );
202
233
 
203
234
  try {
204
- return Compiler.compile( String( term ), this.termFunctions, this.termsCache )( data );
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;
205
243
  } catch ( cause ) {
206
244
  throw new Error( `computing variant ID of ${item.$model}#${item.$id} failed: ${cause.message} in ${term}` );
207
245
  }
@@ -218,7 +256,7 @@ export class Shaper extends EventEmitter {
218
256
  getShapeOfModel( shapesPerModelCache, localShape, modelName ) {
219
257
  if ( !shapesPerModelCache.hasOwnProperty( modelName ) ) {
220
258
  // eslint-disable-next-line no-param-reassign
221
- shapesPerModelCache[modelName] = merge( {}, localShape?.common, localShape?.models?.[modelName] );
259
+ shapesPerModelCache[modelName] = merge( { defaults: DefaultShape.common.defaults }, localShape?.common, localShape?.models?.[modelName] );
222
260
  }
223
261
 
224
262
  return shapesPerModelCache[modelName];
@@ -313,9 +351,10 @@ export class Shaper extends EventEmitter {
313
351
  *
314
352
  * @param {Object<string,Object<string,Object>>} itemsPerModel map of model names into either model's map of item IDs into related item
315
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
316
355
  * @returns {void}
317
356
  */
318
- processContributions( itemsPerModel, contributedItems ) {
357
+ processContributions( itemsPerModel, contributedItems, mergedLocalShapes ) {
319
358
  const contributingModels = this.listContributingModels( itemsPerModel );
320
359
  const shapePerModelCache = {};
321
360
 
@@ -328,33 +367,47 @@ export class Shaper extends EventEmitter {
328
367
  continue;
329
368
  }
330
369
 
331
- for ( const targetModelName of Object.keys( contributions ) ) {
332
- const targetModelShape = this.getShapeOfModel( shapePerModelCache, item.$localShape, targetModelName );
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 );
333
375
 
334
376
 
335
377
  // extract contributions to actually declared properties of target model
336
378
  const contributionTerms = {};
379
+ const contributionModes = {};
337
380
  let warn = false;
381
+ let numActualProperties = 0;
338
382
 
339
- for ( const termTargetName of Object.keys( contributions[targetModelName] ) ) {
340
- const propertyName = termTargetName.replace( /\s*\[]$/, "" );
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;
341
386
 
342
- if ( propertyName !== termTargetName && propertyName.startsWith( "$" ) ) {
343
- throw new Error( `invalid collecting contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
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
344
389
  }
345
390
 
346
- if ( Object.prototype.hasOwnProperty.call( targetModelShape.properties, propertyName ) ) {
391
+ if ( propertyName === "$variant" || propertyName === "$id" || Object.prototype.hasOwnProperty.call( targetModelShape.properties, propertyName ) ) {
347
392
  if ( contributionTerms.hasOwnProperty( propertyName ) ) {
348
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
349
394
  }
350
395
 
351
- contributionTerms[propertyName] = contributions[targetModelName][termTargetName];
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
+ };
352
405
  } else {
353
406
  warn = true;
354
407
  }
355
408
  }
356
409
 
357
- if ( Object.keys( contributionTerms ).length < 1 ) {
410
+ if ( numActualProperties < 1 && !isOptionalContribution ) {
358
411
  console.warn( `ignoring eventually empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
359
412
  continue;
360
413
  }
@@ -364,46 +417,44 @@ export class Shaper extends EventEmitter {
364
417
  }
365
418
 
366
419
 
367
- // compile values contributed to target model in context of current item
368
- const augmentedData = augmentDataSpace( item, item );
369
- const contribution = this.compileItem( augmentedData, contributionTerms, {}, ["$id"] );
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
+ };
370
428
 
371
- if ( Object.keys( contribution ).length < 1 ) {
372
- console.warn( `ignoring empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
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
373
434
  continue;
374
435
  }
375
436
 
437
+ const targetIds = Array.isArray( targetId ) ? targetId : [targetId];
376
438
 
377
- // use contribution data's $id property to pick item of target model to contribute to
378
- const targetId = contribution.$id;
379
- if ( !targetId ) {
380
- throw new Error( `contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName} failed due to lack of ID` );
381
- }
382
439
 
383
- const targetIds = Array.isArray( targetId ) ? targetId : [targetId];
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;
384
446
 
385
- // check if this contribution is regarding a variant of selected target item
386
- let variantId;
387
447
 
388
- if ( targetModelShape.variant ) {
389
- // try computing variant ID based on this item's contribution to target item
390
- variantId = this.computeVariantIdOfItem( {
391
- ...contribution,
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,
392
453
  $shape: targetModelShape,
393
- $id: targetId,
394
454
  $model: targetModelName,
395
- $original: { ...item.$original, data: {} },
396
- $segments: item.$segments,
397
- $localShape: item.$localShape,
455
+ $id: targetId,
398
456
  } );
399
- }
400
457
 
401
- if ( variantId == null ) {
402
- variantId = null;
403
- }
404
-
405
-
406
- for ( const id of targetIds ) {
407
458
  // track contributions to selected target item or its variant
408
459
  contributedItems.add( JSON.stringify( [ targetModelName, id, variantId ] ) );
409
460
 
@@ -413,22 +464,19 @@ export class Shaper extends EventEmitter {
413
464
 
414
465
  if ( !itemsPerModel.hasOwnProperty( targetModelName ) ) {
415
466
  // eslint-disable-next-line no-param-reassign
416
- itemsPerModel[targetModelName] = Object.defineProperties( {}, {
417
- $model: { value: targetModelName },
418
- $shape: { value: targetModelShape },
419
- } );
467
+ itemsPerModel[targetModelName] = {};
420
468
  }
421
469
 
422
470
  const model = itemsPerModel[targetModelName];
471
+ const targetItemExists = model.hasOwnProperty( id );
423
472
 
424
- if ( !model.hasOwnProperty( id ) ) {
473
+ if ( !targetItemExists ) {
425
474
  model[id] = Object.defineProperties( {}, {
426
- $original: { value: {} },
427
- $shape: { value: targetModelShape },
475
+ $segments: { value: item.$segments },
476
+ $original: { value: {}, configurable: true },
477
+ $shape: { value: targetModelShape, configurable: true },
428
478
  $model: { value: targetModelName },
429
479
  $id: { value: id },
430
- $segments: { value: item.$segments },
431
- $localShape: { value: item.$localShape, configurable: true },
432
480
  } );
433
481
  }
434
482
 
@@ -449,20 +497,31 @@ export class Shaper extends EventEmitter {
449
497
  }
450
498
 
451
499
 
452
- // merge this item's contribution with any previous contribution to the selected target
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()
453
508
  for ( const name of Object.keys( contribution ) ) {
454
509
  if ( String( name ).startsWith( "$" ) ) {
455
510
  continue;
456
511
  }
457
512
 
458
- const value = contribution[name];
513
+ const { $isCollecting, $isDistributed } = contributionModes[name] || {};
514
+ let value = contribution[name];
459
515
 
460
- if ( contributions[targetModelName][name] ) {
461
- // contribution has been declared without suffix "[]"
462
- // -> do not collect, but replace value in target
463
- target[name] = value;
464
- } else {
465
- // contribution has been declared with suffix "[]"
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. []
466
525
  // -> collect value in target
467
526
  if ( Array.isArray( target[name] ) ) { // eslint-disable-line no-lonely-if,max-depth
468
527
  target[name].push( value );
@@ -471,6 +530,10 @@ export class Shaper extends EventEmitter {
471
530
  } else {
472
531
  target[name] = [ target[name], value ];
473
532
  }
533
+ } else {
534
+ // contribution has been declared without any suffix
535
+ // -> do not collect, but replace value in target
536
+ target[name] = value;
474
537
  }
475
538
  }
476
539
  }
@@ -479,14 +542,75 @@ export class Shaper extends EventEmitter {
479
542
  }
480
543
  }
481
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
+ }
576
+ } else {
577
+ _to[name] = source[name];
578
+ }
579
+ }
580
+ }
581
+
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
+
482
606
  /**
483
607
  * Re-compiles all items in provided pool that have been contributed to.
484
608
  *
485
- * @param {Object<string,Object<string,CollectedItem>>} itemsPerModel pool of items per model to update
609
+ * @param {Object<string,Object<string,ModelItem>>} itemsPerModel pool of items per model to update
486
610
  * @param {Set<string>} contributions lists IDs of items or their variants that have been contributed to
487
- * @returns {Object<string,Object<string,CollectedItem>>} pool of items per model as provided
611
+ * @returns {Object<string,Object<string,ModelItem>>} pool of items per model as provided
488
612
  */
489
- postProcessContributions( itemsPerModel, contributions ) {
613
+ recompileContributionTargets( itemsPerModel, contributions ) {
490
614
  for ( const track of contributions.values() ) {
491
615
  const [ model, id, variantId ] = JSON.parse( track );
492
616
 
@@ -515,7 +639,7 @@ export class Shaper extends EventEmitter {
515
639
  const variants = item.$variants;
516
640
 
517
641
  if ( !variants.hasOwnProperty( variantId ) ) {
518
- console.warn( `inconsistency: missing variant ${variantId} of item ${id} of model ${model} though contributions have been tracked` );
642
+ console.warn( `inconsistency: missing variant #${variantId} of item #${id} of model ${model} though contributions have been tracked` );
519
643
  continue;
520
644
  }
521
645
 
@@ -526,96 +650,6 @@ export class Shaper extends EventEmitter {
526
650
  return itemsPerModel;
527
651
  }
528
652
 
529
- /**
530
- * Merges items collected per model from a single source into provided
531
- * target collection which is covering multiple possible sources.
532
- *
533
- * @param {Object<string,Object<string,Object>>} target collection of items per model to be augmented by provided source collection
534
- * @param {Object<string,Object<string,Object>>} source collection items per model to merge into target
535
- * @returns {void}
536
- */
537
- mergeItemsPerModel( target, source ) {
538
- for ( const modelName of Object.keys( source ) ) {
539
- const sourcePool = source[modelName];
540
-
541
- if ( target.hasOwnProperty( modelName ) ) {
542
- // blend shapes of pools claiming to refer to same model
543
- const mergedShape = merge( {}, target[modelName].$shape, sourcePool.$shape );
544
-
545
- Object.defineProperties( target[modelName], {
546
- $shape: { value: mergedShape, configurable: true },
547
- } );
548
- } else {
549
- // create pool of items for target model as it hasn't been populated before
550
- // eslint-disable-next-line no-param-reassign
551
- target[modelName] = Object.defineProperties( {}, {
552
- $model: { value: sourcePool.$model },
553
- $shape: { value: sourcePool.$shape, configurable: true },
554
- } );
555
- }
556
-
557
- const targetPool = target[modelName];
558
-
559
- for ( const itemId of Object.keys( sourcePool ) ) {
560
- if ( targetPool.hasOwnProperty( itemId ) ) {
561
- const from = sourcePool[itemId];
562
- const to = targetPool[itemId];
563
-
564
- targetPool[itemId] = this.mergeItem( to, from );
565
-
566
- if ( from.$variants ) {
567
- if ( !to.$variants ) {
568
- to.$variants = {};
569
- }
570
-
571
- for ( const variantId of Object.keys( from.$variants ) ) {
572
- to.$variants[variantId] = this.mergeItem( to.$variants[variantId] || {}, from.$variants[variantId] );
573
- }
574
- }
575
- } else {
576
- targetPool[itemId] = sourcePool[itemId];
577
- }
578
- }
579
- }
580
- }
581
-
582
- /**
583
- * Updates a given set of properties blending it with another set of item
584
- * properties.
585
- *
586
- * @param {object} to target object to merge properties of source into
587
- * @param {object} from properties to merge into target
588
- * @returns {object} provided target object, or new object if provided target was falsy
589
- */
590
- mergeItem( to, from ) {
591
- const _to = to || {};
592
-
593
- for ( const name of Object.keys( from ) ) {
594
- if ( !String( name ).startsWith( "$" ) ) {
595
- // TODO reconsider this merging strategy for being beneficial
596
- // they way it is by now (e.g. for it keeps nullish data on
597
- // either end)
598
- if ( Array.isArray( from[name] ) ) {
599
- if ( Array.isArray( _to[name] ) ) {
600
- _to[name].splice( _to[name].length, 0, ...from[name] );
601
- } else if ( _to.hasOwnProperty( name ) ) {
602
- _to[name] = [ _to[name], ...from[name] ];
603
- } else {
604
- _to[name] = [...from[name]];
605
- }
606
- } else if ( Array.isArray( _to[name] ) ) {
607
- if ( from.hasOwnProperty( name ) ) {
608
- _to[name].push( from[name] );
609
- }
610
- } else {
611
- _to[name] = from[name];
612
- }
613
- }
614
- }
615
-
616
- return _to;
617
- }
618
-
619
653
  /**
620
654
  * Detects model of a record.
621
655
  *
@@ -632,6 +666,11 @@ export class Shaper extends EventEmitter {
632
666
 
633
667
  const name = Compiler.compile( selector, this.termFunctions, this.termsCache )( data );
634
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
+
635
674
  if ( !name ) {
636
675
  throw new Error( `detecting model of record failed: ${JSON.stringify( data )}` );
637
676
  }
@@ -647,13 +686,7 @@ export class Shaper extends EventEmitter {
647
686
  * @returns {string} ID of record
648
687
  */
649
688
  identifyRecord( data, modelShape ) {
650
- const selector = modelShape?.properties?.$id;
651
-
652
- if ( !selector || typeof selector !== "string" ) {
653
- throw new Error( `missing or invalid definition of $id in shape of ${data.$segments.join( "/" )}` );
654
- }
655
-
656
- const id = Compiler.compile( selector )( data );
689
+ const id = this.computeId( modelShape?.properties?.$id, data );
657
690
 
658
691
  if ( !id ) {
659
692
  throw new Error( `detecting ID of item related to record ${data.$segments.join( "/" )} failed` );
@@ -676,35 +709,62 @@ export class Shaper extends EventEmitter {
676
709
 
677
710
  for ( const property of Object.keys( properties ) ) {
678
711
  if ( accept.indexOf( property ) > -1 || !String( property ).startsWith( "$" ) ) {
712
+ const propertyName = property.replace( /\s*\[]$/, "" );
713
+ const isCollecting = property !== propertyName;
714
+
679
715
  const term = properties[property];
680
716
  if ( typeof term !== "string" || term.trim().length === 0 ) {
681
- throw new Error( `invalid or missing term for computing value of property ${data.$model}#${property}` );
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` );
682
722
  }
683
723
 
684
724
  let value;
685
725
 
686
726
  try {
687
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
+ }
688
733
  } catch ( cause ) {
689
- throw new Error( `fetching value of property ${data.$model}#${property} failed: ${cause.message} in ${term}` );
734
+ throw new Error( `fetching value of property ${propertyName} of ${data.$model} failed: ${cause.message} in ${term}` );
690
735
  }
691
736
 
692
737
  if ( value == null ) {
693
- if ( defaults.hasOwnProperty( property ) ) {
694
- const defaultValue = defaults[property];
738
+ if ( defaults.hasOwnProperty( propertyName ) || defaults.hasOwnProperty( property ) ) {
739
+ const defaultValue = defaults.hasOwnProperty( propertyName ) ? defaults[propertyName] : defaults[property];
695
740
  const trimmed = typeof defaultValue === "string" ? defaultValue.trim() : "";
696
741
 
697
742
  try {
698
743
  const code = trimmed.startsWith( "=" ) ? trimmed.slice( 1 ) : undefined;
699
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
+ }
700
750
  } catch ( cause ) {
701
- 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}"` );
702
752
  }
703
753
  }
704
754
  }
705
755
 
706
756
  if ( value != null ) {
707
- 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
+ }
708
768
  }
709
769
  }
710
770
  }
@@ -714,18 +774,57 @@ export class Shaper extends EventEmitter {
714
774
  return merge( {}, item );
715
775
  }
716
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
+ }
801
+ }
802
+
717
803
  /**
718
804
  * Populates pool of endpoints with collected records of discovered models.
719
805
  *
720
806
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
721
- * @param {Object<string,Object<string,CollectedItem>>} 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
722
809
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
723
810
  */
724
- populateModels( endpoints, models ) {
725
- 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 ) {
726
816
  const model = models[modelName];
817
+ const itemIds = Object.keys( model );
727
818
 
728
- 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 ) {
729
828
  const normalized = normalizePathname( `${this.prefix}${modelName}/item/${itemId}` );
730
829
  if ( normalized.endsWith( "/" ) ) {
731
830
  throw new Error( `item ID "${itemId}" of model "${modelName}" results in invalid route for endpoint exposing it` );
@@ -734,7 +833,7 @@ export class Shaper extends EventEmitter {
734
833
  endpoints[normalized] = model[itemId]; // eslint-disable-line no-param-reassign
735
834
  }
736
835
 
737
- this.populateModelCollections( endpoints, models, modelName );
836
+ this.populateModelCollections( endpoints, models, modelName, mergedLocalShapes );
738
837
  }
739
838
 
740
839
  return endpoints;
@@ -745,13 +844,14 @@ export class Shaper extends EventEmitter {
745
844
  * a model.
746
845
  *
747
846
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
748
- * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
847
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
749
848
  * @param {string} modelName name of model to process
849
+ * @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
750
850
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
751
851
  */
752
- populateModelCollections( endpoints, models, modelName ) {
753
- const model = models[modelName];
754
- 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 || {};
755
855
  const cache = {};
756
856
 
757
857
  // prepare to process collection instances prior to collection references
@@ -770,9 +870,15 @@ export class Shaper extends EventEmitter {
770
870
  } );
771
871
 
772
872
  // process collection by collection
873
+ let index = 0;
874
+
773
875
  for ( const route of keys ) {
774
876
  const normalized = normalizePathname( route );
775
877
 
878
+ if ( this.options.verbose ) {
879
+ console.error( ` - collection /${normalized} [${++index}/${keys.length}] ...` );
880
+ }
881
+
776
882
  if ( String( normalized ).startsWith( "item/" ) || /(^|\/)\.\.(\/|$)|[\\%]/.test( normalized ) ) {
777
883
  throw new Error( `invalid custom route "${normalized}" for collection of model "${modelName}"` );
778
884
  }
@@ -892,15 +998,15 @@ export class Shaper extends EventEmitter {
892
998
  * Creates managed data space for evaluating terms in context of a set of
893
999
  * items.
894
1000
  *
895
- * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
1001
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
896
1002
  * @param {string} modelName name of model to process
897
1003
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
898
- * @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
899
1005
  */
900
1006
  createTermContextCollection( models, modelName, collectionDefinition ) {
901
1007
  const context = {
902
1008
  $collection: collectionDefinition,
903
- $models: models,
1009
+ $database: models,
904
1010
  $model: modelName,
905
1011
  };
906
1012
 
@@ -920,7 +1026,7 @@ export class Shaper extends EventEmitter {
920
1026
  /**
921
1027
  * Compiles collection according to its definition in provided shape.
922
1028
  *
923
- * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
1029
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
924
1030
  * @param {string} modelName name of model to process
925
1031
  * @param {ShapeCollection} definition rules controlling compilation of collection
926
1032
  * @param {string} route route for resulting collection
@@ -943,12 +1049,22 @@ export class Shaper extends EventEmitter {
943
1049
 
944
1050
  try {
945
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
+ }
946
1057
  } catch ( cause ) {
947
1058
  throw new Error( `invalid rule for defining key of collection at "${route}" in ${modelName}: ${cause.message}` );
948
1059
  }
949
1060
 
950
1061
  try {
951
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
+ }
952
1068
  } catch ( cause ) {
953
1069
  throw new Error( `invalid rule for defining filter of collection at "${route}" in ${modelName}: ${cause.message}` );
954
1070
  }
@@ -960,6 +1076,11 @@ export class Shaper extends EventEmitter {
960
1076
  if ( code && typeof code === "string" ) {
961
1077
  const fn = Compiler.compile( code, this.termFunctions, this.termsCache );
962
1078
 
1079
+ // support processed term requesting to fail computed definition of collection
1080
+ if ( fn instanceof Error ) {
1081
+ throw fn;
1082
+ }
1083
+
963
1084
  fn.target = target;
964
1085
 
965
1086
  return fn;
@@ -1030,7 +1151,7 @@ export class Shaper extends EventEmitter {
1030
1151
  /**
1031
1152
  * Sorts provided collection either in-place or as a copy.
1032
1153
  *
1033
- * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
1154
+ * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
1034
1155
  * @param {string} modelName name of model to process
1035
1156
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
1036
1157
  * @param {string} route route for resulting collection
@@ -1044,6 +1165,11 @@ export class Shaper extends EventEmitter {
1044
1165
 
1045
1166
  try {
1046
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
+ }
1047
1173
  } catch ( cause ) {
1048
1174
  throw new Error( `invalid rule for defining sorting of collection at "${route}" in ${modelName}: ${cause.message}` );
1049
1175
  }