@genrojs/bag 0.10.3 → 0.11.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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # genro-bag-js
2
2
 
3
+ Current release: **0.11.1**. Fixes deletion origin propagation in `popNode`, `pop` and `delItem`.
4
+
5
+ > **0.11.0 — breaking API:** read the [ingestion and scheduling migration note](docs/migration-ingestion.md) before upgrading dependent projects.
6
+
3
7
  TypeScript/JavaScript port of [genro-bag](https://github.com/nicofirst1/genro-bag) - hierarchical data container with XML serialization, Builder pattern, and Compiler system.
4
8
 
5
9
  ## Status
@@ -0,0 +1,218 @@
1
+ # Python 0.28.0 / JavaScript 0.11.0: explicit ingestion and no internal scheduling (breaking API)
2
+
3
+ This release changes Python and JavaScript together. Downstream projects must
4
+ review constructors, population calls, JSON readers, persisted Bag payloads and
5
+ scheduled resolver/subscription usage before upgrading. Related: [genro-bag #69](https://github.com/genro-org/genro-bag/issues/69).
6
+
7
+ ## Constructor and replacement
8
+
9
+ ```python
10
+ bag = Bag(source, recursive=True)
11
+ bag.fill_from(source, recursive=True) # replaces content; returns bag
12
+ ```
13
+
14
+ ```javascript
15
+ const bag = new Bag(source, {recursive: true});
16
+ bag.fillFrom(source, {recursive: true}); // replaces content; returns bag
17
+ ```
18
+
19
+ The root must be a dictionary/plain JS object, list/array, or `None`/`null`.
20
+ Omitting the source creates an empty Bag; omitting it in `fill_from`/`fillFrom`
21
+ clears the existing Bag. JS also treats `undefined` as an omitted source.
22
+ A Bag, tuple, scalar, serialized string, bytes, path, open file or URL is not a
23
+ valid constructor/population source. Unsupported inputs, including `0`, `False`
24
+ and `""`, raise an explicit error. Use `bag.deepcopy()` to copy a Bag.
25
+
26
+ With the default `recursive=True`, every nested dictionary/list is converted to
27
+ a fresh Bag. With `recursive=False`, only the root is converted: nested
28
+ containers remain the original shared objects. Other leaves are always shared,
29
+ including dates, Decimal values, binary buffers, class instances and existing
30
+ Bag values. Existing Bags keep their contents; their internal lists are not
31
+ reinterpreted. This is conversion, not a general deep copy. The existing
32
+ `deepcopy()` method copies Bag structure and resolvers; it does not promise to
33
+ clone every arbitrary leaf object.
34
+
35
+ Python uses `dict` and `list` (including subclasses); tuples are leaves. JS uses
36
+ arrays and objects whose prototype is `Object.prototype` or `null`. Other class
37
+ instances are leaves. No custom-type registry is consulted during conversion.
38
+ Normal resolver objects retain Bag's existing resolver attachment semantics.
39
+
40
+ ```python
41
+ source = {"items": [{"name": "Ada"}]}
42
+ Bag(source)["items.r_00.name"] # "Ada"
43
+ Bag(source, recursive=False)["items"] is source["items"] # True
44
+ ```
45
+
46
+ Repeated references on different branches produce separate converted Bags.
47
+ Cycles in dictionaries/lists raise an error identifying the conversion path
48
+ when recursion is enabled. Shallow ingestion does not traverse nested cycles.
49
+ Preparation failures in `fill_from`/`fillFrom` leave the old content intact;
50
+ successful attached replacement retains the existing single update event.
51
+ Listener failures after the replacement commits are not rolled back.
52
+
53
+ ## Literal labels and list order
54
+
55
+ Dictionary keys must be strings and are inserted literally. `"a.b"`, `"?attr"`,
56
+ `"#0"` and the empty string are labels, not path expressions. Existing path-based
57
+ accessors still have their usual syntax: use node iteration or positional access
58
+ to inspect labels that contain path/query syntax. Assign paths explicitly with
59
+ `set_item`/`setItem` when you intend to create branches.
60
+
61
+ List labels start at zero and use `r_` plus a zero-padded index. Width is
62
+ `len(str(number_of_elements)) + 1`, calculated separately for each list:
63
+
64
+ | List length | Labels |
65
+ | --- | --- |
66
+ | 5 | `r_00` … `r_04` |
67
+ | 25 | `r_000` … `r_024` |
68
+ | 150 | `r_0000` … `r_0149` |
69
+
70
+ This preserves initial order under lexicographic sorting. Labels are not
71
+ renumbered when a Bag is edited later. Converting a different-sized list may
72
+ choose another width. Empty dictionaries and lists both become empty Bags.
73
+ Lists of tuples/arrays are data, never implicit node descriptions.
74
+
75
+ ## One reader for JSON and TYTX
76
+
77
+ ```python
78
+ Bag.from_tytx(text, recursive=True, sign_key=None)
79
+ Bag.from_tytx(binary, transport="msgpack", recursive=False)
80
+ ```
81
+
82
+ ```javascript
83
+ Bag.fromTytx(text, {recursive: true});
84
+ Bag.fromTytx(binary, 'msgpack', {recursive: false});
85
+ ```
86
+
87
+ Ordinary JSON and generic TYTX dictionaries/lists use the constructor's
88
+ conversion rules. Typed values encoded by TYTX are restored before conversion.
89
+ Ordinary JSON cannot restore dates or decimals that were reduced to ordinary
90
+ strings by its producer. Scalar roots are rejected, including JSON `null`.
91
+
92
+ A serialized Bag is explicitly identified by its type marker (`::X` for Bag).
93
+ It is reconstructed with its labels, attributes, tags and resolvers; `recursive`
94
+ does not reinterpret its stored list/dictionary values. The Python reader
95
+ continues to verify resolver signatures when `sign_key` is supplied, including
96
+ registered Bag payloads decoded through TYTX. Signed resolver tokens remain
97
+ opaque in JS and can return unchanged to Python.
98
+
99
+ `from_json` / `fromJson` is deprecated and now delegates to the new reader.
100
+ Python emits `DeprecationWarning`; JS warns once per module instance. The alias
101
+ accepts serialized input, not native objects. `list_joiner` / `listJoiner` is
102
+ removed from this API. Normal fields such as `label`, `value`, `attr`, `resolver`
103
+ and `rows` have no special meaning in generic input.
104
+
105
+ ## Serialized Bag format
106
+
107
+ `bag.to_tytx()` / `bag.toTytx()` now emits the outer Bag marker exactly once.
108
+ Generic `genro_tytx.to_tytx(bag)` / `toTytx(bag)` uses separate registry payload
109
+ hooks and produces the same type identity. MessagePack uses TYTX custom-type
110
+ extension 4, carrying the Bag suffix and its JSON TYTX payload.
111
+
112
+ Within Bag rows, parent paths are arrays of literal label segments; compact
113
+ path dictionaries also contain segment arrays. This distinguishes a label
114
+ `"a.b"` from two labels `"a"`, `"b"`. The marked reader still accepts the old
115
+ string parent paths inside an explicitly marked payload.
116
+
117
+ Old **unmarked** `{rows: ...}` payloads are now generic application dictionaries.
118
+ For a known old JSON Bag file, migrate offline by adding its `::X` marker; never
119
+ infer that an arbitrary input is a Bag from a `rows` key. For old MessagePack Bag
120
+ files, read with the previous release and write the new format with an explicit
121
+ migration tool. Keep payload provenance and resolver-signature requirements.
122
+
123
+ Existing custom Bag subtype registrations must register the payload hooks with
124
+ TYTX's `register_type` / `registerType`, not register the public marked methods:
125
+
126
+ ```python
127
+ register_type(MyBag, MyBag.__tytx_suffix__,
128
+ lambda bag: bag._to_tytx_payload(), MyBag._from_tytx_payload)
129
+ ```
130
+
131
+ ```javascript
132
+ registerType(MyBag, MyBag.tytxSuffix,
133
+ bag => bag._toTytxPayload(), payload => MyBag._fromTytxPayload(payload));
134
+ ```
135
+
136
+ `to_json` / `toJson` still exports the historical node-description format.
137
+ It is **not** the inverse of the deprecated reader anymore. Use the TYTX pair
138
+ for a Bag round trip. The Python Genropy compatibility adapter retains the
139
+ historical node-description decoder and its signature checks.
140
+
141
+ ## Call-site migration
142
+
143
+ | Old use | New use |
144
+ | --- | --- |
145
+ | `Bag(other_bag)` | `other_bag.deepcopy()` |
146
+ | `Bag(text)` / `fill_from(text)` | `Bag.from_tytx(text)` or `Bag.from_xml(text)` |
147
+ | `Bag(path)` / `fill_from(path)` | `Bag.from_file(path)` in Python |
148
+ | Native dict/list passed to `from_json` / `fromJson` | Constructor |
149
+ | Array kept as a value by the old constructor | Explicit `recursive=False`, or assign using `set_item` / `setItem` |
150
+ | `to_json` → `from_json` Bag round trip | `to_tytx` → `from_tytx` |
151
+ | Implicit node descriptions in lists | Explicit `set_item` / `setItem` calls |
152
+ | Old numeric or field-prefixed list paths | New padded `r_…` labels |
153
+
154
+ No `from_data`/`fromData` or `from_nodes`/`fromNodes` is introduced. Compression
155
+ and whole-document authentication are a separate TYTX proposal (#59).
156
+
157
+ ## No internal scheduling
158
+
159
+ Bag and its resolvers no longer schedule background work. This applies in both
160
+ Python and JavaScript:
161
+
162
+ - `subscribe` and `unsubscribe` reject `timer` and `interval` options. The `tmr`
163
+ event and its propagation have been removed.
164
+ - Resolver construction rejects `interval` and `reactive`, including
165
+ `interval=None` / `null` and `reactive=False` / `false`. Remove obsolete
166
+ defaults from custom resolver classes and previously serialized descriptors.
167
+ Reissue signed descriptors on the server; do not edit signed client tokens.
168
+ - `reset()` takes no arguments. `reset(refresh=True)` and JS
169
+ `reset({refresh: true})` raise `TypeError` instead of scheduling a reload.
170
+ - Attaching, detaching, expiring or invalidating a resolver never loads it.
171
+ Attribute changes are used on the next explicit read. A read updates the
172
+ cache silently; it does not emit a value-change event.
173
+ - Mutation subscriptions remain synchronous. Python keeps the synchronous resolver contract published in 0.23.0; JS also
174
+ accepts explicit asynchronous reads. Passive TTL caches, infinite caches and
175
+ explicit invalidation remain.
176
+ Retry delays belong to a requested load; they do
177
+ not start a background polling task.
178
+
179
+ The application owns polling, cancellation, error handling and publication.
180
+ For example, an application scheduler can call this function:
181
+
182
+ ```python
183
+ def poll_once(resolver, output_bag):
184
+ resolver.reset()
185
+ value = resolver()
186
+ output_bag.set_item('result', value) # Explicit mutation notifies subscribers
187
+ ```
188
+
189
+ ```javascript
190
+ async function pollOnce(resolver, outputBag) {
191
+ resolver.reset();
192
+ const value = await resolver.resolve();
193
+ outputBag.setItem('result', value);
194
+ }
195
+ ```
196
+
197
+ Use a separate output node for published results. Explicitly writing a resolved
198
+ value can replace an existing resolver attached to that destination node.
199
+
200
+ In JS, node attributes now override resolver defaults on the next read, matching
201
+ Python. Explicit call parameters still take precedence. Cache sentinels remain aligned with the published versions: both Python and JS
202
+ use a negative TTL for infinite caching and reject boolean cache durations.
203
+ Python's synchronous parameter preparation, readonly cache semantics, fired
204
+ events and TYTX `__cls` subtype metadata from releases through 0.27.0 are preserved.
205
+
206
+ Python retains `replace(Bag)` and explicit legacy codecs (`legacy_mode=True`).
207
+ Its deprecated camelCase `fillFrom` remains a compatibility source loader;
208
+ the modern `fill_from` accepts native dictionaries/lists only. Old constructor
209
+ keyword items must become a dictionary: `Bag(name="Ada")` becomes
210
+ `Bag({"name": "Ada"})`. Node tuples must be inserted explicitly with `set_item`;
211
+ use the GenroPy adapter when the historical polymorphic constructor is required.
212
+
213
+
214
+ ## Validation and dependency note
215
+
216
+ Both implementations use TYTX 0.16.2. Python requires Toolbox >=0.18.0; obsolete
217
+ timer imports have been removed. Validation runs against Toolbox 0.18.0 without
218
+ falling back to 0.14.
@@ -154,3 +154,15 @@ Zero, false and empty strings are preserved in either mode. `Bag.setItem`
154
154
  forwards its existing null-removal option when constructing a new node as well
155
155
  as when updating one. This is an optional argument and a propagation bug fix,
156
156
  not a new default policy. See `constructor-null-attributes.test.js`.
157
+
158
+
159
+ ## Passive resolvers and application scheduling
160
+
161
+ Bag does not own a scheduler in either language. Resolver `interval` and
162
+ `reactive` options and subscription `timer` / `interval` options raise TypeError.
163
+ `reset()` only invalidates the cache, with no refresh argument. Attachment,
164
+ attribute changes and TTL expiry do not start background loads. Node attributes
165
+ override resolver defaults on the next read; explicit call parameters have the
166
+ highest priority. Applications schedule polling and publish results with
167
+ `setItem` when observers need a mutation event. See the
168
+ [migration note](migration-ingestion.md#no-internal-scheduling).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genrojs/bag",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "JavaScript port of genro-bag - hierarchical data container",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -18,7 +18,8 @@
18
18
  "README.md",
19
19
  "LICENSE",
20
20
  "NOTICE",
21
- "docs/python-alignment.md"
21
+ "docs/python-alignment.md",
22
+ "docs/migration-ingestion.md"
22
23
  ],
23
24
  "scripts": {
24
25
  "test": "node --test tests/*.test.js",
@@ -41,7 +42,7 @@
41
42
  "node": ">=22"
42
43
  },
43
44
  "dependencies": {
44
- "@genrojs/tytx": "^0.16.1",
45
+ "@genrojs/tytx": "^0.16.2",
45
46
  "@msgpack/msgpack": "^3.1.3",
46
47
  "@xmldom/xmldom": "^0.9.12",
47
48
  "big.js": "^7.0.1"
package/src/bag.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { BagNodeContainer } from './bag-node-container.js';
4
4
  import { BagNode } from './bag-node.js';
5
- import { toTytx as tytxEncode, fromTytx as tytxDecode, registerClass, getRegisteredType, getSubtypeDict, setSubtypeDict } from '@genrojs/tytx';
5
+ import { toTytx as tytxEncode, fromTytx as tytxDecode, registerType, getRegisteredType, getSubtypeDict, setSubtypeDict } from '@genrojs/tytx';
6
+ import { encode as msgpackEncode, ExtData } from '@msgpack/msgpack';
6
7
  import { DOMParser as XmlDOMParser } from '@xmldom/xmldom';
7
8
  import { BagCbResolver, BagResolver } from './resolver.js';
8
9
  import { BagSerializationError, encodeResolver, decodeResolver, encodeAttrs, decodeAttrs } from './resolver-wire.js';
@@ -10,9 +11,9 @@ import { CLS_ATTRIBUTE, getClsMarker, getInheritedClass, getSubtypeClass } from
10
11
 
11
12
  // Resolver caches are not structural branches on the wire: the descriptor
12
13
  // owns that node, and serializing cached descendants would orphan them.
13
- function* serializationNodes(bag, prefix = '') {
14
+ function* serializationNodes(bag, prefix = []) {
14
15
  for (const node of bag) {
15
- const path = prefix ? `${prefix}.${node.label}` : node.label;
16
+ const path = [...prefix, node.label];
16
17
  yield [path, node];
17
18
  const value = node.getValue(true);
18
19
  if (!node.resolver && value instanceof Bag) yield* serializationNodes(value, path);
@@ -35,6 +36,28 @@ function xmlDateText(value) {
35
36
  return timestamp;
36
37
  }
37
38
 
39
+ function isDictionary(value) {
40
+ if (value === null || typeof value !== 'object') return false;
41
+ const proto = Object.getPrototypeOf(value);
42
+ return proto === Object.prototype || proto === null;
43
+ }
44
+
45
+ function wireValue(value) {
46
+ if (typeof value === 'string') return value.includes('::') ? `${value}::T` : value;
47
+ if (Array.isArray(value)) return value.map(wireValue);
48
+ if (isDictionary(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, wireValue(v)]));
49
+ return value;
50
+ }
51
+
52
+ function ingestionOptions(options) {
53
+ if (!isDictionary(options)) throw new TypeError('Bag options must be an object');
54
+ const { recursive = true } = options;
55
+ if (typeof recursive !== 'boolean') throw new TypeError('recursive must be a boolean');
56
+ return recursive;
57
+ }
58
+
59
+ let warnedFromJson = false;
60
+
38
61
  /**
39
62
  * Bag - Hierarchical data container with path-based access.
40
63
  *
@@ -48,9 +71,10 @@ export class Bag {
48
71
  /**
49
72
  * Create a new Bag.
50
73
  *
51
- * @param {Object} [source=null] - Optional dict to initialize from.
74
+ * @param {Object|Array|null} [source=null] - In-memory data with literal string labels.
75
+ * @param {{recursive?: boolean}} [options={}] - Convert nested containers (default true).
52
76
  */
53
- constructor(source = null) {
77
+ constructor(source = null, options = {}) {
54
78
  this._nodes = new BagNodeContainer();
55
79
  this._backref = false;
56
80
  this._parent = null;
@@ -61,8 +85,9 @@ export class Bag {
61
85
  this._insSubscribers = {};
62
86
  this._delSubscribers = {};
63
87
 
64
- if (source) {
65
- this._loadSource(source);
88
+ ingestionOptions(options);
89
+ if (source !== null && source !== undefined) {
90
+ this.fillFrom(source, options);
66
91
  }
67
92
  }
68
93
 
@@ -432,7 +457,7 @@ export class Bag {
432
457
  if (p >= 0) {
433
458
  const node = this._nodes.pop(p);
434
459
  if (this.backref) {
435
- this._onNodeDeleted(node, p, reason);
460
+ this._onNodeDeleted(node, p, null, reason);
436
461
  }
437
462
  return node;
438
463
  }
@@ -717,7 +742,11 @@ export class Bag {
717
742
  * @param {Function} [options.delete] - Callback for delete events.
718
743
  * @param {Function} [options.any] - Callback for all events.
719
744
  */
720
- subscribe(subscriberId, { update = null, insert = null, delete: del = null, any = null } = {}) {
745
+ subscribe(subscriberId, options = {}) {
746
+ if (Object.hasOwn(options, 'timer') || Object.hasOwn(options, 'interval')) {
747
+ throw new TypeError('Timer subscriptions were removed. Schedule polling in the application.');
748
+ }
749
+ const { update = null, insert = null, delete: del = null, any = null } = options;
721
750
  if (!this.backref) {
722
751
  this.setBackref();
723
752
  }
@@ -737,7 +766,11 @@ export class Bag {
737
766
  * @param {boolean} [options.delete=false] - Remove delete subscription.
738
767
  * @param {boolean} [options.any=false] - Remove all subscriptions.
739
768
  */
740
- unsubscribe(subscriberId, { update = false, insert = false, delete: del = false, any = false } = {}) {
769
+ unsubscribe(subscriberId, options = {}) {
770
+ if (Object.hasOwn(options, 'timer') || Object.hasOwn(options, 'interval')) {
771
+ throw new TypeError('Timer subscriptions were removed. Schedule polling in the application.');
772
+ }
773
+ const { update = false, insert = false, delete: del = false, any = false } = options;
741
774
  if (update || any) {
742
775
  delete this._updSubscribers[subscriberId];
743
776
  }
@@ -1633,57 +1666,56 @@ export class Bag {
1633
1666
  * @param {Object} data - Object where keys become labels and values become node values.
1634
1667
  * @private
1635
1668
  */
1636
- _fillFromDict(data) {
1637
- this.clear();
1638
- for (const [key, value] of Object.entries(data)) {
1639
- if (value !== null && typeof value === 'object' && !Array.isArray(value)
1640
- && !(value instanceof Bag) && !(value instanceof BagNode)) {
1641
- this.setItem(key, this.createChildBag()._loadSource(value));
1642
- } else {
1643
- this.setItem(key, value);
1644
- }
1645
- }
1669
+ _appendLiteral(label, value, attr = null, resolver = null, nodeTag = null) {
1670
+ if (typeof label !== 'string') throw new TypeError('Bag labels must be strings');
1671
+ const node = new this.nodeClass(this, label, null, attr, resolver, nodeTag, null, false);
1672
+ if (value instanceof BagResolver) node.resolver = value;
1673
+ else node._value = value;
1674
+ this._nodes._dict[label] = node;
1675
+ this._nodes._list.push(node);
1676
+ return node;
1646
1677
  }
1647
1678
 
1648
- /**
1649
- * Fill this Bag from a source (another Bag or plain object).
1650
- *
1651
- * Prepares replacement contents before changing this Bag:
1652
- * - If source is null/undefined: no-op
1653
- * - If source is a Bag: copies all nodes (deep copy for nested Bags)
1654
- * - If source is a plain object: keys become labels, values become node values.
1655
- * Nested objects are recursively converted to Bags.
1656
- *
1657
- * Preparation errors leave this Bag unchanged. Attached Bags emit one
1658
- * upd_value event after replacement, carrying the previous content.
1659
- * Listener exceptions propagate after the replacement has committed.
1660
- *
1661
- * @param {Bag|Object|null} source - Source to fill from.
1662
- * @returns {Bag} This Bag (for chaining).
1663
- *
1664
- * @example
1665
- * const bag = new Bag();
1666
- * bag.replace(new Bag({ a: 1, b: { c: 2 } }));
1667
- * // bag has 'a' = 1, 'b' = Bag with 'c' = 2
1668
- *
1669
- * const other = new Bag();
1670
- * other.setItem('x', 10);
1671
- * bag.replace(other);
1672
- * // bag now has only 'x' = 10
1673
- */
1674
- _loadSource(source) {
1675
- if (source == null) return this;
1676
- const prepared = this.createChildBag();
1677
- if (source instanceof Bag) {
1678
- prepared._fillFromBag(source);
1679
- } else if (typeof source === 'object' && !Array.isArray(source)) {
1680
- prepared._fillFromDict(source);
1681
- } else {
1682
- return this;
1679
+ /** Replace in-memory data atomically. None/null clears; leaves stay shared. */
1680
+ fillFrom(source = null, options = {}) {
1681
+ const recursive = ingestionOptions(options);
1682
+ if (source != null && !isDictionary(source) && !Array.isArray(source)) {
1683
+ throw new TypeError('Bag source must be a dictionary, array or null');
1683
1684
  }
1685
+ const prepared = this.createChildBag();
1686
+ const active = new Set();
1687
+ const populate = (bag, data, path) => {
1688
+ if (active.has(data)) throw new TypeError(`Cyclic Bag source at ${path}`);
1689
+ active.add(data);
1690
+ try {
1691
+ const width = Array.isArray(data) ? String(data.length).length + 1 : 0;
1692
+ const entries = Array.isArray(data)
1693
+ ? Array.from(data, (value, i) => [`r_${String(i).padStart(width, '0')}`, value])
1694
+ : Object.entries(data);
1695
+ if (!Array.isArray(data) && Object.getOwnPropertySymbols(data)
1696
+ .some(key => Object.prototype.propertyIsEnumerable.call(data, key))) {
1697
+ throw new TypeError(`Bag labels must be strings at ${path}`);
1698
+ }
1699
+ for (const [label, item] of entries) {
1700
+ let value = item;
1701
+ if (recursive && (isDictionary(value) || Array.isArray(value))) {
1702
+ value = this.createChildBag();
1703
+ populate(value, item, `${path}[${JSON.stringify(label)}]`);
1704
+ }
1705
+ bag._appendLiteral(label, value);
1706
+ }
1707
+ } finally {
1708
+ active.delete(data);
1709
+ }
1710
+ };
1711
+ if (source != null) populate(prepared, source, '$');
1684
1712
  return this._replacePrepared(prepared);
1685
1713
  }
1686
1714
 
1715
+ _loadSource(source, options = {}) {
1716
+ return this.fillFrom(source, options);
1717
+ }
1718
+
1687
1719
  /** Replace all contents from a Bag, preserving identity. Returns this. */
1688
1720
  replace(other) {
1689
1721
  if (!(other instanceof Bag)) throw new TypeError('Bag.replace expects a Bag');
@@ -1787,11 +1819,12 @@ export class Bag {
1787
1819
  let codeCounter = 0;
1788
1820
  // Class of each branch by path: a node's parent class decides whether
1789
1821
  // its branch needs __cls (parentBag is null when the backref is off).
1790
- const pathToClass = new Map([['', this.constructor]]);
1822
+ const pathToClass = new Map([['[]', this.constructor]]);
1791
1823
 
1792
1824
  for (const [path, node] of serializationNodes(this)) {
1793
- const lastDot = path.lastIndexOf('.');
1794
- const parentPath = lastDot >= 0 ? path.slice(0, lastDot) : '';
1825
+ const parentPath = path.slice(0, -1);
1826
+ const pathKey = JSON.stringify(path);
1827
+ const parentKey = JSON.stringify(parentPath);
1795
1828
 
1796
1829
  const nodeValue = node.getValue(true);
1797
1830
 
@@ -1803,29 +1836,29 @@ export class Bag {
1803
1836
  } else if (nodeValue instanceof Bag) {
1804
1837
  const cls = nodeValue.constructor;
1805
1838
  const suffix = cls.tytxSuffix;
1806
- pathToClass.set(path, cls);
1807
- branchCls = getClsMarker(cls, getInheritedClass(pathToClass.get(parentPath), suffix));
1839
+ pathToClass.set(pathKey, cls);
1840
+ branchCls = getClsMarker(cls, getInheritedClass(pathToClass.get(parentKey), suffix));
1808
1841
  value = `::${suffix}`;
1809
1842
  } else if (nodeValue === null) {
1810
1843
  value = '::NN';
1811
1844
  } else {
1812
- value = nodeValue;
1845
+ value = wireValue(nodeValue);
1813
1846
  }
1814
1847
 
1815
1848
  if (node.attr && Object.hasOwn(node.attr, CLS_ATTRIBUTE)) {
1816
1849
  throw new BagSerializationError(
1817
1850
  `node '${path}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name`);
1818
1851
  }
1819
- const attr = encodeAttrs(node.attr);
1852
+ const attr = encodeAttrs(wireValue(node.attr));
1820
1853
  if (branchCls !== null) attr[CLS_ATTRIBUTE] = branchCls;
1821
1854
  const tag = node.nodeTag;
1822
1855
 
1823
1856
  if (compact) {
1824
- const parentRef = parentPath ? pathToCode[parentPath] : null;
1857
+ const parentRef = parentPath.length ? pathToCode[parentKey] : null;
1825
1858
  yield [parentRef, node.label, tag, value, attr];
1826
1859
 
1827
1860
  if (nodeValue instanceof Bag) {
1828
- pathToCode[path] = codeCounter;
1861
+ pathToCode[pathKey] = codeCounter;
1829
1862
  pathRegistry[codeCounter] = path;
1830
1863
  codeCounter++;
1831
1864
  }
@@ -1847,6 +1880,16 @@ export class Bag {
1847
1880
  * @returns {string|Uint8Array} Serialized data.
1848
1881
  */
1849
1882
  toTytx(transport = 'json', compact = false) {
1883
+ const payload = this._toTytxPayload(compact);
1884
+ const suffix = this.constructor.tytxSuffix;
1885
+ if (transport === 'json') return `${payload}::${suffix}`;
1886
+ if (transport === 'msgpack') {
1887
+ return msgpackEncode(new ExtData(4, new TextEncoder().encode(`${suffix}:${payload}`)));
1888
+ }
1889
+ throw new TypeError(`Unsupported transport: ${transport}`);
1890
+ }
1891
+
1892
+ _toTytxPayload(compact = false) {
1850
1893
  let data;
1851
1894
 
1852
1895
  if (compact) {
@@ -1861,8 +1904,10 @@ export class Bag {
1861
1904
  const rootCls = getClsMarker(rootClass, getInheritedClass(null, rootClass.tytxSuffix));
1862
1905
  if (rootCls !== null) data[CLS_ATTRIBUTE] = rootCls;
1863
1906
 
1864
- const tytxTransport = transport === 'json' ? null : transport;
1865
- return tytxEncode(data, tytxTransport);
1907
+ data.rows = data.rows.map(([parent, label, tag, value, attr]) =>
1908
+ [wireValue(parent), wireValue(label), wireValue(tag), value, attr]);
1909
+ if (data.paths) data.paths = wireValue(data.paths);
1910
+ return tytxEncode(data);
1866
1911
  }
1867
1912
 
1868
1913
  /**
@@ -1872,13 +1917,31 @@ export class Bag {
1872
1917
  * @param {string} [transport='json'] - Input format: 'json' or 'msgpack'.
1873
1918
  * @returns {Bag} Reconstructed Bag.
1874
1919
  */
1875
- static fromTytx(data, transport = 'json') {
1920
+ static fromTytx(data, transport = 'json', options = {}) {
1921
+ if (isDictionary(transport)) { options = transport; transport = 'json'; }
1922
+ ingestionOptions(options);
1923
+ if (typeof data !== 'string' && !(data instanceof Uint8Array)) {
1924
+ throw new TypeError('fromTytx requires serialized text or bytes');
1925
+ }
1926
+ if (transport !== 'json' && transport !== 'msgpack') {
1927
+ throw new TypeError(`Unsupported transport: ${transport}`);
1928
+ }
1929
+ if (transport === 'json' && data instanceof Uint8Array) data = new TextDecoder().decode(data);
1930
+ const decoded = tytxDecode(data, transport === 'json' ? null : transport);
1931
+ if (decoded instanceof Bag) return decoded;
1932
+ if (!isDictionary(decoded) && !Array.isArray(decoded)) {
1933
+ throw new TypeError('fromTytx requires a dictionary, array or typed Bag root');
1934
+ }
1935
+ return new this(decoded, options);
1936
+ }
1937
+
1938
+ static _fromTytxPayload(data) {
1876
1939
  // The payload alone decides the class of the root: without __cls it is
1877
1940
  // the class registered for the suffix (Bag for "X"), whatever this is.
1878
1941
  const suffix = this.tytxSuffix;
1879
1942
  // The registry passes an empty payload for a structural branch marker.
1880
1943
  if (data === '') return new (getInheritedClass(null, suffix))();
1881
- const parsed = tytxDecode(data, transport === 'json' ? null : transport);
1944
+ const parsed = tytxDecode(data);
1882
1945
  if (!parsed || !Array.isArray(parsed.rows)) {
1883
1946
  throw new BagSerializationError('Invalid TYTX Bag: expected rows');
1884
1947
  }
@@ -1888,7 +1951,7 @@ export class Bag {
1888
1951
  ? getSubtypeClass(suffix, parsed[CLS_ATTRIBUTE])
1889
1952
  : getInheritedClass(null, suffix);
1890
1953
  const bag = new rootClass();
1891
- const pathToBag = new Map([['', bag]]);
1954
+ const pathToBag = new Map([['[]', bag]]);
1892
1955
 
1893
1956
  for (const row of parsed.rows) {
1894
1957
  if (!Array.isArray(row) || row.length !== 5) {
@@ -1902,18 +1965,15 @@ export class Bag {
1902
1965
  }
1903
1966
  parentPath = parentRef === null ? '' : paths[parentRef];
1904
1967
  }
1905
- if (!pathToBag.has(parentPath)) {
1968
+ if (typeof parentPath === 'string') parentPath = parentPath ? parentPath.split('.') : [];
1969
+ if (!Array.isArray(parentPath)) throw new BagSerializationError('Invalid TYTX parent path');
1970
+ const parentKey = JSON.stringify(parentPath);
1971
+ if (!pathToBag.has(parentKey)) {
1906
1972
  throw new BagSerializationError(`Missing or undecodable TYTX parent branch: ${parentPath}`);
1907
1973
  }
1908
- const parentBag = pathToBag.get(parentPath);
1909
- const fullPath = parentPath ? `${parentPath}.${label}` : label;
1974
+ const parentBag = pathToBag.get(parentKey);
1975
+ const fullPath = JSON.stringify([...parentPath, label]);
1910
1976
  let value = rawValue;
1911
- // MessagePack does not rescan text. Only Bag-owned empty markers
1912
- // are structural: arbitrary scalar decoders must never run here.
1913
- if (transport === 'msgpack' && typeof value === 'string' && value.startsWith('::')) {
1914
- const cls = getRegisteredType(value.slice(2));
1915
- if (cls === Bag || cls?.prototype instanceof Bag) value = tytxDecode(value);
1916
- }
1917
1977
  const resolver = decodeResolver(value);
1918
1978
  const attr = decodeAttrs(rawAttr);
1919
1979
  const branchCls = Object.hasOwn(attr, CLS_ATTRIBUTE) ? attr[CLS_ATTRIBUTE] : null;
@@ -1929,10 +1989,8 @@ export class Bag {
1929
1989
  } else if (branchCls !== null) {
1930
1990
  throw new BagSerializationError(
1931
1991
  `node '${fullPath}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name of a branch`);
1932
- } else if (value === '::NN') {
1933
- value = null;
1934
1992
  }
1935
- const node = parentBag.setItem(label, resolver || value, attr);
1993
+ const node = parentBag._appendLiteral(label, resolver ? null : value, attr, resolver);
1936
1994
  node.nodeTag = tag ?? null;
1937
1995
  }
1938
1996
  return bag;
@@ -2198,7 +2256,7 @@ export class Bag {
2198
2256
  const text = await response.text();
2199
2257
 
2200
2258
  if (contentType.includes('json')) {
2201
- return Bag.fromJson(text);
2259
+ return Bag.fromTytx(text);
2202
2260
  }
2203
2261
  if (contentType.includes('xml')) {
2204
2262
  return Bag.fromXml(text);
@@ -2209,7 +2267,7 @@ export class Bag {
2209
2267
  return Bag.fromXml(text);
2210
2268
  }
2211
2269
  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
2212
- return Bag.fromJson(text);
2270
+ return Bag.fromTytx(text);
2213
2271
  }
2214
2272
  throw new BagException(`Unsupported content-type: ${contentType}`);
2215
2273
  }
@@ -2262,79 +2320,17 @@ export class Bag {
2262
2320
  }
2263
2321
 
2264
2322
  /**
2265
- * Deserialize JSON to Bag.
2266
- *
2267
- * Accepts JSON string, dict, or list. Recursively converts nested
2268
- * structures to Bag hierarchy.
2269
- *
2270
- * @param {string|Object|Array} source - JSON string, dict or list to parse.
2271
- * @param {string} [listJoiner=null] - If provided, join string arrays with this separator.
2272
- * @returns {Bag} Deserialized Bag.
2323
+ * Deprecated alias of fromTytx: serialized text only, no node heuristics.
2324
+ * @deprecated Use fromTytx, or new Bag(data) for native dictionaries/arrays.
2325
+ * @param {string|Uint8Array} source
2326
+ * @param {{recursive?: boolean}} [options={}]
2273
2327
  */
2274
- static fromJson(source, listJoiner = null) {
2275
- if (typeof source === 'string') {
2276
- source = tytxDecode(source);
2328
+ static fromJson(source, options = {}) {
2329
+ if (!warnedFromJson) {
2330
+ console.warn('Bag.fromJson is deprecated; use Bag.fromTytx');
2331
+ warnedFromJson = true;
2277
2332
  }
2278
-
2279
- if (!Array.isArray(source) && typeof source !== 'object') {
2280
- source = { value: source };
2281
- }
2282
-
2283
- return Bag._fromJsonRecursive(source, listJoiner);
2284
- }
2285
-
2286
- /**
2287
- * Recursively convert JSON data to Bag (internal).
2288
- * @private
2289
- */
2290
- static _fromJsonRecursive(data, listJoiner = null) {
2291
- if (data instanceof Bag) return data;
2292
- if (Array.isArray(data)) {
2293
- // listJoiner: join string arrays into a single string
2294
- if (listJoiner !== null && data.every(item => typeof item === 'string')) {
2295
- return data.join(listJoiner);
2296
- }
2297
-
2298
- if (data.length === 0) {
2299
- return new Bag();
2300
- }
2301
-
2302
- // Check if list items have 'label' key (Bag node format)
2303
- if (typeof data[0] === 'object' && data[0] !== null && 'label' in data[0]) {
2304
- const result = new Bag();
2305
- for (const item of data) {
2306
- const label = item.label;
2307
- const value = Bag._fromJsonRecursive(item.value, listJoiner);
2308
- const attr = decodeAttrs(item.attr);
2309
- const node = result.setItem(label, value, attr);
2310
- node.nodeTag = item.tag ?? null;
2311
- const resolver = decodeResolver(item.resolver);
2312
- if (resolver) node.resolver = resolver;
2313
- }
2314
- return result;
2315
- }
2316
-
2317
- // Generic list -> Bag with r_N keys
2318
- const result = new Bag();
2319
- for (let n = 0; n < data.length; n++) {
2320
- result.setItem(`r_${n}`, Bag._fromJsonRecursive(data[n], listJoiner));
2321
- }
2322
- return result;
2323
- }
2324
-
2325
- if (typeof data === 'object' && data !== null) {
2326
- if (Object.keys(data).length === 0) {
2327
- return new Bag();
2328
- }
2329
- const result = new Bag();
2330
- for (const [k, v] of Object.entries(data)) {
2331
- result.setItem(k, Bag._fromJsonRecursive(v, listJoiner));
2332
- }
2333
- return result;
2334
- }
2335
-
2336
- // Scalar value
2337
- return data;
2333
+ return this.fromTytx(source, 'json', options);
2338
2334
  }
2339
2335
 
2340
2336
  // -------------------------------------------------------------------------
@@ -2511,7 +2507,7 @@ export class BagException extends Error {
2511
2507
  BagResolver.registerBagClass(Bag);
2512
2508
 
2513
2509
  // Shared TYTX registry: subclasses register their own wire suffix explicitly.
2514
- registerClass(Bag);
2510
+ registerType(Bag, 'X', bag => bag._toTytxPayload(), payload => Bag._fromTytxPayload(payload));
2515
2511
  // Bag is the base class of the "X" type: its name must be in the subtype
2516
2512
  // dictionary like every subclass that travels as "::X" with a __cls name.
2517
2513
  setSubtypeDict(Bag.tytxSuffix, { ...getSubtypeDict(Bag.tytxSuffix), Bag });
package/src/index.d.ts CHANGED
@@ -20,7 +20,11 @@ export const RETRY_POLICIES: {
20
20
 
21
21
  /** Options shared by every resolver. */
22
22
  export interface BagResolverOptions {
23
- /** Cache duration in ms. 0 disables the cache, a negative value never expires. */
23
+ /** Removed: schedule polling in the application. */
24
+ interval?: never;
25
+ /** Removed: invalidate and read explicitly. */
26
+ reactive?: never;
27
+ /** Cache duration in seconds. 0 disables the cache, a negative value never expires. */
24
28
  cacheTime?: number;
25
29
  /** If true, the resolved value is not stored in the node. */
26
30
  readOnly?: boolean;
@@ -85,7 +89,8 @@ export class BagSerializationError extends Error {
85
89
  export class Bag implements Iterable<BagNode> {
86
90
  /** Wire suffix of the base class in the TYTX registry. */
87
91
  static tytxSuffix: string;
88
- constructor(source?: Record<string, any> | Bag | null);
92
+ constructor(source?: Record<string, any> | any[] | null, options?: { recursive?: boolean });
93
+ fillFrom(source?: Record<string, any> | any[] | null, options?: { recursive?: boolean }): this;
89
94
 
90
95
  parent: Bag | null;
91
96
  parentNode: BagNode | null;
@@ -158,12 +163,13 @@ export class Bag implements Iterable<BagNode> {
158
163
  traverse(): Generator<BagNode>;
159
164
 
160
165
  toTytx(transport?: 'json' | 'msgpack', compact?: boolean): string | Uint8Array;
161
- static fromTytx(data: string | Uint8Array, transport?: 'json' | 'msgpack'): Bag;
166
+ static fromTytx(data: string | Uint8Array, transport?: 'json' | 'msgpack' | { recursive?: boolean }, options?: { recursive?: boolean }): Bag;
162
167
  toXml(options?: ToXmlOptions): string;
163
168
  static fromXml(source: string, options?: FromXmlOptions): Bag;
164
169
  static fromUrl(url: string, options?: FromUrlOptions): Promise<Bag>;
165
170
  toJson(typed?: boolean): string;
166
- static fromJson(source: string | Record<string, any> | any[], listJoiner?: string | null): Bag;
171
+ /** @deprecated Use fromTytx for text and the constructor for native data. */
172
+ static fromJson(source: string | Uint8Array, options?: { recursive?: boolean }): Bag;
167
173
  toStringTree(isStatic?: boolean): string;
168
174
  toString(isStatic?: boolean): string;
169
175
  }
@@ -240,6 +246,7 @@ export class BagResolver {
240
246
  init(): void;
241
247
  setNode(node: BagNode | null): void;
242
248
  onSetResolver(node: BagNode): void;
249
+ /** Invalidate the cache; reload only on the next explicit read. No refresh option. */
243
250
  reset(): void;
244
251
  resolve(options?: { static?: boolean; [key: string]: any }): any;
245
252
  load(kwargs?: Record<string, any>): any;
package/src/resolver.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * - cacheTime = 0: never cache, always resolve
12
12
  * - cacheTime < 0: cache forever (resolve once)
13
13
  *
14
+ * Resolvers never schedule background loads. reset() reloads on the next read.
15
+ *
14
16
  * Retry Policy:
15
17
  * - retryPolicy = null -> NO retry (default)
16
18
  * - retryPolicy = 'network' -> use predefined RETRY_POLICIES.network
@@ -160,6 +162,11 @@ export class BagResolver {
160
162
  constructor(kwargs = {}) {
161
163
  const defaults = this.constructor.classKwargs;
162
164
  const merged = { ...defaults, ...kwargs };
165
+ for (const option of ['interval', 'reactive']) {
166
+ if (Object.hasOwn(merged, option)) {
167
+ throw new TypeError(`Removed resolver option: ${option}. Schedule updates in the application; use reset() and read explicitly.`);
168
+ }
169
+ }
163
170
 
164
171
  // Extract internal params
165
172
  this.cacheTime = merged.cacheTime;
@@ -282,7 +289,10 @@ export class BagResolver {
282
289
  /**
283
290
  * Reset the cache, forcing next resolve to call load().
284
291
  */
285
- reset() {
292
+ reset(...args) {
293
+ if (args.length) {
294
+ throw new TypeError('reset() takes no arguments. It invalidates the cache for the next explicit read.');
295
+ }
286
296
  this._lastUpdate = null;
287
297
  this._lastEffectiveFingerprint = null;
288
298
  }
@@ -342,7 +352,7 @@ export class BagResolver {
342
352
  if (this._node && this._node.attr) {
343
353
  const internalParams = this.constructor.internalParams;
344
354
  for (const [key, value] of Object.entries(this._node.attr)) {
345
- if (!internalParams.has(key) && !(key in kwargs)) {
355
+ if (!internalParams.has(key)) {
346
356
  kwargs[key] = value;
347
357
  }
348
358
  }
@@ -435,8 +445,8 @@ export class BagResolver {
435
445
  if (result.startsWith('<') && BagClass.fromXml) {
436
446
  return BagClass.fromXml(result);
437
447
  }
438
- if ((result.startsWith('{') || result.startsWith('[')) && BagClass.fromJson) {
439
- return BagClass.fromJson(result);
448
+ if ((result.startsWith('{') || result.startsWith('[')) && BagClass.fromTytx) {
449
+ return BagClass.fromTytx(result);
440
450
  }
441
451
  }
442
452
  return result;