@genrojs/tytx 0.16.1

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/js/src/qs.js ADDED
@@ -0,0 +1,98 @@
1
+ // Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
2
+ /**
3
+ * TYTX Query String Encoding/Decoding.
4
+ *
5
+ * QS format is a flat key=value structure separated by &:
6
+ * alfa=33::L&date=2025-12-14::D::QS → {"alfa": 33, "date": Date(2025, 12, 14)}
7
+ * alfa&beta&gamma::QS → ["alfa", "beta", "gamma"]
8
+ *
9
+ * Rules:
10
+ * - All items with = → object
11
+ * - All items without = → array
12
+ * - Mixed → error (use ::JS embedded for complex structures)
13
+ */
14
+
15
+ import { fromTytx } from './decode.js';
16
+ import { rawEncode } from './utils.js';
17
+
18
+ /**
19
+ * Encode a JavaScript object or array to TYTX QS string.
20
+ *
21
+ * @param {Object|Array} value - Object with scalar values or array of strings
22
+ * @returns {string} QS string with typed values marked (without ::QS suffix)
23
+ *
24
+ * @example
25
+ * toQs({"alfa": 33, "date": new Date(Date.UTC(2025, 11, 14))})
26
+ * // 'alfa=33::L&date=2025-12-14::D'
27
+ *
28
+ * toQs(["alfa", "beta", "gamma"])
29
+ * // 'alfa&beta&gamma'
30
+ */
31
+ function toQs(value) {
32
+ if (Array.isArray(value)) {
33
+ return value.map(item => String(item)).join('&');
34
+ }
35
+
36
+ if (value !== null && typeof value === 'object') {
37
+ const parts = [];
38
+ for (const [k, v] of Object.entries(value)) {
39
+ const [encoded, result] = rawEncode(v, true); // force_suffix=true
40
+ if (encoded) {
41
+ parts.push(`${k}=${result}`);
42
+ } else {
43
+ parts.push(`${k}=${v}`);
44
+ }
45
+ }
46
+ return parts.join('&');
47
+ }
48
+
49
+ throw new TypeError(`toQs expects object or array, got ${typeof value}`);
50
+ }
51
+
52
+ /**
53
+ * Decode a TYTX QS string to JavaScript object or array.
54
+ *
55
+ * @param {string} data - QS string (without ::QS suffix)
56
+ * @returns {Object|Array} Object if all items have =, array if none have =
57
+ * @throws {Error} If mixed (some with =, some without)
58
+ *
59
+ * @example
60
+ * fromQs('alfa=33::L&date=2025-12-14::D')
61
+ * // {"alfa": 33, "date": Date(2025, 11, 14)}
62
+ *
63
+ * fromQs('alfa&beta&gamma')
64
+ * // ["alfa", "beta", "gamma"]
65
+ */
66
+ function fromQs(data) {
67
+
68
+ if (!data) {
69
+ return [];
70
+ }
71
+
72
+ const parts = data.split('&');
73
+ const hasEq = parts.map(p => p.includes('='));
74
+
75
+ const allWithEq = hasEq.every(Boolean);
76
+ const noneWithEq = !hasEq.some(Boolean);
77
+
78
+ if (!allWithEq && !noneWithEq) {
79
+ throw new Error("QS format error: mixed items with and without '='");
80
+ }
81
+
82
+ if (noneWithEq) {
83
+ // Array mode: decode each item
84
+ return parts.map(p => fromTytx(p));
85
+ }
86
+
87
+ // Object mode: split key=value and decode values
88
+ const result = {};
89
+ for (const part of parts) {
90
+ const eqIndex = part.indexOf('=');
91
+ const key = part.slice(0, eqIndex);
92
+ const value = part.slice(eqIndex + 1);
93
+ result[key] = fromTytx(value);
94
+ }
95
+ return result;
96
+ }
97
+
98
+ export { toQs, fromQs };
@@ -0,0 +1,463 @@
1
+ // Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
2
+ /**
3
+ * Type Registry for TYTX Base.
4
+ *
5
+ * Maps JavaScript types to/from TYTX suffixes.
6
+ * Only scalar types are supported in base version.
7
+ */
8
+
9
+ import { fromQs } from './qs.js';
10
+ import { BigJS, DecimalJS } from './platform/dependencies.js';
11
+
12
+ // =============================================================================
13
+ // DECIMAL LIBRARY DETECTION
14
+ // =============================================================================
15
+
16
+ // Import all decimal libraries at startup
17
+ // Current active class and library name
18
+ let DecimalClass = DecimalJS || BigJS || Number;
19
+ let decimalLibrary = DecimalJS ? 'decimal.js' : BigJS ? 'big.js' : 'number';
20
+
21
+ /**
22
+ * Set the decimal library to use.
23
+ * @param {'decimal.js'|'big.js'|'number'} name
24
+ */
25
+ function setDecimalLibrary(name) {
26
+ if (name === 'decimal.js' && DecimalJS) {
27
+ DecimalClass = DecimalJS;
28
+ decimalLibrary = 'decimal.js';
29
+ } else if (name === 'big.js' && BigJS) {
30
+ DecimalClass = BigJS;
31
+ decimalLibrary = 'big.js';
32
+ } else {
33
+ DecimalClass = Number;
34
+ decimalLibrary = 'number';
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Get current decimal library name.
40
+ * @returns {'decimal.js'|'big.js'|'number'}
41
+ */
42
+ function getDecimalLibrary() {
43
+ return decimalLibrary;
44
+ }
45
+
46
+ /**
47
+ * Create a Decimal value using the current library.
48
+ * @param {string|number} value
49
+ * @returns {Decimal|Big|number}
50
+ */
51
+ function createDecimal(value) {
52
+ return new DecimalClass(value);
53
+ }
54
+
55
+ /**
56
+ * Check if a value is a Decimal instance.
57
+ * @param {any} value
58
+ * @returns {boolean}
59
+ */
60
+ function isDecimal(value) {
61
+ if (decimalLibrary === 'number') {
62
+ return false; // Cannot distinguish from regular Number
63
+ }
64
+ return value instanceof DecimalClass;
65
+ }
66
+
67
+ // =============================================================================
68
+ // DATE TYPE DETECTION
69
+ // =============================================================================
70
+
71
+ /**
72
+ * Determine the TYTX type for a Date object based on its content.
73
+ * @param {Date} d
74
+ * @returns {'D'|'H'|'DHZ'}
75
+ */
76
+ function getDateType(d) {
77
+ const isEpochDate = d.getUTCFullYear() === 1970 &&
78
+ d.getUTCMonth() === 0 &&
79
+ d.getUTCDate() === 1;
80
+ const isMidnight = d.getUTCHours() === 0 &&
81
+ d.getUTCMinutes() === 0 &&
82
+ d.getUTCSeconds() === 0 &&
83
+ d.getUTCMilliseconds() === 0;
84
+
85
+ if (isEpochDate && !isMidnight) return 'H'; // time only
86
+ if (isMidnight && !isEpochDate) return 'D'; // date only
87
+ return 'DHZ'; // full datetime
88
+ }
89
+
90
+ // =============================================================================
91
+ // SERIALIZERS (JavaScript type -> string)
92
+ // =============================================================================
93
+
94
+ function _serializeDecimal(v) {
95
+ return v.toString();
96
+ }
97
+
98
+ function _serializeDate(v) {
99
+ // Format: YYYY-MM-DD
100
+ const year = v.getUTCFullYear();
101
+ const month = String(v.getUTCMonth() + 1).padStart(2, '0');
102
+ const day = String(v.getUTCDate()).padStart(2, '0');
103
+ return `${year}-${month}-${day}`;
104
+ }
105
+
106
+ function _serializeDatetime(v) {
107
+ // Format: YYYY-MM-DDTHH:MM:SS.mmmZ (millisecond precision)
108
+ return v.toISOString();
109
+ }
110
+
111
+ function _serializeTime(v) {
112
+ // Format: HH:MM:SS.mmm
113
+ const hours = String(v.getUTCHours()).padStart(2, '0');
114
+ const minutes = String(v.getUTCMinutes()).padStart(2, '0');
115
+ const seconds = String(v.getUTCSeconds()).padStart(2, '0');
116
+ const millis = String(v.getUTCMilliseconds()).padStart(3, '0');
117
+ return `${hours}:${minutes}:${seconds}.${millis}`;
118
+ }
119
+
120
+ function _serializeBool(v) {
121
+ return v ? 'true' : 'false';
122
+ }
123
+
124
+ function _serializeInt(v) {
125
+ return v.toString();
126
+ }
127
+
128
+ function _serializeFloat(v) {
129
+ return v.toString();
130
+ }
131
+
132
+ function _serializeRaw(v) {
133
+ // Standard base64 (RFC 4648, padded), built from a binary string in
134
+ // chunks so a large view does not overflow the argument list. Text
135
+ // transports only: msgpack carries bytes as its native bin type.
136
+ let binary = '';
137
+ for (let i = 0; i < v.length; i += 0x8000) {
138
+ binary += String.fromCharCode.apply(null, v.subarray(i, i + 0x8000));
139
+ }
140
+ return btoa(binary);
141
+ }
142
+
143
+ // =============================================================================
144
+ // TYPE REGISTRY
145
+ // =============================================================================
146
+
147
+ // Type detection and serialization
148
+ // For JS we need functions to detect types since we can't use type() like Python
149
+
150
+ // Custom types registered at runtime: [cls, suffix, serializer, jsonNative].
151
+ let CUSTOM_TYPES = [];
152
+
153
+ // Suffix -> subtype dictionary. TYTX stores it and never reads it: the type
154
+ // that owns the suffix decides its content (for "X": symbolic name -> class).
155
+ const SUBTYPE_DICTS = new Map();
156
+
157
+ /**
158
+ * Get the registered custom-type entry for a value, or null.
159
+ *
160
+ * The prototype chain is walked from the value up, mirroring Python's MRO
161
+ * lookup: the exact class wins, otherwise the nearest registered ancestor,
162
+ * so an unregistered subclass travels under that ancestor's suffix.
163
+ *
164
+ * @param {any} value
165
+ * @returns {[string, function, boolean]|null} [suffix, serializer, jsonNative] or null
166
+ */
167
+ function getCustomTypeEntry(value) {
168
+ if (value === null || typeof value !== 'object') {
169
+ return null;
170
+ }
171
+ for (let proto = Object.getPrototypeOf(value); proto !== null; proto = Object.getPrototypeOf(proto)) {
172
+ for (const [cls, suffix, serializer, jsonNative] of CUSTOM_TYPES) {
173
+ if (cls.prototype === proto) {
174
+ return [suffix, serializer, jsonNative];
175
+ }
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Get type entry for a value.
183
+ * @param {any} value
184
+ * @returns {[string, function, boolean]|null} [suffix, serializer, jsonNative] or null
185
+ */
186
+ function getTypeEntry(value) {
187
+ if (value === null) {
188
+ return ['NN', () => '', true];
189
+ }
190
+ if (isDecimal(value)) {
191
+ return ['N', _serializeDecimal, false];
192
+ }
193
+ if (value instanceof Uint8Array) {
194
+ // Node's Buffer is a Uint8Array too, so it travels as RAW as well.
195
+ return ['RAW', _serializeRaw, false];
196
+ }
197
+ if (value instanceof Date) {
198
+ const dateType = getDateType(value);
199
+ if (dateType === 'D') {
200
+ return ['D', _serializeDate, false];
201
+ } else if (dateType === 'H') {
202
+ return ['H', _serializeTime, false];
203
+ } else {
204
+ return ['DHZ', _serializeDatetime, false];
205
+ }
206
+ }
207
+ if (typeof value === 'boolean') {
208
+ return ['B', _serializeBool, true];
209
+ }
210
+ if (typeof value === 'number') {
211
+ if (Number.isInteger(value)) {
212
+ return ['L', _serializeInt, true];
213
+ } else {
214
+ return ['R', _serializeFloat, true];
215
+ }
216
+ }
217
+ return getCustomTypeEntry(value);
218
+ }
219
+
220
+ // =============================================================================
221
+ // DESERIALIZERS (string -> JavaScript type)
222
+ // =============================================================================
223
+
224
+ function _deserializeDecimal(s) {
225
+ return createDecimal(s);
226
+ }
227
+
228
+ function _deserializeDate(s) {
229
+ // Input: YYYY-MM-DD
230
+ const [year, month, day] = s.split('-').map(Number);
231
+ return new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
232
+ }
233
+
234
+ function _deserializeDatetime(s) {
235
+ return new Date(s);
236
+ }
237
+
238
+ function _deserializeTime(s) {
239
+ // Input: HH:MM:SS.mmm
240
+ const [h, m, rest] = s.split(':');
241
+ const [sec, ms] = rest.split('.');
242
+ return new Date(Date.UTC(1970, 0, 1, Number(h), Number(m), Number(sec), Number(ms || 0)));
243
+ }
244
+
245
+ function _deserializeBool(s) {
246
+ return s.toLowerCase() === 'true';
247
+ }
248
+
249
+ function _deserializeInt(s) {
250
+ return parseInt(s, 10);
251
+ }
252
+
253
+ function _deserializeFloat(s) {
254
+ return parseFloat(s);
255
+ }
256
+
257
+ function _deserializeStr(s) {
258
+ return s;
259
+ }
260
+
261
+ function _deserializeNone(s) {
262
+ return null;
263
+ }
264
+
265
+ // Standard base64 with padding, whole string: atob alone accepts missing
266
+ // padding and whitespace, which Python's strict decoder refuses.
267
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
268
+
269
+ function _deserializeRaw(s) {
270
+ if (!BASE64_PATTERN.test(s)) {
271
+ throw new Error(`RAW payload is not standard padded base64: '${s}'`);
272
+ }
273
+ const binary = atob(s);
274
+ const out = new Uint8Array(binary.length);
275
+ for (let i = 0; i < binary.length; i++) {
276
+ out[i] = binary.charCodeAt(i);
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function _deserializeQs(s) {
282
+ return fromQs(s);
283
+ }
284
+
285
+ // A type code is one or more uppercase ASCII letters ("N", "QS", "DHZ"). No
286
+ // length limit. The grammar rules out ":" so a code can never be confused with
287
+ // the "::" suffix separator or with the ":" that splits the msgpack ext-4
288
+ // payload.
289
+ const SUFFIX_PATTERN = /^[A-Z]+$/;
290
+
291
+ // Suffix -> [type, deserializer] - includes all for decoding
292
+ // Accepts both DH (deprecated) and DHZ (canonical) for datetime
293
+ const SUFFIX_TO_TYPE = {
294
+ 'N': [Object, _deserializeDecimal], // Object as placeholder for Decimal type
295
+ 'D': [Date, _deserializeDate],
296
+ 'DH': [Date, _deserializeDatetime], // deprecated, still accepted
297
+ 'DHZ': [Date, _deserializeDatetime], // canonical
298
+ 'H': [Date, _deserializeTime],
299
+ 'L': [Number, _deserializeInt],
300
+ 'R': [Number, _deserializeFloat],
301
+ 'T': [String, _deserializeStr],
302
+ 'B': [Boolean, _deserializeBool],
303
+ 'QS': [Object, _deserializeQs],
304
+ 'NN': [null, _deserializeNone],
305
+ 'RAW': [Uint8Array, _deserializeRaw],
306
+ };
307
+
308
+ // =============================================================================
309
+ // CUSTOM TYPE REGISTRATION
310
+ // =============================================================================
311
+
312
+ /** Return the constructor registered for a suffix, without running its decoder. */
313
+ function getRegisteredType(suffix) {
314
+ return Object.hasOwn(SUFFIX_TO_TYPE, suffix) ? SUFFIX_TO_TYPE[suffix][0] : null;
315
+ }
316
+
317
+ /**
318
+ * Register a custom type for TYTX serialization.
319
+ *
320
+ * Lets external code extend TYTX with its own types. The encode side matches
321
+ * the exact constructor first; an unregistered subclass travels under the
322
+ * suffix of its nearest registered ancestor, written by the serializer (for
323
+ * registerClass, the subclass's own toTytx). The concrete class of a subclass
324
+ * is the type's own business, carried through its subtype dictionary
325
+ * (setSubtypeDict). The decode side maps the suffix back via SUFFIX_TO_TYPE.
326
+ * Re-registering the same class replaces its hooks; reusing a suffix owned by
327
+ * a different type throws.
328
+ *
329
+ * @param {Function} cls - the class/constructor to register
330
+ * @param {string} suffix - the TYTX suffix: uppercase ASCII letters (e.g. "X")
331
+ * @param {function(any): string} serializer - instance -> string
332
+ * @param {function(string): any} deserializer - string -> instance
333
+ * @param {boolean} [jsonNative=false] - if true, skip suffix when JSON-native
334
+ * @throws {Error} if the suffix does not match SUFFIX_PATTERN, or is already
335
+ * registered for a different type
336
+ */
337
+ function registerType(cls, suffix, serializer, deserializer, jsonNative = false) {
338
+ if (typeof suffix !== 'string' || !SUFFIX_PATTERN.test(suffix)) {
339
+ throw new Error(
340
+ `TYTX suffix '${suffix}' is invalid: expected uppercase ASCII letters only`);
341
+ }
342
+ const existing = SUFFIX_TO_TYPE[suffix];
343
+ if (existing !== undefined && existing[0] !== cls) {
344
+ const owner = existing[0] === null ? 'null' : existing[0].name;
345
+ throw new Error(`TYTX suffix '${suffix}' is already registered for ${owner}`);
346
+ }
347
+ // Replace semantics: drop any previous entry for the same class so the
348
+ // encode loop cannot keep serving stale hooks.
349
+ CUSTOM_TYPES = CUSTOM_TYPES.filter(([c]) => c !== cls);
350
+ CUSTOM_TYPES.push([cls, suffix, serializer, jsonNative]);
351
+ SUFFIX_TO_TYPE[suffix] = [cls, deserializer];
352
+ }
353
+
354
+ /**
355
+ * Register a class that declares its own TYTX hooks.
356
+ *
357
+ * Reads from the class:
358
+ * static tytxSuffix: the TYTX suffix (e.g. "X")
359
+ * toTytx(): instance -> string
360
+ * static fromTytx(s): string -> instance (must be static: decode starts
361
+ * from the suffix and rebuilds the instance from scratch)
362
+ * static tytxJsonNative: optional boolean, default false
363
+ *
364
+ * @param {Function} cls
365
+ * @returns {Function} the class, so it can be used as a decorator
366
+ */
367
+ function registerClass(cls) {
368
+ if (!cls.tytxSuffix) {
369
+ throw new Error(`registerClass: ${cls.name} is missing a static tytxSuffix`);
370
+ }
371
+ if (typeof cls.prototype?.toTytx !== 'function') {
372
+ throw new Error(`registerClass: ${cls.name} is missing a toTytx method`);
373
+ }
374
+ if (typeof cls.fromTytx !== 'function') {
375
+ throw new Error(`registerClass: ${cls.name} is missing a static fromTytx method`);
376
+ }
377
+ registerType(
378
+ cls,
379
+ cls.tytxSuffix,
380
+ obj => obj.toTytx(),
381
+ s => cls.fromTytx(s),
382
+ cls.tytxJsonNative || false,
383
+ );
384
+ return cls;
385
+ }
386
+
387
+ /**
388
+ * Store the subtype dictionary of a suffix, replacing the previous one.
389
+ *
390
+ * Nothing is checked: the suffix need not be registered and the content is up
391
+ * to the type that owns the suffix. To extend it, read it with getSubtypeDict,
392
+ * add the entries and set it again.
393
+ *
394
+ * @param {string} suffix
395
+ * @param {Object} subtypes
396
+ */
397
+ function setSubtypeDict(suffix, subtypes) {
398
+ SUBTYPE_DICTS.set(suffix, subtypes);
399
+ }
400
+
401
+ /**
402
+ * Return the subtype dictionary stored for a suffix, or {} if none was set.
403
+ *
404
+ * @param {string} suffix
405
+ * @returns {Object}
406
+ */
407
+ function getSubtypeDict(suffix) {
408
+ return SUBTYPE_DICTS.has(suffix) ? SUBTYPE_DICTS.get(suffix) : {};
409
+ }
410
+
411
+ /**
412
+ * Remove all custom type registrations and subtype dictionaries (test helper).
413
+ */
414
+ function _resetCustomTypes() {
415
+ for (const [, suffix] of CUSTOM_TYPES) {
416
+ delete SUFFIX_TO_TYPE[suffix];
417
+ }
418
+ CUSTOM_TYPES = [];
419
+ SUBTYPE_DICTS.clear();
420
+ }
421
+
422
+ export {
423
+ // Decimal utilities
424
+ decimalLibrary,
425
+ createDecimal,
426
+ isDecimal,
427
+ setDecimalLibrary,
428
+ getDecimalLibrary,
429
+ // Date type detection
430
+ getDateType,
431
+ // Type registry
432
+ getTypeEntry,
433
+ getRegisteredType,
434
+ getCustomTypeEntry,
435
+ SUFFIX_TO_TYPE,
436
+ SUFFIX_PATTERN,
437
+ // Custom type registration
438
+ registerType,
439
+ registerClass,
440
+ setSubtypeDict,
441
+ getSubtypeDict,
442
+ _resetCustomTypes,
443
+ // Serializers (exported for testing)
444
+ _serializeDecimal,
445
+ _serializeDate,
446
+ _serializeDatetime,
447
+ _serializeTime,
448
+ _serializeBool,
449
+ _serializeInt,
450
+ _serializeFloat,
451
+ _serializeRaw,
452
+ // Deserializers (exported for testing)
453
+ _deserializeDecimal,
454
+ _deserializeDate,
455
+ _deserializeDatetime,
456
+ _deserializeTime,
457
+ _deserializeBool,
458
+ _deserializeInt,
459
+ _deserializeFloat,
460
+ _deserializeStr,
461
+ _deserializeNone,
462
+ _deserializeRaw,
463
+ };