s3db.js 12.2.3 → 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.
@@ -7,6 +7,7 @@ import { PromisePool } from "@supercharge/promise-pool";
7
7
  import { chunk, cloneDeep, merge, isEmpty, isObject } from "lodash-es";
8
8
 
9
9
  import Schema from "./schema.class.js";
10
+ import { ValidatorManager } from "./validator.class.js";
10
11
  import { streamToString } from "./stream/index.js";
11
12
  import tryFn, { tryFnSync } from "./concerns/try-fn.js";
12
13
  import { ResourceReader, ResourceWriter } from "./stream/index.js"
@@ -425,6 +426,164 @@ export class Resource extends AsyncEventEmitter {
425
426
  return { oldAttributes, newAttributes };
426
427
  }
427
428
 
429
+ /**
430
+ * Add a plugin-created attribute to the resource schema
431
+ * This ensures plugin attributes don't interfere with user-defined attributes
432
+ * by using a separate mapping namespace (p0, p1, p2, ...)
433
+ *
434
+ * @param {string} name - Attribute name (e.g., '_hasEmbedding', 'clusterId')
435
+ * @param {Object|string} definition - Attribute definition
436
+ * @param {string} pluginName - Name of plugin adding the attribute
437
+ * @returns {void}
438
+ *
439
+ * @example
440
+ * // VectorPlugin adding tracking field
441
+ * resource.addPluginAttribute('_hasEmbedding', {
442
+ * type: 'boolean',
443
+ * optional: true,
444
+ * default: false
445
+ * }, 'VectorPlugin');
446
+ *
447
+ * // Shorthand notation
448
+ * resource.addPluginAttribute('clusterId', 'string|optional', 'VectorPlugin');
449
+ */
450
+ addPluginAttribute(name, definition, pluginName) {
451
+ if (!pluginName) {
452
+ throw new ResourceError(
453
+ 'Plugin name is required when adding plugin attributes',
454
+ { resource: this.name, attribute: name }
455
+ );
456
+ }
457
+
458
+ // If attribute already exists and is not a plugin attribute, throw error
459
+ const existingDef = this.schema.getAttributeDefinition(name);
460
+ if (existingDef && (!existingDef.__plugin__ || existingDef.__plugin__ !== pluginName)) {
461
+ throw new ResourceError(
462
+ `Attribute '${name}' already exists and is not from plugin '${pluginName}'`,
463
+ { resource: this.name, attribute: name, plugin: pluginName }
464
+ );
465
+ }
466
+
467
+ // Use the definition as-is (string or object)
468
+ // The schema preprocessor will handle string notation validation
469
+ let defObject = definition;
470
+ if (typeof definition === 'object' && definition !== null) {
471
+ // Clone to avoid mutation
472
+ defObject = { ...definition };
473
+ }
474
+
475
+ // Mark as plugin-created with metadata
476
+ // For string definitions, we need to preserve them but track plugin ownership separately
477
+ if (typeof defObject === 'object' && defObject !== null) {
478
+ defObject.__plugin__ = pluginName;
479
+ defObject.__pluginCreated__ = Date.now();
480
+ }
481
+
482
+ // Add to schema attributes
483
+ // Store original definition (string or object) as the validator expects
484
+ this.schema.attributes[name] = defObject;
485
+
486
+ // Also update resource.attributes to keep them in sync
487
+ this.attributes[name] = defObject;
488
+
489
+ // For string definitions, add metadata separately
490
+ if (typeof defObject === 'string') {
491
+ // Create a marker object to track plugin ownership in a parallel structure
492
+ if (!this.schema._pluginAttributeMetadata) {
493
+ this.schema._pluginAttributeMetadata = {};
494
+ }
495
+ this.schema._pluginAttributeMetadata[name] = {
496
+ __plugin__: pluginName,
497
+ __pluginCreated__: Date.now()
498
+ };
499
+ }
500
+
501
+ // Regenerate plugin mapping only (not user mapping)
502
+ this.schema.regeneratePluginMapping();
503
+
504
+ // Regenerate hooks for the new attribute
505
+ if (this.schema.options.generateAutoHooks) {
506
+ this.schema.generateAutoHooks();
507
+ }
508
+
509
+ // Recompile validator to include new attribute
510
+ const processedAttributes = this.schema.preprocessAttributesForValidation(this.schema.attributes);
511
+ this.schema.validator = new ValidatorManager({ autoEncrypt: false }).compile(merge(
512
+ { $$async: true, $$strict: false },
513
+ processedAttributes
514
+ ));
515
+
516
+ // Emit event
517
+ if (this.database) {
518
+ this.database.emit('plugin-attribute-added', {
519
+ resource: this.name,
520
+ attribute: name,
521
+ plugin: pluginName,
522
+ definition: defObject
523
+ });
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Remove a plugin-created attribute from the resource schema
529
+ * Called when a plugin is uninstalled or no longer needs the attribute
530
+ *
531
+ * @param {string} name - Attribute name to remove
532
+ * @param {string} [pluginName] - Optional plugin name for safety check
533
+ * @returns {boolean} True if attribute was removed, false if not found
534
+ *
535
+ * @example
536
+ * resource.removePluginAttribute('_hasEmbedding', 'VectorPlugin');
537
+ */
538
+ removePluginAttribute(name, pluginName = null) {
539
+ const attrDef = this.schema.getAttributeDefinition(name);
540
+
541
+ // Check metadata for string definitions
542
+ const metadata = this.schema._pluginAttributeMetadata?.[name];
543
+ const isPluginAttr = (typeof attrDef === 'object' && attrDef?.__plugin__) || metadata;
544
+
545
+ // Check if attribute exists and is a plugin attribute
546
+ if (!attrDef || !isPluginAttr) {
547
+ return false;
548
+ }
549
+
550
+ // Get plugin name from either object or metadata
551
+ const actualPlugin = attrDef?.__plugin__ || metadata?.__plugin__;
552
+
553
+ // Safety check: if pluginName provided, ensure it matches
554
+ if (pluginName && actualPlugin !== pluginName) {
555
+ throw new ResourceError(
556
+ `Attribute '${name}' belongs to plugin '${actualPlugin}', not '${pluginName}'`,
557
+ { resource: this.name, attribute: name, actualPlugin, requestedPlugin: pluginName }
558
+ );
559
+ }
560
+
561
+ // Remove from schema
562
+ delete this.schema.attributes[name];
563
+
564
+ // Also remove from resource.attributes to keep them in sync
565
+ delete this.attributes[name];
566
+
567
+ // Remove metadata if it exists
568
+ if (this.schema._pluginAttributeMetadata?.[name]) {
569
+ delete this.schema._pluginAttributeMetadata[name];
570
+ }
571
+
572
+ // Regenerate plugin mapping
573
+ this.schema.regeneratePluginMapping();
574
+
575
+ // Emit event
576
+ if (this.database) {
577
+ this.database.emit('plugin-attribute-removed', {
578
+ resource: this.name,
579
+ attribute: name,
580
+ plugin: actualPlugin
581
+ });
582
+ }
583
+
584
+ return true;
585
+ }
586
+
428
587
  /**
429
588
  * Add a hook function for a specific event
430
589
  * @param {string} event - Hook event (beforeInsert, afterInsert, etc.)
@@ -3284,11 +3443,17 @@ export class Resource extends AsyncEventEmitter {
3284
3443
  unmappedMetadata = ok ? unmapped : metadata;
3285
3444
  // Helper function to filter out internal S3DB fields
3286
3445
  // Preserve geo-related fields (_geohash, _geohash_zoom*) for GeoPlugin
3446
+ // Preserve plugin attributes (fields in _pluginAttributes)
3287
3447
  const filterInternalFields = (obj) => {
3288
3448
  if (!obj || typeof obj !== 'object') return obj;
3289
3449
  const filtered = {};
3450
+ const pluginAttrNames = this.schema._pluginAttributes
3451
+ ? Object.values(this.schema._pluginAttributes).flat()
3452
+ : [];
3453
+
3290
3454
  for (const [key, value] of Object.entries(obj)) {
3291
- if (!key.startsWith('_') || key === '_geohash' || key.startsWith('_geohash_zoom')) {
3455
+ // Keep field if it doesn't start with _, or if it's a special field, or if it's a plugin attribute
3456
+ if (!key.startsWith('_') || key === '_geohash' || key.startsWith('_geohash_zoom') || pluginAttrNames.includes(key)) {
3292
3457
  filtered[key] = value;
3293
3458
  }
3294
3459
  }
@@ -3315,7 +3480,19 @@ export class Resource extends AsyncEventEmitter {
3315
3480
  if (hasOverflow && body) {
3316
3481
  const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(JSON.parse(body)));
3317
3482
  if (okBody) {
3318
- const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody));
3483
+ // Extract pluginMap for backwards compatibility when plugins are added/removed
3484
+ let pluginMapFromMeta = null;
3485
+ // S3 metadata keys are case-insensitive and stored as lowercase
3486
+ if (metadata && metadata._pluginmap) {
3487
+ const [okPluginMap, errPluginMap, parsedPluginMap] = await tryFn(() =>
3488
+ Promise.resolve(typeof metadata._pluginmap === 'string' ? JSON.parse(metadata._pluginmap) : metadata._pluginmap)
3489
+ );
3490
+ pluginMapFromMeta = okPluginMap ? parsedPluginMap : null;
3491
+ }
3492
+
3493
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() =>
3494
+ this.schema.unmapper(parsedBody, undefined, pluginMapFromMeta)
3495
+ );
3319
3496
  bodyData = okUnmap ? unmappedBody : {};
3320
3497
  }
3321
3498
  }
@@ -3330,11 +3507,21 @@ export class Resource extends AsyncEventEmitter {
3330
3507
  if (behavior === 'body-only') {
3331
3508
  const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(body ? JSON.parse(body) : {}));
3332
3509
  let mapFromMeta = this.schema.map;
3510
+ let pluginMapFromMeta = null;
3511
+
3333
3512
  if (metadata && metadata._map) {
3334
3513
  const [okMap, errMap, parsedMap] = await tryFn(() => Promise.resolve(typeof metadata._map === 'string' ? JSON.parse(metadata._map) : metadata._map));
3335
3514
  mapFromMeta = okMap ? parsedMap : this.schema.map;
3336
3515
  }
3337
- const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody, mapFromMeta));
3516
+
3517
+ // S3 metadata keys are case-insensitive and stored as lowercase
3518
+ // So _pluginMap becomes _pluginmap
3519
+ if (metadata && metadata._pluginmap) {
3520
+ const [okPluginMap, errPluginMap, parsedPluginMap] = await tryFn(() => Promise.resolve(typeof metadata._pluginmap === 'string' ? JSON.parse(metadata._pluginmap) : metadata._pluginmap));
3521
+ pluginMapFromMeta = okPluginMap ? parsedPluginMap : null;
3522
+ }
3523
+
3524
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody, mapFromMeta, pluginMapFromMeta));
3338
3525
  const result = okUnmap ? { ...unmappedBody, id } : { id };
3339
3526
  Object.keys(result).forEach(k => { result[k] = fixValue(result[k]); });
3340
3527
  return result;
@@ -3344,7 +3531,19 @@ export class Resource extends AsyncEventEmitter {
3344
3531
  if (behavior === 'user-managed' && body && body.trim() !== '') {
3345
3532
  const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(JSON.parse(body)));
3346
3533
  if (okBody) {
3347
- const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody));
3534
+ // Extract pluginMap for backwards compatibility when plugins are added/removed
3535
+ let pluginMapFromMeta = null;
3536
+ // S3 metadata keys are case-insensitive and stored as lowercase
3537
+ if (metadata && metadata._pluginmap) {
3538
+ const [okPluginMap, errPluginMap, parsedPluginMap] = await tryFn(() =>
3539
+ Promise.resolve(typeof metadata._pluginmap === 'string' ? JSON.parse(metadata._pluginmap) : metadata._pluginmap)
3540
+ );
3541
+ pluginMapFromMeta = okPluginMap ? parsedPluginMap : null;
3542
+ }
3543
+
3544
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() =>
3545
+ this.schema.unmapper(parsedBody, undefined, pluginMapFromMeta)
3546
+ );
3348
3547
  const bodyData = okUnmap ? unmappedBody : {};
3349
3548
  const merged = { ...bodyData, ...unmappedMetadata, id };
3350
3549
  Object.keys(merged).forEach(k => { merged[k] = fixValue(merged[k]); });
@@ -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