s3db.js 6.2.0 → 7.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/PLUGINS.md +2724 -0
- package/README.md +372 -469
- package/UNLICENSE +24 -0
- package/dist/s3db.cjs.js +30057 -18387
- package/dist/s3db.cjs.min.js +1 -1
- package/dist/s3db.d.ts +373 -72
- package/dist/s3db.es.js +30043 -18384
- package/dist/s3db.es.min.js +1 -1
- package/dist/s3db.iife.js +29730 -18061
- package/dist/s3db.iife.min.js +1 -1
- package/package.json +44 -69
- package/src/behaviors/body-only.js +110 -0
- package/src/behaviors/body-overflow.js +153 -0
- package/src/behaviors/enforce-limits.js +195 -0
- package/src/behaviors/index.js +39 -0
- package/src/behaviors/truncate-data.js +204 -0
- package/src/behaviors/user-managed.js +147 -0
- package/src/client.class.js +515 -0
- package/src/concerns/base62.js +61 -0
- package/src/concerns/calculator.js +204 -0
- package/src/concerns/crypto.js +142 -0
- package/src/concerns/id.js +8 -0
- package/src/concerns/index.js +5 -0
- package/src/concerns/try-fn.js +151 -0
- package/src/connection-string.class.js +75 -0
- package/src/database.class.js +599 -0
- package/src/errors.js +261 -0
- package/src/index.js +17 -0
- package/src/plugins/audit.plugin.js +442 -0
- package/src/plugins/cache/cache.class.js +53 -0
- package/src/plugins/cache/index.js +6 -0
- package/src/plugins/cache/memory-cache.class.js +164 -0
- package/src/plugins/cache/s3-cache.class.js +189 -0
- package/src/plugins/cache.plugin.js +275 -0
- package/src/plugins/consumers/index.js +24 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
- package/src/plugins/consumers/sqs-consumer.js +102 -0
- package/src/plugins/costs.plugin.js +81 -0
- package/src/plugins/fulltext.plugin.js +473 -0
- package/src/plugins/index.js +12 -0
- package/src/plugins/metrics.plugin.js +603 -0
- package/src/plugins/plugin.class.js +210 -0
- package/src/plugins/plugin.obj.js +13 -0
- package/src/plugins/queue-consumer.plugin.js +134 -0
- package/src/plugins/replicator.plugin.js +769 -0
- package/src/plugins/replicators/base-replicator.class.js +85 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
- package/src/plugins/replicators/index.js +44 -0
- package/src/plugins/replicators/postgres-replicator.class.js +427 -0
- package/src/plugins/replicators/s3db-replicator.class.js +352 -0
- package/src/plugins/replicators/sqs-replicator.class.js +427 -0
- package/src/resource.class.js +2626 -0
- package/src/s3db.d.ts +1263 -0
- package/src/schema.class.js +706 -0
- package/src/stream/index.js +16 -0
- package/src/stream/resource-ids-page-reader.class.js +10 -0
- package/src/stream/resource-ids-reader.class.js +63 -0
- package/src/stream/resource-reader.class.js +81 -0
- package/src/stream/resource-writer.class.js +92 -0
- package/src/validator.class.js +97 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { flatten, unflatten } from "flat";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
set,
|
|
5
|
+
get,
|
|
6
|
+
uniq,
|
|
7
|
+
merge,
|
|
8
|
+
invert,
|
|
9
|
+
isEmpty,
|
|
10
|
+
isString,
|
|
11
|
+
cloneDeep,
|
|
12
|
+
} from "lodash-es";
|
|
13
|
+
|
|
14
|
+
import { encrypt, decrypt } from "./concerns/crypto.js";
|
|
15
|
+
import { ValidatorManager } from "./validator.class.js";
|
|
16
|
+
import { tryFn, tryFnSync } from "./concerns/try-fn.js";
|
|
17
|
+
import { SchemaError } from "./errors.js";
|
|
18
|
+
import { encode as toBase62, decode as fromBase62, encodeDecimal, decodeDecimal } from "./concerns/base62.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate base62 mapping for attributes
|
|
22
|
+
* @param {string[]} keys - Array of attribute keys
|
|
23
|
+
* @returns {Object} Mapping object with base62 keys
|
|
24
|
+
*/
|
|
25
|
+
function generateBase62Mapping(keys) {
|
|
26
|
+
const mapping = {};
|
|
27
|
+
const reversedMapping = {};
|
|
28
|
+
keys.forEach((key, index) => {
|
|
29
|
+
const base62Key = toBase62(index);
|
|
30
|
+
mapping[key] = base62Key;
|
|
31
|
+
reversedMapping[base62Key] = key;
|
|
32
|
+
});
|
|
33
|
+
return { mapping, reversedMapping };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SchemaActions = {
|
|
37
|
+
trim: (value) => value == null ? value : value.trim(),
|
|
38
|
+
|
|
39
|
+
encrypt: async (value, { passphrase }) => {
|
|
40
|
+
if (value === null || value === undefined) return value;
|
|
41
|
+
const [ok, err, res] = await tryFn(() => encrypt(value, passphrase));
|
|
42
|
+
return ok ? res : value;
|
|
43
|
+
},
|
|
44
|
+
decrypt: async (value, { passphrase }) => {
|
|
45
|
+
if (value === null || value === undefined) return value;
|
|
46
|
+
const [ok, err, raw] = await tryFn(() => decrypt(value, passphrase));
|
|
47
|
+
if (!ok) return value;
|
|
48
|
+
if (raw === 'null') return null;
|
|
49
|
+
if (raw === 'undefined') return undefined;
|
|
50
|
+
return raw;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
toString: (value) => value == null ? value : String(value),
|
|
54
|
+
|
|
55
|
+
fromArray: (value, { separator }) => {
|
|
56
|
+
if (value === null || value === undefined || !Array.isArray(value)) {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (value.length === 0) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
const escapedItems = value.map(item => {
|
|
63
|
+
if (typeof item === 'string') {
|
|
64
|
+
return item
|
|
65
|
+
.replace(/\\/g, '\\\\')
|
|
66
|
+
.replace(new RegExp(`\\${separator}`, 'g'), `\\${separator}`);
|
|
67
|
+
}
|
|
68
|
+
return String(item);
|
|
69
|
+
});
|
|
70
|
+
return escapedItems.join(separator);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
toArray: (value, { separator }) => {
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
if (value === null || value === undefined) {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
if (value === '') {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const items = [];
|
|
84
|
+
let current = '';
|
|
85
|
+
let i = 0;
|
|
86
|
+
const str = String(value);
|
|
87
|
+
while (i < str.length) {
|
|
88
|
+
if (str[i] === '\\' && i + 1 < str.length) {
|
|
89
|
+
// If next char is separator or backslash, add it literally
|
|
90
|
+
current += str[i + 1];
|
|
91
|
+
i += 2;
|
|
92
|
+
} else if (str[i] === separator) {
|
|
93
|
+
items.push(current);
|
|
94
|
+
current = '';
|
|
95
|
+
i++;
|
|
96
|
+
} else {
|
|
97
|
+
current += str[i];
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
items.push(current);
|
|
102
|
+
return items;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
toJSON: (value) => {
|
|
106
|
+
if (value === null) return null;
|
|
107
|
+
if (value === undefined) return undefined;
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
const [ok, err, parsed] = tryFnSync(() => JSON.parse(value));
|
|
110
|
+
if (ok && typeof parsed === 'object') return value;
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
const [ok, err, json] = tryFnSync(() => JSON.stringify(value));
|
|
114
|
+
return ok ? json : value;
|
|
115
|
+
},
|
|
116
|
+
fromJSON: (value) => {
|
|
117
|
+
if (value === null) return null;
|
|
118
|
+
if (value === undefined) return undefined;
|
|
119
|
+
if (typeof value !== 'string') return value;
|
|
120
|
+
if (value === '') return '';
|
|
121
|
+
const [ok, err, parsed] = tryFnSync(() => JSON.parse(value));
|
|
122
|
+
return ok ? parsed : value;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
toNumber: (value) => isString(value) ? value.includes('.') ? parseFloat(value) : parseInt(value) : value,
|
|
126
|
+
|
|
127
|
+
toBool: (value) => [true, 1, 'true', '1', 'yes', 'y'].includes(value),
|
|
128
|
+
fromBool: (value) => [true, 1, 'true', '1', 'yes', 'y'].includes(value) ? '1' : '0',
|
|
129
|
+
fromBase62: (value) => {
|
|
130
|
+
if (value === null || value === undefined || value === '') return value;
|
|
131
|
+
if (typeof value === 'number') return value;
|
|
132
|
+
if (typeof value === 'string') {
|
|
133
|
+
const n = fromBase62(value);
|
|
134
|
+
return isNaN(n) ? undefined : n;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
},
|
|
138
|
+
toBase62: (value) => {
|
|
139
|
+
if (value === null || value === undefined || value === '') return value;
|
|
140
|
+
if (typeof value === 'number') {
|
|
141
|
+
return toBase62(value);
|
|
142
|
+
}
|
|
143
|
+
if (typeof value === 'string') {
|
|
144
|
+
const n = Number(value);
|
|
145
|
+
return isNaN(n) ? value : toBase62(n);
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
},
|
|
149
|
+
fromBase62Decimal: (value) => {
|
|
150
|
+
if (value === null || value === undefined || value === '') return value;
|
|
151
|
+
if (typeof value === 'number') return value;
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const n = decodeDecimal(value);
|
|
154
|
+
return isNaN(n) ? undefined : n;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
},
|
|
158
|
+
toBase62Decimal: (value) => {
|
|
159
|
+
if (value === null || value === undefined || value === '') return value;
|
|
160
|
+
if (typeof value === 'number') {
|
|
161
|
+
return encodeDecimal(value);
|
|
162
|
+
}
|
|
163
|
+
if (typeof value === 'string') {
|
|
164
|
+
const n = Number(value);
|
|
165
|
+
return isNaN(n) ? value : encodeDecimal(n);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
},
|
|
169
|
+
fromArrayOfNumbers: (value, { separator }) => {
|
|
170
|
+
if (value === null || value === undefined || !Array.isArray(value)) {
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
if (value.length === 0) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
const base62Items = value.map(item => {
|
|
177
|
+
if (typeof item === 'number' && !isNaN(item)) {
|
|
178
|
+
return toBase62(item);
|
|
179
|
+
}
|
|
180
|
+
// fallback: try to parse as number, else keep as is
|
|
181
|
+
const n = Number(item);
|
|
182
|
+
return isNaN(n) ? '' : toBase62(n);
|
|
183
|
+
});
|
|
184
|
+
return base62Items.join(separator);
|
|
185
|
+
},
|
|
186
|
+
toArrayOfNumbers: (value, { separator }) => {
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
return value.map(v => (typeof v === 'number' ? v : fromBase62(v)));
|
|
189
|
+
}
|
|
190
|
+
if (value === null || value === undefined) {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
if (value === '') {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const str = String(value);
|
|
197
|
+
const items = [];
|
|
198
|
+
let current = '';
|
|
199
|
+
let i = 0;
|
|
200
|
+
while (i < str.length) {
|
|
201
|
+
if (str[i] === '\\' && i + 1 < str.length) {
|
|
202
|
+
current += str[i + 1];
|
|
203
|
+
i += 2;
|
|
204
|
+
} else if (str[i] === separator) {
|
|
205
|
+
items.push(current);
|
|
206
|
+
current = '';
|
|
207
|
+
i++;
|
|
208
|
+
} else {
|
|
209
|
+
current += str[i];
|
|
210
|
+
i++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
items.push(current);
|
|
214
|
+
return items.map(v => {
|
|
215
|
+
if (typeof v === 'number') return v;
|
|
216
|
+
if (typeof v === 'string' && v !== '') {
|
|
217
|
+
const n = fromBase62(v);
|
|
218
|
+
return isNaN(n) ? NaN : n;
|
|
219
|
+
}
|
|
220
|
+
return NaN;
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
fromArrayOfDecimals: (value, { separator }) => {
|
|
224
|
+
if (value === null || value === undefined || !Array.isArray(value)) {
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
if (value.length === 0) {
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
const base62Items = value.map(item => {
|
|
231
|
+
if (typeof item === 'number' && !isNaN(item)) {
|
|
232
|
+
return encodeDecimal(item);
|
|
233
|
+
}
|
|
234
|
+
// fallback: try to parse as number, else keep as is
|
|
235
|
+
const n = Number(item);
|
|
236
|
+
return isNaN(n) ? '' : encodeDecimal(n);
|
|
237
|
+
});
|
|
238
|
+
return base62Items.join(separator);
|
|
239
|
+
},
|
|
240
|
+
toArrayOfDecimals: (value, { separator }) => {
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.map(v => (typeof v === 'number' ? v : decodeDecimal(v)));
|
|
243
|
+
}
|
|
244
|
+
if (value === null || value === undefined) {
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
if (value === '') {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
const str = String(value);
|
|
251
|
+
const items = [];
|
|
252
|
+
let current = '';
|
|
253
|
+
let i = 0;
|
|
254
|
+
while (i < str.length) {
|
|
255
|
+
if (str[i] === '\\' && i + 1 < str.length) {
|
|
256
|
+
current += str[i + 1];
|
|
257
|
+
i += 2;
|
|
258
|
+
} else if (str[i] === separator) {
|
|
259
|
+
items.push(current);
|
|
260
|
+
current = '';
|
|
261
|
+
i++;
|
|
262
|
+
} else {
|
|
263
|
+
current += str[i];
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
items.push(current);
|
|
268
|
+
return items.map(v => {
|
|
269
|
+
if (typeof v === 'number') return v;
|
|
270
|
+
if (typeof v === 'string' && v !== '') {
|
|
271
|
+
const n = decodeDecimal(v);
|
|
272
|
+
return isNaN(n) ? NaN : n;
|
|
273
|
+
}
|
|
274
|
+
return NaN;
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export class Schema {
|
|
281
|
+
constructor(args) {
|
|
282
|
+
const {
|
|
283
|
+
map,
|
|
284
|
+
name,
|
|
285
|
+
attributes,
|
|
286
|
+
passphrase,
|
|
287
|
+
version = 1,
|
|
288
|
+
options = {}
|
|
289
|
+
} = args;
|
|
290
|
+
|
|
291
|
+
this.name = name;
|
|
292
|
+
this.version = version;
|
|
293
|
+
this.attributes = attributes || {};
|
|
294
|
+
this.passphrase = passphrase ?? "secret";
|
|
295
|
+
this.options = merge({}, this.defaultOptions(), options);
|
|
296
|
+
this.allNestedObjectsOptional = this.options.allNestedObjectsOptional ?? false;
|
|
297
|
+
|
|
298
|
+
// Preprocess attributes to handle nested objects for validator compilation
|
|
299
|
+
const processedAttributes = this.preprocessAttributesForValidation(this.attributes);
|
|
300
|
+
|
|
301
|
+
this.validator = new ValidatorManager({ autoEncrypt: false }).compile(merge(
|
|
302
|
+
{ $$async: true },
|
|
303
|
+
processedAttributes,
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
if (this.options.generateAutoHooks) this.generateAutoHooks();
|
|
307
|
+
|
|
308
|
+
if (!isEmpty(map)) {
|
|
309
|
+
this.map = map;
|
|
310
|
+
this.reversedMap = invert(map);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const flatAttrs = flatten(this.attributes, { safe: true });
|
|
314
|
+
const leafKeys = Object.keys(flatAttrs).filter(k => !k.includes('$$'));
|
|
315
|
+
|
|
316
|
+
// Also include parent object keys for objects that can be empty
|
|
317
|
+
const objectKeys = this.extractObjectKeys(this.attributes);
|
|
318
|
+
|
|
319
|
+
// Combine leaf keys and object keys, removing duplicates
|
|
320
|
+
const allKeys = [...new Set([...leafKeys, ...objectKeys])];
|
|
321
|
+
|
|
322
|
+
// Generate base62 mapping instead of sequential numbers
|
|
323
|
+
const { mapping, reversedMapping } = generateBase62Mapping(allKeys);
|
|
324
|
+
this.map = mapping;
|
|
325
|
+
this.reversedMap = reversedMapping;
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
defaultOptions() {
|
|
332
|
+
return {
|
|
333
|
+
autoEncrypt: true,
|
|
334
|
+
autoDecrypt: true,
|
|
335
|
+
arraySeparator: "|",
|
|
336
|
+
generateAutoHooks: true,
|
|
337
|
+
|
|
338
|
+
hooks: {
|
|
339
|
+
beforeMap: {},
|
|
340
|
+
afterMap: {},
|
|
341
|
+
beforeUnmap: {},
|
|
342
|
+
afterUnmap: {},
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
addHook(hook, attribute, action) {
|
|
348
|
+
if (!this.options.hooks[hook][attribute]) this.options.hooks[hook][attribute] = [];
|
|
349
|
+
this.options.hooks[hook][attribute] = uniq([...this.options.hooks[hook][attribute], action])
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
extractObjectKeys(obj, prefix = '') {
|
|
353
|
+
const objectKeys = [];
|
|
354
|
+
|
|
355
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
356
|
+
if (key.startsWith('$$')) continue; // Skip schema metadata
|
|
357
|
+
|
|
358
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
359
|
+
|
|
360
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
361
|
+
// This is an object, add its key
|
|
362
|
+
objectKeys.push(fullKey);
|
|
363
|
+
|
|
364
|
+
// Check if it has nested objects
|
|
365
|
+
if (value.$$type === 'object') {
|
|
366
|
+
// Recursively extract nested object keys
|
|
367
|
+
objectKeys.push(...this.extractObjectKeys(value, fullKey));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return objectKeys;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
generateAutoHooks() {
|
|
376
|
+
const schema = flatten(cloneDeep(this.attributes), { safe: true });
|
|
377
|
+
|
|
378
|
+
for (const [name, definition] of Object.entries(schema)) {
|
|
379
|
+
// Handle arrays first to avoid conflicts
|
|
380
|
+
if (definition.includes("array")) {
|
|
381
|
+
if (definition.includes('items:string')) {
|
|
382
|
+
this.addHook("beforeMap", name, "fromArray");
|
|
383
|
+
this.addHook("afterUnmap", name, "toArray");
|
|
384
|
+
} else if (definition.includes('items:number')) {
|
|
385
|
+
// Check if the array items should be treated as integers
|
|
386
|
+
const isIntegerArray = definition.includes("integer:true") ||
|
|
387
|
+
definition.includes("|integer:") ||
|
|
388
|
+
definition.includes("|integer");
|
|
389
|
+
|
|
390
|
+
if (isIntegerArray) {
|
|
391
|
+
// Use standard base62 for arrays of integers
|
|
392
|
+
this.addHook("beforeMap", name, "fromArrayOfNumbers");
|
|
393
|
+
this.addHook("afterUnmap", name, "toArrayOfNumbers");
|
|
394
|
+
} else {
|
|
395
|
+
// Use decimal-aware base62 for arrays of decimals
|
|
396
|
+
this.addHook("beforeMap", name, "fromArrayOfDecimals");
|
|
397
|
+
this.addHook("afterUnmap", name, "toArrayOfDecimals");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Skip other processing for arrays to avoid conflicts
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Handle secrets
|
|
405
|
+
if (definition.includes("secret")) {
|
|
406
|
+
if (this.options.autoEncrypt) {
|
|
407
|
+
this.addHook("beforeMap", name, "encrypt");
|
|
408
|
+
}
|
|
409
|
+
if (this.options.autoDecrypt) {
|
|
410
|
+
this.addHook("afterUnmap", name, "decrypt");
|
|
411
|
+
}
|
|
412
|
+
// Skip other processing for secrets
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Handle numbers (only for non-array fields)
|
|
417
|
+
if (definition.includes("number")) {
|
|
418
|
+
// Check if it's specifically an integer field
|
|
419
|
+
const isInteger = definition.includes("integer:true") ||
|
|
420
|
+
definition.includes("|integer:") ||
|
|
421
|
+
definition.includes("|integer");
|
|
422
|
+
|
|
423
|
+
if (isInteger) {
|
|
424
|
+
// Use standard base62 for integers
|
|
425
|
+
this.addHook("beforeMap", name, "toBase62");
|
|
426
|
+
this.addHook("afterUnmap", name, "fromBase62");
|
|
427
|
+
} else {
|
|
428
|
+
// Use decimal-aware base62 for decimal numbers
|
|
429
|
+
this.addHook("beforeMap", name, "toBase62Decimal");
|
|
430
|
+
this.addHook("afterUnmap", name, "fromBase62Decimal");
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Handle booleans
|
|
436
|
+
if (definition.includes("boolean")) {
|
|
437
|
+
this.addHook("beforeMap", name, "fromBool");
|
|
438
|
+
this.addHook("afterUnmap", name, "toBool");
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Handle JSON fields
|
|
443
|
+
if (definition.includes("json")) {
|
|
444
|
+
this.addHook("beforeMap", name, "toJSON");
|
|
445
|
+
this.addHook("afterUnmap", name, "fromJSON");
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Handle object fields - add JSON serialization hooks
|
|
450
|
+
if (definition === "object" || definition.includes("object")) {
|
|
451
|
+
this.addHook("beforeMap", name, "toJSON");
|
|
452
|
+
this.addHook("afterUnmap", name, "fromJSON");
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
static import(data) {
|
|
459
|
+
let {
|
|
460
|
+
map,
|
|
461
|
+
name,
|
|
462
|
+
options,
|
|
463
|
+
version,
|
|
464
|
+
attributes
|
|
465
|
+
} = isString(data) ? JSON.parse(data) : data;
|
|
466
|
+
|
|
467
|
+
// Corrige atributos aninhados que possam ter sido serializados como string JSON
|
|
468
|
+
const [ok, err, attrs] = tryFnSync(() => Schema._importAttributes(attributes));
|
|
469
|
+
if (!ok) throw new SchemaError('Failed to import schema attributes', { original: err, input: attributes });
|
|
470
|
+
attributes = attrs;
|
|
471
|
+
|
|
472
|
+
const schema = new Schema({
|
|
473
|
+
map,
|
|
474
|
+
name,
|
|
475
|
+
options,
|
|
476
|
+
version,
|
|
477
|
+
attributes
|
|
478
|
+
});
|
|
479
|
+
return schema;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Recursively import attributes, parsing only stringified objects (legacy)
|
|
484
|
+
*/
|
|
485
|
+
static _importAttributes(attrs) {
|
|
486
|
+
if (typeof attrs === 'string') {
|
|
487
|
+
// Tenta detectar se é um objeto serializado como string JSON
|
|
488
|
+
const [ok, err, parsed] = tryFnSync(() => JSON.parse(attrs));
|
|
489
|
+
if (ok && typeof parsed === 'object' && parsed !== null) {
|
|
490
|
+
const [okNested, errNested, nested] = tryFnSync(() => Schema._importAttributes(parsed));
|
|
491
|
+
if (!okNested) throw new SchemaError('Failed to parse nested schema attribute', { original: errNested, input: attrs });
|
|
492
|
+
return nested;
|
|
493
|
+
}
|
|
494
|
+
return attrs;
|
|
495
|
+
}
|
|
496
|
+
if (Array.isArray(attrs)) {
|
|
497
|
+
const [okArr, errArr, arr] = tryFnSync(() => attrs.map(a => Schema._importAttributes(a)));
|
|
498
|
+
if (!okArr) throw new SchemaError('Failed to import array schema attributes', { original: errArr, input: attrs });
|
|
499
|
+
return arr;
|
|
500
|
+
}
|
|
501
|
+
if (typeof attrs === 'object' && attrs !== null) {
|
|
502
|
+
const out = {};
|
|
503
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
504
|
+
const [okObj, errObj, val] = tryFnSync(() => Schema._importAttributes(v));
|
|
505
|
+
if (!okObj) throw new SchemaError('Failed to import object schema attribute', { original: errObj, key: k, input: v });
|
|
506
|
+
out[k] = val;
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
return attrs;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export() {
|
|
514
|
+
const data = {
|
|
515
|
+
version: this.version,
|
|
516
|
+
name: this.name,
|
|
517
|
+
options: this.options,
|
|
518
|
+
attributes: this._exportAttributes(this.attributes),
|
|
519
|
+
map: this.map,
|
|
520
|
+
};
|
|
521
|
+
return data;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Recursively export attributes, keeping objects as objects and only serializing leaves as string
|
|
526
|
+
*/
|
|
527
|
+
_exportAttributes(attrs) {
|
|
528
|
+
if (typeof attrs === 'string') {
|
|
529
|
+
return attrs;
|
|
530
|
+
}
|
|
531
|
+
if (Array.isArray(attrs)) {
|
|
532
|
+
return attrs.map(a => this._exportAttributes(a));
|
|
533
|
+
}
|
|
534
|
+
if (typeof attrs === 'object' && attrs !== null) {
|
|
535
|
+
const out = {};
|
|
536
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
537
|
+
out[k] = this._exportAttributes(v);
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
return attrs;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async applyHooksActions(resourceItem, hook) {
|
|
545
|
+
const cloned = cloneDeep(resourceItem);
|
|
546
|
+
for (const [attribute, actions] of Object.entries(this.options.hooks[hook])) {
|
|
547
|
+
for (const action of actions) {
|
|
548
|
+
const value = get(cloned, attribute)
|
|
549
|
+
if (value !== undefined && typeof SchemaActions[action] === 'function') {
|
|
550
|
+
set(cloned, attribute, await SchemaActions[action](value, {
|
|
551
|
+
passphrase: this.passphrase,
|
|
552
|
+
separator: this.options.arraySeparator,
|
|
553
|
+
}))
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return cloned;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async validate(resourceItem, { mutateOriginal = false } = {}) {
|
|
561
|
+
let data = mutateOriginal ? resourceItem : cloneDeep(resourceItem)
|
|
562
|
+
const result = await this.validator(data);
|
|
563
|
+
return result
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async mapper(resourceItem) {
|
|
567
|
+
let obj = cloneDeep(resourceItem);
|
|
568
|
+
// Always apply beforeMap hooks for all fields
|
|
569
|
+
obj = await this.applyHooksActions(obj, "beforeMap");
|
|
570
|
+
// Then flatten the object
|
|
571
|
+
const flattenedObj = flatten(obj, { safe: true });
|
|
572
|
+
const rest = { '_v': this.version + '' };
|
|
573
|
+
for (const [key, value] of Object.entries(flattenedObj)) {
|
|
574
|
+
const mappedKey = this.map[key] || key;
|
|
575
|
+
// Always map numbers to base36
|
|
576
|
+
const attrDef = this.getAttributeDefinition(key);
|
|
577
|
+
if (typeof value === 'number' && typeof attrDef === 'string' && attrDef.includes('number')) {
|
|
578
|
+
rest[mappedKey] = toBase62(value);
|
|
579
|
+
} else if (typeof value === 'string') {
|
|
580
|
+
if (value === '[object Object]') {
|
|
581
|
+
rest[mappedKey] = '{}';
|
|
582
|
+
} else if (value.startsWith('{') || value.startsWith('[')) {
|
|
583
|
+
rest[mappedKey] = value;
|
|
584
|
+
} else {
|
|
585
|
+
rest[mappedKey] = value;
|
|
586
|
+
}
|
|
587
|
+
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
|
588
|
+
rest[mappedKey] = JSON.stringify(value);
|
|
589
|
+
} else {
|
|
590
|
+
rest[mappedKey] = value;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
await this.applyHooksActions(rest, "afterMap");
|
|
594
|
+
return rest;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async unmapper(mappedResourceItem, mapOverride) {
|
|
598
|
+
let obj = cloneDeep(mappedResourceItem);
|
|
599
|
+
delete obj._v;
|
|
600
|
+
obj = await this.applyHooksActions(obj, "beforeUnmap");
|
|
601
|
+
const reversedMap = mapOverride ? invert(mapOverride) : this.reversedMap;
|
|
602
|
+
const rest = {};
|
|
603
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
604
|
+
const originalKey = reversedMap && reversedMap[key] ? reversedMap[key] : key;
|
|
605
|
+
let parsedValue = value;
|
|
606
|
+
const attrDef = this.getAttributeDefinition(originalKey);
|
|
607
|
+
// Always unmap base62 strings to numbers for number fields (but not array fields or decimal fields)
|
|
608
|
+
if (typeof attrDef === 'string' && attrDef.includes('number') && !attrDef.includes('array') && !attrDef.includes('decimal')) {
|
|
609
|
+
if (typeof parsedValue === 'string' && parsedValue !== '') {
|
|
610
|
+
parsedValue = fromBase62(parsedValue);
|
|
611
|
+
} else if (typeof parsedValue === 'number') {
|
|
612
|
+
// Already a number, do nothing
|
|
613
|
+
} else {
|
|
614
|
+
parsedValue = undefined;
|
|
615
|
+
}
|
|
616
|
+
} else if (typeof value === 'string') {
|
|
617
|
+
if (value === '[object Object]') {
|
|
618
|
+
parsedValue = {};
|
|
619
|
+
} else if (value.startsWith('{') || value.startsWith('[')) {
|
|
620
|
+
const [ok, err, parsed] = tryFnSync(() => JSON.parse(value));
|
|
621
|
+
if (ok) parsedValue = parsed;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// PATCH: ensure arrays are always arrays
|
|
625
|
+
if (this.attributes) {
|
|
626
|
+
if (typeof attrDef === 'string' && attrDef.includes('array')) {
|
|
627
|
+
if (Array.isArray(parsedValue)) {
|
|
628
|
+
// Already an array
|
|
629
|
+
} else if (typeof parsedValue === 'string' && parsedValue.trim().startsWith('[')) {
|
|
630
|
+
const [okArr, errArr, arr] = tryFnSync(() => JSON.parse(parsedValue));
|
|
631
|
+
if (okArr && Array.isArray(arr)) {
|
|
632
|
+
parsedValue = arr;
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
parsedValue = SchemaActions.toArray(parsedValue, { separator: this.options.arraySeparator });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// PATCH: apply afterUnmap hooks for type restoration
|
|
640
|
+
if (this.options.hooks && this.options.hooks.afterUnmap && this.options.hooks.afterUnmap[originalKey]) {
|
|
641
|
+
for (const action of this.options.hooks.afterUnmap[originalKey]) {
|
|
642
|
+
if (typeof SchemaActions[action] === 'function') {
|
|
643
|
+
parsedValue = await SchemaActions[action](parsedValue, {
|
|
644
|
+
passphrase: this.passphrase,
|
|
645
|
+
separator: this.options.arraySeparator,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
rest[originalKey] = parsedValue;
|
|
651
|
+
}
|
|
652
|
+
await this.applyHooksActions(rest, "afterUnmap");
|
|
653
|
+
const result = unflatten(rest);
|
|
654
|
+
for (const [key, value] of Object.entries(mappedResourceItem)) {
|
|
655
|
+
if (key.startsWith('$')) {
|
|
656
|
+
result[key] = value;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Helper to get attribute definition by dot notation key
|
|
663
|
+
getAttributeDefinition(key) {
|
|
664
|
+
const parts = key.split('.');
|
|
665
|
+
let def = this.attributes;
|
|
666
|
+
for (const part of parts) {
|
|
667
|
+
if (!def) return undefined;
|
|
668
|
+
def = def[part];
|
|
669
|
+
}
|
|
670
|
+
return def;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Preprocess attributes to convert nested objects into validator-compatible format
|
|
675
|
+
* @param {Object} attributes - Original attributes
|
|
676
|
+
* @returns {Object} Processed attributes for validator
|
|
677
|
+
*/
|
|
678
|
+
preprocessAttributesForValidation(attributes) {
|
|
679
|
+
const processed = {};
|
|
680
|
+
|
|
681
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
682
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
683
|
+
const isExplicitRequired = value.$$type && value.$$type.includes('required');
|
|
684
|
+
const isExplicitOptional = value.$$type && value.$$type.includes('optional');
|
|
685
|
+
const objectConfig = {
|
|
686
|
+
type: 'object',
|
|
687
|
+
properties: this.preprocessAttributesForValidation(value),
|
|
688
|
+
strict: false
|
|
689
|
+
};
|
|
690
|
+
// Se for explicitamente required, não marca como opcional
|
|
691
|
+
if (isExplicitRequired) {
|
|
692
|
+
// nada
|
|
693
|
+
} else if (isExplicitOptional || this.allNestedObjectsOptional) {
|
|
694
|
+
objectConfig.optional = true;
|
|
695
|
+
}
|
|
696
|
+
processed[key] = objectConfig;
|
|
697
|
+
} else {
|
|
698
|
+
processed[key] = value;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return processed;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export default Schema
|