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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "12.2.4",
3
+ "version": "12.3.0",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -59,10 +59,15 @@ export async function handleInsert({ resource, data, mappedData }) {
59
59
  '_v': mappedData._v || String(resource.version)
60
60
  };
61
61
  metadataOnly._map = JSON.stringify(resource.schema.map);
62
-
62
+
63
+ // Store pluginMap for backwards compatibility when plugins are added/removed
64
+ if (resource.schema.pluginMap && Object.keys(resource.schema.pluginMap).length > 0) {
65
+ metadataOnly._pluginMap = JSON.stringify(resource.schema.pluginMap);
66
+ }
67
+
63
68
  // Use the original object for the body
64
69
  const body = JSON.stringify(mappedData);
65
-
70
+
66
71
  return { mappedData: metadataOnly, body };
67
72
  }
68
73
 
@@ -70,16 +75,21 @@ export async function handleUpdate({ resource, id, data, mappedData }) {
70
75
  // For updates, we need to merge with existing data
71
76
  // Since we can't easily read the existing body during update,
72
77
  // we'll put the update data in the body and let the resource handle merging
73
-
78
+
74
79
  // Keep only the version field in metadata
75
80
  const metadataOnly = {
76
81
  '_v': mappedData._v || String(resource.version)
77
82
  };
78
83
  metadataOnly._map = JSON.stringify(resource.schema.map);
79
-
84
+
85
+ // Store pluginMap for backwards compatibility when plugins are added/removed
86
+ if (resource.schema.pluginMap && Object.keys(resource.schema.pluginMap).length > 0) {
87
+ metadataOnly._pluginMap = JSON.stringify(resource.schema.pluginMap);
88
+ }
89
+
80
90
  // Use the original object for the body
81
91
  const body = JSON.stringify(mappedData);
82
-
92
+
83
93
  return { mappedData: metadataOnly, body };
84
94
  }
85
95
 
@@ -90,6 +90,15 @@ export async function handleInsert({ resource, data, mappedData, originalData })
90
90
  currentSize += attributeSizes._v;
91
91
  }
92
92
 
93
+ // Always include plugin map for backwards compatibility when plugins are added/removed
94
+ // Note: _pluginMap is not in mappedData, it's added separately by behaviors
95
+ if (resource.schema?.pluginMap && Object.keys(resource.schema.pluginMap).length > 0) {
96
+ const pluginMapStr = JSON.stringify(resource.schema.pluginMap);
97
+ const pluginMapSize = calculateUTF8Bytes('_pluginMap') + calculateUTF8Bytes(pluginMapStr);
98
+ metadataFields._pluginMap = pluginMapStr;
99
+ currentSize += pluginMapSize;
100
+ }
101
+
93
102
  // Reserve space for $overflow if overflow is possible
94
103
  let reservedLimit = effectiveLimit;
95
104
  for (const [fieldName, size] of sortedFields) {
@@ -88,7 +88,14 @@ export async function handleInsert({ resource, data, mappedData, originalData })
88
88
  data: originalData || data
89
89
  });
90
90
  // If data exceeds limit, store in body
91
- return { mappedData: { _v: mappedData._v }, body: JSON.stringify(mappedData) };
91
+ const metadataOnly = { _v: mappedData._v };
92
+
93
+ // Store pluginMap for backwards compatibility when plugins are added/removed
94
+ if (resource.schema?.pluginMap && Object.keys(resource.schema.pluginMap).length > 0) {
95
+ metadataOnly._pluginMap = JSON.stringify(resource.schema.pluginMap);
96
+ }
97
+
98
+ return { mappedData: metadataOnly, body: JSON.stringify(mappedData) };
92
99
  }
93
100
 
94
101
  // If data fits in metadata, store only in metadata
@@ -190,10 +190,20 @@ export async function generateTypes(database, options = {}) {
190
190
  const resourceInterfaces = [];
191
191
 
192
192
  for (const [name, resource] of Object.entries(database.resources)) {
193
- const attributes = resource.config?.attributes || resource.attributes || {};
193
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
194
194
  const timestamps = resource.config?.timestamps || false;
195
195
 
196
- const interfaceDef = generateResourceInterface(name, attributes, timestamps);
196
+ // Filter out plugin attributes - they are internal implementation details
197
+ // and should not be exposed in public TypeScript interfaces
198
+ const pluginAttrNames = resource.schema?._pluginAttributes
199
+ ? Object.values(resource.schema._pluginAttributes).flat()
200
+ : [];
201
+
202
+ const userAttributes = Object.fromEntries(
203
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
204
+ );
205
+
206
+ const interfaceDef = generateResourceInterface(name, userAttributes, timestamps);
197
207
  lines.push(interfaceDef);
198
208
 
199
209
  resourceInterfaces.push({
@@ -98,7 +98,17 @@ function generateResourceSchema(resource) {
98
98
  const properties = {};
99
99
  const required = [];
100
100
 
101
- const attributes = resource.config?.attributes || resource.attributes || {};
101
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
102
+
103
+ // Filter out plugin attributes - they are internal implementation details
104
+ // and should not be exposed in public API documentation
105
+ const pluginAttrNames = resource.schema?._pluginAttributes
106
+ ? Object.values(resource.schema._pluginAttributes).flat()
107
+ : [];
108
+
109
+ const attributes = Object.fromEntries(
110
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
111
+ );
102
112
 
103
113
  // Extract resource description (supports both string and object format)
104
114
  const resourceDescription = resource.config?.description;
@@ -254,7 +264,16 @@ function generateResourcePaths(resource, version, config = {}) {
254
264
  }
255
265
 
256
266
  // Create query parameters only for partition fields
257
- const attributes = resource.config?.attributes || resource.attributes || {};
267
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
268
+
269
+ // Filter out plugin attributes
270
+ const pluginAttrNames = resource.schema?._pluginAttributes
271
+ ? Object.values(resource.schema._pluginAttributes).flat()
272
+ : [];
273
+
274
+ const attributes = Object.fromEntries(
275
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
276
+ );
258
277
 
259
278
  for (const fieldName of partitionFieldsSet) {
260
279
  const fieldDef = attributes[fieldName];
@@ -186,7 +186,15 @@ class BigqueryReplicator extends BaseReplicator {
186
186
  continue;
187
187
  }
188
188
 
189
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
189
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
190
+
191
+ // Filter out plugin attributes - they are internal and should not be replicated
192
+ const pluginAttrNames = resource.schema?._pluginAttributes
193
+ ? Object.values(resource.schema._pluginAttributes).flat()
194
+ : [];
195
+ const attributes = Object.fromEntries(
196
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
197
+ );
190
198
 
191
199
  for (const tableConfig of tableConfigs) {
192
200
  const tableName = tableConfig.table;
@@ -221,7 +221,15 @@ class MySQLReplicator extends BaseReplicator {
221
221
  continue;
222
222
  }
223
223
 
224
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
224
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
225
+
226
+ // Filter out plugin attributes - they are internal and should not be replicated
227
+ const pluginAttrNames = resource.schema?._pluginAttributes
228
+ ? Object.values(resource.schema._pluginAttributes).flat()
229
+ : [];
230
+ const attributes = Object.fromEntries(
231
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
232
+ );
225
233
 
226
234
  for (const tableConfig of tableConfigs) {
227
235
  const tableName = tableConfig.table;
@@ -183,7 +183,15 @@ class PlanetScaleReplicator extends BaseReplicator {
183
183
  continue;
184
184
  }
185
185
 
186
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
186
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
187
+
188
+ // Filter out plugin attributes - they are internal and should not be replicated
189
+ const pluginAttrNames = resource.schema?._pluginAttributes
190
+ ? Object.values(resource.schema._pluginAttributes).flat()
191
+ : [];
192
+ const attributes = Object.fromEntries(
193
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
194
+ );
187
195
 
188
196
  for (const tableConfig of tableConfigs) {
189
197
  const tableName = tableConfig.table;
@@ -225,7 +225,15 @@ class PostgresReplicator extends BaseReplicator {
225
225
  }
226
226
 
227
227
  // Get resource attributes from current version
228
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
228
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
229
+
230
+ // Filter out plugin attributes - they are internal and should not be replicated
231
+ const pluginAttrNames = resource.schema?._pluginAttributes
232
+ ? Object.values(resource.schema._pluginAttributes).flat()
233
+ : [];
234
+ const attributes = Object.fromEntries(
235
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
236
+ );
229
237
 
230
238
  // Sync each table configured for this resource
231
239
  for (const tableConfig of tableConfigs) {
@@ -7,6 +7,25 @@
7
7
 
8
8
  import tryFn from "#src/concerns/try-fn.js";
9
9
 
10
+ /**
11
+ * Filter out plugin attributes from attributes object
12
+ * Plugin attributes are internal implementation details and should not be replicated
13
+ * @param {Object} attributes - All attributes including plugin attributes
14
+ * @param {Object} resource - Resource instance with schema._pluginAttributes
15
+ * @returns {Object} Filtered attributes (user attributes only)
16
+ */
17
+ function filterPluginAttributes(attributes, resource) {
18
+ if (!resource?.schema?._pluginAttributes) {
19
+ return attributes;
20
+ }
21
+
22
+ const pluginAttrNames = Object.values(resource.schema._pluginAttributes).flat();
23
+
24
+ return Object.fromEntries(
25
+ Object.entries(attributes).filter(([name]) => !pluginAttrNames.includes(name))
26
+ );
27
+ }
28
+
10
29
  /**
11
30
  * Parse s3db field type notation (e.g., 'string|required|maxlength:50')
12
31
  */
@@ -177,7 +177,15 @@ class TursoReplicator extends BaseReplicator {
177
177
  continue;
178
178
  }
179
179
 
180
- const attributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
180
+ const allAttributes = resource.config.versions[resource.config.currentVersion]?.attributes || {};
181
+
182
+ // Filter out plugin attributes - they are internal and should not be replicated
183
+ const pluginAttrNames = resource.schema?._pluginAttributes
184
+ ? Object.values(resource.schema._pluginAttributes).flat()
185
+ : [];
186
+ const attributes = Object.fromEntries(
187
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
188
+ );
181
189
 
182
190
  for (const tableConfig of tableConfigs) {
183
191
  const tableName = tableConfig.table;
@@ -183,13 +183,13 @@ export class VectorPlugin extends Plugin {
183
183
  }
184
184
  };
185
185
 
186
- // Add tracking field to schema if not present
186
+ // Add tracking field to schema if not present using plugin API
187
187
  if (!resource.schema.attributes[trackingFieldName]) {
188
- resource.schema.attributes[trackingFieldName] = {
188
+ resource.addPluginAttribute(trackingFieldName, {
189
189
  type: 'boolean',
190
190
  optional: true,
191
191
  default: false
192
- };
192
+ }, 'VectorPlugin');
193
193
  }
194
194
 
195
195
  // Emit event
@@ -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]); });