@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.
@@ -0,0 +1,164 @@
1
+ // Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
2
+ /**
3
+ * TYTX Utilities.
4
+ *
5
+ * Provides:
6
+ * - walk: recursive data structure transformation
7
+ * - tytxEquivalent: semantic equivalence for roundtrip testing
8
+ */
9
+
10
+ import { getTypeEntry, SUFFIX_TO_TYPE } from './registry.js';
11
+
12
+ /**
13
+ * Encode a scalar value to TYTX string with suffix.
14
+ *
15
+ * @param {any} value - JavaScript scalar value (Decimal, Date, boolean, number)
16
+ * @param {boolean} forceSuffix - If true, force suffix for all types (including int/bool/float)
17
+ * @returns {[boolean, string]} [encoded, result]
18
+ * - (true, "serialized::SUFFIX") if type is registered and needs suffix
19
+ * - (false, String(value)) if type not registered or jsonNative without force
20
+ */
21
+ function rawEncode(value, forceSuffix = false) {
22
+ const entry = getTypeEntry(value);
23
+ if (entry === null) {
24
+ return [false, String(value)];
25
+ }
26
+ const [suffix, serializer, jsonNative] = entry;
27
+ if (jsonNative && !forceSuffix) {
28
+ return [false, String(value)];
29
+ }
30
+ return [true, `${serializer(value)}::${suffix}`];
31
+ }
32
+
33
+ /**
34
+ * Decode a string with TYTX suffix.
35
+ *
36
+ * @param {string} s - String possibly ending with ::XX where XX is a registered suffix
37
+ * @returns {[boolean, any]} [decoded, value]
38
+ * - (true, decodedValue) if suffix found and decoded
39
+ * - (false, originalValue) if no valid suffix
40
+ */
41
+ function rawDecode(s) {
42
+ if (!s.includes('::')) {
43
+ return [false, s];
44
+ }
45
+ const lastIndex = s.lastIndexOf('::');
46
+ const value = s.slice(0, lastIndex);
47
+ const suffix = s.slice(lastIndex + 2);
48
+ const entry = SUFFIX_TO_TYPE[suffix];
49
+ if (entry === undefined) {
50
+ return [false, s];
51
+ }
52
+ const [, decoder] = entry;
53
+ return [true, decoder(value)];
54
+ }
55
+
56
+ /**
57
+ * Walk a data structure and apply callback to values matching filtercb.
58
+ *
59
+ * @param {any} data - Data structure to walk
60
+ * @param {function} callback - Function to apply to matching values
61
+ * @param {function} filtercb - Filter function. Applies callback when filtercb(value) is true.
62
+ * @returns {any} Transformed data
63
+ */
64
+ function walk(data, callback, filtercb) {
65
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
66
+ const result = {};
67
+ for (const [k, v] of Object.entries(data)) {
68
+ result[k] = walk(v, callback, filtercb);
69
+ }
70
+ return result;
71
+ }
72
+ if (Array.isArray(data)) {
73
+ return data.map(item => walk(item, callback, filtercb));
74
+ }
75
+ if (filtercb(data)) {
76
+ return callback(data);
77
+ }
78
+ return data;
79
+ }
80
+
81
+ /**
82
+ * Truncate Date to milliseconds (TYTX precision).
83
+ * @param {Date} dt
84
+ * @returns {Date}
85
+ */
86
+ function _truncateToMillis(dt) {
87
+ // JavaScript Date already has millisecond precision, no truncation needed
88
+ return dt;
89
+ }
90
+
91
+ /**
92
+ * Check if two Dates represent the same instant in time.
93
+ *
94
+ * TYTX serializes all datetimes as UTC (DHZ) with millisecond precision.
95
+ * This function handles the semantic equivalence for roundtrip comparison.
96
+ *
97
+ * @param {Date} a - Original Date (before roundtrip)
98
+ * @param {Date} b - Decoded Date (after roundtrip)
99
+ * @returns {boolean} True if both represent the same instant in time
100
+ */
101
+ function datetimeEquivalent(a, b) {
102
+ // JavaScript Dates are always comparable directly in milliseconds
103
+ return a.getTime() === b.getTime();
104
+ }
105
+
106
+ /**
107
+ * Check if two values are semantically equivalent after TYTX roundtrip.
108
+ *
109
+ * Handles special cases:
110
+ * - Date: timestamp equivalence
111
+ * - dict/list: recursive comparison
112
+ * - other types: standard equality
113
+ *
114
+ * @param {any} a - Original value (before roundtrip)
115
+ * @param {any} b - Decoded value (after roundtrip)
116
+ * @returns {boolean} True if values are semantically equivalent
117
+ */
118
+ function tytxEquivalent(a, b) {
119
+ // Fast path: identical values
120
+ if (a === b) {
121
+ return true;
122
+ }
123
+
124
+ // Date special case
125
+ if (a instanceof Date && b instanceof Date) {
126
+ return datetimeEquivalent(a, b);
127
+ }
128
+
129
+ // dict: recursive comparison (needed to find nested Dates)
130
+ if (a !== null && typeof a === 'object' && !Array.isArray(a) &&
131
+ b !== null && typeof b === 'object' && !Array.isArray(b)) {
132
+ const keysA = Object.keys(a);
133
+ const keysB = Object.keys(b);
134
+ if (keysA.length !== keysB.length) {
135
+ return false;
136
+ }
137
+ const keysSetB = new Set(keysB);
138
+ for (const k of keysA) {
139
+ if (!keysSetB.has(k)) {
140
+ return false;
141
+ }
142
+ }
143
+ return keysA.every(k => tytxEquivalent(a[k], b[k]));
144
+ }
145
+
146
+ // list: recursive comparison (needed to find nested Dates)
147
+ if (Array.isArray(a) && Array.isArray(b)) {
148
+ if (a.length !== b.length) {
149
+ return false;
150
+ }
151
+ return a.every((ai, i) => tytxEquivalent(ai, b[i]));
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ export {
158
+ rawEncode,
159
+ rawDecode,
160
+ walk,
161
+ datetimeEquivalent,
162
+ tytxEquivalent,
163
+ _truncateToMillis,
164
+ };
package/js/src/xml.js ADDED
@@ -0,0 +1,228 @@
1
+ // Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
2
+ /**
3
+ * TYTX XML Encoding/Decoding.
4
+ *
5
+ * XML format follows the structure:
6
+ * {"tag": {"attrs": {...}, "value": ...}}
7
+ *
8
+ * Where:
9
+ * - attrs: dict of attributes (hydrated with type suffixes)
10
+ * - value: scalar, dict of children, list, or null
11
+ *
12
+ * Type suffixes are used in both text content and attributes:
13
+ * <price>100.50::N</price>
14
+ * <order id="123::L" created="2025-01-15::D">...</order>
15
+ */
16
+
17
+ import { toTytx } from './encode.js';
18
+ import { fromTytx } from './decode.js';
19
+ import { NodeDOMParser, NodeXMLSerializer } from './platform/dependencies.js';
20
+
21
+ // XML DOM support - use @xmldom/xmldom for Node.js
22
+ let DOMParser, XMLSerializer;
23
+ if (typeof window !== 'undefined' && window.DOMParser) {
24
+ // Browser environment
25
+ DOMParser = window.DOMParser;
26
+ XMLSerializer = window.XMLSerializer;
27
+ } else {
28
+ DOMParser = NodeDOMParser;
29
+ XMLSerializer = NodeXMLSerializer;
30
+ }
31
+
32
+ /**
33
+ * Check if item is a valid XML element format: {tag: {"value": ...}}
34
+ *
35
+ * A valid XML element is a dict with exactly one key (the tag),
36
+ * whose value is a dict containing at least a "value" key.
37
+ *
38
+ * @param {any} item
39
+ * @returns {boolean}
40
+ */
41
+ function _isXmlElement(item) {
42
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
43
+ return false;
44
+ }
45
+ const keys = Object.keys(item);
46
+ if (keys.length !== 1) {
47
+ return false;
48
+ }
49
+ const itemData = item[keys[0]];
50
+ return itemData !== null && typeof itemData === 'object' && 'value' in itemData;
51
+ }
52
+
53
+ /**
54
+ * Serialize a dict with 'value' key (and optional 'attrs') to XML element.
55
+ *
56
+ * @param {Document} doc - XML Document
57
+ * @param {string} tag - Element tag name
58
+ * @param {Object} data - Dict with 'value' key and optional 'attrs' key
59
+ * @returns {Element} XML Element
60
+ */
61
+ function _serializeElement(doc, tag, data) {
62
+
63
+ const element = doc.createElement(tag);
64
+
65
+ const attrs = data.attrs || {};
66
+ const value = data.value;
67
+
68
+ // Set attributes
69
+ for (const [attrName, attrValue] of Object.entries(attrs)) {
70
+ element.setAttribute(attrName, toTytx(attrValue, null, { _forceSuffix: true }));
71
+ }
72
+
73
+ // Set value
74
+ if (Array.isArray(value)) {
75
+ // List of children
76
+ for (const item of value) {
77
+ if (_isXmlElement(item)) {
78
+ const [itemTag] = Object.keys(item);
79
+ const itemData = item[itemTag];
80
+ const childElement = _serializeElement(doc, itemTag, itemData);
81
+ element.appendChild(childElement);
82
+ } else {
83
+ element.textContent = toTytx(value);
84
+ break;
85
+ }
86
+ }
87
+ } else {
88
+ element.textContent = toTytx(value);
89
+ }
90
+
91
+ return element;
92
+ }
93
+
94
+ /**
95
+ * Encode a JavaScript value to TYTX XML string.
96
+ *
97
+ * @param {any} value - Data to encode
98
+ * @returns {string} XML string with typed values marked
99
+ */
100
+ function toXml(value) {
101
+ if (!DOMParser) {
102
+ throw new Error('XML support requires @xmldom/xmldom package in Node.js');
103
+ }
104
+
105
+
106
+ // Check if value is valid XML element format
107
+ if (_isXmlElement(value)) {
108
+ // Valid XML format: {tag: {"value": ...}}
109
+ const [rootTag] = Object.keys(value);
110
+ const rootData = value[rootTag];
111
+
112
+ const doc = new DOMParser().parseFromString('<root/>', 'text/xml');
113
+ const element = _serializeElement(doc, rootTag, rootData);
114
+
115
+ const serializer = new XMLSerializer();
116
+ return serializer.serializeToString(element);
117
+ } else {
118
+ // Not valid XML format: serialize as JSON
119
+ return toTytx(value);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Deserialize XML element to dict with 'attrs' and 'value' keys.
125
+ *
126
+ * @param {Element} element
127
+ * @returns {Object} Dict with 'attrs' and 'value' keys
128
+ */
129
+ function fromXmlnode(element) {
130
+
131
+ // Hydrate attributes
132
+ const attrs = {};
133
+ for (let i = 0; i < element.attributes.length; i++) {
134
+ const attr = element.attributes[i];
135
+ attrs[attr.name] = fromTytx(attr.value);
136
+ }
137
+
138
+ // Process children
139
+ const children = [];
140
+ for (let i = 0; i < element.childNodes.length; i++) {
141
+ const node = element.childNodes[i];
142
+ if (node.nodeType === 1) { // ELEMENT_NODE
143
+ children.push(node);
144
+ }
145
+ }
146
+
147
+ if (children.length > 0) {
148
+ if (children.length === 1) {
149
+ // Single child: return as dict {tag: {...}}
150
+ const child = children[0];
151
+ const childData = fromXmlnode(child);
152
+ return { attrs, value: { [child.tagName]: childData } };
153
+ } else {
154
+ // Multiple children: return as list [{tag: {...}}, ...]
155
+ const valueList = [];
156
+ for (const child of children) {
157
+ const childData = fromXmlnode(child);
158
+ valueList.push({ [child.tagName]: childData });
159
+ }
160
+ return { attrs, value: valueList };
161
+ }
162
+ }
163
+
164
+ // Leaf node
165
+ return { attrs, value: fromTytx(element.textContent) };
166
+ }
167
+
168
+ /**
169
+ * Decode a TYTX XML string to JavaScript value.
170
+ *
171
+ * If the root element is 'tytx_root', it is automatically unwrapped
172
+ * and the inner value is returned directly.
173
+ *
174
+ * @param {string} data - XML string with typed values
175
+ * @returns {Object|any} If root is 'tytx_root': the unwrapped value.
176
+ * Otherwise: Dict in format {"tag": {"attrs": {...}, "value": ...}}
177
+ *
178
+ * @example
179
+ * fromXml('<order id="123::L"><total>100.50::N</total></order>')
180
+ * // {
181
+ * // "order": {
182
+ * // "attrs": {"id": 123},
183
+ * // "value": {"total": {"attrs": {}, "value": Decimal("100.50")}}
184
+ * // }
185
+ * // }
186
+ *
187
+ * fromXml('<tytx_root><price>100.50::N</price></tytx_root>')
188
+ * // {"price": {"attrs": {}, "value": Decimal("100.50")}}
189
+ */
190
+ function fromXml(data) {
191
+ if (!DOMParser) {
192
+ throw new Error('XML support requires @xmldom/xmldom package in Node.js');
193
+ }
194
+
195
+
196
+ const parser = new DOMParser();
197
+ const doc = parser.parseFromString(data, 'text/xml');
198
+ let root = doc.documentElement;
199
+
200
+ // Unwrap tytx_root: work on inner content
201
+ if (root.tagName === 'tytx_root') {
202
+ // Get first element child
203
+ let firstElementChild = null;
204
+ for (let i = 0; i < root.childNodes.length; i++) {
205
+ if (root.childNodes[i].nodeType === 1) {
206
+ firstElementChild = root.childNodes[i];
207
+ break;
208
+ }
209
+ }
210
+
211
+ if (!firstElementChild) {
212
+ return fromTytx(root.textContent);
213
+ }
214
+ root = firstElementChild;
215
+ }
216
+
217
+ // From here: root is the real node
218
+ const result = fromXmlnode(root);
219
+ return { [root.tagName]: result };
220
+ }
221
+
222
+ export {
223
+ toXml,
224
+ fromXml,
225
+ fromXmlnode,
226
+ _isXmlElement,
227
+ _serializeElement,
228
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@genrojs/tytx",
3
+ "version": "0.16.1",
4
+ "description": "Typed data interchange between Python and JavaScript over JSON, XML and MessagePack: Decimal, dates and custom types arrive with their type.",
5
+ "main": "js/src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./js/src/index.d.ts",
10
+ "default": "./js/src/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "test": "node --test js/test/test_extended.js js/test/test_registry.js js/test/test_registry_lookup.js",
15
+ "test:all": "node --test js/test/test_*.js",
16
+ "test:browser": "node --test js/test/test_browser_bundle.js"
17
+ },
18
+ "keywords": [
19
+ "tytx",
20
+ "typed",
21
+ "serialization",
22
+ "json",
23
+ "xml",
24
+ "msgpack"
25
+ ],
26
+ "author": "Genropy Team",
27
+ "license": "Apache-2.0",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/genro-org/genro-tytx"
31
+ },
32
+ "devDependencies": {
33
+ "esbuild": "^0.27.2"
34
+ },
35
+ "types": "./js/src/index.d.ts",
36
+ "dependencies": {
37
+ "@xmldom/xmldom": "^0.9.12",
38
+ "big.js": "^6.2.1",
39
+ "decimal.js": "^10.4.3",
40
+ "@msgpack/msgpack": "^3.1.3"
41
+ },
42
+ "files": [
43
+ "js/src/**/*.js",
44
+ "js/src/**/*.d.ts",
45
+ "README.md",
46
+ "LICENSE",
47
+ "NOTICE"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }