@cepharum/concrete-db 0.3.0 → 0.4.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022 cepharum GmbH
3
+ Copyright (c) 2026 cepharum GmbH
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/cure.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import File from "fs";
3
3
  import Path from "path";
4
+ import { pathToFileURL } from "url";
4
5
 
5
6
  import minimist from "minimist";
6
7
  import YAML from "yaml";
@@ -28,7 +29,8 @@ Supported options are:
28
29
  --debug be extra verbose
29
30
  -s / --shape=file selects global shape controlling transformation
30
31
  -o / --output=folder selects folder resulting files are written to
31
- -c / --clean remove all existing files from output folder
32
+ -c / --clean remove stale files from output folder that aren't
33
+ part of the freshly cured database
32
34
  -l / --library=file selects file exposing custom term functions to use
33
35
  in addition to regular ones
34
36
  ` );
@@ -56,17 +58,14 @@ if ( args.V || args.version ) {
56
58
 
57
59
  options.library = {};
58
60
  for ( const library of libraries ) {
59
- let pathname = Path.resolve( ".", library );
61
+ const pathname = Path.resolve( ".", library );
60
62
 
61
63
  if ( options.verbose ) {
62
64
  console.error( `including library ${pathname}` );
63
65
  }
64
66
 
65
- if ( /^[a-z]:/i.test( pathname ) ) {
66
- pathname = "file://" + pathname.replace( /\//g, "%2F" );
67
- }
68
-
69
- const functions = await import( pathname ); // eslint-disable-line no-await-in-loop
67
+ // import via file URL so absolute paths work cross-platform (incl. Windows drive letters)
68
+ const functions = await import( pathToFileURL( pathname ).href ); // eslint-disable-line no-await-in-loop
70
69
 
71
70
  for ( const name of Object.keys( functions ) ) {
72
71
  if ( options.verbose > 1 ) {
package/lib/generator.mjs CHANGED
@@ -44,6 +44,10 @@ export class Generator extends EventEmitter {
44
44
  console.error( `cleaning output folder ${subfolder} ...` );
45
45
  }
46
46
 
47
+ // routes of all endpoints to keep (matching the names of files written
48
+ // below), used to detect stale files left over from previous curing
49
+ const known = new Set( Object.keys( endpoints ).map( key => key.replace( /\.json$/, "" ) ) );
50
+
47
51
  await FileEssentials.find( subfolder, {
48
52
  depthFirst: true,
49
53
  filter: ( localPath, fullPath, stat ) => {
@@ -54,10 +58,23 @@ export class Generator extends EventEmitter {
54
58
  return stat.isFile();
55
59
  },
56
60
  converter: ( localPath, fullPath, stat ) => {
57
- const name = fullPath.replace( /\.json$/, "" );
61
+ if ( stat.isDirectory() ) {
62
+ // keep the freshly created prefix folder itself even when
63
+ // it is (currently) empty
64
+ if ( Path.resolve( fullPath ) === Path.resolve( subfolder ) ) {
65
+ return undefined;
66
+ }
67
+
68
+ // drop directories that became empty after removing stale
69
+ // files (depth-first ensures children are handled first);
70
+ // keep directories still holding files of the database
71
+ return File.promises.rmdir( fullPath ).catch( () => undefined );
72
+ }
73
+
74
+ const route = Path.relative( folder, fullPath ).replace( /\.json$/, "" ).split( Path.sep ).join( "/" );
58
75
 
59
- if ( !database.hasOwnProperty( name ) ) {
60
- return stat.isDirectory() ? File.promises.rmdir( fullPath ) : File.promises.unlink( fullPath );
76
+ if ( !known.has( route ) ) {
77
+ return File.promises.unlink( fullPath );
61
78
  }
62
79
 
63
80
  return undefined;
package/lib/helper.mjs CHANGED
@@ -1,5 +1,3 @@
1
- export * from "simple-terms/lib/helper.js";
2
-
3
1
  /**
4
2
  * Wraps provided object in a proxy offering case-insensitive access on
5
3
  * object's properties. Any accessed property's value is wrapped before
@@ -93,10 +91,22 @@ export class ForeignKey {
93
91
  /**
94
92
  * Deeply freezes provided object and all its properties recursively.
95
93
  *
94
+ * Non-object values are returned as-is. Already frozen objects are skipped which
95
+ * keeps the recursion safe in case of cyclic references.
96
+ *
96
97
  * @param {object} object object to freeze deeply; will be frozen on return, too
97
98
  * @returns {object} deeply frozen object
98
99
  */
99
100
  export function deepFreeze( object ) {
100
- // FIXME implement actually deep freezing of provided object
101
- return Object.freeze( object );
101
+ if ( !object || typeof object !== "object" || Object.isFrozen( object ) ) {
102
+ return object;
103
+ }
104
+
105
+ Object.freeze( object );
106
+
107
+ for ( const value of Object.values( object ) ) {
108
+ deepFreeze( value );
109
+ }
110
+
111
+ return object;
102
112
  }
package/lib/shaper.mjs CHANGED
@@ -172,7 +172,7 @@ export class Shaper extends EventEmitter {
172
172
  metaProperties.$id = await this.identifyRecord( augmentedData, metaProperties.$shape );
173
173
 
174
174
  if ( !itemsPerModel.hasOwnProperty( metaProperties.$model ) ) {
175
- // eslint-disable-next-line no-param-reassign
175
+
176
176
  itemsPerModel[metaProperties.$model] = {};
177
177
  }
178
178
 
@@ -257,8 +257,15 @@ export class Shaper extends EventEmitter {
257
257
  */
258
258
  getShapeOfModel( shapesPerModelCache, localShape, modelName ) {
259
259
  if ( !shapesPerModelCache.hasOwnProperty( modelName ) ) {
260
- // eslint-disable-next-line no-param-reassign
261
- shapesPerModelCache[modelName] = merge( { defaults: DefaultShape.common.defaults }, localShape?.common, localShape?.models?.[modelName] );
260
+
261
+ // NOTE: `{ defaults: {} }` must be a fresh target so the subsequent
262
+ // merges never write into the shared module-level DefaultShape.
263
+ shapesPerModelCache[modelName] = merge(
264
+ { defaults: {} },
265
+ { defaults: DefaultShape.common.defaults },
266
+ localShape?.common,
267
+ localShape?.models?.[modelName]
268
+ );
262
269
  }
263
270
 
264
271
  return shapesPerModelCache[modelName];
@@ -454,7 +461,9 @@ export class Shaper extends EventEmitter {
454
461
  $original: item.$original,
455
462
  $shape: targetModelShape,
456
463
  $model: targetModelName,
457
- $id: targetId,
464
+ $id: id,
465
+ $targetid: id,
466
+ $targetindex: index,
458
467
  } );
459
468
 
460
469
  // track contributions to selected target item or its variant
@@ -465,7 +474,7 @@ export class Shaper extends EventEmitter {
465
474
  let target;
466
475
 
467
476
  if ( !itemsPerModel.hasOwnProperty( targetModelName ) ) {
468
- // eslint-disable-next-line no-param-reassign
477
+
469
478
  itemsPerModel[targetModelName] = {};
470
479
  }
471
480
 
@@ -492,7 +501,15 @@ export class Shaper extends EventEmitter {
492
501
  }
493
502
 
494
503
  if ( !targetItem.$variants.hasOwnProperty( variantId ) ) {
495
- targetItem.$variants[variantId] = {};
504
+ // a variant target needs the same meta properties as a
505
+ // regular target item so it can be recompiled afterwards
506
+ targetItem.$variants[variantId] = Object.defineProperties( {}, {
507
+ $segments: { value: item.$segments },
508
+ $original: { value: {}, configurable: true },
509
+ $shape: { value: targetModelShape, configurable: true },
510
+ $model: { value: targetModelName },
511
+ $id: { value: id },
512
+ } );
496
513
  }
497
514
 
498
515
  target = targetItem.$variants[variantId];
@@ -525,7 +542,7 @@ export class Shaper extends EventEmitter {
525
542
  if ( $isCollecting ) {
526
543
  // contribution has been declared with one or more suffices, e.g. []
527
544
  // -> collect value in target
528
- if ( Array.isArray( target[name] ) ) { // eslint-disable-line no-lonely-if,max-depth
545
+ if ( Array.isArray( target[name] ) ) { // eslint-disable-line max-depth
529
546
  target[name].push( value );
530
547
  } else if ( target[name] == null ) {
531
548
  target[name] = [value];
@@ -761,7 +778,7 @@ export class Shaper extends EventEmitter {
761
778
 
762
779
  if ( value != null ) {
763
780
  if ( isCollecting ) {
764
- if ( Array.isArray( item[propertyName] ) ) { // eslint-disable-line no-lonely-if
781
+ if ( Array.isArray( item[propertyName] ) ) {
765
782
  item[propertyName].push( value );
766
783
  } else if ( item[propertyName] == null ) {
767
784
  item[propertyName] = [value];
@@ -836,7 +853,7 @@ export class Shaper extends EventEmitter {
836
853
  throw new Error( `item ID "${itemId}" of model "${modelName}" results in invalid route for endpoint exposing it` );
837
854
  }
838
855
 
839
- endpoints[normalized] = model[itemId]; // eslint-disable-line no-param-reassign
856
+ endpoints[normalized] = model[itemId];
840
857
  }
841
858
 
842
859
  await this.populateModelCollections( endpoints, models, modelName, mergedLocalShapes );
@@ -858,23 +875,8 @@ export class Shaper extends EventEmitter {
858
875
  async populateModelCollections( endpoints, models, modelName, mergedLocalShapes ) {
859
876
  const finalModelShape = merge( {}, mergedLocalShapes.common, mergedLocalShapes.models[modelName] );
860
877
  const collections = finalModelShape.collections || {};
861
- const cache = {};
862
-
863
- // prepare to process collection instances prior to collection references
864
878
  const keys = Object.keys( collections );
865
879
 
866
- keys.sort( ( l, r ) => {
867
- if ( l.reference ) {
868
- cache[l.reference] = null;
869
- }
870
-
871
- if ( r.reference ) {
872
- cache[r.reference] = null;
873
- }
874
-
875
- return l.reference ? r.reference ? 0 : 1 : r.reference ? -1 : 0;
876
- } );
877
-
878
880
  // process collection by collection
879
881
  let index = 0;
880
882
 
@@ -891,28 +893,20 @@ export class Shaper extends EventEmitter {
891
893
 
892
894
  const definition = collections[route];
893
895
  if ( definition && typeof definition === "object" ) {
894
- let collection;
895
- let isKeyed;
896
-
897
- // compile (and cache) collection or re-use collection found in cache?
898
- if ( definition.reference ) {
899
- collection = cache[definition.reference];
900
- isKeyed = collection.$isKeyed;
901
- } else {
902
- collection = await this.compileCollection( models, modelName, definition, normalized );
903
- isKeyed = definition.key && typeof definition.key === "string";
904
-
905
- Object.defineProperties( collection, {
906
- $isKeyed: { value: isKeyed },
907
- } );
908
-
909
- if ( cache.hasOwnProperty( normalized ) ) {
910
- cache[route] = collection;
911
- }
896
+ // The `reference` rule for re-using another collection's extracted
897
+ // items has been retired: it never worked correctly and is
898
+ // superseded by YAML anchors/aliases for sharing definitions.
899
+ // (Check for presence, not truthiness the documented usage was
900
+ // `reference: ""` addressing the unnamed root collection.)
901
+ if ( Object.prototype.hasOwnProperty.call( definition, "reference" ) ) {
902
+ throw new Error( `collection "${normalized}" of model "${modelName}" uses the retired "reference" rule; `
903
+ + `share collection definitions via YAML anchors/aliases instead` );
912
904
  }
913
905
 
914
- const isCached = definition.reference || cache.hasOwnProperty( route );
915
- collection = this.sortCollection( models, modelName, definition, normalized, collection, isKeyed, isCached );
906
+ const isKeyed = definition.key && typeof definition.key === "string";
907
+ let collection = await this.compileCollection( models, modelName, definition, normalized );
908
+
909
+ collection = this.sortCollection( models, modelName, definition, normalized, collection, isKeyed );
916
910
 
917
911
  // provide separate endpoints per key of collection?
918
912
  ptnKey.lastIndex = 0;
@@ -971,11 +965,11 @@ export class Shaper extends EventEmitter {
971
965
 
972
966
  for ( let offset = 0; offset < items.length; offset += limit ) {
973
967
  const normalized = normalizePathname( prefix + route.replace( ptnOffset, String( offset ) ) );
974
- endpoints[normalized] = { count: items.length, items: items.slice( offset, offset + limit ) }; // eslint-disable-line no-param-reassign
968
+ endpoints[normalized] = { count: items.length, items: items.slice( offset, offset + limit ) };
975
969
  }
976
970
  } else if ( isKeyed ) {
977
971
  const normalized = normalizePathname( this.prefix + modelName + "/" + route );
978
- const base = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
972
+ const base = endpoints[normalized] = {};
979
973
 
980
974
  for ( const key of Object.keys( collection ) ) {
981
975
  const items = collection[key].items;
@@ -989,14 +983,14 @@ export class Shaper extends EventEmitter {
989
983
  // expose separate endpoint per group of items
990
984
  const subNormalized = normalizePathname( this.prefix + modelName + "/" + route + "/" + key );
991
985
 
992
- endpoints[subNormalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
986
+ endpoints[subNormalized] = { count: items.length, items };
993
987
  }
994
988
  }
995
989
  } else {
996
990
  const normalized = normalizePathname( this.prefix + modelName + "/" + route );
997
991
  const items = collection.items;
998
992
 
999
- endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
993
+ endpoints[normalized] = { count: items.length, items };
1000
994
  }
1001
995
  }
1002
996
 
@@ -1155,7 +1149,7 @@ export class Shaper extends EventEmitter {
1155
1149
  }
1156
1150
 
1157
1151
  /**
1158
- * Sorts provided collection either in-place or as a copy.
1152
+ * Sorts provided collection in-place.
1159
1153
  *
1160
1154
  * @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
1161
1155
  * @param {string} modelName name of model to process
@@ -1163,10 +1157,9 @@ export class Shaper extends EventEmitter {
1163
1157
  * @param {string} route route for resulting collection
1164
1158
  * @param {DatabaseCollection} collection collection to sort
1165
1159
  * @param {boolean} isKeyed true if collection is a keyed collection instead of regular one
1166
- * @param {boolean} copy true if collection must not be sorted in-place
1167
- * @returns {DatabaseCollection} sorted collection, can be same as `collection` or some copy of it
1160
+ * @returns {DatabaseCollection} provided collection sorted in-place
1168
1161
  */
1169
- sortCollection( models, modelName, collectionDefinition, route, collection, isKeyed, copy = false ) {
1162
+ sortCollection( models, modelName, collectionDefinition, route, collection, isKeyed ) {
1170
1163
  let $sort = collectionDefinition.sort;
1171
1164
 
1172
1165
  try {
@@ -1215,7 +1208,7 @@ export class Shaper extends EventEmitter {
1215
1208
 
1216
1209
  if ( _tl === _tr ) {
1217
1210
  if ( _tl === "number" ) {
1218
- return dir * ( _tl - _tr );
1211
+ return dir * ( _l - _r );
1219
1212
  }
1220
1213
 
1221
1214
  return dir * String( _l ).localeCompare( String( _r ) );
@@ -1233,30 +1226,17 @@ export class Shaper extends EventEmitter {
1233
1226
  };
1234
1227
  }
1235
1228
 
1236
- const sorted = copy ? {} : collection;
1237
-
1238
1229
  if ( isKeyed ) {
1239
1230
  for ( const key in collection ) {
1240
1231
  if ( collection.hasOwnProperty( key ) ) {
1241
- if ( copy ) {
1242
- const items = collection[key].items.slice( 0 );
1243
- items.sort( sorterFn );
1244
- sorted[key] = { items };
1245
- } else {
1246
- collection[key].items.sort( sorterFn );
1247
- }
1232
+ collection[key].items.sort( sorterFn );
1248
1233
  }
1249
1234
  }
1250
- } else if ( copy ) {
1251
- const items = collection.items.slice( 0 );
1252
- items.sort( sorterFn );
1253
- sorted.items = items;
1254
1235
  } else {
1255
1236
  collection.items.sort( sorterFn );
1256
1237
  }
1257
1238
 
1258
-
1259
- return sorted;
1239
+ return collection;
1260
1240
  }
1261
1241
  }
1262
1242
 
package/package.json CHANGED
@@ -1,45 +1,53 @@
1
1
  {
2
2
  "name": "@cepharum/concrete-db",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "a read-only web database generator",
5
- "main": "lib/collector.mjs",
5
+ "type": "module",
6
+ "main": "lib/index.mjs",
6
7
  "types": "concrete-db.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./concrete-db.d.ts",
11
+ "import": "./lib/index.mjs"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
7
15
  "bin": {
8
16
  "cure": "cure.mjs"
9
17
  },
10
18
  "scripts": {
11
19
  "cure": "node ./cure.mjs",
12
- "lint": "eslint lib/**/*.{js,mjs,cjs} test/**/*.{js,mjs,cjs} cure.mjs",
13
- "lint:fix": "eslint --fix lib/**/*.{js,mjs,cjs} test/**/*.{js,mjs,cjs} cure.mjs",
14
- "test": "c8 mocha --ui=tdd --recursive test/**/*.spec.*",
20
+ "lint": "eslint .",
21
+ "lint:fix": "eslint --fix .",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "coverage": "vitest run --coverage",
15
25
  "test:getting-started": "node ./test/curing.shell.mjs",
16
- "docs:dev": "vuepress dev docs",
17
- "docs:build": "vuepress build docs"
26
+ "docs:dev": "vitepress dev docs",
27
+ "docs:build": "vitepress build docs"
18
28
  },
19
29
  "keywords": [],
20
30
  "author": "cepharum GmbH",
21
31
  "license": "MIT",
22
32
  "dependencies": {
23
- "ajv": "^8.12.0",
24
33
  "file-essentials": "^0.1.2",
25
34
  "lodash.merge": "^4.6.2",
26
35
  "minimist": "^1.2.8",
27
36
  "promise-essentials": "^0.2.0",
28
- "simple-terms": "^0.4.1",
29
- "yaml": "^2.2.2"
37
+ "simple-terms": "^0.5.0",
38
+ "yaml": "^2.9.0"
30
39
  },
31
40
  "devDependencies": {
32
- "c8": "^7.13.0",
33
- "eslint": "^8.40.0",
34
- "eslint-config-cepharum": "^1.0.13",
35
- "eslint-plugin-promise": "^6.1.1",
36
- "mocha": "^10.2.0",
37
- "should": "^13.2.3",
38
- "vuepress": "^1.9.9",
39
- "vuepress-plugin-mermaidjs": "^1.9.1"
41
+ "@vitest/coverage-v8": "^4.1.9",
42
+ "eslint": "^10.6.0",
43
+ "eslint-config-cepharum": "^2.0.2",
44
+ "mermaid": "^11.16.0",
45
+ "vitepress": "^1.6.4",
46
+ "vitepress-plugin-mermaid": "^2.0.17",
47
+ "vitest": "^4.1.9"
40
48
  },
41
49
  "engines": {
42
- "node": ">=10"
50
+ "node": ">=20"
43
51
  },
44
52
  "files": [
45
53
  "lib",