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.
- package/README.md +102 -8
- package/dist/s3db.cjs.js +36664 -15480
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +57 -0
- package/dist/s3db.es.js +36661 -15531
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +27 -6
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +41 -46
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +39 -19
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +539 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +350 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +14 -10
- package/src/s3db.d.ts +57 -0
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- package/src/partition-drivers/sync-partition-driver.js +0 -38
package/src/resource.class.js
CHANGED
|
@@ -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='
|
|
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
|
-
|
|
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., '
|
|
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
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
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
|
|
2893
|
-
return
|
|
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
|
|
package/src/schema.class.js
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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[
|
|
710
|
-
set(cloned, attribute, await SchemaActions[
|
|
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 (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
//
|
|
870
|
-
|
|
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');
|