s3db.js 12.2.4 → 12.3.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.
@@ -1,4 +1,5 @@
1
1
  import { flatten, unflatten } from "flat";
2
+ import { createHash } from "crypto";
2
3
 
3
4
  import {
4
5
  set,
@@ -36,6 +37,73 @@ function generateBase62Mapping(keys) {
36
37
  return { mapping, reversedMapping };
37
38
  }
38
39
 
40
+ /**
41
+ * Generate stable hash for plugin attribute ID
42
+ * Uses plugin name + attribute name to create deterministic, stable IDs
43
+ * This ensures IDs don't change when other plugins are added/removed
44
+ *
45
+ * Uses base62 encoding for maximum compactness (3 chars = 238,328 combinations)
46
+ * Perfect for realistic plugin usage (2-15 plugins per resource)
47
+ *
48
+ * @param {string} pluginName - Name of the plugin
49
+ * @param {string} attributeName - Name of the attribute
50
+ * @returns {string} Stable hash ID like 'pX7k' (3 chars base62)
51
+ */
52
+ function generatePluginAttributeHash(pluginName, attributeName) {
53
+ const input = `${pluginName}:${attributeName}`;
54
+ const hash = createHash('sha256').update(input).digest();
55
+
56
+ // Convert first 4 bytes to integer
57
+ const num = hash.readUInt32BE(0);
58
+
59
+ // Convert to base62 and take first 3 characters
60
+ const base62Hash = toBase62(num);
61
+
62
+ // Pad with zeros if needed and take first 3 chars
63
+ const paddedHash = base62Hash.padStart(3, '0').substring(0, 3);
64
+
65
+ // S3 metadata keys are case-insensitive and stored in lowercase
66
+ // So we must lowercase the hash to ensure consistency
67
+ return 'p' + paddedHash.toLowerCase();
68
+ }
69
+
70
+ /**
71
+ * Generate plugin attribute mapping with stable hash-based IDs
72
+ * Each plugin attribute gets a unique, stable ID based on plugin name + attribute name
73
+ * IDs don't change when other plugins are added/removed, preventing data corruption
74
+ *
75
+ * Uses 3-char base62 encoding for maximum compactness:
76
+ * - 62^3 = 238,328 possible combinations
77
+ * - Perfect for realistic usage (2-15 plugins per resource)
78
+ * - Saves ~62.5% metadata space vs 8-char hex format
79
+ *
80
+ * @param {Array<{key: string, pluginName: string}>} attributes - Array of plugin attributes with metadata
81
+ * @returns {Object} Mapping object with stable hash-based keys (e.g., 'pX7k')
82
+ */
83
+ function generatePluginMapping(attributes) {
84
+ const mapping = {};
85
+ const reversedMapping = {};
86
+ const usedHashes = new Set();
87
+
88
+ for (const { key, pluginName } of attributes) {
89
+ let hash = generatePluginAttributeHash(pluginName, key);
90
+
91
+ // Handle collisions by appending counter
92
+ let counter = 1;
93
+ let finalHash = hash;
94
+ while (usedHashes.has(finalHash)) {
95
+ finalHash = `${hash}${counter}`; // e.g., pX7k1, pX7k2
96
+ counter++;
97
+ }
98
+
99
+ usedHashes.add(finalHash);
100
+ mapping[key] = finalHash;
101
+ reversedMapping[finalHash] = key;
102
+ }
103
+
104
+ return { mapping, reversedMapping };
105
+ }
106
+
39
107
  export const SchemaActions = {
40
108
  trim: (value) => value == null ? value : value.trim(),
41
109
 
@@ -463,11 +531,14 @@ export class Schema {
463
531
  constructor(args) {
464
532
  const {
465
533
  map,
534
+ pluginMap,
466
535
  name,
467
536
  attributes,
468
537
  passphrase,
469
538
  version = 1,
470
- options = {}
539
+ options = {},
540
+ _pluginAttributeMetadata,
541
+ _pluginAttributes
471
542
  } = args;
472
543
 
473
544
  this.name = name;
@@ -477,6 +548,10 @@ export class Schema {
477
548
  this.options = merge({}, this.defaultOptions(), options);
478
549
  this.allNestedObjectsOptional = this.options.allNestedObjectsOptional ?? false;
479
550
 
551
+ // Initialize plugin attribute metadata tracking
552
+ this._pluginAttributeMetadata = _pluginAttributeMetadata || {};
553
+ this._pluginAttributes = _pluginAttributes || {};
554
+
480
555
  // Preprocess attributes to handle nested objects for validator compilation
481
556
  const processedAttributes = this.preprocessAttributesForValidation(this.attributes);
482
557
 
@@ -494,19 +569,65 @@ export class Schema {
494
569
  else {
495
570
  const flatAttrs = flatten(this.attributes, { safe: true });
496
571
  const leafKeys = Object.keys(flatAttrs).filter(k => !k.includes('$$'));
497
-
572
+
498
573
  // Also include parent object keys for objects that can be empty
499
574
  const objectKeys = this.extractObjectKeys(this.attributes);
500
-
575
+
501
576
  // Combine leaf keys and object keys, removing duplicates
502
577
  const allKeys = [...new Set([...leafKeys, ...objectKeys])];
503
-
504
- // Generate base62 mapping instead of sequential numbers
505
- const { mapping, reversedMapping } = generateBase62Mapping(allKeys);
578
+
579
+ // Separate user attributes from plugin attributes
580
+ const userKeys = [];
581
+ const pluginAttributes = []; // Array of {key, pluginName}
582
+
583
+ for (const key of allKeys) {
584
+ const attrDef = this.getAttributeDefinition(key);
585
+ // Check if it's a plugin attribute (object with __plugin__ OR string with metadata)
586
+ if (typeof attrDef === 'object' && attrDef !== null && attrDef.__plugin__) {
587
+ pluginAttributes.push({ key, pluginName: attrDef.__plugin__ });
588
+ } else if (typeof attrDef === 'string' && this._pluginAttributeMetadata && this._pluginAttributeMetadata[key]) {
589
+ const pluginName = this._pluginAttributeMetadata[key].__plugin__;
590
+ pluginAttributes.push({ key, pluginName });
591
+ } else {
592
+ userKeys.push(key);
593
+ }
594
+ }
595
+
596
+ // Generate base62 mapping for user attributes
597
+ const { mapping, reversedMapping } = generateBase62Mapping(userKeys);
506
598
  this.map = mapping;
507
599
  this.reversedMap = reversedMapping;
508
-
509
600
 
601
+ // Generate plugin mapping with stable hash-based IDs
602
+ const { mapping: pMapping, reversedMapping: pReversedMapping } = generatePluginMapping(pluginAttributes);
603
+ this.pluginMap = pMapping;
604
+ this.reversedPluginMap = pReversedMapping;
605
+
606
+ // Build _pluginAttributes reverse mapping (pluginName -> array of attribute names)
607
+ this._pluginAttributes = {};
608
+ for (const { key, pluginName } of pluginAttributes) {
609
+ if (!this._pluginAttributes[pluginName]) {
610
+ this._pluginAttributes[pluginName] = [];
611
+ }
612
+ this._pluginAttributes[pluginName].push(key);
613
+ }
614
+ }
615
+
616
+ // If pluginMap was provided, use it
617
+ if (!isEmpty(pluginMap)) {
618
+ this.pluginMap = pluginMap;
619
+ this.reversedPluginMap = invert(pluginMap);
620
+ }
621
+
622
+ // Initialize plugin maps if not set
623
+ if (!this.pluginMap) {
624
+ this.pluginMap = {};
625
+ this.reversedPluginMap = {};
626
+ }
627
+
628
+ // Initialize _pluginAttributes if not set
629
+ if (!this._pluginAttributes) {
630
+ this._pluginAttributes = {};
510
631
  }
511
632
  }
512
633
 
@@ -839,6 +960,8 @@ export class Schema {
839
960
  static import(data) {
840
961
  let {
841
962
  map,
963
+ pluginMap,
964
+ _pluginAttributeMetadata,
842
965
  name,
843
966
  options,
844
967
  version,
@@ -852,11 +975,18 @@ export class Schema {
852
975
 
853
976
  const schema = new Schema({
854
977
  map,
978
+ pluginMap: pluginMap || {},
855
979
  name,
856
980
  options,
857
981
  version,
858
982
  attributes
859
983
  });
984
+
985
+ // Restore plugin metadata for string definitions
986
+ if (_pluginAttributeMetadata) {
987
+ schema._pluginAttributeMetadata = _pluginAttributeMetadata;
988
+ }
989
+
860
990
  return schema;
861
991
  }
862
992
 
@@ -898,6 +1028,9 @@ export class Schema {
898
1028
  options: this.options,
899
1029
  attributes: this._exportAttributes(this.attributes),
900
1030
  map: this.map,
1031
+ pluginMap: this.pluginMap || {},
1032
+ _pluginAttributeMetadata: this._pluginAttributeMetadata || {},
1033
+ _pluginAttributes: this._pluginAttributes || {}
901
1034
  };
902
1035
  return data;
903
1036
  }
@@ -956,8 +1089,10 @@ export class Schema {
956
1089
  // Then flatten the object
957
1090
  const flattenedObj = flatten(obj, { safe: true });
958
1091
  const rest = { '_v': this.version + '' };
1092
+
959
1093
  for (const [key, value] of Object.entries(flattenedObj)) {
960
- const mappedKey = this.map[key] || key;
1094
+ // Try plugin map first, then user map, then use original key
1095
+ const mappedKey = this.pluginMap[key] || this.map[key] || key;
961
1096
  // Always map numbers to base36
962
1097
  const attrDef = this.getAttributeDefinition(key);
963
1098
  if (typeof value === 'number' && typeof attrDef === 'string' && attrDef.includes('number')) {
@@ -980,14 +1115,22 @@ export class Schema {
980
1115
  return rest;
981
1116
  }
982
1117
 
983
- async unmapper(mappedResourceItem, mapOverride) {
1118
+ async unmapper(mappedResourceItem, mapOverride, pluginMapOverride) {
984
1119
  let obj = cloneDeep(mappedResourceItem);
985
1120
  delete obj._v;
986
1121
  obj = await this.applyHooksActions(obj, "beforeUnmap");
987
1122
  const reversedMap = mapOverride ? invert(mapOverride) : this.reversedMap;
1123
+ const reversedPluginMap = pluginMapOverride ? invert(pluginMapOverride) : this.reversedPluginMap;
988
1124
  const rest = {};
989
1125
  for (const [key, value] of Object.entries(obj)) {
990
- const originalKey = reversedMap && reversedMap[key] ? reversedMap[key] : key;
1126
+ // Try plugin reversed map first, then user reversed map, then use original key
1127
+ let originalKey = reversedPluginMap[key] || reversedMap[key] || key;
1128
+
1129
+ // If key not found in either map, use original key
1130
+ if (!originalKey) {
1131
+ originalKey = key;
1132
+ }
1133
+
991
1134
  let parsedValue = value;
992
1135
  const attrDef = this.getAttributeDefinition(originalKey);
993
1136
  const hasAfterUnmapHook = this.options.hooks?.afterUnmap?.[originalKey];
@@ -1067,6 +1210,50 @@ export class Schema {
1067
1210
  return def;
1068
1211
  }
1069
1212
 
1213
+ /**
1214
+ * Regenerate plugin attribute mapping
1215
+ * Called when plugin attributes are added or removed
1216
+ * @returns {void}
1217
+ */
1218
+ regeneratePluginMapping() {
1219
+ const flatAttrs = flatten(this.attributes, { safe: true });
1220
+ const leafKeys = Object.keys(flatAttrs).filter(k => !k.includes('$$'));
1221
+
1222
+ // Also include parent object keys for objects that can be empty
1223
+ const objectKeys = this.extractObjectKeys(this.attributes);
1224
+
1225
+ // Combine leaf keys and object keys, removing duplicates
1226
+ const allKeys = [...new Set([...leafKeys, ...objectKeys])];
1227
+
1228
+ // Extract only plugin attributes
1229
+ const pluginAttributes = []; // Array of {key, pluginName}
1230
+ for (const key of allKeys) {
1231
+ const attrDef = this.getAttributeDefinition(key);
1232
+ // Check if it's a plugin attribute (object with __plugin__ OR string with metadata)
1233
+ if (typeof attrDef === 'object' && attrDef !== null && attrDef.__plugin__) {
1234
+ pluginAttributes.push({ key, pluginName: attrDef.__plugin__ });
1235
+ } else if (typeof attrDef === 'string' && this._pluginAttributeMetadata && this._pluginAttributeMetadata[key]) {
1236
+ // String definition with plugin metadata
1237
+ const pluginName = this._pluginAttributeMetadata[key].__plugin__;
1238
+ pluginAttributes.push({ key, pluginName });
1239
+ }
1240
+ }
1241
+
1242
+ // Regenerate plugin mapping with stable hash-based IDs
1243
+ const { mapping, reversedMapping } = generatePluginMapping(pluginAttributes);
1244
+ this.pluginMap = mapping;
1245
+ this.reversedPluginMap = reversedMapping;
1246
+
1247
+ // Rebuild _pluginAttributes reverse mapping (pluginName -> array of attribute names)
1248
+ this._pluginAttributes = {};
1249
+ for (const { key, pluginName } of pluginAttributes) {
1250
+ if (!this._pluginAttributes[pluginName]) {
1251
+ this._pluginAttributes[pluginName] = [];
1252
+ }
1253
+ this._pluginAttributes[pluginName].push(key);
1254
+ }
1255
+ }
1256
+
1070
1257
  /**
1071
1258
  * Preprocess attributes to convert nested objects into validator-compatible format
1072
1259
  * @param {Object} attributes - Original attributes
@@ -1160,45 +1347,48 @@ export class Schema {
1160
1347
  const hasValidatorType = value.type !== undefined && key !== '$$type';
1161
1348
 
1162
1349
  if (hasValidatorType) {
1350
+ // Remove plugin metadata from all object definitions
1351
+ const { __plugin__, __pluginCreated__, ...cleanValue } = value;
1352
+
1163
1353
  // Handle ip4 and ip6 object notation
1164
- if (value.type === 'ip4') {
1165
- processed[key] = { ...value, type: 'string' };
1166
- } else if (value.type === 'ip6') {
1167
- processed[key] = { ...value, type: 'string' };
1168
- } else if (value.type === 'money' || value.type === 'crypto') {
1354
+ if (cleanValue.type === 'ip4') {
1355
+ processed[key] = { ...cleanValue, type: 'string' };
1356
+ } else if (cleanValue.type === 'ip6') {
1357
+ processed[key] = { ...cleanValue, type: 'string' };
1358
+ } else if (cleanValue.type === 'money' || cleanValue.type === 'crypto') {
1169
1359
  // Money/crypto type → number with min:0
1170
- processed[key] = { ...value, type: 'number', min: value.min !== undefined ? value.min : 0 };
1171
- } else if (value.type === 'decimal') {
1360
+ processed[key] = { ...cleanValue, type: 'number', min: cleanValue.min !== undefined ? cleanValue.min : 0 };
1361
+ } else if (cleanValue.type === 'decimal') {
1172
1362
  // Decimal type → number
1173
- processed[key] = { ...value, type: 'number' };
1174
- } else if (value.type === 'geo:lat' || value.type === 'geo-lat') {
1363
+ processed[key] = { ...cleanValue, type: 'number' };
1364
+ } else if (cleanValue.type === 'geo:lat' || cleanValue.type === 'geo-lat') {
1175
1365
  // Geo latitude → number with range [-90, 90]
1176
1366
  processed[key] = {
1177
- ...value,
1367
+ ...cleanValue,
1178
1368
  type: 'number',
1179
- min: value.min !== undefined ? value.min : -90,
1180
- max: value.max !== undefined ? value.max : 90
1369
+ min: cleanValue.min !== undefined ? cleanValue.min : -90,
1370
+ max: cleanValue.max !== undefined ? cleanValue.max : 90
1181
1371
  };
1182
- } else if (value.type === 'geo:lon' || value.type === 'geo-lon') {
1372
+ } else if (cleanValue.type === 'geo:lon' || cleanValue.type === 'geo-lon') {
1183
1373
  // Geo longitude → number with range [-180, 180]
1184
1374
  processed[key] = {
1185
- ...value,
1375
+ ...cleanValue,
1186
1376
  type: 'number',
1187
- min: value.min !== undefined ? value.min : -180,
1188
- max: value.max !== undefined ? value.max : 180
1377
+ min: cleanValue.min !== undefined ? cleanValue.min : -180,
1378
+ max: cleanValue.max !== undefined ? cleanValue.max : 180
1189
1379
  };
1190
- } else if (value.type === 'geo:point' || value.type === 'geo-point') {
1380
+ } else if (cleanValue.type === 'geo:point' || cleanValue.type === 'geo-point') {
1191
1381
  // Geo point → any (will be validated in hooks)
1192
- processed[key] = { ...value, type: 'any' };
1193
- } else if (value.type === 'object' && value.properties) {
1382
+ processed[key] = { ...cleanValue, type: 'any' };
1383
+ } else if (cleanValue.type === 'object' && cleanValue.properties) {
1194
1384
  // Recursively process nested object properties
1195
1385
  processed[key] = {
1196
- ...value,
1197
- properties: this.preprocessAttributesForValidation(value.properties)
1386
+ ...cleanValue,
1387
+ properties: this.preprocessAttributesForValidation(cleanValue.properties)
1198
1388
  };
1199
1389
  } else {
1200
- // This is a validator type definition (e.g., { type: 'array', items: 'number' }), pass it through
1201
- processed[key] = value;
1390
+ // This is a validator type definition (e.g., { type: 'array', items: 'number' })
1391
+ processed[key] = cleanValue;
1202
1392
  }
1203
1393
  } else {
1204
1394
  // This is a nested object structure, wrap it for validation