s3db.js 11.3.2 → 12.0.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.
Files changed (82) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36664 -15480
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +57 -0
  5. package/dist/s3db.es.js +36661 -15531
  6. package/dist/s3db.es.js.map +1 -1
  7. package/mcp/entrypoint.js +58 -0
  8. package/mcp/tools/documentation.js +434 -0
  9. package/mcp/tools/index.js +4 -0
  10. package/package.json +27 -6
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +41 -46
  13. package/src/concerns/base62.js +85 -0
  14. package/src/concerns/dictionary-encoding.js +294 -0
  15. package/src/concerns/geo-encoding.js +256 -0
  16. package/src/concerns/high-performance-inserter.js +34 -30
  17. package/src/concerns/ip.js +325 -0
  18. package/src/concerns/metadata-encoding.js +345 -66
  19. package/src/concerns/money.js +193 -0
  20. package/src/concerns/partition-queue.js +7 -4
  21. package/src/concerns/plugin-storage.js +39 -19
  22. package/src/database.class.js +76 -74
  23. package/src/errors.js +0 -4
  24. package/src/plugins/api/auth/api-key-auth.js +88 -0
  25. package/src/plugins/api/auth/basic-auth.js +154 -0
  26. package/src/plugins/api/auth/index.js +112 -0
  27. package/src/plugins/api/auth/jwt-auth.js +169 -0
  28. package/src/plugins/api/index.js +539 -0
  29. package/src/plugins/api/middlewares/index.js +15 -0
  30. package/src/plugins/api/middlewares/validator.js +185 -0
  31. package/src/plugins/api/routes/auth-routes.js +241 -0
  32. package/src/plugins/api/routes/resource-routes.js +304 -0
  33. package/src/plugins/api/server.js +350 -0
  34. package/src/plugins/api/utils/error-handler.js +147 -0
  35. package/src/plugins/api/utils/openapi-generator.js +1240 -0
  36. package/src/plugins/api/utils/response-formatter.js +218 -0
  37. package/src/plugins/backup/streaming-exporter.js +132 -0
  38. package/src/plugins/backup.plugin.js +103 -50
  39. package/src/plugins/cache/s3-cache.class.js +95 -47
  40. package/src/plugins/cache.plugin.js +107 -9
  41. package/src/plugins/concerns/plugin-dependencies.js +313 -0
  42. package/src/plugins/concerns/prometheus-formatter.js +255 -0
  43. package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
  44. package/src/plugins/consumers/sqs-consumer.js +4 -0
  45. package/src/plugins/costs.plugin.js +255 -39
  46. package/src/plugins/eventual-consistency/helpers.js +15 -1
  47. package/src/plugins/geo.plugin.js +873 -0
  48. package/src/plugins/importer/index.js +1020 -0
  49. package/src/plugins/index.js +11 -0
  50. package/src/plugins/metrics.plugin.js +163 -4
  51. package/src/plugins/queue-consumer.plugin.js +6 -27
  52. package/src/plugins/relation.errors.js +139 -0
  53. package/src/plugins/relation.plugin.js +1242 -0
  54. package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
  55. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  56. package/src/plugins/replicators/index.js +28 -3
  57. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  58. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  59. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  60. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  61. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  62. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  63. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  64. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  65. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  66. package/src/plugins/state-machine.plugin.js +122 -68
  67. package/src/plugins/tfstate/README.md +745 -0
  68. package/src/plugins/tfstate/base-driver.js +80 -0
  69. package/src/plugins/tfstate/errors.js +112 -0
  70. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  71. package/src/plugins/tfstate/index.js +2660 -0
  72. package/src/plugins/tfstate/s3-driver.js +192 -0
  73. package/src/plugins/ttl.plugin.js +536 -0
  74. package/src/resource.class.js +14 -10
  75. package/src/s3db.d.ts +57 -0
  76. package/src/schema.class.js +366 -32
  77. package/SECURITY.md +0 -76
  78. package/src/partition-drivers/base-partition-driver.js +0 -106
  79. package/src/partition-drivers/index.js +0 -66
  80. package/src/partition-drivers/memory-partition-driver.js +0 -289
  81. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  82. package/src/partition-drivers/sync-partition-driver.js +0 -38
@@ -22,7 +22,7 @@ export class Resource extends AsyncEventEmitter {
22
22
  * @param {Object} config - Resource configuration
23
23
  * @param {string} config.name - Resource name
24
24
  * @param {Object} config.client - S3 client instance
25
- * @param {string} [config.version='v0'] - Resource version
25
+ * @param {string} [config.version='v1'] - Resource version
26
26
  * @param {Object} [config.attributes={}] - Resource attributes schema
27
27
  * @param {string} [config.behavior='user-managed'] - Resource behavior strategy
28
28
  * @param {string} [config.passphrase='secret'] - Encryption passphrase
@@ -392,7 +392,8 @@ export class Resource extends AsyncEventEmitter {
392
392
  this.attributes = newAttributes;
393
393
 
394
394
  // Apply configuration to ensure timestamps and hooks are set up
395
- this.applyConfiguration({ map: this.schema?.map });
395
+ // Don't pass old map - let it regenerate with new attributes
396
+ this.applyConfiguration();
396
397
 
397
398
  return { oldAttributes, newAttributes };
398
399
  }
@@ -2291,7 +2292,7 @@ export class Resource extends AsyncEventEmitter {
2291
2292
 
2292
2293
  /**
2293
2294
  * Get schema for a specific version
2294
- * @param {string} version - Version string (e.g., 'v0', 'v1')
2295
+ * @param {string} version - Version string (e.g., 'v1', 'v2')
2295
2296
  * @returns {Object} Schema object for the version
2296
2297
  */
2297
2298
  async getSchemaForVersion(version) {
@@ -2785,11 +2786,12 @@ export class Resource extends AsyncEventEmitter {
2785
2786
  const [ok, err, unmapped] = await tryFn(() => this.schema.unmapper(metadata));
2786
2787
  unmappedMetadata = ok ? unmapped : metadata;
2787
2788
  // Helper function to filter out internal S3DB fields
2789
+ // Preserve geo-related fields (_geohash, _geohash_zoom*) for GeoPlugin
2788
2790
  const filterInternalFields = (obj) => {
2789
2791
  if (!obj || typeof obj !== 'object') return obj;
2790
2792
  const filtered = {};
2791
2793
  for (const [key, value] of Object.entries(obj)) {
2792
- if (!key.startsWith('_')) {
2794
+ if (!key.startsWith('_') || key === '_geohash' || key.startsWith('_geohash_zoom')) {
2793
2795
  filtered[key] = value;
2794
2796
  }
2795
2797
  }
@@ -2884,16 +2886,18 @@ export class Resource extends AsyncEventEmitter {
2884
2886
  }
2885
2887
  if (waited >= maxWait) {
2886
2888
  }
2887
- try {
2888
- const result = await this.insert({ ...attributes, id });
2889
- return result;
2890
- } catch (err) {
2889
+
2890
+ const [ok, err, result] = await tryFn(() => this.insert({ ...attributes, id }));
2891
+
2892
+ if (!ok) {
2891
2893
  if (err && err.message && err.message.includes('already exists')) {
2892
- const result = await this.update(id, attributes);
2893
- return result;
2894
+ const updateResult = await this.update(id, attributes);
2895
+ return updateResult;
2894
2896
  }
2895
2897
  throw err;
2896
2898
  }
2899
+
2900
+ return result;
2897
2901
  }
2898
2902
 
2899
2903
  // --- MIDDLEWARE SYSTEM ---
package/src/s3db.d.ts CHANGED
@@ -319,6 +319,20 @@ declare module 's3db.js' {
319
319
  maxResults?: number;
320
320
  }
321
321
 
322
+ /** Geo Plugin resource config */
323
+ export interface GeoResourceConfig {
324
+ latField: string;
325
+ lonField: string;
326
+ precision?: number;
327
+ addGeohash?: boolean;
328
+ }
329
+
330
+ /** Geo Plugin config */
331
+ export interface GeoPluginConfig extends PluginConfig {
332
+ resources?: Record<string, GeoResourceConfig>;
333
+ verbose?: boolean;
334
+ }
335
+
322
336
  /** Metrics Plugin config */
323
337
  export interface MetricsPluginConfig extends PluginConfig {
324
338
  trackLatency?: boolean;
@@ -1016,6 +1030,16 @@ declare module 's3db.js' {
1016
1030
  getIndexStats(): any;
1017
1031
  }
1018
1032
 
1033
+ /** Geo Plugin */
1034
+ export class GeoPlugin extends Plugin {
1035
+ constructor(config?: GeoPluginConfig);
1036
+ encodeGeohash(latitude: number, longitude: number, precision?: number): string;
1037
+ decodeGeohash(geohash: string): { latitude: number; longitude: number; error: { latitude: number; longitude: number } };
1038
+ calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number;
1039
+ getNeighbors(geohash: string): string[];
1040
+ getStats(): any;
1041
+ }
1042
+
1019
1043
  /** Metrics Plugin */
1020
1044
  export class MetricsPlugin extends Plugin {
1021
1045
  constructor(config?: MetricsPluginConfig);
@@ -1254,6 +1278,33 @@ declare module 's3db.js' {
1254
1278
  }
1255
1279
 
1256
1280
  /** Resource extensions added by EventualConsistencyPlugin */
1281
+ export interface GeoResourceExtensions {
1282
+ /** Find locations within radius of a point */
1283
+ findNearby(options: {
1284
+ lat: number;
1285
+ lon: number;
1286
+ radius?: number;
1287
+ limit?: number;
1288
+ }): Promise<Array<any & { _distance: number }>>;
1289
+
1290
+ /** Find locations within bounding box */
1291
+ findInBounds(options: {
1292
+ north: number;
1293
+ south: number;
1294
+ east: number;
1295
+ west: number;
1296
+ limit?: number;
1297
+ }): Promise<any[]>;
1298
+
1299
+ /** Get distance between two records */
1300
+ getDistance(id1: string, id2: string): Promise<{
1301
+ distance: number;
1302
+ unit: string;
1303
+ from: string;
1304
+ to: string;
1305
+ }>;
1306
+ }
1307
+
1257
1308
  export interface EventualConsistencyResourceExtensions {
1258
1309
  /** Set field value (replaces current value) */
1259
1310
  set(id: string, field: string, value: number): Promise<number>;
@@ -1264,6 +1315,12 @@ declare module 's3db.js' {
1264
1315
  /** Decrement field value */
1265
1316
  sub(id: string, field: string, amount: number): Promise<number>;
1266
1317
 
1318
+ /** Increment field value by 1 (shorthand for add(id, field, 1)) */
1319
+ increment(id: string, field: string): Promise<number>;
1320
+
1321
+ /** Decrement field value by 1 (shorthand for sub(id, field, 1)) */
1322
+ decrement(id: string, field: string): Promise<number>;
1323
+
1267
1324
  /** Manually trigger consolidation */
1268
1325
  consolidate(id: string, field: string): Promise<number>;
1269
1326
 
@@ -15,7 +15,10 @@ import { encrypt, decrypt } from "./concerns/crypto.js";
15
15
  import { ValidatorManager } from "./validator.class.js";
16
16
  import { tryFn, tryFnSync } from "./concerns/try-fn.js";
17
17
  import { SchemaError } from "./errors.js";
18
- import { encode as toBase62, decode as fromBase62, encodeDecimal, decodeDecimal, encodeFixedPoint, decodeFixedPoint } from "./concerns/base62.js";
18
+ import { encode as toBase62, decode as fromBase62, encodeDecimal, decodeDecimal, encodeFixedPoint, decodeFixedPoint, encodeFixedPointBatch, decodeFixedPointBatch } from "./concerns/base62.js";
19
+ import { encodeIPv4, decodeIPv4, encodeIPv6, decodeIPv6, isValidIPv4, isValidIPv6 } from "./concerns/ip.js";
20
+ import { encodeMoney, decodeMoney, getCurrencyDecimals } from "./concerns/money.js";
21
+ import { encodeGeoLat, decodeGeoLat, encodeGeoLon, decodeGeoLon, encodeGeoPoint, decodeGeoPoint } from "./concerns/geo-encoding.js";
19
22
 
20
23
  /**
21
24
  * Generate base62 mapping for attributes
@@ -279,29 +282,33 @@ export const SchemaActions = {
279
282
  return value;
280
283
  }
281
284
  if (value.length === 0) {
282
- return '';
285
+ return '^[]';
283
286
  }
284
- const encodedItems = value.map(item => {
285
- if (typeof item === 'number' && !isNaN(item)) {
286
- return encodeFixedPoint(item, precision);
287
- }
288
- // fallback: try to parse as number, else keep as is
289
- const n = Number(item);
290
- return isNaN(n) ? '' : encodeFixedPoint(n, precision);
291
- });
292
- return encodedItems.join(separator);
287
+ // Use batch encoding for massive compression (17% additional savings)
288
+ // Format: ^[val1,val2,val3,...] instead of ^val1,^val2,^val3,...
289
+ return encodeFixedPointBatch(value, precision);
293
290
  },
294
291
  toArrayOfEmbeddings: (value, { separator, precision = 6 }) => {
295
292
  if (Array.isArray(value)) {
296
- return value.map(v => (typeof v === 'number' ? v : decodeFixedPoint(v, precision)));
293
+ // Already an array, return as-is
294
+ return value;
297
295
  }
298
296
  if (value === null || value === undefined) {
299
297
  return value;
300
298
  }
301
- if (value === '') {
299
+ if (value === '' || value === '^[]') {
302
300
  return [];
303
301
  }
302
+
304
303
  const str = String(value);
304
+
305
+ // Check if this is batch-encoded (^[...])
306
+ if (str.startsWith('^[')) {
307
+ return decodeFixedPointBatch(str, precision);
308
+ }
309
+
310
+ // Fallback: Legacy format with individual prefixes (^val,^val,^val)
311
+ // This maintains backwards compatibility with data encoded before batch optimization
305
312
  const items = [];
306
313
  let current = '';
307
314
  let i = 0;
@@ -329,6 +336,129 @@ export const SchemaActions = {
329
336
  });
330
337
  },
331
338
 
339
+ encodeIPv4: (value) => {
340
+ if (value === null || value === undefined) return value;
341
+ if (typeof value !== 'string') return value;
342
+ if (!isValidIPv4(value)) return value;
343
+ const [ok, err, encoded] = tryFnSync(() => encodeIPv4(value));
344
+ return ok ? encoded : value;
345
+ },
346
+ decodeIPv4: (value) => {
347
+ if (value === null || value === undefined) return value;
348
+ if (typeof value !== 'string') return value;
349
+ const [ok, err, decoded] = tryFnSync(() => decodeIPv4(value));
350
+ return ok ? decoded : value;
351
+ },
352
+
353
+ encodeIPv6: (value) => {
354
+ if (value === null || value === undefined) return value;
355
+ if (typeof value !== 'string') return value;
356
+ if (!isValidIPv6(value)) return value;
357
+ const [ok, err, encoded] = tryFnSync(() => encodeIPv6(value));
358
+ return ok ? encoded : value;
359
+ },
360
+ decodeIPv6: (value) => {
361
+ if (value === null || value === undefined) return value;
362
+ if (typeof value !== 'string') return value;
363
+ const [ok, err, decoded] = tryFnSync(() => decodeIPv6(value));
364
+ return ok ? decoded : value;
365
+ },
366
+
367
+ // Money type - Integer-based (banking standard)
368
+ // Simplified approach: decimals instead of currency
369
+ encodeMoney: (value, { decimals = 2 } = {}) => {
370
+ if (value === null || value === undefined) return value;
371
+ if (typeof value !== 'number') return value;
372
+
373
+ // Use decimal places directly instead of currency lookup
374
+ const multiplier = Math.pow(10, decimals);
375
+ const integerValue = Math.round(value * multiplier);
376
+
377
+ // Encode as base62 with $ prefix
378
+ const [ok, err, encoded] = tryFnSync(() => '$' + toBase62(integerValue));
379
+ return ok ? encoded : value;
380
+ },
381
+ decodeMoney: (value, { decimals = 2 } = {}) => {
382
+ if (value === null || value === undefined) return value;
383
+ if (typeof value !== 'string') return value;
384
+ if (!value.startsWith('$')) return value;
385
+
386
+ // Decode base62 and convert back to decimal
387
+ const [ok, err, integerValue] = tryFnSync(() => fromBase62(value.slice(1)));
388
+ if (!ok || isNaN(integerValue)) return value;
389
+
390
+ const divisor = Math.pow(10, decimals);
391
+ return integerValue / divisor;
392
+ },
393
+
394
+ // Decimal type - Fixed-point for non-monetary decimals
395
+ encodeDecimalFixed: (value, { precision = 2 } = {}) => {
396
+ if (value === null || value === undefined) return value;
397
+ if (typeof value !== 'number') return value;
398
+ const [ok, err, encoded] = tryFnSync(() => encodeFixedPoint(value, precision));
399
+ return ok ? encoded : value;
400
+ },
401
+ decodeDecimalFixed: (value, { precision = 2 } = {}) => {
402
+ if (value === null || value === undefined) return value;
403
+ if (typeof value !== 'string') return value;
404
+ const [ok, err, decoded] = tryFnSync(() => decodeFixedPoint(value, precision));
405
+ return ok ? decoded : value;
406
+ },
407
+
408
+ // Geo types - Latitude
409
+ encodeGeoLatitude: (value, { precision = 6 } = {}) => {
410
+ if (value === null || value === undefined) return value;
411
+ if (typeof value !== 'number') return value;
412
+ const [ok, err, encoded] = tryFnSync(() => encodeGeoLat(value, precision));
413
+ return ok ? encoded : value;
414
+ },
415
+ decodeGeoLatitude: (value, { precision = 6 } = {}) => {
416
+ if (value === null || value === undefined) return value;
417
+ if (typeof value !== 'string') return value;
418
+ const [ok, err, decoded] = tryFnSync(() => decodeGeoLat(value, precision));
419
+ return ok ? decoded : value;
420
+ },
421
+
422
+ // Geo types - Longitude
423
+ encodeGeoLongitude: (value, { precision = 6 } = {}) => {
424
+ if (value === null || value === undefined) return value;
425
+ if (typeof value !== 'number') return value;
426
+ const [ok, err, encoded] = tryFnSync(() => encodeGeoLon(value, precision));
427
+ return ok ? encoded : value;
428
+ },
429
+ decodeGeoLongitude: (value, { precision = 6 } = {}) => {
430
+ if (value === null || value === undefined) return value;
431
+ if (typeof value !== 'string') return value;
432
+ const [ok, err, decoded] = tryFnSync(() => decodeGeoLon(value, precision));
433
+ return ok ? decoded : value;
434
+ },
435
+
436
+ // Geo types - Point (lat+lon pair)
437
+ encodeGeoPointPair: (value, { precision = 6 } = {}) => {
438
+ if (value === null || value === undefined) return value;
439
+ // Accept object with lat/lon or array [lat, lon]
440
+ if (Array.isArray(value) && value.length === 2) {
441
+ const [ok, err, encoded] = tryFnSync(() => encodeGeoPoint(value[0], value[1], precision));
442
+ return ok ? encoded : value;
443
+ }
444
+ if (typeof value === 'object' && value.lat !== undefined && value.lon !== undefined) {
445
+ const [ok, err, encoded] = tryFnSync(() => encodeGeoPoint(value.lat, value.lon, precision));
446
+ return ok ? encoded : value;
447
+ }
448
+ if (typeof value === 'object' && value.latitude !== undefined && value.longitude !== undefined) {
449
+ const [ok, err, encoded] = tryFnSync(() => encodeGeoPoint(value.latitude, value.longitude, precision));
450
+ return ok ? encoded : value;
451
+ }
452
+ return value;
453
+ },
454
+ decodeGeoPointPair: (value, { precision = 6 } = {}) => {
455
+ if (value === null || value === undefined) return value;
456
+ if (typeof value !== 'string') return value;
457
+ const [ok, err, decoded] = tryFnSync(() => decodeGeoPoint(value, precision));
458
+ // Return as object { latitude, longitude }
459
+ return ok ? decoded : value;
460
+ },
461
+
332
462
  }
333
463
 
334
464
  export class Schema {
@@ -353,7 +483,7 @@ export class Schema {
353
483
  const processedAttributes = this.preprocessAttributesForValidation(this.attributes);
354
484
 
355
485
  this.validator = new ValidatorManager({ autoEncrypt: false }).compile(merge(
356
- { $$async: true },
486
+ { $$async: true, $$strict: false },
357
487
  processedAttributes,
358
488
  ))
359
489
 
@@ -398,9 +528,11 @@ export class Schema {
398
528
  }
399
529
  }
400
530
 
401
- addHook(hook, attribute, action) {
531
+ addHook(hook, attribute, action, params = {}) {
402
532
  if (!this.options.hooks[hook][attribute]) this.options.hooks[hook][attribute] = [];
403
- this.options.hooks[hook][attribute] = uniq([...this.options.hooks[hook][attribute], action])
533
+ // Store action with parameters if provided
534
+ const hookEntry = Object.keys(params).length > 0 ? { action, params } : action;
535
+ this.options.hooks[hook][attribute] = uniq([...this.options.hooks[hook][attribute], hookEntry])
404
536
  }
405
537
 
406
538
  extractObjectKeys(obj, prefix = '') {
@@ -573,6 +705,97 @@ export class Schema {
573
705
  continue;
574
706
  }
575
707
 
708
+ // Handle ip4 type
709
+ if (defStr.includes("ip4") || defType === 'ip4') {
710
+ this.addHook("beforeMap", name, "encodeIPv4");
711
+ this.addHook("afterUnmap", name, "decodeIPv4");
712
+ continue;
713
+ }
714
+
715
+ // Handle ip6 type
716
+ if (defStr.includes("ip6") || defType === 'ip6') {
717
+ this.addHook("beforeMap", name, "encodeIPv6");
718
+ this.addHook("afterUnmap", name, "decodeIPv6");
719
+ continue;
720
+ }
721
+
722
+ // Handle money type (integer-based, decimal-aware)
723
+ if (defStr.includes("money") || defType === 'money' || defStr.includes("crypto") || defType === 'crypto') {
724
+ // Extract decimals from money:8 or crypto:8 notation
725
+ let decimals = 2; // Default for fiat money (2 decimal places)
726
+
727
+ // If it's crypto, default to 8 decimals (satoshi/standard crypto)
728
+ if (defStr.includes("crypto") || defType === 'crypto') {
729
+ decimals = 8;
730
+ }
731
+
732
+ // Override with explicit decimals if provided: money:8, crypto:18
733
+ const decimalsMatch = defStr.match(/(?:money|crypto):(\d+)/i);
734
+ if (decimalsMatch) {
735
+ decimals = parseInt(decimalsMatch[1], 10);
736
+ }
737
+
738
+ this.addHook("beforeMap", name, "encodeMoney", { decimals });
739
+ this.addHook("afterUnmap", name, "decodeMoney", { decimals });
740
+ continue;
741
+ }
742
+
743
+ // Handle decimal type (fixed-point for non-monetary decimals)
744
+ if (defStr.includes("decimal") || defType === 'decimal') {
745
+ // Extract precision from decimal:4 notation
746
+ let precision = 2; // Default precision
747
+ const precisionMatch = defStr.match(/decimal:(\d+)/);
748
+ if (precisionMatch) {
749
+ precision = parseInt(precisionMatch[1], 10);
750
+ }
751
+
752
+ this.addHook("beforeMap", name, "encodeDecimalFixed", { precision });
753
+ this.addHook("afterUnmap", name, "decodeDecimalFixed", { precision });
754
+ continue;
755
+ }
756
+
757
+ // Handle geo:lat type (latitude)
758
+ if (defStr.includes("geo:lat") || (defType === 'geo' && defStr.includes('lat'))) {
759
+ // Extract precision from geo:lat:6 notation
760
+ let precision = 6; // Default precision (GPS standard)
761
+ const precisionMatch = defStr.match(/geo:lat:(\d+)/);
762
+ if (precisionMatch) {
763
+ precision = parseInt(precisionMatch[1], 10);
764
+ }
765
+
766
+ this.addHook("beforeMap", name, "encodeGeoLatitude", { precision });
767
+ this.addHook("afterUnmap", name, "decodeGeoLatitude", { precision });
768
+ continue;
769
+ }
770
+
771
+ // Handle geo:lon type (longitude)
772
+ if (defStr.includes("geo:lon") || (defType === 'geo' && defStr.includes('lon'))) {
773
+ // Extract precision from geo:lon:6 notation
774
+ let precision = 6; // Default precision
775
+ const precisionMatch = defStr.match(/geo:lon:(\d+)/);
776
+ if (precisionMatch) {
777
+ precision = parseInt(precisionMatch[1], 10);
778
+ }
779
+
780
+ this.addHook("beforeMap", name, "encodeGeoLongitude", { precision });
781
+ this.addHook("afterUnmap", name, "decodeGeoLongitude", { precision });
782
+ continue;
783
+ }
784
+
785
+ // Handle geo:point type (lat+lon pair)
786
+ if (defStr.includes("geo:point") || defType === 'geo:point') {
787
+ // Extract precision from geo:point:6 notation
788
+ let precision = 6; // Default precision
789
+ const precisionMatch = defStr.match(/geo:point:(\d+)/);
790
+ if (precisionMatch) {
791
+ precision = parseInt(precisionMatch[1], 10);
792
+ }
793
+
794
+ this.addHook("beforeMap", name, "encodeGeoPointPair", { precision });
795
+ this.addHook("afterUnmap", name, "decodeGeoPointPair", { precision });
796
+ continue;
797
+ }
798
+
576
799
  // Handle numbers (only for non-array fields)
577
800
  if (defStr.includes("number") || defType === 'number') {
578
801
  // Check if it's specifically an integer field
@@ -704,12 +927,17 @@ export class Schema {
704
927
  async applyHooksActions(resourceItem, hook) {
705
928
  const cloned = cloneDeep(resourceItem);
706
929
  for (const [attribute, actions] of Object.entries(this.options.hooks[hook])) {
707
- for (const action of actions) {
930
+ for (const actionEntry of actions) {
931
+ // Support both string actions and {action, params} objects
932
+ const actionName = typeof actionEntry === 'string' ? actionEntry : actionEntry.action;
933
+ const actionParams = typeof actionEntry === 'object' ? actionEntry.params : {};
934
+
708
935
  const value = get(cloned, attribute)
709
- if (value !== undefined && typeof SchemaActions[action] === 'function') {
710
- set(cloned, attribute, await SchemaActions[action](value, {
936
+ if (value !== undefined && typeof SchemaActions[actionName] === 'function') {
937
+ set(cloned, attribute, await SchemaActions[actionName](value, {
711
938
  passphrase: this.passphrase,
712
939
  separator: this.options.arraySeparator,
940
+ ...actionParams // Merge custom parameters (currency, precision, etc.)
713
941
  }))
714
942
  }
715
943
  }
@@ -785,27 +1013,35 @@ export class Schema {
785
1013
  }
786
1014
  }
787
1015
  // PATCH: ensure arrays are always arrays
1016
+ // Skip automatic array conversion if there's an afterUnmap hook that will handle it
788
1017
  if (this.attributes) {
789
1018
  if (typeof attrDef === 'string' && attrDef.includes('array')) {
790
- if (Array.isArray(parsedValue)) {
791
- // Already an array
792
- } else if (typeof parsedValue === 'string' && parsedValue.trim().startsWith('[')) {
793
- const [okArr, errArr, arr] = tryFnSync(() => JSON.parse(parsedValue));
794
- if (okArr && Array.isArray(arr)) {
795
- parsedValue = arr;
1019
+ if (!hasAfterUnmapHook) {
1020
+ if (Array.isArray(parsedValue)) {
1021
+ // Already an array
1022
+ } else if (typeof parsedValue === 'string' && parsedValue.trim().startsWith('[')) {
1023
+ const [okArr, errArr, arr] = tryFnSync(() => JSON.parse(parsedValue));
1024
+ if (okArr && Array.isArray(arr)) {
1025
+ parsedValue = arr;
1026
+ }
1027
+ } else {
1028
+ parsedValue = SchemaActions.toArray(parsedValue, { separator: this.options.arraySeparator });
796
1029
  }
797
- } else {
798
- parsedValue = SchemaActions.toArray(parsedValue, { separator: this.options.arraySeparator });
799
1030
  }
800
1031
  }
801
1032
  }
802
1033
  // PATCH: apply afterUnmap hooks for type restoration
803
1034
  if (this.options.hooks && this.options.hooks.afterUnmap && this.options.hooks.afterUnmap[originalKey]) {
804
- for (const action of this.options.hooks.afterUnmap[originalKey]) {
805
- if (typeof SchemaActions[action] === 'function') {
806
- parsedValue = await SchemaActions[action](parsedValue, {
1035
+ for (const actionEntry of this.options.hooks.afterUnmap[originalKey]) {
1036
+ // Support both string actions and {action, params} objects
1037
+ const actionName = typeof actionEntry === 'string' ? actionEntry : actionEntry.action;
1038
+ const actionParams = typeof actionEntry === 'object' ? actionEntry.params : {};
1039
+
1040
+ if (typeof SchemaActions[actionName] === 'function') {
1041
+ parsedValue = await SchemaActions[actionName](parsedValue, {
807
1042
  passphrase: this.passphrase,
808
1043
  separator: this.options.arraySeparator,
1044
+ ...actionParams // Merge custom parameters (currency, precision, etc.)
809
1045
  });
810
1046
  }
811
1047
  }
@@ -843,6 +1079,66 @@ export class Schema {
843
1079
 
844
1080
  for (const [key, value] of Object.entries(attributes)) {
845
1081
  if (typeof value === 'string') {
1082
+ // Expand ip4 shorthand to string type with custom validation
1083
+ if (value === 'ip4' || value.startsWith('ip4|')) {
1084
+ processed[key] = value.replace(/^ip4/, 'string');
1085
+ continue;
1086
+ }
1087
+ // Expand ip6 shorthand to string type with custom validation
1088
+ if (value === 'ip6' || value.startsWith('ip6|')) {
1089
+ processed[key] = value.replace(/^ip6/, 'string');
1090
+ continue;
1091
+ }
1092
+ // Expand money/crypto shorthand to number type with min validation
1093
+ if (value === 'money' || value.startsWith('money:') || value.startsWith('money|') ||
1094
+ value === 'crypto' || value.startsWith('crypto:') || value.startsWith('crypto|')) {
1095
+ // Extract any modifiers after money:N or crypto:N
1096
+ const rest = value.replace(/^(?:money|crypto)(?::\d+)?/, '');
1097
+ // Money must be non-negative
1098
+ const hasMin = rest.includes('min:');
1099
+ processed[key] = hasMin ? `number${rest}` : `number|min:0${rest}`;
1100
+ continue;
1101
+ }
1102
+ // Expand decimal shorthand to number type
1103
+ if (value === 'decimal' || value.startsWith('decimal:') || value.startsWith('decimal|')) {
1104
+ // Extract any modifiers after decimal:PRECISION
1105
+ const rest = value.replace(/^decimal(:\d+)?/, '');
1106
+ processed[key] = `number${rest}`;
1107
+ continue;
1108
+ }
1109
+ // Expand geo:lat shorthand to number type with range validation
1110
+ if (value.startsWith('geo:lat')) {
1111
+ // Extract any modifiers after geo:lat:PRECISION
1112
+ const rest = value.replace(/^geo:lat(:\d+)?/, '');
1113
+ // Latitude range: -90 to 90
1114
+ const hasMin = rest.includes('min:');
1115
+ const hasMax = rest.includes('max:');
1116
+ let validation = 'number';
1117
+ if (!hasMin) validation += '|min:-90';
1118
+ if (!hasMax) validation += '|max:90';
1119
+ processed[key] = validation + rest;
1120
+ continue;
1121
+ }
1122
+ // Expand geo:lon shorthand to number type with range validation
1123
+ if (value.startsWith('geo:lon')) {
1124
+ // Extract any modifiers after geo:lon:PRECISION
1125
+ const rest = value.replace(/^geo:lon(:\d+)?/, '');
1126
+ // Longitude range: -180 to 180
1127
+ const hasMin = rest.includes('min:');
1128
+ const hasMax = rest.includes('max:');
1129
+ let validation = 'number';
1130
+ if (!hasMin) validation += '|min:-180';
1131
+ if (!hasMax) validation += '|max:180';
1132
+ processed[key] = validation + rest;
1133
+ continue;
1134
+ }
1135
+ // Expand geo:point shorthand to object with lat/lon
1136
+ if (value.startsWith('geo:point')) {
1137
+ // geo:point is an object or array with lat/lon
1138
+ // For simplicity, allow it as any type (will be validated in hooks)
1139
+ processed[key] = 'any';
1140
+ continue;
1141
+ }
846
1142
  // Expand embedding:XXX shorthand to array|items:number|length:XXX
847
1143
  if (value.startsWith('embedding:')) {
848
1144
  const lengthMatch = value.match(/embedding:(\d+)/);
@@ -866,8 +1162,46 @@ export class Schema {
866
1162
  const hasValidatorType = value.type !== undefined && key !== '$$type';
867
1163
 
868
1164
  if (hasValidatorType) {
869
- // This is a validator type definition (e.g., { type: 'array', items: 'number' }), pass it through
870
- processed[key] = value;
1165
+ // Handle ip4 and ip6 object notation
1166
+ if (value.type === 'ip4') {
1167
+ processed[key] = { ...value, type: 'string' };
1168
+ } else if (value.type === 'ip6') {
1169
+ processed[key] = { ...value, type: 'string' };
1170
+ } else if (value.type === 'money' || value.type === 'crypto') {
1171
+ // Money/crypto type → number with min:0
1172
+ processed[key] = { ...value, type: 'number', min: value.min !== undefined ? value.min : 0 };
1173
+ } else if (value.type === 'decimal') {
1174
+ // Decimal type → number
1175
+ processed[key] = { ...value, type: 'number' };
1176
+ } else if (value.type === 'geo:lat' || value.type === 'geo-lat') {
1177
+ // Geo latitude → number with range [-90, 90]
1178
+ processed[key] = {
1179
+ ...value,
1180
+ type: 'number',
1181
+ min: value.min !== undefined ? value.min : -90,
1182
+ max: value.max !== undefined ? value.max : 90
1183
+ };
1184
+ } else if (value.type === 'geo:lon' || value.type === 'geo-lon') {
1185
+ // Geo longitude → number with range [-180, 180]
1186
+ processed[key] = {
1187
+ ...value,
1188
+ type: 'number',
1189
+ min: value.min !== undefined ? value.min : -180,
1190
+ max: value.max !== undefined ? value.max : 180
1191
+ };
1192
+ } else if (value.type === 'geo:point' || value.type === 'geo-point') {
1193
+ // Geo point → any (will be validated in hooks)
1194
+ processed[key] = { ...value, type: 'any' };
1195
+ } else if (value.type === 'object' && value.properties) {
1196
+ // Recursively process nested object properties
1197
+ processed[key] = {
1198
+ ...value,
1199
+ properties: this.preprocessAttributesForValidation(value.properties)
1200
+ };
1201
+ } else {
1202
+ // This is a validator type definition (e.g., { type: 'array', items: 'number' }), pass it through
1203
+ processed[key] = value;
1204
+ }
871
1205
  } else {
872
1206
  // This is a nested object structure, wrap it for validation
873
1207
  const isExplicitRequired = value.$$type && value.$$type.includes('required');