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.
- package/README.md +915 -1669
- package/dist/s3db.cjs.js +610 -50
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +610 -50
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/behaviors/body-only.js +15 -5
- package/src/behaviors/body-overflow.js +9 -0
- package/src/behaviors/user-managed.js +8 -1
- package/src/concerns/typescript-generator.js +12 -2
- package/src/plugins/api/utils/openapi-generator.js +21 -2
- package/src/plugins/replicators/bigquery-replicator.class.js +9 -1
- package/src/plugins/replicators/mysql-replicator.class.js +9 -1
- package/src/plugins/replicators/planetscale-replicator.class.js +9 -1
- package/src/plugins/replicators/postgres-replicator.class.js +9 -1
- package/src/plugins/replicators/schema-sync.helper.js +19 -0
- package/src/plugins/replicators/turso-replicator.class.js +9 -1
- package/src/plugins/vector.plugin.js +361 -9
- package/src/resource.class.js +203 -4
- package/src/schema.class.js +223 -33
package/src/resource.class.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]); });
|
package/src/schema.class.js
CHANGED
|
@@ -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
|
-
//
|
|
505
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1165
|
-
processed[key] = { ...
|
|
1166
|
-
} else if (
|
|
1167
|
-
processed[key] = { ...
|
|
1168
|
-
} else if (
|
|
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] = { ...
|
|
1171
|
-
} else if (
|
|
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] = { ...
|
|
1174
|
-
} else if (
|
|
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
|
-
...
|
|
1367
|
+
...cleanValue,
|
|
1178
1368
|
type: 'number',
|
|
1179
|
-
min:
|
|
1180
|
-
max:
|
|
1369
|
+
min: cleanValue.min !== undefined ? cleanValue.min : -90,
|
|
1370
|
+
max: cleanValue.max !== undefined ? cleanValue.max : 90
|
|
1181
1371
|
};
|
|
1182
|
-
} else if (
|
|
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
|
-
...
|
|
1375
|
+
...cleanValue,
|
|
1186
1376
|
type: 'number',
|
|
1187
|
-
min:
|
|
1188
|
-
max:
|
|
1377
|
+
min: cleanValue.min !== undefined ? cleanValue.min : -180,
|
|
1378
|
+
max: cleanValue.max !== undefined ? cleanValue.max : 180
|
|
1189
1379
|
};
|
|
1190
|
-
} else if (
|
|
1380
|
+
} else if (cleanValue.type === 'geo:point' || cleanValue.type === 'geo-point') {
|
|
1191
1381
|
// Geo point → any (will be validated in hooks)
|
|
1192
|
-
processed[key] = { ...
|
|
1193
|
-
} else if (
|
|
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
|
-
...
|
|
1197
|
-
properties: this.preprocessAttributesForValidation(
|
|
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' })
|
|
1201
|
-
processed[key] =
|
|
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
|