@genrojs/bag 0.10.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/src/bag.js ADDED
@@ -0,0 +1,2497 @@
1
+ // Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { BagNodeContainer } from './bag-node-container.js';
4
+ import { BagNode } from './bag-node.js';
5
+ import { toTytx as tytxEncode, fromTytx as tytxDecode, registerClass, getRegisteredType, getSubtypeDict, setSubtypeDict } from '@genrojs/tytx';
6
+ import { DOMParser as XmlDOMParser } from '@xmldom/xmldom';
7
+ import { BagCbResolver, BagResolver } from './resolver.js';
8
+ import { BagSerializationError, encodeResolver, decodeResolver, encodeAttrs, decodeAttrs } from './resolver-wire.js';
9
+ import { CLS_ATTRIBUTE, getClsMarker, getInheritedClass, getSubtypeClass } from './subtype-wire.js';
10
+
11
+ // Resolver caches are not structural branches on the wire: the descriptor
12
+ // owns that node, and serializing cached descendants would orphan them.
13
+ function* serializationNodes(bag, prefix = '') {
14
+ for (const node of bag) {
15
+ const path = prefix ? `${prefix}.${node.label}` : node.label;
16
+ yield [path, node];
17
+ const value = node.getValue(true);
18
+ if (!node.resolver && value instanceof Bag) yield* serializationNodes(value, path);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Bag - Hierarchical data container with path-based access.
24
+ *
25
+ * A Bag is an ordered container of BagNodes, accessible by label, numeric index,
26
+ * or hierarchical path. Nested elements can be accessed with dot-separated paths
27
+ * like 'a.b.c'.
28
+ */
29
+ export class Bag {
30
+ static tytxSuffix = 'X';
31
+
32
+ /**
33
+ * Create a new Bag.
34
+ *
35
+ * @param {Object} [source=null] - Optional dict to initialize from.
36
+ */
37
+ constructor(source = null) {
38
+ this._nodes = new BagNodeContainer();
39
+ this._backref = false;
40
+ this._parent = null;
41
+ this._parentNode = null;
42
+ this._rootAttributes = null;
43
+ // Subscriber dictionaries for events
44
+ this._updSubscribers = {};
45
+ this._insSubscribers = {};
46
+ this._delSubscribers = {};
47
+
48
+ if (source) {
49
+ this._loadSource(source);
50
+ }
51
+ }
52
+
53
+ // -------------------------------------------------------------------------
54
+ // Properties
55
+ // -------------------------------------------------------------------------
56
+
57
+ get parent() {
58
+ return this._parent;
59
+ }
60
+
61
+ set parent(value) {
62
+ this._parent = value;
63
+ }
64
+
65
+ get parentNode() {
66
+ return this._parentNode;
67
+ }
68
+
69
+ set parentNode(value) {
70
+ this._parentNode = value;
71
+ }
72
+
73
+ get backref() {
74
+ return Boolean(this._backref);
75
+ }
76
+
77
+ /**
78
+ * Node class used to create new nodes. Override in subclasses
79
+ * to use custom BagNode subclasses.
80
+ *
81
+ * @returns {Function} The BagNode constructor.
82
+ */
83
+ get nodeClass() {
84
+ return BagNode;
85
+ }
86
+
87
+ createChildBag() {
88
+ return new this.constructor();
89
+ }
90
+
91
+ /**
92
+ * Full path from root Bag to this Bag.
93
+ *
94
+ * Returns the dot-separated path from the root of the hierarchy to this
95
+ * Bag. Returns null if backref mode is not enabled or if this is the root.
96
+ *
97
+ * @returns {string|null} The full path or null.
98
+ */
99
+ get fullpath() {
100
+ if (this._parent !== null && this._parentNode !== null) {
101
+ const parentFullpath = this._parent.fullpath;
102
+ if (parentFullpath) {
103
+ return `${parentFullpath}.${this._parentNode.label}`;
104
+ } else {
105
+ return this._parentNode.label;
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Get dot-separated path from this Bag to a descendant node.
113
+ *
114
+ * Walks up from the node to this Bag collecting labels.
115
+ * Requires backref mode enabled.
116
+ *
117
+ * @param {BagNode} node - A descendant BagNode.
118
+ * @returns {string|null} The relative path, or null if not a descendant.
119
+ */
120
+ relativePath(node) {
121
+ const parts = [];
122
+ let current = node;
123
+ while (current !== null) {
124
+ if (current.parentBag === this) {
125
+ parts.push(current.label);
126
+ parts.reverse();
127
+ return parts.join('.');
128
+ }
129
+ parts.push(current.label);
130
+ current = current.parentNode;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ get length() {
136
+ return this._nodes.length;
137
+ }
138
+
139
+ /**
140
+ * Root Bag of the hierarchy.
141
+ *
142
+ * Traverses the parent chain to find the topmost Bag. If this Bag has no
143
+ * parent, returns itself.
144
+ *
145
+ * @returns {Bag} The root Bag.
146
+ */
147
+ get root() {
148
+ let curr = this;
149
+ while (curr.parent !== null) {
150
+ curr = curr.parent;
151
+ }
152
+ return curr;
153
+ }
154
+
155
+ /**
156
+ * Attributes of the node containing this Bag.
157
+ *
158
+ * Returns the attributes of the parent node that contains this Bag.
159
+ * Returns an empty object if this Bag has no parent node.
160
+ *
161
+ * @returns {Object} Attributes dictionary.
162
+ */
163
+ get attributes() {
164
+ if (this._parentNode !== null) {
165
+ return this._parentNode.getAttr();
166
+ }
167
+ return {};
168
+ }
169
+
170
+ /**
171
+ * Root-level attributes for this Bag hierarchy.
172
+ *
173
+ * These are special attributes stored at the hierarchy level,
174
+ * independent of any node attributes.
175
+ *
176
+ * @returns {Object|null} Root attributes or null.
177
+ */
178
+ get rootAttributes() {
179
+ return this._rootAttributes;
180
+ }
181
+
182
+ set rootAttributes(attrs) {
183
+ this._rootAttributes = attrs !== null ? { ...attrs } : null;
184
+ }
185
+
186
+ // -------------------------------------------------------------------------
187
+ // _htraverse helpers
188
+ // -------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Parse path and handle #parent navigation.
192
+ *
193
+ * @param {string|Array} path - Dot-separated path or array of segments.
194
+ * @returns {Array} Tuple of [curr, pathlist].
195
+ */
196
+ _htraverseBefore(path) {
197
+ let curr = this;
198
+
199
+ let pathlist;
200
+ if (typeof path === 'string') {
201
+ path = path.replace(/\.\.\//g, '#parent.');
202
+ pathlist = path.split('.').filter(x => x);
203
+ } else {
204
+ pathlist = [...path];
205
+ }
206
+
207
+ // Handle parent reference #parent at the beginning
208
+ while (pathlist.length && pathlist[0] === '#parent' && curr !== null) {
209
+ pathlist.shift();
210
+ curr = curr.parent;
211
+ }
212
+
213
+ return [curr, pathlist];
214
+ }
215
+
216
+ // -------------------------------------------------------------------------
217
+ // _htraverse
218
+ // -------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Traverse a hierarchical path.
222
+ *
223
+ * A `#parent` segment (also the `../` alias, expanded by _htraverseBefore)
224
+ * walks up to the parent Bag. It is handled both as a leading segment and
225
+ * inside the path; it requires backref/parent to be set, otherwise the
226
+ * traversal breaks (read → null; write → stops short).
227
+ *
228
+ * @param {string|Array} path - Path as dot-separated string or array.
229
+ * @param {boolean} [writeMode=false] - If true, create intermediate Bags.
230
+ * @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
231
+ * @returns {Array} Tuple of [container, label].
232
+ */
233
+ _htraverse(path, writeMode = false, isStatic = true) {
234
+ let [curr, pathlist] = this._htraverseBefore(path);
235
+
236
+ if (curr === null) {
237
+ return [null, null];
238
+ }
239
+ if (pathlist.length === 0) {
240
+ return [curr, ''];
241
+ }
242
+
243
+ // Traverse path segments
244
+ while (pathlist.length > 1 && curr instanceof Bag) {
245
+ const segment = pathlist[0];
246
+
247
+ // Inner #parent: walk up to the parent Bag, same behaviour as
248
+ // _htraverseBefore applies for leading segments. Without this
249
+ // branch the lookup below would fail (no node named '#parent')
250
+ // and traversal would break. Requires backref/parent to be set;
251
+ // otherwise it breaks (safe no-op). Aligns with Python #58.
252
+ if (segment === '#parent') {
253
+ if (curr.parent === null || curr.parent === undefined) {
254
+ break;
255
+ }
256
+ pathlist.shift();
257
+ curr = curr.parent;
258
+ continue;
259
+ }
260
+
261
+ const node = curr._nodes.get(segment);
262
+
263
+ if (!node) {
264
+ break;
265
+ }
266
+
267
+ const value = node.getValue(isStatic);
268
+
269
+ if (value instanceof Bag) {
270
+ pathlist.shift();
271
+ curr = value;
272
+ } else if (writeMode) {
273
+ // Promote scalar to intermediate Bag
274
+ const newBag = curr.createChildBag();
275
+ node.setValue(newBag, true, null, true, true, 'autocreate');
276
+ pathlist.shift();
277
+ curr = newBag;
278
+ } else {
279
+ break;
280
+ }
281
+ }
282
+
283
+ // Finalize
284
+ if (!writeMode) {
285
+ if (pathlist.length > 1) {
286
+ return [null, null];
287
+ }
288
+ return [curr, pathlist[0]];
289
+ }
290
+
291
+ // Write mode: create intermediate nodes
292
+ while (pathlist.length > 1) {
293
+ const label = pathlist.shift();
294
+ if (label.startsWith('#')) {
295
+ throw new BagException('Not existing index in #n syntax');
296
+ }
297
+ const newBag = curr.createChildBag();
298
+ curr._nodes.set(label, newBag, '>', null, curr, null, false, true, 'autocreate');
299
+ curr = newBag;
300
+ }
301
+
302
+ return [curr, pathlist[0]];
303
+ }
304
+
305
+ // -------------------------------------------------------------------------
306
+ // get (single level)
307
+ // -------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Get value at a single level (no path traversal).
311
+ *
312
+ * @param {string} label - Node label to look up.
313
+ * @param {*} [defaultValue=null] - Value to return if label not found.
314
+ * @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
315
+ * @param {Object} [kwargs={}] - Additional kwargs passed to resolver.
316
+ * @returns {*} The node's value if found, otherwise default.
317
+ */
318
+ get(label, defaultValue = null, isStatic = true, kwargs = {}) {
319
+ if (!label) {
320
+ return this;
321
+ }
322
+ if (label === '#parent') {
323
+ return this.parent;
324
+ }
325
+ // Parse queryString from label
326
+ let queryString = null;
327
+ if (label.includes('?')) {
328
+ [label, queryString] = label.split('?', 2);
329
+ }
330
+ const node = this._nodes.get(label);
331
+ if (!node) {
332
+ return defaultValue;
333
+ }
334
+ return node.getValue(isStatic, queryString, kwargs);
335
+ }
336
+
337
+ // -------------------------------------------------------------------------
338
+ // getItem
339
+ // -------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Get value at a hierarchical path.
343
+ *
344
+ * @param {string} path - Hierarchical path like 'a.b.c'.
345
+ * @param {*} [defaultValue=null] - Value to return if path not found.
346
+ * @param {boolean} [isStatic=false] - If true, don't trigger resolvers.
347
+ * @param {Object} [kwargs={}] - Additional kwargs passed to resolver at final path.
348
+ * @returns {*} The value at the path if found, otherwise default.
349
+ */
350
+ getItem(path, defaultValue = null, isStatic = false, kwargs = {}) {
351
+ if (!path) {
352
+ return this;
353
+ }
354
+
355
+ const [obj, label] = this._htraverse(path, false, isStatic);
356
+
357
+ if (obj instanceof Bag) {
358
+ return obj.get(label, defaultValue, isStatic, kwargs);
359
+ }
360
+ return defaultValue;
361
+ }
362
+
363
+ // -------------------------------------------------------------------------
364
+ // setItem
365
+ // -------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Set value at a hierarchical path.
369
+ *
370
+ * An empty path raises RangeError without modifying the Bag.
371
+ *
372
+ * Resolver handling:
373
+ * - resolver=null (default): throw if node already has a resolver
374
+ * - resolver=false: remove existing resolver and set value
375
+ * - resolver=BagResolver: replace resolver
376
+ *
377
+ * @param {string} path - Hierarchical path like 'a.b.c'.
378
+ * @param {*} value - Value to set at the path.
379
+ * @param {Object} [attr=null] - Optional attributes to set on the node.
380
+ * @param {string|number|null} [nodePosition='>'] - Position for new nodes.
381
+ * @param {boolean} [updattr=false] - If false, clear existing attributes first.
382
+ * @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
383
+ * @param {string} [reason=null] - Reason for the change (for events).
384
+ * @param {boolean} [fired=false] - If true, reset value to null after setting.
385
+ * @param {boolean} [doTrigger=true] - If false, suppress events.
386
+ * @param {*} [resolver=null] - Resolver handling for existing nodes.
387
+ * @param {string} [nodeTag=null] - Semantic type tag for the node.
388
+ * @returns {BagNode} The created or updated BagNode.
389
+ */
390
+ setItem(path, value, attr = null, nodePosition = '>', updattr = false,
391
+ removeNullAttributes = true, reason = null, fired = false,
392
+ doTrigger = true, resolver = null, nodeTag = null) {
393
+ if (path === '') {
394
+ throw new RangeError('setItem requires a non-empty path');
395
+ }
396
+
397
+ const [obj, label] = this._htraverse(path, true);
398
+
399
+ return obj._nodes.set(label, value, nodePosition, attr, obj,
400
+ resolver, updattr, removeNullAttributes, reason, doTrigger, fired, nodeTag);
401
+ }
402
+
403
+ // -------------------------------------------------------------------------
404
+ // _pop (internal)
405
+ // -------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Internal pop by label at current level.
409
+ *
410
+ * @param {string} label - Node label to remove.
411
+ * @param {string|null} [reason=null] - Reason for deletion (for events).
412
+ * @returns {BagNode|null} The removed BagNode, or null if not found.
413
+ */
414
+ _pop(label, reason = null) {
415
+ const p = this._nodes.index(label);
416
+ if (p >= 0) {
417
+ const node = this._nodes.pop(p);
418
+ if (this.backref) {
419
+ this._onNodeDeleted(node, p, reason);
420
+ }
421
+ return node;
422
+ }
423
+ return null;
424
+ }
425
+
426
+ // -------------------------------------------------------------------------
427
+ // pop
428
+ // -------------------------------------------------------------------------
429
+
430
+ /**
431
+ * Remove a node and return its value.
432
+ *
433
+ * Traverses to the path, removes the node, and returns its value.
434
+ *
435
+ * @param {string} path - Hierarchical path to the node to remove.
436
+ * @param {*} [defaultValue=null] - Value to return if path not found.
437
+ * @param {string|null} [reason=null] - Reason for deletion (for events).
438
+ * @returns {*} The value of the removed node, or default if not found.
439
+ */
440
+ pop(path, defaultValue = null, reason = null) {
441
+ const node = this.popNode(path, reason);
442
+ return node ? node.value : defaultValue;
443
+ }
444
+
445
+ /**
446
+ * Alias for pop.
447
+ */
448
+ delItem(path, defaultValue = null, reason = null) {
449
+ return this.pop(path, defaultValue, reason);
450
+ }
451
+
452
+ // -------------------------------------------------------------------------
453
+ // popNode
454
+ // -------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Remove and return the BagNode at a path.
458
+ *
459
+ * Like pop(), but returns the entire BagNode instead of just its value.
460
+ *
461
+ * @param {string} path - Hierarchical path to the node to remove.
462
+ * @param {string|null} [reason=null] - Reason for deletion (for events).
463
+ * @returns {BagNode|null} The removed BagNode, or null if not found.
464
+ */
465
+ popNode(path, reason = null) {
466
+ const [obj, label] = this._htraverse(path, false, true);
467
+ if (!obj || !label) return null;
468
+ const node = obj._nodes.get(label);
469
+ if (!node) return null;
470
+ try {
471
+ return obj._pop(label, reason);
472
+ } finally {
473
+ // Subscribers may fail, reinsert the node, or transfer ownership.
474
+ if (node.parentBag === obj && obj._nodes._dict[node.label] !== node) {
475
+ node.parentBag = null;
476
+ }
477
+ }
478
+ }
479
+
480
+ // -------------------------------------------------------------------------
481
+ // clear
482
+ // -------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Remove all nodes from this Bag.
486
+ *
487
+ * Empties the Bag completely. In backref mode, triggers delete events
488
+ * for all removed nodes.
489
+ */
490
+ clear() {
491
+ const oldNodes = [...this._nodes];
492
+ this._nodes.clear();
493
+ if (this.backref) {
494
+ this._onNodeDeleted(oldNodes, -1);
495
+ }
496
+ for (const node of oldNodes) {
497
+ node.parentBag = null;
498
+ }
499
+ }
500
+
501
+ // -------------------------------------------------------------------------
502
+ // getNode
503
+ // -------------------------------------------------------------------------
504
+
505
+ /**
506
+ * Get the BagNode at a path (not its value).
507
+ *
508
+ * @param {string|number|null} path - Hierarchical path like 'a.b.c', integer index, or null.
509
+ * @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
510
+ * @param {boolean} [autocreate=false] - If true, create node if not found.
511
+ * @param {*} [defaultValue=null] - Default value for autocreated node.
512
+ * @returns {BagNode|null} The BagNode if found, null otherwise.
513
+ */
514
+ getNode(path, isStatic = true, autocreate = false, defaultValue = null) {
515
+ if (path === null || path === undefined || path === '') {
516
+ return this.parentNode;
517
+ }
518
+ if (typeof path === 'number') {
519
+ return this._nodes.get(path);
520
+ }
521
+ if (typeof path === 'string') {
522
+ path = path.split('?', 1)[0];
523
+ }
524
+ const [obj, label] = this._htraverse(path, autocreate, isStatic);
525
+ if (obj instanceof Bag && label) {
526
+ const node = obj._nodes.get(label);
527
+ if (node) {
528
+ return node;
529
+ }
530
+ if (autocreate) {
531
+ obj._nodes.set(label, defaultValue, '>', null, obj, null, false, true,
532
+ 'autocreate');
533
+ return obj._nodes.get(label);
534
+ }
535
+ }
536
+ return null;
537
+ }
538
+
539
+ // -------------------------------------------------------------------------
540
+ // Backref system
541
+ // -------------------------------------------------------------------------
542
+
543
+ /**
544
+ * Enable backref mode (tree-leaf model with parent references).
545
+ *
546
+ * @param {BagNode|null} [node=null] - The BagNode that contains this Bag.
547
+ * @param {Bag|null} [parent=null] - The parent Bag.
548
+ */
549
+ setBackref(node = null, parent = null) {
550
+ const alreadyEnabled = this._backref;
551
+ this._backref = true;
552
+ this._parent = parent;
553
+ this._parentNode = node;
554
+ if (alreadyEnabled) return;
555
+ this._nodes._parentBag = this;
556
+ for (const n of this) {
557
+ n.parentBag = this;
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Clear parent reference and disable backref.
563
+ */
564
+ delParentRef() {
565
+ this._parent = null;
566
+ this._parentNode = null;
567
+ this._backref = false;
568
+ }
569
+
570
+ /**
571
+ * Clear all backref assumptions recursively.
572
+ */
573
+ clearBackref() {
574
+ if (this._backref) {
575
+ this._backref = false;
576
+ this._parent = null;
577
+ this._parentNode = null;
578
+ this._nodes._parentBag = null;
579
+ for (const node of this) {
580
+ node.parentBag = null;
581
+ const value = node.getValue(true);
582
+ if (value instanceof Bag) {
583
+ value.clearBackref();
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ // -------------------------------------------------------------------------
590
+ // Event triggers
591
+ // -------------------------------------------------------------------------
592
+
593
+ /**
594
+ * Trigger for node change events.
595
+ *
596
+ * @param {BagNode} node - The changed node.
597
+ * @param {string[]} pathlist - Path to the node.
598
+ * @param {string} evt - Event type ('upd_value', 'upd_attrs', 'upd_value_attr').
599
+ * @param {*} [oldvalue=null] - Previous value (for value changes).
600
+ * @param {Object|null} [attrsDiff=null] - Attribute diff dict { name: { old, new } }
601
+ * (for attribute changes).
602
+ * @param {string|null} [reason=null] - Reason for change.
603
+ * @param {boolean} [fired=false] - True when the change is a fired write.
604
+ */
605
+ _onNodeChanged(node, pathlist, evt, oldvalue = null, attrsDiff = null, reason = null,
606
+ fired = false) {
607
+ for (const s of Object.values(this._updSubscribers)) {
608
+ s({ node, pathlist, oldvalue, attrs_diff: attrsDiff, evt, reason, fired });
609
+ }
610
+ if (this._parent && this._parentNode) {
611
+ this._parent._onNodeChanged(
612
+ node,
613
+ [this._parentNode.label, ...pathlist],
614
+ evt,
615
+ oldvalue,
616
+ attrsDiff,
617
+ reason,
618
+ fired
619
+ );
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Trigger for node insert events.
625
+ *
626
+ * @param {BagNode} node - The inserted node.
627
+ * @param {number} ind - Index where inserted.
628
+ * @param {string[]|null} [pathlist=null] - Path to the node.
629
+ * @param {string|null} [reason=null] - Reason for insertion.
630
+ * @param {boolean} [fired=false] - True when the insertion is a fired write.
631
+ */
632
+ _onNodeInserted(node, ind, pathlist = null, reason = null, fired = false) {
633
+ const parent = node.parentBag;
634
+ const value = node.getValue(true);
635
+ if (parent !== null && parent.backref && value instanceof Bag) {
636
+ value.setBackref(node, parent);
637
+ }
638
+
639
+ if (pathlist === null) {
640
+ pathlist = [];
641
+ }
642
+ for (const s of Object.values(this._insSubscribers)) {
643
+ s({ node, pathlist, ind, evt: 'ins', reason, fired });
644
+ }
645
+ if (this._parent && this._parentNode) {
646
+ this._parent._onNodeInserted(
647
+ node,
648
+ ind,
649
+ [this._parentNode.label, ...pathlist],
650
+ reason,
651
+ fired
652
+ );
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Trigger for node delete events.
658
+ *
659
+ * @param {BagNode|BagNode[]} node - The deleted node(s).
660
+ * @param {number} ind - Index where deleted (-1 for clear).
661
+ * @param {string[]|null} [pathlist=null] - Path to the node.
662
+ * @param {string|null} [reason=null] - Reason for deletion.
663
+ */
664
+ _onNodeDeleted(node, ind, pathlist = null, reason = null) {
665
+ for (const s of Object.values(this._delSubscribers)) {
666
+ s({ node, pathlist, ind, evt: 'del', reason });
667
+ }
668
+ if (this._parent && this._parentNode) {
669
+ if (pathlist === null) {
670
+ pathlist = [];
671
+ }
672
+ this._parent._onNodeDeleted(
673
+ node,
674
+ ind,
675
+ [this._parentNode.label, ...pathlist],
676
+ reason
677
+ );
678
+ }
679
+ }
680
+
681
+ // -------------------------------------------------------------------------
682
+ // Subscription
683
+ // -------------------------------------------------------------------------
684
+
685
+ /**
686
+ * Internal subscribe helper.
687
+ */
688
+ _subscribe(subscriberId, subscribersDict, callback) {
689
+ if (callback !== null && callback !== undefined) {
690
+ subscribersDict[subscriberId] = callback;
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Subscribe to bag events.
696
+ *
697
+ * @param {string} subscriberId - Unique identifier for this subscription.
698
+ * @param {Object} options - Subscription options.
699
+ * @param {Function} [options.update] - Callback for update events.
700
+ * @param {Function} [options.insert] - Callback for insert events.
701
+ * @param {Function} [options.delete] - Callback for delete events.
702
+ * @param {Function} [options.any] - Callback for all events.
703
+ */
704
+ subscribe(subscriberId, { update = null, insert = null, delete: del = null, any = null } = {}) {
705
+ if (!this.backref) {
706
+ this.setBackref();
707
+ }
708
+
709
+ this._subscribe(subscriberId, this._updSubscribers, update || any);
710
+ this._subscribe(subscriberId, this._insSubscribers, insert || any);
711
+ this._subscribe(subscriberId, this._delSubscribers, del || any);
712
+ }
713
+
714
+ /**
715
+ * Unsubscribe from bag events.
716
+ *
717
+ * @param {string} subscriberId - The subscription identifier to remove.
718
+ * @param {Object} options - Unsubscription options.
719
+ * @param {boolean} [options.update=false] - Remove update subscription.
720
+ * @param {boolean} [options.insert=false] - Remove insert subscription.
721
+ * @param {boolean} [options.delete=false] - Remove delete subscription.
722
+ * @param {boolean} [options.any=false] - Remove all subscriptions.
723
+ */
724
+ unsubscribe(subscriberId, { update = false, insert = false, delete: del = false, any = false } = {}) {
725
+ if (update || any) {
726
+ delete this._updSubscribers[subscriberId];
727
+ }
728
+ if (insert || any) {
729
+ delete this._insSubscribers[subscriberId];
730
+ }
731
+ if (del || any) {
732
+ delete this._delSubscribers[subscriberId];
733
+ }
734
+ }
735
+
736
+ // -------------------------------------------------------------------------
737
+ // Iteration
738
+ // -------------------------------------------------------------------------
739
+
740
+ /**
741
+ * Iterate over BagNodes.
742
+ */
743
+ [Symbol.iterator]() {
744
+ return this._nodes[Symbol.iterator]();
745
+ }
746
+
747
+ /**
748
+ * Return node labels in order.
749
+ *
750
+ * @returns {string[]} Array of labels.
751
+ */
752
+ keys() {
753
+ return this._nodes.keys();
754
+ }
755
+
756
+ /**
757
+ * Return node values in order.
758
+ *
759
+ * @returns {Array} Array of values.
760
+ */
761
+ values() {
762
+ return this._nodes.values();
763
+ }
764
+
765
+ /**
766
+ * Return key/value objects in node order, resolving values as needed.
767
+ *
768
+ * @returns {Array<{key: string, value: *}>} Array of key/value objects.
769
+ */
770
+ items() {
771
+ return [...this._nodes].map(node => ({key: node.label, value: node.getValue()}));
772
+ }
773
+
774
+ // -------------------------------------------------------------------------
775
+ // Node Access Methods
776
+ // -------------------------------------------------------------------------
777
+
778
+ /**
779
+ * Property alias for getNodes().
780
+ *
781
+ * @returns {BagNode[]} List of BagNodes.
782
+ */
783
+ get nodes() {
784
+ return this.getNodes();
785
+ }
786
+
787
+ /**
788
+ * Get a first-level node by label or index.
789
+ *
790
+ * Sync method for quick access to direct child nodes.
791
+ * Does not traverse paths or trigger resolvers.
792
+ *
793
+ * @param {string|number} key - Node label (str) or index (int).
794
+ * @returns {BagNode|null} The BagNode if found, null otherwise.
795
+ *
796
+ * @example
797
+ * bag.node('a').value // 1
798
+ * bag.node(0).label // 'a'
799
+ */
800
+ node(key) {
801
+ return this._nodes.get(key);
802
+ }
803
+
804
+ /**
805
+ * Set attributes on a node at the given path.
806
+ *
807
+ * @param {string|null} [path=null] - Path to the node.
808
+ * @param {Object|null} [attr=null] - Dict of attributes to set.
809
+ * @param {boolean} [removeNullAttributes=true] - If true, remove attributes with null value.
810
+ */
811
+ setAttr(path = null, attr = null, removeNullAttributes = true) {
812
+ // Use the native signature: compatibility subclasses may repurpose
813
+ // getNode's second argument as a reserved legacy slot.
814
+ const node = Bag.prototype.getNode.call(this, path, true, true);
815
+ if (node) {
816
+ node.setAttr(attr, true, true, removeNullAttributes);
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Get an attribute from a node at the given path.
822
+ *
823
+ * @param {string|null} [path=null] - Path to the node.
824
+ * @param {string|null} [attr=null] - Attribute name to get.
825
+ * @param {*} [defaultVal=null] - Default value if node or attribute not found.
826
+ * @returns {*} Attribute value or default.
827
+ */
828
+ getAttr(path = null, attr = null, defaultVal = null) {
829
+ const node = this.getNode(path);
830
+ if (node) {
831
+ return node.getAttr(attr, defaultVal);
832
+ }
833
+ return defaultVal;
834
+ }
835
+
836
+ /**
837
+ * Delete attributes from a node at the given path.
838
+ *
839
+ * @param {string|null} [path=null] - Path to the node.
840
+ * @param {...string} attrs - Attribute names to delete.
841
+ */
842
+ delAttr(path = null, ...attrs) {
843
+ const node = this.getNode(path);
844
+ if (node) {
845
+ node.delAttr(...attrs);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Get inherited attributes from parent chain.
851
+ *
852
+ * @returns {Object} Dict of attributes inherited from parent nodes.
853
+ */
854
+ getInheritedAttributes() {
855
+ if (this._parentNode) {
856
+ return this._parentNode.getInheritedAttributes();
857
+ }
858
+ return {};
859
+ }
860
+
861
+ // -------------------------------------------------------------------------
862
+ // Query Methods (BagQuery)
863
+ // -------------------------------------------------------------------------
864
+
865
+ /**
866
+ * Get the actual list of nodes contained in the Bag.
867
+ *
868
+ * The getNodes method works as the filter of a list.
869
+ *
870
+ * @param {Function|null} [condition=null] - Optional callable that takes a BagNode and returns bool.
871
+ * Returns a new list of shared nodes. List mutations do not change the Bag;
872
+ * node mutations do. Later structural changes are not reflected in the list.
873
+ * @returns {BagNode[]} Snapshot of BagNodes, optionally filtered by condition.
874
+ */
875
+ getNodes(condition = null) {
876
+ if (!condition) {
877
+ return [...this._nodes];
878
+ }
879
+ return [...this._nodes].filter(n => condition(n));
880
+ }
881
+
882
+ /**
883
+ * Return the first BagNode whose value contains key=value.
884
+ *
885
+ * Searches only direct children (not recursive).
886
+ * The node's value must be dict-like (Bag or dict).
887
+ *
888
+ * @param {string} key - Key to look for in node.value.
889
+ * @param {*} value - Value to match.
890
+ * @returns {BagNode|null} BagNode if found, null otherwise.
891
+ */
892
+ getNodeByValue(key, value) {
893
+ for (const node of this._nodes) {
894
+ const nodeValue = node.value;
895
+ if (nodeValue instanceof Bag) {
896
+ if (nodeValue.getItem(key) === value) return node;
897
+ } else if (nodeValue && nodeValue.get && nodeValue.get(key) === value) {
898
+ return node;
899
+ }
900
+ }
901
+ return null;
902
+ }
903
+
904
+ /**
905
+ * Return the first BagNode with the requested attribute value.
906
+ *
907
+ * Search strategy (hybrid depth-first with level priority):
908
+ * 1. First checks all direct children of current Bag
909
+ * 2. Then recursively searches into sub-Bags (depth-first)
910
+ *
911
+ * This means a match at the current level is always found before
912
+ * descending into nested Bags, but once descent begins, it proceeds
913
+ * depth-first through the subtree before checking siblings.
914
+ *
915
+ * @param {string} attr - Attribute name to search.
916
+ * @param {*} value - Attribute value; undefined searches for presence.
917
+ * @param {boolean} [caseInsensitive=false] - Compare strings ignoring case.
918
+ * @param {boolean} [deep_first=false] - Visit subtrees before next siblings.
919
+ * @returns {BagNode|null} BagNode if found, null otherwise.
920
+ */
921
+ getNodeByAttr(attr, value, caseInsensitive = false, deep_first = false) {
922
+ const existsOnly = value === undefined;
923
+ const expected = caseInsensitive && typeof value === 'string' ? value.toLowerCase() : value;
924
+ const search = bag => {
925
+ const subBags = [];
926
+ for (const node of bag._nodes) {
927
+ if (attr in node.attr) {
928
+ const current = caseInsensitive && typeof node.attr[attr] === 'string'
929
+ ? node.attr[attr].toLowerCase() : node.attr[attr];
930
+ if (existsOnly || current == expected) return node;
931
+ }
932
+ if (node._value instanceof Bag) {
933
+ if (deep_first) {
934
+ const found = search(node._value);
935
+ if (found) return found;
936
+ } else {
937
+ subBags.push(node._value);
938
+ }
939
+ }
940
+ }
941
+ for (const child of subBags) {
942
+ const found = search(child);
943
+ if (found) return found;
944
+ }
945
+ return null;
946
+ };
947
+ return search(this);
948
+ }
949
+
950
+ /**
951
+ * Check if the Bag is empty.
952
+ *
953
+ * A node is considered non-empty if:
954
+ * - It has a resolver (even if static value is null, the resolver
955
+ * represents potential content that can be loaded)
956
+ * - It has a non-null static value (unless zeroIsNone/blankIsNone apply)
957
+ *
958
+ * This method never triggers resolver I/O - it only checks static values
959
+ * and resolver presence.
960
+ *
961
+ * @param {boolean} [zeroIsNone=false] - If true, treat 0 values as empty.
962
+ * @param {boolean} [blankIsNone=false] - If true, treat blank strings as empty.
963
+ * @returns {boolean} True if Bag is empty according to criteria, false otherwise.
964
+ */
965
+ isEmpty(zeroIsNone = false, blankIsNone = false) {
966
+ if (this._nodes.length === 0) {
967
+ return true;
968
+ }
969
+
970
+ for (const node of this._nodes) {
971
+ // A node with a resolver is not empty (has potential content)
972
+ if (node._resolver !== null) {
973
+ return false;
974
+ }
975
+ const v = node.getValue(true); // static=true
976
+ if (v === null || v === undefined) {
977
+ continue;
978
+ }
979
+ if (zeroIsNone && v === 0) {
980
+ continue;
981
+ }
982
+ if (blankIsNone && v === '') {
983
+ continue;
984
+ }
985
+ return false;
986
+ }
987
+
988
+ return true;
989
+ }
990
+
991
+ /**
992
+ * Query Bag elements, extracting specified data.
993
+ *
994
+ * @param {string|Array|null} [what=null] - String of special keys separated by comma, or array of keys.
995
+ * Special keys:
996
+ * - '#k': label of each item
997
+ * - '#v': value of each item
998
+ * - '#v.path': inner values of each item
999
+ * - '#__v': static value (always bypasses resolver)
1000
+ * - '#a': all attributes of each item
1001
+ * - '#a.attrname': specific attribute for each item
1002
+ * - '#p': path (full path from root, useful with deep=true)
1003
+ * - '#n': node (the BagNode itself)
1004
+ * - callable: custom function applied to each node
1005
+ * @param {Function|boolean|null} [condition=null] - Optional callable filter (receives BagNode, returns bool).
1006
+ * A boolean is the deprecated legacy asColumns argument and emits a warning.
1007
+ * @param {boolean} [iter=false] - If true, return a generator instead of an array.
1008
+ * @param {boolean} [deep=false] - If true, traverse recursively (depth-first) instead of first level only.
1009
+ * @param {boolean} [leaf=true] - If true (default), include leaf nodes (non-Bag values).
1010
+ * @param {boolean} [branch=true] - If true (default), include branch nodes (Bag values).
1011
+ * @param {number|null} [limit=null] - Maximum number of results to return. null means no limit.
1012
+ * @param {boolean} [isStatic=true] - If true (default), don't trigger resolvers during traversal.
1013
+ * @returns {Array|Generator} Array of tuples, or generator if iter=true.
1014
+ */
1015
+ query(what = null, condition = null, iter = false, deep = false, leaf = true, branch = true, limit = null, isStatic = true) {
1016
+ if (!what) {
1017
+ what = '#k,#v,#a';
1018
+ }
1019
+
1020
+ let obj = this;
1021
+ let whatsplit;
1022
+
1023
+ if (typeof what === 'string') {
1024
+ if (what.includes(':')) {
1025
+ const [where, whatPart] = what.split(':');
1026
+ obj = this.getItem(where);
1027
+ what = whatPart;
1028
+ }
1029
+ whatsplit = what.split(',').map(x => x.trim());
1030
+ } else {
1031
+ whatsplit = what;
1032
+ }
1033
+
1034
+ const _extractValue = (node, w, path, isDeep, readValue) => {
1035
+ if (w === '#k') {
1036
+ return node.label;
1037
+ } else if (w === '#p') {
1038
+ return path;
1039
+ } else if (w === '#n') {
1040
+ return node;
1041
+ } else if (typeof w === 'function') {
1042
+ return w(node);
1043
+ } else if (w === '#v') {
1044
+ const v = readValue();
1045
+ // With deep=true, Bag values return null (content comes in later iterations)
1046
+ return isDeep && v instanceof Bag ? null : v;
1047
+ } else if (w.startsWith('#v.')) {
1048
+ const innerPath = w.split('.').slice(1).join('.');
1049
+ const value = readValue();
1050
+ return value && value.getItem ? value.getItem(innerPath) : null;
1051
+ } else if (w === '#__v') {
1052
+ return node.getValue(true); // Always static
1053
+ } else if (w.startsWith('#a')) {
1054
+ const attr = w.includes('.') ? w.split('.').slice(1).join('.') : null;
1055
+ return node.getAttr(attr);
1056
+ } else {
1057
+ const value = readValue();
1058
+ return value && value.getItem ? value.getItem(w) : null;
1059
+ }
1060
+ };
1061
+
1062
+ function* _iterDigest() {
1063
+ let count = 0;
1064
+ function* visit(bag, prefix) {
1065
+ for (const node of bag._nodes) {
1066
+ const path = prefix ? `${prefix}.${node.label}` : node.label;
1067
+ // Per-visit state, independent of the resolver's own cache.
1068
+ let loaded = false;
1069
+ let value;
1070
+ const readValue = () => {
1071
+ if (!loaded) {
1072
+ value = node.getValue(isStatic);
1073
+ loaded = true;
1074
+ }
1075
+ return value;
1076
+ };
1077
+ const included = (leaf && branch) ||
1078
+ (readValue() instanceof Bag ? branch : leaf);
1079
+ if (included && (condition === null || condition(node))) {
1080
+ yield whatsplit.length === 1
1081
+ ? _extractValue(node, whatsplit[0], path, deep, readValue)
1082
+ : whatsplit.map(w => _extractValue(node, w, path, deep, readValue));
1083
+ count++;
1084
+ if (limit !== null && count >= limit) return;
1085
+ }
1086
+ if (deep) {
1087
+ const child = readValue();
1088
+ if (child instanceof Bag) {
1089
+ yield* visit(child, path);
1090
+ if (limit !== null && count >= limit) return;
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ yield* visit(obj, '');
1096
+ }
1097
+
1098
+ if (iter) {
1099
+ return _iterDigest();
1100
+ }
1101
+
1102
+ return [..._iterDigest()];
1103
+ }
1104
+
1105
+ /**
1106
+ * Return [relative path, value] pairs for non-Bag leaves.
1107
+ * Resolve each node once per occurrence. Empty Bags produce no leaf entry.
1108
+ * Paths do not require parent backrefs.
1109
+ */
1110
+ getLeaves() {
1111
+ const result = [];
1112
+ const collect = (bag, prefix) => {
1113
+ for (const node of bag._nodes) {
1114
+ const path = prefix ? `${prefix}.${node.label}` : node.label;
1115
+ const value = node.getValue(false);
1116
+ if (value instanceof Bag) collect(value, path);
1117
+ else result.push([path, value]);
1118
+ }
1119
+ };
1120
+ collect(this, '');
1121
+ return result;
1122
+ }
1123
+
1124
+ /**
1125
+ * Return a list of tuples with keys/values/attributes (backward compatible).
1126
+ *
1127
+ * This is an alias for query() with iter=false, deep=false for backward
1128
+ * compatibility. Use query() for new code.
1129
+ *
1130
+ * @param {string|Array|null} [what=null] - String of special keys separated by comma, or array of keys.
1131
+ * @param {Function|boolean|null} [condition=null] - Optional callable filter (receives BagNode, returns bool).
1132
+ * A boolean is the deprecated legacy asColumns argument and emits a warning.
1133
+ * @param {boolean} [asColumns=false] - If true, return array of arrays (transposed).
1134
+ * @returns {Array} Array of tuples (or array of arrays if asColumns=true).
1135
+ */
1136
+ digest(what = null, condition = null, asColumns = false) {
1137
+ if (typeof condition === 'boolean') {
1138
+ console.warn('Bag.digest(what, asColumns) is deprecated; use digest(what, null, asColumns).');
1139
+ asColumns = condition;
1140
+ condition = null;
1141
+ }
1142
+ const result = this.query(what, condition, false, false, true, true, null, false);
1143
+
1144
+ if (asColumns) {
1145
+ if (!result || result.length === 0) {
1146
+ const whatStr = typeof what === 'string' ? what : '#k,#v,#a';
1147
+ const whatsplit = whatStr.split(',').map(x => x.trim());
1148
+ return whatsplit.map(() => []);
1149
+ }
1150
+ const resultList = [...result];
1151
+ if (resultList.length && Array.isArray(resultList[0])) {
1152
+ // Transpose: list of rows → list of columns
1153
+ const numCols = resultList[0].length;
1154
+ const columns = [];
1155
+ for (let i = 0; i < numCols; i++) {
1156
+ columns.push(resultList.map(row => row[i]));
1157
+ }
1158
+ return columns;
1159
+ }
1160
+ return [resultList];
1161
+ }
1162
+ return [...result];
1163
+ }
1164
+
1165
+ /**
1166
+ * Return digest result as columns.
1167
+ *
1168
+ * @param {string|Array} cols - Column names as comma-separated string or array.
1169
+ * @param {boolean} [attrMode=false] - If true, prefix columns with '#a.' for attribute access.
1170
+ * @returns {Array} Array of arrays (columns).
1171
+ */
1172
+ columns(cols, attrMode = false) {
1173
+ if (typeof cols === 'string') {
1174
+ cols = cols.split(',');
1175
+ }
1176
+ const mode = attrMode ? '#a.' : '';
1177
+ const what = cols.map(col => `${mode}${col}`).join(',');
1178
+ return this.digest(what, null, true);
1179
+ }
1180
+
1181
+ /**
1182
+ * Sum selected values at the current level; never traverse recursively.
1183
+ * @param {string} [what='#v'] - Query criterion, or comma-separated criteria.
1184
+ * @param {boolean} [strict=false] - Return null for null/undefined/empty string.
1185
+ * Otherwise these values contribute zero. Non-numeric values are ignored
1186
+ * unless strict, which raises TypeError. Zero and false remain valid.
1187
+ * @param {Function|null} [condition=null] - Optional BagNode predicate.
1188
+ * @returns {number|null|Array} One result, or one result per criterion.
1189
+ */
1190
+ sum(what = '#v', strict = false, condition = null) {
1191
+ if (strict != null && typeof strict !== 'boolean') {
1192
+ throw new TypeError('sum strict must be a boolean; pass condition as the third argument');
1193
+ }
1194
+ if (condition != null && typeof condition !== 'function') {
1195
+ throw new TypeError('sum condition must be callable; deep is no longer supported');
1196
+ }
1197
+ const total = criterion => {
1198
+ let result = 0;
1199
+ let missing = false;
1200
+ for (const value of this.query(criterion, condition)) {
1201
+ if (value == null || value === '') {
1202
+ missing = true;
1203
+ continue;
1204
+ }
1205
+ if (typeof value !== 'number' && typeof value !== 'boolean') {
1206
+ if (strict) throw new TypeError(`sum encountered non-numeric value: ${typeof value}`);
1207
+ continue;
1208
+ }
1209
+ result += value;
1210
+ }
1211
+ return strict && missing ? null : result;
1212
+ };
1213
+ return what.includes(',') ? what.split(',').map(w => total(w.trim())) : total(what);
1214
+ }
1215
+
1216
+ /**
1217
+ * Sort nodes in place.
1218
+ *
1219
+ * @param {string|Function} [key='#k:a'] - Sort specification string or callable.
1220
+ * If callable, used directly as key function for sort.
1221
+ * If string, format is 'criterion:mode' or multiple 'c1:m1,c2:m2'.
1222
+ *
1223
+ * Criteria:
1224
+ * - '#k': sort by label
1225
+ * - '#v': sort by value
1226
+ * - '#a.attrname': sort by attribute
1227
+ * - 'fieldname': sort by field in value (if value is dict/Bag)
1228
+ *
1229
+ * Modes:
1230
+ * - 'a': ascending, case-insensitive (default)
1231
+ * - 'A': ascending, case-sensitive
1232
+ * - 'd': descending, case-insensitive
1233
+ * - 'D': descending, case-sensitive
1234
+ *
1235
+ * @returns {Bag} Self (for chaining).
1236
+ *
1237
+ * @example
1238
+ * bag.sort('#k') // by label ascending
1239
+ * bag.sort('#k:d') // by label descending
1240
+ * bag.sort('#v:A') // by value ascending, case-sensitive
1241
+ * bag.sort('#a.name:a') // by attribute 'name'
1242
+ * bag.sort('field:d') // by field in value
1243
+ * bag.sort('#k:a,#v:d') // multi-level sort
1244
+ * bag.sort(n => n.value) // custom key function
1245
+ */
1246
+ sort(key = '#k:a') {
1247
+ /**
1248
+ * Create sort key handling null/undefined and case sensitivity.
1249
+ * @param {*} value - The value to create key for.
1250
+ * @param {boolean} caseInsensitive - Whether to ignore case for strings.
1251
+ * @returns {Array} Tuple [priority, value] where priority -1 means null (sort first ascending).
1252
+ */
1253
+ const sortKey = (value, caseInsensitive) => {
1254
+ if (value === null || value === undefined) {
1255
+ return [-1, '']; // null/undefined first ascending, last descending
1256
+ }
1257
+ if (caseInsensitive && typeof value === 'string') {
1258
+ return [0, value.toLowerCase()];
1259
+ }
1260
+ return [0, value];
1261
+ };
1262
+
1263
+ /**
1264
+ * Compare two sort keys.
1265
+ * @param {Array} a - First sort key [priority, value].
1266
+ * @param {Array} b - Second sort key [priority, value].
1267
+ * @returns {number} Comparison result for Array.sort.
1268
+ */
1269
+ const compareKeys = (a, b) => {
1270
+ // First compare by priority (nulls first ascending)
1271
+ if (a[0] !== b[0]) {
1272
+ return a[0] - b[0];
1273
+ }
1274
+ // Then compare by value
1275
+ if (a[1] < b[1]) return -1;
1276
+ if (a[1] > b[1]) return 1;
1277
+ return 0;
1278
+ };
1279
+
1280
+ if (typeof key === 'function') {
1281
+ this._nodes._list.sort((a, b) => {
1282
+ const ka = key(a);
1283
+ const kb = key(b);
1284
+ if (ka < kb) return -1;
1285
+ if (ka > kb) return 1;
1286
+ return 0;
1287
+ });
1288
+ } else {
1289
+ const levels = key.split(',');
1290
+ levels.reverse(); // process in reverse for stable multi-level sort
1291
+ for (const level of levels) {
1292
+ let what, mode;
1293
+ if (level.includes(':')) {
1294
+ [what, mode] = level.split(':', 2);
1295
+ } else {
1296
+ what = level;
1297
+ mode = 'a';
1298
+ }
1299
+ what = what.trim();
1300
+ mode = mode.trim();
1301
+
1302
+ const reverse = mode === 'd' || mode === 'D';
1303
+ const caseInsensitive = mode === 'a' || mode === 'd';
1304
+
1305
+ let keyFn;
1306
+ if (what.toLowerCase() === '#k') {
1307
+ keyFn = n => sortKey(n.label, caseInsensitive);
1308
+ } else if (what.toLowerCase() === '#v') {
1309
+ keyFn = n => sortKey(n.value, caseInsensitive);
1310
+ } else if (what.toLowerCase().startsWith('#a.')) {
1311
+ const attrname = what.slice(3);
1312
+ keyFn = n => sortKey(n.getAttr(attrname), caseInsensitive);
1313
+ } else {
1314
+ // Sort by field in value
1315
+ keyFn = n => {
1316
+ const value = n.value;
1317
+ const field = value instanceof Bag ? value.getItem(what) :
1318
+ (value ? value[what] : null);
1319
+ return sortKey(field, caseInsensitive);
1320
+ };
1321
+ }
1322
+
1323
+ this._nodes._list.sort((a, b) => {
1324
+ const result = compareKeys(keyFn(a), keyFn(b));
1325
+ return reverse ? -result : result;
1326
+ });
1327
+ }
1328
+ }
1329
+ return this;
1330
+ }
1331
+
1332
+ /**
1333
+ * Check equality with another Bag.
1334
+ *
1335
+ * @param {Bag} other - Bag to compare with.
1336
+ * @returns {boolean} True if both Bags have same nodes with same values and attributes.
1337
+ */
1338
+ equalTo(other) {
1339
+ if (!(other instanceof Bag)) return false;
1340
+ return this._nodes.isEqual(other._nodes);
1341
+ }
1342
+
1343
+ /**
1344
+ * Check if a path or node exists in the Bag.
1345
+ *
1346
+ * Equivalent to Python's `__contains__` / `in` operator.
1347
+ *
1348
+ * With the `?attr` query syntax it checks the existence of the named
1349
+ * attribute on the target node; `?a&b` requires every named attribute
1350
+ * to be present. The check is static: it does not trigger resolvers
1351
+ * along the path.
1352
+ *
1353
+ * @param {string|BagNode} what - Path to check, optionally with a
1354
+ * `?attr` or `?a&b` suffix, or a BagNode to check if it's in this Bag.
1355
+ * @returns {boolean} True if the path/node (and named attributes if
1356
+ * provided) exists, false otherwise.
1357
+ *
1358
+ * @example
1359
+ * bag.setItem('a.b', 1);
1360
+ * bag.has('a.b') // true
1361
+ * bag.has('a.c') // false
1362
+ * bag.has('a.b?color') // true if node a.b has attribute 'color'
1363
+ * bag.has('a.b?x&y') // true only if both 'x' and 'y' are present
1364
+ */
1365
+ has(what) {
1366
+ if (typeof what === 'string') {
1367
+ let queryString = null;
1368
+ if (what.includes('?')) {
1369
+ [what, queryString] = what.split('?', 2);
1370
+ }
1371
+ const node = this.getNode(what);
1372
+ if (node === null) {
1373
+ return false;
1374
+ }
1375
+ if (queryString === null) {
1376
+ return true;
1377
+ }
1378
+ return queryString.split('&').every(a => a in node.attr);
1379
+ } else if (what && what.label !== undefined) {
1380
+ // Assume it's a BagNode-like object
1381
+ return [...this._nodes].includes(what);
1382
+ }
1383
+ return false;
1384
+ }
1385
+
1386
+ // -------------------------------------------------------------------------
1387
+ // Resolver Methods
1388
+ // -------------------------------------------------------------------------
1389
+
1390
+ /**
1391
+ * Get the resolver at the given path.
1392
+ *
1393
+ * @param {string} path - Path to the node.
1394
+ * @returns {*} The resolver, or null if path doesn't exist or has no resolver.
1395
+ */
1396
+ getResolver(path) {
1397
+ const node = this.getNode(path);
1398
+ return node ? node.resolver : null;
1399
+ }
1400
+
1401
+ /**
1402
+ * Set a resolver at the given path.
1403
+ *
1404
+ * Creates the node if it doesn't exist, with value=null.
1405
+ *
1406
+ * @param {string} path - Path to the node.
1407
+ * @param {*} resolver - The resolver to set.
1408
+ */
1409
+ setResolver(path, resolver) {
1410
+ let node = this.getNode(path);
1411
+ if (!node) {
1412
+ this.setItem(path, null);
1413
+ node = this.getNode(path);
1414
+ }
1415
+ node.resolver = resolver;
1416
+ }
1417
+
1418
+ /**
1419
+ * Set a callback resolver at the given path.
1420
+ *
1421
+ * Shortcut for creating a BagCbResolver and setting it on a node.
1422
+ *
1423
+ * @param {string} path - Path to the node.
1424
+ * @param {Function} callback - Callable that returns the value.
1425
+ * @param {Object} [options={}] - Options passed to BagCbResolver constructor.
1426
+ * - cacheTime: Cache duration in ms (default 0, no cache).
1427
+ * - readOnly: If true, value not saved in node (default false).
1428
+ */
1429
+ setCallbackItem(path, callback, options = {}) {
1430
+ const resolver = new BagCbResolver({ callback, ...options });
1431
+ this.setResolver(path, resolver);
1432
+ }
1433
+
1434
+ // -------------------------------------------------------------------------
1435
+ // Structure Manipulation Methods
1436
+ // -------------------------------------------------------------------------
1437
+
1438
+ /**
1439
+ * Move element(s) to a new position.
1440
+ *
1441
+ * @param {number|number[]} what - Index or list of indices to move.
1442
+ * @param {number} position - Target index position.
1443
+ * @param {boolean} [trigger=true] - If true, fire del/ins events.
1444
+ *
1445
+ * @example
1446
+ * bag.move(0, 2) // move first element to position 2
1447
+ * bag.move([0, 2], 1) // move indices 0 and 2 to position 1
1448
+ */
1449
+ move(what, position, trigger = true) {
1450
+ this._nodes.move(what, position, trigger);
1451
+ }
1452
+
1453
+ /**
1454
+ * Convert Bag to plain object (first level only).
1455
+ *
1456
+ * @param {boolean} [ascii=false] - If true, convert keys to ASCII.
1457
+ * @param {boolean} [lower=false] - If true, convert keys to lowercase.
1458
+ * @param {boolean} [recursive=false] - Convert nested Bags (not ordinary objects/arrays).
1459
+ * @param {boolean} [excludeNullValues=false] - Omit null/undefined, retaining empty Bags.
1460
+ * @returns {Object} Plain JavaScript object with key-value pairs.
1461
+ */
1462
+ asDict(ascii = false, lower = false, recursive = false, excludeNullValues = false) {
1463
+ const result = {};
1464
+ for (const el of this._nodes) {
1465
+ let value = el.value;
1466
+ if (excludeNullValues && value == null) continue;
1467
+ let key = el.label;
1468
+ if (ascii) {
1469
+ key = String(key);
1470
+ }
1471
+ if (lower) {
1472
+ key = key.toLowerCase();
1473
+ }
1474
+ if (recursive && value instanceof Bag) {
1475
+ value = Bag.prototype.asDict.call(value, ascii, lower, recursive, excludeNullValues);
1476
+ }
1477
+ Object.defineProperty(result, key, {
1478
+ value, enumerable: true, writable: true, configurable: true
1479
+ });
1480
+ }
1481
+ return result;
1482
+ }
1483
+
1484
+ /**
1485
+ * Return value at path, setting it to default if not present.
1486
+ *
1487
+ * @param {string} path - Path to the value.
1488
+ * @param {*} [defaultVal=null] - Default value to set if path doesn't exist.
1489
+ * @returns {*} The value at path (existing or newly set default).
1490
+ */
1491
+ setdefault(path, defaultVal = null) {
1492
+ const node = this.getNode(path);
1493
+ if (!node) {
1494
+ this.setItem(path, defaultVal);
1495
+ return defaultVal;
1496
+ }
1497
+ return node.value;
1498
+ }
1499
+
1500
+ /**
1501
+ * Return a deep copy of this Bag.
1502
+ *
1503
+ * Creates a new Bag with copies of all nodes. Nested Bags are
1504
+ * recursively deep copied. Values are copied by reference unless
1505
+ * they are Bags. Node attributes are copied as a new dict.
1506
+ *
1507
+ * @param {boolean} [resolve=false] - Resolve values before copying.
1508
+ * @returns {Bag} A new Bag with copied nodes.
1509
+ *
1510
+ * @example
1511
+ * const copy = bag.deepcopy();
1512
+ * copy.setItem('b.c', 3);
1513
+ * // Original bag['b.c'] unchanged
1514
+ */
1515
+ deepcopy(resolve = false) {
1516
+ const result = this.createChildBag();
1517
+ for (const node of this._nodes) {
1518
+ let value = node.getValue(!resolve);
1519
+ if (value instanceof Bag) value = value.deepcopy(resolve);
1520
+ const copied = new result.nodeClass(result, node.label, value);
1521
+ copied.setAttr({...node.attr}, false, false, false);
1522
+ copied.nodeTag = node.nodeTag;
1523
+ copied.xmlTag = node.xmlTag;
1524
+ // Insert by position so repeated labels do not overwrite each other.
1525
+ result._nodes._list.push(copied);
1526
+ result._nodes._dict[copied.label] = copied;
1527
+ }
1528
+ return result;
1529
+ }
1530
+
1531
+ /**
1532
+ * Merge a Bag or object using the legacy JavaScript argument order.
1533
+ *
1534
+ * @param {Bag|Object} source - Incoming nodes or key/value properties.
1535
+ * @param {string|null} [mode=null] - 'static' preserves source resolvers;
1536
+ * otherwise their values are resolved before copying.
1537
+ * @param {*} [reason=null] - Modification reason passed to subscribers.
1538
+ * @param {boolean} [ignoreNone=false] - Optional extension: preserve existing
1539
+ * values when the incoming value is null. It is not the mode argument.
1540
+ */
1541
+ update(source, mode = null, reason = null, ignoreNone = false) {
1542
+ if (!(source instanceof Bag)) {
1543
+ for (const label in source) {
1544
+ const value = source[label];
1545
+ if (!ignoreNone || value !== null || !this._nodes.has(label)) {
1546
+ this.setItem(label, value, null, '>', false, false,
1547
+ reason, false, reason !== false);
1548
+ }
1549
+ }
1550
+ return;
1551
+ }
1552
+ for (const incoming of [...source]) {
1553
+ const label = incoming.label;
1554
+ const resolver = mode === 'static' ? incoming.resolver : null;
1555
+ const value = resolver ? null : incoming.getValue();
1556
+ const replaceContent = incoming.attr.__replace;
1557
+ delete incoming.attr.__replace;
1558
+ const current = this._nodes.get(label);
1559
+ if (current) {
1560
+ current.setAttr(incoming.attr, reason === null ? true : reason, true, false);
1561
+ if (incoming.nodeTag != null) current.nodeTag = incoming.nodeTag;
1562
+ if (incoming.xmlTag != null) current.xmlTag = incoming.xmlTag;
1563
+ if (resolver) {
1564
+ current.resolver = resolver;
1565
+ current.setValue(null, reason === null ? true : reason,
1566
+ null, null, false, reason);
1567
+ } else {
1568
+ if (incoming.resolver) current.resolver = null;
1569
+ const previous = current.getValue();
1570
+ if (value instanceof Bag && previous instanceof Bag && !replaceContent) {
1571
+ previous.update(value, mode, reason, ignoreNone);
1572
+ } else if (!ignoreNone || value !== null) {
1573
+ current.setValue(value, reason === null ? true : reason,
1574
+ null, null, false, reason);
1575
+ }
1576
+ }
1577
+ } else {
1578
+ const node = this.setItem(label, resolver || value, incoming.attr,
1579
+ '>', false, false, reason, false, reason !== false);
1580
+ node.nodeTag = incoming.nodeTag;
1581
+ node.xmlTag = incoming.xmlTag;
1582
+ }
1583
+ }
1584
+ }
1585
+
1586
+ // -------------------------------------------------------------------------
1587
+ // Construction and Filling Methods
1588
+ // -------------------------------------------------------------------------
1589
+
1590
+ /**
1591
+ * Copy nodes from another Bag.
1592
+ *
1593
+ * Clears current contents and copies all nodes from the source Bag.
1594
+ * Nested Bags are deep copied.
1595
+ *
1596
+ * @param {Bag} other - Source Bag to copy from.
1597
+ * @private
1598
+ */
1599
+ _fillFromBag(other) {
1600
+ this.clear();
1601
+ for (const node of other) {
1602
+ const copied = new this.nodeClass(this, node.label);
1603
+ copied.replace(node);
1604
+ copied.nodeTag = node.nodeTag;
1605
+ copied.xmlTag = node.xmlTag;
1606
+ this._nodes._list.push(copied);
1607
+ this._nodes._dict[copied.label] = copied;
1608
+ }
1609
+ }
1610
+
1611
+ /**
1612
+ * Populate bag from a plain object (dictionary).
1613
+ *
1614
+ * Clears current contents and creates nodes from object properties.
1615
+ * Nested objects are converted to nested Bags.
1616
+ *
1617
+ * @param {Object} data - Object where keys become labels and values become node values.
1618
+ * @private
1619
+ */
1620
+ _fillFromDict(data) {
1621
+ this.clear();
1622
+ for (const [key, value] of Object.entries(data)) {
1623
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)
1624
+ && !(value instanceof Bag) && !(value instanceof BagNode)) {
1625
+ this.setItem(key, this.createChildBag()._loadSource(value));
1626
+ } else {
1627
+ this.setItem(key, value);
1628
+ }
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Fill this Bag from a source (another Bag or plain object).
1634
+ *
1635
+ * Prepares replacement contents before changing this Bag:
1636
+ * - If source is null/undefined: no-op
1637
+ * - If source is a Bag: copies all nodes (deep copy for nested Bags)
1638
+ * - If source is a plain object: keys become labels, values become node values.
1639
+ * Nested objects are recursively converted to Bags.
1640
+ *
1641
+ * Preparation errors leave this Bag unchanged. Attached Bags emit one
1642
+ * upd_value event after replacement, carrying the previous content.
1643
+ * Listener exceptions propagate after the replacement has committed.
1644
+ *
1645
+ * @param {Bag|Object|null} source - Source to fill from.
1646
+ * @returns {Bag} This Bag (for chaining).
1647
+ *
1648
+ * @example
1649
+ * const bag = new Bag();
1650
+ * bag.replace(new Bag({ a: 1, b: { c: 2 } }));
1651
+ * // bag has 'a' = 1, 'b' = Bag with 'c' = 2
1652
+ *
1653
+ * const other = new Bag();
1654
+ * other.setItem('x', 10);
1655
+ * bag.replace(other);
1656
+ * // bag now has only 'x' = 10
1657
+ */
1658
+ _loadSource(source) {
1659
+ if (source == null) return this;
1660
+ const prepared = this.createChildBag();
1661
+ if (source instanceof Bag) {
1662
+ prepared._fillFromBag(source);
1663
+ } else if (typeof source === 'object' && !Array.isArray(source)) {
1664
+ prepared._fillFromDict(source);
1665
+ } else {
1666
+ return this;
1667
+ }
1668
+ return this._replacePrepared(prepared);
1669
+ }
1670
+
1671
+ /** Replace all contents from a Bag, preserving identity. Returns this. */
1672
+ replace(other) {
1673
+ if (!(other instanceof Bag)) throw new TypeError('Bag.replace expects a Bag');
1674
+ if (other === this) return this;
1675
+ const prepared = this.createChildBag();
1676
+ prepared._fillFromBag(other);
1677
+ return this._replacePrepared(prepared);
1678
+ }
1679
+
1680
+ _replacePrepared(prepared) {
1681
+ // Preparation must finish before touching the destination, including
1682
+ // when the source is this Bag. Keep the previous content as a snapshot.
1683
+ for (const node of this._nodes) {
1684
+ node.parentBag = null;
1685
+ const value = node.getValue(true);
1686
+ if (value instanceof Bag) value.clearBackref();
1687
+ }
1688
+ const oldNodes = this._nodes;
1689
+ this._nodes = prepared._nodes;
1690
+ prepared._nodes = oldNodes;
1691
+ this._nodes._parentBag = this.backref ? this : null;
1692
+ prepared._nodes._parentBag = null;
1693
+ for (const node of this._nodes) node.parentBag = this;
1694
+ for (const node of prepared._nodes) node._parentBag = prepared;
1695
+
1696
+ if (this.backref && this.parent && this.parentNode) {
1697
+ this.parent._onNodeChanged(this.parentNode,
1698
+ [this.parentNode.label], 'upd_value', prepared);
1699
+ }
1700
+ return this;
1701
+ }
1702
+
1703
+ /**
1704
+ * Visit direct nodes, or the full tree with deep=true.
1705
+ * Null/undefined descends; falsey non-null results skip children;
1706
+ * a truthy result stops the visit and is returned.
1707
+ * Callback receives (node, kwargs, siblingIndex).
1708
+ */
1709
+ forEach(callback, {static: isStatic = true, deep = false, kwargs = {}} = {}) {
1710
+ if (typeof callback !== 'function') throw new TypeError('forEach requires a callback');
1711
+ const visit = (bag, context) => {
1712
+ for (let index = 0; index < bag._nodes.length; index++) {
1713
+ const node = bag._nodes._list[index];
1714
+ const kw = {...context};
1715
+ if ('_pathlist' in context) kw._pathlist = [...context._pathlist, node.label];
1716
+ if ('_indexlist' in context) kw._indexlist = [...context._indexlist, index];
1717
+ const result = callback(node, kw, index);
1718
+ if (result) return result;
1719
+ if (result == null && deep) {
1720
+ const value = node.getValue(isStatic);
1721
+ if (value instanceof Bag) {
1722
+ const innerResult = visit(value, kw);
1723
+ if (innerResult) return innerResult;
1724
+ }
1725
+ }
1726
+ }
1727
+ return null;
1728
+ };
1729
+ return visit(this, kwargs);
1730
+ }
1731
+
1732
+ /**
1733
+ * Yield original nodes depth-first, parent before children.
1734
+ * Values are read statically: lazy resolvers are not invoked.
1735
+ * Shared subtrees are visited at each path, as in legacy Python.
1736
+ * @yields {BagNode}
1737
+ */
1738
+ *traverse() {
1739
+ for (const node of this._nodes) {
1740
+ yield node;
1741
+ const value = node.getValue(true);
1742
+ if (value instanceof Bag) {
1743
+ yield* value.traverse();
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ /** Stream path/node pairs for internal query and serialization use. */
1749
+ *_iterNodesWithPaths(isStatic = true, prefix = '') {
1750
+ for (const node of this._nodes) {
1751
+ const path = prefix ? `${prefix}.${node.label}` : node.label;
1752
+ yield [path, node];
1753
+ const value = node.getValue(isStatic);
1754
+ if (value instanceof Bag) yield* value._iterNodesWithPaths(isStatic, path);
1755
+ }
1756
+ }
1757
+
1758
+ // -------------------------------------------------------------------------
1759
+ // TyTx Serialization
1760
+ // -------------------------------------------------------------------------
1761
+
1762
+ /**
1763
+ * Flatten nodes into (parent, label, tag, value, attr) tuples for TyTx.
1764
+ *
1765
+ * @param {Object|null} [pathRegistry=null] - If provided, enable compact mode.
1766
+ * @yields {Array} Tuples of [parent, label, tag, value, attr].
1767
+ */
1768
+ *_nodeFlattener(pathRegistry = null) {
1769
+ const compact = pathRegistry !== null;
1770
+ const pathToCode = compact ? Object.create(null) : null;
1771
+ let codeCounter = 0;
1772
+ // Class of each branch by path: a node's parent class decides whether
1773
+ // its branch needs __cls (parentBag is null when the backref is off).
1774
+ const pathToClass = new Map([['', this.constructor]]);
1775
+
1776
+ for (const [path, node] of serializationNodes(this)) {
1777
+ const lastDot = path.lastIndexOf('.');
1778
+ const parentPath = lastDot >= 0 ? path.slice(0, lastDot) : '';
1779
+
1780
+ const nodeValue = node.getValue(true);
1781
+
1782
+ // Value encoding
1783
+ let value;
1784
+ let branchCls = null;
1785
+ if (node.resolver) {
1786
+ value = encodeResolver(node.resolver);
1787
+ } else if (nodeValue instanceof Bag) {
1788
+ const cls = nodeValue.constructor;
1789
+ const suffix = cls.tytxSuffix;
1790
+ pathToClass.set(path, cls);
1791
+ branchCls = getClsMarker(cls, getInheritedClass(pathToClass.get(parentPath), suffix));
1792
+ value = `::${suffix}`;
1793
+ } else if (nodeValue === null) {
1794
+ value = '::NN';
1795
+ } else {
1796
+ value = nodeValue;
1797
+ }
1798
+
1799
+ if (node.attr && Object.hasOwn(node.attr, CLS_ATTRIBUTE)) {
1800
+ throw new BagSerializationError(
1801
+ `node '${path}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name`);
1802
+ }
1803
+ const attr = encodeAttrs(node.attr);
1804
+ if (branchCls !== null) attr[CLS_ATTRIBUTE] = branchCls;
1805
+ const tag = node.nodeTag;
1806
+
1807
+ if (compact) {
1808
+ const parentRef = parentPath ? pathToCode[parentPath] : null;
1809
+ yield [parentRef, node.label, tag, value, attr];
1810
+
1811
+ if (nodeValue instanceof Bag) {
1812
+ pathToCode[path] = codeCounter;
1813
+ pathRegistry[codeCounter] = path;
1814
+ codeCounter++;
1815
+ }
1816
+ } else {
1817
+ // Use empty string for root level (matches Python behavior)
1818
+ yield [parentPath, node.label, tag, value, attr];
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ /**
1824
+ * Serialize Bag to TyTx format.
1825
+ *
1826
+ * Converts the Bag hierarchy into a flat list of row tuples,
1827
+ * then encodes using TyTx which preserves types (Decimal, Date, etc.).
1828
+ *
1829
+ * @param {string} [transport='json'] - Output format: 'json' or 'msgpack'.
1830
+ * @param {boolean} [compact=false] - If true, use numeric path codes.
1831
+ * @returns {string|Uint8Array} Serialized data.
1832
+ */
1833
+ toTytx(transport = 'json', compact = false) {
1834
+ let data;
1835
+
1836
+ if (compact) {
1837
+ const paths = {};
1838
+ const rows = [...this._nodeFlattener(paths)];
1839
+ data = { rows, paths };
1840
+ } else {
1841
+ const rows = [...this._nodeFlattener(null)];
1842
+ data = { rows };
1843
+ }
1844
+ const rootClass = this.constructor;
1845
+ const rootCls = getClsMarker(rootClass, getInheritedClass(null, rootClass.tytxSuffix));
1846
+ if (rootCls !== null) data[CLS_ATTRIBUTE] = rootCls;
1847
+
1848
+ const tytxTransport = transport === 'json' ? null : transport;
1849
+ return tytxEncode(data, tytxTransport);
1850
+ }
1851
+
1852
+ /**
1853
+ * Deserialize Bag from TyTx format.
1854
+ *
1855
+ * @param {string|Uint8Array} data - Serialized data from toTytx().
1856
+ * @param {string} [transport='json'] - Input format: 'json' or 'msgpack'.
1857
+ * @returns {Bag} Reconstructed Bag.
1858
+ */
1859
+ static fromTytx(data, transport = 'json') {
1860
+ // The payload alone decides the class of the root: without __cls it is
1861
+ // the class registered for the suffix (Bag for "X"), whatever this is.
1862
+ const suffix = this.tytxSuffix;
1863
+ // The registry passes an empty payload for a structural branch marker.
1864
+ if (data === '') return new (getInheritedClass(null, suffix))();
1865
+ const parsed = tytxDecode(data, transport === 'json' ? null : transport);
1866
+ if (!parsed || !Array.isArray(parsed.rows)) {
1867
+ throw new BagSerializationError('Invalid TYTX Bag: expected rows');
1868
+ }
1869
+ const paths = parsed.paths;
1870
+ const compact = paths != null;
1871
+ const rootClass = Object.hasOwn(parsed, CLS_ATTRIBUTE)
1872
+ ? getSubtypeClass(suffix, parsed[CLS_ATTRIBUTE])
1873
+ : getInheritedClass(null, suffix);
1874
+ const bag = new rootClass();
1875
+ const pathToBag = new Map([['', bag]]);
1876
+
1877
+ for (const row of parsed.rows) {
1878
+ if (!Array.isArray(row) || row.length !== 5) {
1879
+ throw new BagSerializationError('Invalid TYTX Bag row');
1880
+ }
1881
+ const [parentRef, label, tag, rawValue, rawAttr] = row;
1882
+ let parentPath = parentRef ?? '';
1883
+ if (compact) {
1884
+ if (parentRef !== null && !Object.hasOwn(paths, parentRef)) {
1885
+ throw new BagSerializationError(`Unknown TYTX parent reference: ${parentRef}`);
1886
+ }
1887
+ parentPath = parentRef === null ? '' : paths[parentRef];
1888
+ }
1889
+ if (!pathToBag.has(parentPath)) {
1890
+ throw new BagSerializationError(`Missing or undecodable TYTX parent branch: ${parentPath}`);
1891
+ }
1892
+ const parentBag = pathToBag.get(parentPath);
1893
+ const fullPath = parentPath ? `${parentPath}.${label}` : label;
1894
+ let value = rawValue;
1895
+ // MessagePack does not rescan text. Only Bag-owned empty markers
1896
+ // are structural: arbitrary scalar decoders must never run here.
1897
+ if (transport === 'msgpack' && typeof value === 'string' && value.startsWith('::')) {
1898
+ const cls = getRegisteredType(value.slice(2));
1899
+ if (cls === Bag || cls?.prototype instanceof Bag) value = tytxDecode(value);
1900
+ }
1901
+ const resolver = decodeResolver(value);
1902
+ const attr = decodeAttrs(rawAttr);
1903
+ const branchCls = Object.hasOwn(attr, CLS_ATTRIBUTE) ? attr[CLS_ATTRIBUTE] : null;
1904
+ delete attr[CLS_ATTRIBUTE];
1905
+ if (value instanceof Bag) {
1906
+ // The class is the __cls name, or the one inherited from the parent.
1907
+ const branchSuffix = value.constructor.tytxSuffix;
1908
+ const cls = branchCls !== null
1909
+ ? getSubtypeClass(branchSuffix, branchCls)
1910
+ : getInheritedClass(parentBag.constructor, branchSuffix);
1911
+ value = new cls();
1912
+ pathToBag.set(fullPath, value);
1913
+ } else if (branchCls !== null) {
1914
+ throw new BagSerializationError(
1915
+ `node '${fullPath}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name of a branch`);
1916
+ } else if (value === '::NN') {
1917
+ value = null;
1918
+ }
1919
+ const node = parentBag.setItem(label, resolver || value, attr);
1920
+ node.nodeTag = tag ?? null;
1921
+ }
1922
+ return bag;
1923
+ }
1924
+
1925
+ // -------------------------------------------------------------------------
1926
+ // XML Serialization
1927
+ // -------------------------------------------------------------------------
1928
+
1929
+ /**
1930
+ * Serialize Bag to XML format.
1931
+ *
1932
+ * All values are converted to strings without type information.
1933
+ * For type-preserving serialization, use toTytx() instead.
1934
+ *
1935
+ * @param {Object} [options={}] - Serialization options.
1936
+ * @param {boolean} [options.pretty=false] - If true, format with indentation.
1937
+ * @param {string} [options.encoding='UTF-8'] - XML encoding.
1938
+ * @param {boolean|string} [options.docHeader=null] - XML declaration.
1939
+ * @param {string[]} [options.selfClosedTags=null] - Tags to self-close when empty.
1940
+ * @returns {string} XML string.
1941
+ */
1942
+ toXml(options = {}) {
1943
+ const { pretty = false, encoding = 'UTF-8', docHeader = null, selfClosedTags = null } = options;
1944
+
1945
+ let content = this._bagToXml(selfClosedTags, pretty);
1946
+
1947
+ if (docHeader === true) {
1948
+ content = `<?xml version='1.0' encoding='${encoding}'?>\n${content}`;
1949
+ } else if (typeof docHeader === 'string') {
1950
+ content = `${docHeader}\n${content}`;
1951
+ }
1952
+
1953
+ return content;
1954
+ }
1955
+
1956
+ /**
1957
+ * Convert Bag to XML string (internal).
1958
+ * @private
1959
+ */
1960
+ _bagToXml(selfClosedTags = null, pretty = false, depth = 0) {
1961
+ const parts = [];
1962
+ for (const node of this._nodes) {
1963
+ parts.push((pretty ? ' '.repeat(depth) : '') +
1964
+ this._nodeToXml(node, selfClosedTags, pretty, depth));
1965
+ }
1966
+ return parts.join(pretty ? '\n' : '');
1967
+ }
1968
+
1969
+ /**
1970
+ * Convert a BagNode to XML string (internal).
1971
+ * @private
1972
+ */
1973
+ _nodeToXml(node, selfClosedTags = null, pretty = false, depth = 0) {
1974
+ // Priority: xml_tag > node_tag > label (same as Python)
1975
+ const originalTag = node.xmlTag || node.nodeTag || node.label;
1976
+ const tag = this._sanitizeTag(originalTag);
1977
+
1978
+ // Build attributes string
1979
+ const attrsParts = [];
1980
+
1981
+ // If tag was sanitized, save original as _tag attribute
1982
+ if (tag !== originalTag) {
1983
+ attrsParts.push(`_tag="${this._escapeAttr(originalTag)}"`);
1984
+ }
1985
+
1986
+ if (node.resolver) {
1987
+ attrsParts.push(`_resolver="${this._escapeAttr(encodeResolver(node.resolver))}"`);
1988
+ }
1989
+ if (node.attr) {
1990
+ for (const [k, v] of Object.entries(encodeAttrs(node.attr))) {
1991
+ if (v !== null && v !== false && v !== undefined) {
1992
+ attrsParts.push(`${k}="${this._escapeAttr(String(v))}"`);
1993
+ }
1994
+ }
1995
+ }
1996
+ const attrsStr = attrsParts.length ? ' ' + attrsParts.join(' ') : '';
1997
+
1998
+ // Handle value
1999
+ const value = node.resolver ? null : node.getValue(true); // static=true
2000
+
2001
+ // Check if value is a Bag
2002
+ if (value && typeof value._bagToXml === 'function') {
2003
+ // Never add whitespace inside a subtree that explicitly preserves it.
2004
+ pretty = pretty && node.attr?.['xml:space'] !== 'preserve';
2005
+ const inner = value._bagToXml(selfClosedTags, pretty, depth + 1);
2006
+ if (inner) {
2007
+ if (pretty) {
2008
+ return `<${tag}${attrsStr}>\n${inner}\n${' '.repeat(depth)}</${tag}>`;
2009
+ }
2010
+ return `<${tag}${attrsStr}>${inner}</${tag}>`;
2011
+ }
2012
+ // Empty Bag
2013
+ if (selfClosedTags === null || selfClosedTags.includes(tag)) {
2014
+ return `<${tag}${attrsStr}/>`;
2015
+ }
2016
+ return `<${tag}${attrsStr}></${tag}>`;
2017
+ }
2018
+
2019
+ // Scalar value
2020
+ if (value === null || value === undefined || value === '') {
2021
+ if (selfClosedTags === null || selfClosedTags.includes(tag)) {
2022
+ return `<${tag}${attrsStr}/>`;
2023
+ }
2024
+ return `<${tag}${attrsStr}></${tag}>`;
2025
+ }
2026
+
2027
+ const text = this._escapeXml(String(value));
2028
+ return `<${tag}${attrsStr}>${text}</${tag}>`;
2029
+ }
2030
+
2031
+ /**
2032
+ * Sanitize tag name for XML.
2033
+ * @private
2034
+ */
2035
+ _sanitizeTag(tag) {
2036
+ if (!tag) return '_none_';
2037
+ // Replace invalid characters with underscore
2038
+ return tag.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
2039
+ }
2040
+
2041
+ /**
2042
+ * Escape XML text content.
2043
+ * @private
2044
+ */
2045
+ _escapeXml(str) {
2046
+ return str
2047
+ .replace(/&/g, '&amp;')
2048
+ .replace(/</g, '&lt;')
2049
+ .replace(/>/g, '&gt;');
2050
+ }
2051
+
2052
+ /**
2053
+ * Escape XML attribute value.
2054
+ * @private
2055
+ */
2056
+ _escapeAttr(str) {
2057
+ return str
2058
+ .replace(/&/g, '&amp;')
2059
+ .replace(/</g, '&lt;')
2060
+ .replace(/>/g, '&gt;')
2061
+ .replace(/"/g, '&quot;');
2062
+ }
2063
+
2064
+ /**
2065
+ * Deserialize Bag from XML format.
2066
+ *
2067
+ * @param {string} source - XML string to parse.
2068
+ * @param {Object} [options={}] - Parsing options.
2069
+ * @param {string} [options.tagAttribute=null] - If set, save XML tag name as this attribute on each node.
2070
+ * @returns {Bag} Reconstructed Bag hierarchy.
2071
+ */
2072
+ static fromXml(source, options = {}) {
2073
+ const { tagAttribute = null } = options;
2074
+
2075
+ // Use DOMParser (browser) or @xmldom/xmldom (Node.js)
2076
+ let doc;
2077
+ if (typeof DOMParser !== 'undefined') {
2078
+ const parser = new DOMParser();
2079
+ doc = parser.parseFromString(source, 'application/xml');
2080
+ // Check for parse errors (browser-specific)
2081
+ const parseError = doc.querySelector('parsererror');
2082
+ if (parseError) {
2083
+ throw new Error(`XML parse error: ${parseError.textContent}`);
2084
+ }
2085
+ } else {
2086
+ // Node.js environment - use @xmldom/xmldom
2087
+ const parser = new XmlDOMParser();
2088
+ doc = parser.parseFromString(source, 'application/xml');
2089
+ }
2090
+
2091
+ return this._xmlElementToBag(doc.documentElement, tagAttribute);
2092
+ }
2093
+
2094
+ /**
2095
+ * Convert XML element to Bag (recursive).
2096
+ * @param {Element} element - XML element to convert.
2097
+ * @param {string|null} [tagAttribute=null] - If set, save tag name as this attribute.
2098
+ * @private
2099
+ */
2100
+ static _xmlElementToBag(element, tagAttribute = null) {
2101
+ const bag = new this();
2102
+
2103
+ // Use childNodes and filter for element nodes (nodeType === 1)
2104
+ // This works both in browser and @xmldom/xmldom
2105
+ const childElements = Array.from(element.childNodes).filter(n => n.nodeType === 1);
2106
+
2107
+ for (const child of childElements) {
2108
+ const originalXmlTag = child.tagName;
2109
+ const attr = {};
2110
+
2111
+ // Collect attributes
2112
+ for (let i = 0; i < child.attributes.length; i++) {
2113
+ const attrNode = child.attributes[i];
2114
+ attr[attrNode.name] = attrNode.value;
2115
+ }
2116
+
2117
+ const resolver = decodeResolver(attr._resolver);
2118
+ if (resolver) delete attr._resolver;
2119
+ const isBagValue = String(attr._T || '').toUpperCase() === 'BAG';
2120
+ Object.assign(attr, decodeAttrs(attr));
2121
+
2122
+ // Resolve label: _tag attribute > tagAttribute > XML tag name
2123
+ let label = originalXmlTag;
2124
+ if ('_tag' in attr) {
2125
+ label = attr._tag;
2126
+ delete attr._tag;
2127
+ }
2128
+ if (tagAttribute && tagAttribute in attr) {
2129
+ label = attr[tagAttribute];
2130
+ delete attr[tagAttribute];
2131
+ }
2132
+
2133
+ // Repeated XML elements must not replace earlier siblings.
2134
+ // Keep their original tag separately for serialization.
2135
+ const requestedLabel = label;
2136
+ let suffix = 1;
2137
+ while (bag.getNode(label)) {
2138
+ label = `${requestedLabel}_${suffix++}`;
2139
+ }
2140
+
2141
+ // Check if has child elements (nested Bag)
2142
+ const childChildElements = Array.from(child.childNodes).filter(n => n.nodeType === 1);
2143
+ let node;
2144
+ if (childChildElements.length > 0 || isBagValue) {
2145
+ const childBag = this._xmlElementToBag(child, tagAttribute);
2146
+ node = bag.setItem(label, childBag, Object.keys(attr).length > 0 ? attr : null);
2147
+ } else {
2148
+ // Text content
2149
+ const value = child.textContent || '';
2150
+ node = bag.setItem(label, value, Object.keys(attr).length > 0 ? attr : null);
2151
+ }
2152
+
2153
+ if (resolver) node.resolver = resolver;
2154
+ // Save original XML tag for round-trip serialization
2155
+ node.xmlTag = originalXmlTag;
2156
+ }
2157
+
2158
+ return bag;
2159
+ }
2160
+
2161
+ /**
2162
+ * Load Bag from URL. Auto-detects format from content-type.
2163
+ *
2164
+ * @param {string} url - HTTP/HTTPS URL to fetch.
2165
+ * @param {Object} [options={}] - Fetch options.
2166
+ * @param {number} [options.timeout=30] - Timeout in seconds.
2167
+ * @returns {Promise<Bag>} Parsed content as Bag.
2168
+ */
2169
+ static async fromUrl(url, options = {}) {
2170
+ const { timeout = 30 } = options;
2171
+ const fetchOptions = {};
2172
+ if (timeout) {
2173
+ fetchOptions.signal = AbortSignal.timeout(timeout * 1000);
2174
+ }
2175
+
2176
+ const response = await fetch(url, fetchOptions);
2177
+ const contentType = response.headers.get('content-type') || '';
2178
+ const text = await response.text();
2179
+
2180
+ if (contentType.includes('json')) {
2181
+ return Bag.fromJson(text);
2182
+ }
2183
+ if (contentType.includes('xml')) {
2184
+ return Bag.fromXml(text);
2185
+ }
2186
+ // Try to auto-detect from content
2187
+ const trimmed = text.trim();
2188
+ if (trimmed.startsWith('<')) {
2189
+ return Bag.fromXml(text);
2190
+ }
2191
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
2192
+ return Bag.fromJson(text);
2193
+ }
2194
+ throw new BagException(`Unsupported content-type: ${contentType}`);
2195
+ }
2196
+
2197
+ // -------------------------------------------------------------------------
2198
+ // JSON Serialization
2199
+ // -------------------------------------------------------------------------
2200
+
2201
+ /**
2202
+ * Serialize Bag to JSON string.
2203
+ *
2204
+ * Each node becomes {"label": ..., "value": ..., "attr": {...}}.
2205
+ * Nested Bags have value as a list of child nodes.
2206
+ *
2207
+ * @param {boolean} [typed=true] - If true, encode types for date/datetime/Decimal (TYTX).
2208
+ * @returns {string} JSON string representation.
2209
+ */
2210
+ toJson(typed = true) {
2211
+ const result = [];
2212
+ for (const node of this._nodes) {
2213
+ result.push(this._nodeToJsonDict(node, typed));
2214
+ }
2215
+
2216
+ if (typed) {
2217
+ return tytxEncode(result);
2218
+ }
2219
+ return JSON.stringify(result);
2220
+ }
2221
+
2222
+ /**
2223
+ * Convert a BagNode to JSON-serializable dict (internal).
2224
+ * @private
2225
+ */
2226
+ _nodeToJsonDict(node, typed) {
2227
+ let value = node.resolver ? null : node.getValue(true); // static=true
2228
+
2229
+ // Check if value is a Bag
2230
+ if (value && typeof value._nodeToJsonDict === 'function') {
2231
+ const childResult = [];
2232
+ for (const childNode of value._nodes) {
2233
+ childResult.push(value._nodeToJsonDict(childNode, typed));
2234
+ }
2235
+ value = childResult;
2236
+ }
2237
+
2238
+ const result = { label: node.label, value, attr: encodeAttrs(node.attr) };
2239
+ if (node.nodeTag !== null) result.tag = node.nodeTag;
2240
+ if (node.resolver) result.resolver = encodeResolver(node.resolver);
2241
+ return result;
2242
+ }
2243
+
2244
+ /**
2245
+ * Deserialize JSON to Bag.
2246
+ *
2247
+ * Accepts JSON string, dict, or list. Recursively converts nested
2248
+ * structures to Bag hierarchy.
2249
+ *
2250
+ * @param {string|Object|Array} source - JSON string, dict or list to parse.
2251
+ * @param {string} [listJoiner=null] - If provided, join string arrays with this separator.
2252
+ * @returns {Bag} Deserialized Bag.
2253
+ */
2254
+ static fromJson(source, listJoiner = null) {
2255
+ if (typeof source === 'string') {
2256
+ source = tytxDecode(source);
2257
+ }
2258
+
2259
+ if (!Array.isArray(source) && typeof source !== 'object') {
2260
+ source = { value: source };
2261
+ }
2262
+
2263
+ return Bag._fromJsonRecursive(source, listJoiner);
2264
+ }
2265
+
2266
+ /**
2267
+ * Recursively convert JSON data to Bag (internal).
2268
+ * @private
2269
+ */
2270
+ static _fromJsonRecursive(data, listJoiner = null) {
2271
+ if (data instanceof Bag) return data;
2272
+ if (Array.isArray(data)) {
2273
+ // listJoiner: join string arrays into a single string
2274
+ if (listJoiner !== null && data.every(item => typeof item === 'string')) {
2275
+ return data.join(listJoiner);
2276
+ }
2277
+
2278
+ if (data.length === 0) {
2279
+ return new Bag();
2280
+ }
2281
+
2282
+ // Check if list items have 'label' key (Bag node format)
2283
+ if (typeof data[0] === 'object' && data[0] !== null && 'label' in data[0]) {
2284
+ const result = new Bag();
2285
+ for (const item of data) {
2286
+ const label = item.label;
2287
+ const value = Bag._fromJsonRecursive(item.value, listJoiner);
2288
+ const attr = decodeAttrs(item.attr);
2289
+ const node = result.setItem(label, value, attr);
2290
+ node.nodeTag = item.tag ?? null;
2291
+ const resolver = decodeResolver(item.resolver);
2292
+ if (resolver) node.resolver = resolver;
2293
+ }
2294
+ return result;
2295
+ }
2296
+
2297
+ // Generic list -> Bag with r_N keys
2298
+ const result = new Bag();
2299
+ for (let n = 0; n < data.length; n++) {
2300
+ result.setItem(`r_${n}`, Bag._fromJsonRecursive(data[n], listJoiner));
2301
+ }
2302
+ return result;
2303
+ }
2304
+
2305
+ if (typeof data === 'object' && data !== null) {
2306
+ if (Object.keys(data).length === 0) {
2307
+ return new Bag();
2308
+ }
2309
+ const result = new Bag();
2310
+ for (const [k, v] of Object.entries(data)) {
2311
+ result.setItem(k, Bag._fromJsonRecursive(v, listJoiner));
2312
+ }
2313
+ return result;
2314
+ }
2315
+
2316
+ // Scalar value
2317
+ return data;
2318
+ }
2319
+
2320
+ // -------------------------------------------------------------------------
2321
+ // String representation
2322
+ // -------------------------------------------------------------------------
2323
+
2324
+ /**
2325
+ * Return ASCII tree representation of bag contents.
2326
+ *
2327
+ * Produces a visual tree structure showing all nodes, their values,
2328
+ * and attributes. Handles nested Bags recursively and detects
2329
+ * circular references.
2330
+ *
2331
+ * @param {boolean} [isStatic=true] - If false, triggers resolvers to get current values.
2332
+ * @param {Map} [_visited=null] - Internal: tracks visited nodes for circular refs.
2333
+ * @param {string} [_prefix=''] - Internal: indentation prefix for nested bags.
2334
+ * @param {boolean} [_isLast=true] - Internal: whether this is the last sibling.
2335
+ * @returns {string} ASCII tree representation.
2336
+ *
2337
+ * @example
2338
+ * const bag = new Bag();
2339
+ * bag.setItem('user.age', 30);
2340
+ * bag.setItem('user.city', 'Rome');
2341
+ * console.log(bag.toStringTree());
2342
+ * // user
2343
+ * // ├── age: 30
2344
+ * // └── city: 'Rome'
2345
+ */
2346
+ toStringTree(isStatic = true, _visited = null, _prefix = '', _isLast = true) {
2347
+ if (_visited === null) {
2348
+ _visited = new Map();
2349
+ }
2350
+
2351
+ const lines = [];
2352
+ const nodes = [...this._nodes];
2353
+
2354
+ for (let idx = 0; idx < nodes.length; idx++) {
2355
+ const node = nodes[idx];
2356
+ const isLast = idx === nodes.length - 1;
2357
+ const value = node.getValue(isStatic);
2358
+
2359
+ // Format attributes
2360
+ const attrs = node.getAttr();
2361
+ let attrStr = '';
2362
+ const attrKeys = Object.keys(attrs);
2363
+ if (attrKeys.length > 0) {
2364
+ const attrParts = attrKeys.map(k => `${k}=${JSON.stringify(attrs[k])}`);
2365
+ attrStr = ' [' + attrParts.join(', ') + ']';
2366
+ }
2367
+
2368
+ // Tree characters
2369
+ const branch = isLast ? '└── ' : '├── ';
2370
+ const childPrefix = _prefix + (isLast ? ' ' : '│ ');
2371
+
2372
+ if (value instanceof Bag) {
2373
+ const nodeId = node; // Use node object as key
2374
+ const backref = value.backref ? '(*)' : '';
2375
+
2376
+ if (_visited.has(nodeId)) {
2377
+ lines.push(`${_prefix}${branch}${node.label}${backref}${attrStr} → (circular ref)`);
2378
+ } else {
2379
+ _visited.set(nodeId, node.label);
2380
+ lines.push(`${_prefix}${branch}${node.label}${backref}${attrStr}`);
2381
+ const inner = value.toStringTree(isStatic, _visited, childPrefix, isLast);
2382
+ if (inner) {
2383
+ lines.push(inner);
2384
+ }
2385
+ }
2386
+ } else {
2387
+ // Format value representation
2388
+ let valueStr;
2389
+ if (value === null) {
2390
+ valueStr = 'null';
2391
+ } else if (value === undefined) {
2392
+ valueStr = 'undefined';
2393
+ } else if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
2394
+ const decoder = new TextDecoder('utf-8', { fatal: false });
2395
+ const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
2396
+ valueStr = decoder.decode(bytes);
2397
+ } else if (typeof value === 'string') {
2398
+ if (value.length > 50) {
2399
+ valueStr = JSON.stringify(value.slice(0, 47) + '...');
2400
+ } else {
2401
+ valueStr = JSON.stringify(value);
2402
+ }
2403
+ } else {
2404
+ valueStr = String(value);
2405
+ }
2406
+
2407
+ lines.push(`${_prefix}${branch}${node.label}: ${valueStr}${attrStr}`);
2408
+ }
2409
+ }
2410
+
2411
+ return lines.join('\n');
2412
+ }
2413
+
2414
+ /**
2415
+ * Return ASCII tree representation of bag contents.
2416
+ *
2417
+ * @param {boolean} [isStatic=true] - If false, trigger resolvers.
2418
+ * @param {Object} [_visited=null] - Internal: tracks visited nodes for circular refs.
2419
+ * @param {string} [_prefix=''] - Internal: indentation prefix.
2420
+ * @returns {string} ASCII tree string.
2421
+ */
2422
+ toString(isStatic = true, _visited = null, _prefix = '') {
2423
+ if (!_visited) {
2424
+ _visited = new Set();
2425
+ }
2426
+
2427
+ const lines = [];
2428
+ const nodes = [...this._nodes];
2429
+
2430
+ for (let idx = 0; idx < nodes.length; idx++) {
2431
+ const node = nodes[idx];
2432
+ const isLast = idx === nodes.length - 1;
2433
+ const value = node.getValue(isStatic);
2434
+
2435
+ // Format attributes
2436
+ let attrStr = '';
2437
+ const attrs = node.attr;
2438
+ if (attrs && Object.keys(attrs).length > 0) {
2439
+ const parts = Object.entries(attrs).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
2440
+ attrStr = ` [${parts.join(', ')}]`;
2441
+ }
2442
+
2443
+ // Tree characters
2444
+ const branch = isLast ? '└── ' : '├── ';
2445
+ const childPrefix = _prefix + (isLast ? ' ' : '│ ');
2446
+
2447
+ if (value && typeof value._htraverse === 'function') {
2448
+ // Bag value
2449
+ const nodeId = node;
2450
+ if (_visited.has(nodeId)) {
2451
+ lines.push(`${_prefix}${branch}${node.label}${attrStr} → (circular ref)`);
2452
+ } else {
2453
+ _visited.add(nodeId);
2454
+ lines.push(`${_prefix}${branch}${node.label}${attrStr}`);
2455
+ const inner = value.toString(isStatic, _visited, childPrefix);
2456
+ if (inner) {
2457
+ lines.push(inner);
2458
+ }
2459
+ }
2460
+ } else {
2461
+ // Scalar value
2462
+ let valueStr;
2463
+ if (value === null || value === undefined) {
2464
+ valueStr = 'null';
2465
+ } else if (typeof value === 'string' && value.length > 50) {
2466
+ valueStr = JSON.stringify(value.slice(0, 47) + '...');
2467
+ } else if (typeof value === 'string') {
2468
+ valueStr = JSON.stringify(value);
2469
+ } else {
2470
+ valueStr = String(value);
2471
+ }
2472
+ lines.push(`${_prefix}${branch}${node.label}: ${valueStr}${attrStr}`);
2473
+ }
2474
+ }
2475
+
2476
+ return lines.join('\n');
2477
+ }
2478
+ }
2479
+
2480
+ /**
2481
+ * Exception raised for Bag-specific errors.
2482
+ */
2483
+ export class BagException extends Error {
2484
+ constructor(message) {
2485
+ super(message);
2486
+ this.name = 'BagException';
2487
+ }
2488
+ }
2489
+
2490
+ // Register Bag class with BagResolver for asBag conversion (avoids circular import)
2491
+ BagResolver.registerBagClass(Bag);
2492
+
2493
+ // Shared TYTX registry: subclasses register their own wire suffix explicitly.
2494
+ registerClass(Bag);
2495
+ // Bag is the base class of the "X" type: its name must be in the subtype
2496
+ // dictionary like every subclass that travels as "::X" with a __cls name.
2497
+ setSubtypeDict(Bag.tytxSuffix, { ...getSubtypeDict(Bag.tytxSuffix), Bag });