@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.
@@ -0,0 +1,538 @@
1
+ // Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * BagResolver - Base class for lazy/dynamic value resolution.
5
+ *
6
+ * Resolvers provide deferred value computation for BagNodes. When a node
7
+ * has a resolver, accessing its value triggers the resolver's load() method.
8
+ *
9
+ * Cache semantics (same as Python):
10
+ * - cacheTime > 0: cache for N seconds (TTL)
11
+ * - cacheTime = 0: never cache, always resolve
12
+ * - cacheTime < 0: cache forever (resolve once)
13
+ *
14
+ * Retry Policy:
15
+ * - retryPolicy = null -> NO retry (default)
16
+ * - retryPolicy = 'network' -> use predefined RETRY_POLICIES.network
17
+ * - retryPolicy = {...} -> custom policy object
18
+ *
19
+ * The load() method can return a value directly (sync) or a Promise (async).
20
+ * The caller handles accordingly.
21
+ */
22
+
23
+ /**
24
+ * Predefined retry policies.
25
+ */
26
+ export const RETRY_POLICIES = {
27
+ network: {
28
+ maxAttempts: 3,
29
+ delay: 1.0,
30
+ backoff: 2.0,
31
+ jitter: true,
32
+ on: ['TypeError', 'NetworkError', 'AbortError']
33
+ },
34
+ aggressive: {
35
+ maxAttempts: 5,
36
+ delay: 0.5,
37
+ backoff: 2.0,
38
+ jitter: true,
39
+ on: ['Error']
40
+ },
41
+ gentle: {
42
+ maxAttempts: 2,
43
+ delay: 2.0,
44
+ backoff: 1.5,
45
+ jitter: false,
46
+ on: ['TypeError', 'NetworkError']
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Get retry policy from resolver, resolving string references.
52
+ * @param {BagResolver} resolver
53
+ * @returns {Object|null}
54
+ */
55
+ function getRetryPolicy(resolver) {
56
+ const policy = resolver._retryPolicy;
57
+ if (policy === null || policy === undefined) {
58
+ return null;
59
+ }
60
+ if (typeof policy === 'string') {
61
+ return RETRY_POLICIES[policy] || null;
62
+ }
63
+ return policy;
64
+ }
65
+
66
+ /**
67
+ * Check if error matches retry policy.
68
+ * @param {Error} error
69
+ * @param {Array<string>} errorTypes
70
+ * @returns {boolean}
71
+ */
72
+ function shouldRetry(error, errorTypes) {
73
+ return errorTypes.some(type => {
74
+ if (type === 'Error') return true;
75
+ return error.name === type || error.constructor.name === type;
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Sleep for specified milliseconds.
81
+ * @param {number} ms
82
+ * @returns {Promise<void>}
83
+ */
84
+ function sleep(ms) {
85
+ return new Promise(resolve => setTimeout(resolve, ms));
86
+ }
87
+
88
+ /**
89
+ * Execute function with retry logic.
90
+ * @param {Function} fn - Function to execute (can be async)
91
+ * @param {Object} policy - Retry policy
92
+ * @returns {*} Result of function
93
+ */
94
+ async function withRetry(fn, policy) {
95
+ if (!policy) {
96
+ return fn();
97
+ }
98
+
99
+ const maxAttempts = policy.maxAttempts || 3;
100
+ const delay = policy.delay || 1.0;
101
+ const backoff = policy.backoff || 2.0;
102
+ const jitter = policy.jitter !== false;
103
+ const errorTypes = policy.on || ['Error'];
104
+
105
+ let lastError = null;
106
+ let currentDelay = delay * 1000; // Convert to ms
107
+
108
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
109
+ try {
110
+ const result = fn();
111
+ if (result instanceof Promise) {
112
+ return await result;
113
+ }
114
+ return result;
115
+ } catch (error) {
116
+ lastError = error;
117
+ if (attempt < maxAttempts - 1 && shouldRetry(error, errorTypes)) {
118
+ let sleepTime = currentDelay;
119
+ if (jitter) {
120
+ sleepTime *= (1 + Math.random() * 0.1);
121
+ }
122
+ await sleep(sleepTime);
123
+ currentDelay *= backoff;
124
+ } else {
125
+ throw error;
126
+ }
127
+ }
128
+ }
129
+ throw lastError;
130
+ }
131
+
132
+ export class BagResolver {
133
+ /**
134
+ * Default options for this resolver class.
135
+ * Subclasses can override to change defaults.
136
+ */
137
+ static classKwargs = {
138
+ cacheTime: 0,
139
+ readOnly: false,
140
+ asBag: null,
141
+ retryPolicy: null
142
+ };
143
+
144
+ /**
145
+ * Positional argument names for constructor.
146
+ * Subclasses can override to accept positional args.
147
+ */
148
+ static classArgs = [];
149
+
150
+ /**
151
+ * Parameters that are internal (not passed to load()).
152
+ */
153
+ static internalParams = new Set(['cacheTime', 'readOnly', 'asBag', 'retryPolicy']);
154
+
155
+ /**
156
+ * Create a new resolver.
157
+ *
158
+ * @param {Object} kwargs - Configuration options merged with classKwargs.
159
+ */
160
+ constructor(kwargs = {}) {
161
+ const defaults = this.constructor.classKwargs;
162
+ const merged = { ...defaults, ...kwargs };
163
+
164
+ // Extract internal params
165
+ this.cacheTime = merged.cacheTime;
166
+ this._readOnly = merged.readOnly;
167
+ this._asBag = merged.asBag;
168
+ this._retryPolicy = merged.retryPolicy;
169
+
170
+ // Store non-internal params for load()
171
+ this._kw = {};
172
+ const internalParams = this.constructor.internalParams;
173
+ for (const [key, value] of Object.entries(merged)) {
174
+ if (!internalParams.has(key)) {
175
+ this._kw[key] = value;
176
+ }
177
+ }
178
+
179
+ // Cache state
180
+ this._lastUpdate = null;
181
+ this._node = null;
182
+ this._lastEffectiveFingerprint = null;
183
+ this._cachedValue = null;
184
+
185
+ // Hook for subclasses
186
+ this.init();
187
+ }
188
+
189
+ /**
190
+ * Hook called at the end of constructor.
191
+ * Override in subclasses for custom initialization.
192
+ */
193
+ init() {
194
+ // Override in subclasses
195
+ }
196
+
197
+ /**
198
+ * Set the parent node (called when resolver is attached to a node).
199
+ *
200
+ * @param {BagNode} node - The node this resolver is attached to.
201
+ */
202
+ setNode(node) {
203
+ if (node !== this._node && !this.readOnly && this._lastUpdate !== null) {
204
+ this.reset();
205
+ }
206
+ this._node = node;
207
+ this.onSetResolver(node);
208
+ }
209
+
210
+ /**
211
+ * Hook called when resolver is attached to a node.
212
+ * Override in subclasses for custom initialization.
213
+ *
214
+ * @param {BagNode} node - The node this resolver is attached to.
215
+ */
216
+ onSetResolver(node) {
217
+ // Override in subclasses
218
+ }
219
+
220
+ /**
221
+ * Get the parent node.
222
+ *
223
+ * @returns {BagNode|null}
224
+ */
225
+ get node() {
226
+ return this._node;
227
+ }
228
+
229
+ /**
230
+ * Get cache time setting.
231
+ *
232
+ * @returns {number}
233
+ */
234
+ get cacheTime() {
235
+ return this._cacheTime;
236
+ }
237
+
238
+ /**
239
+ * Set cache time.
240
+ *
241
+ * @param {number} value
242
+ */
243
+ set cacheTime(value) {
244
+ if (typeof value === 'boolean') {
245
+ throw new TypeError('cacheTime must be numeric; use a negative value for infinite caching');
246
+ }
247
+ this._cacheTime = value;
248
+ }
249
+
250
+ /**
251
+ * Check if resolver is read-only (value not stored in node).
252
+ *
253
+ * @returns {boolean}
254
+ */
255
+ get readOnly() {
256
+ return this._readOnly;
257
+ }
258
+
259
+ /**
260
+ * Check if cached value has expired.
261
+ *
262
+ * @returns {boolean}
263
+ */
264
+ get expired() {
265
+ if (this._cacheTime < 0) {
266
+ // Cache forever: expired only if never resolved
267
+ return this._lastUpdate === null;
268
+ }
269
+ if (this._cacheTime === 0) {
270
+ // Never cache: always expired
271
+ return true;
272
+ }
273
+ // TTL cache
274
+ if (this._lastUpdate === null) {
275
+ return true;
276
+ }
277
+ const now = Date.now();
278
+ const deltaSeconds = (now - this._lastUpdate) / 1000;
279
+ return deltaSeconds > this._cacheTime;
280
+ }
281
+
282
+ /**
283
+ * Reset the cache, forcing next resolve to call load().
284
+ */
285
+ reset() {
286
+ this._lastUpdate = null;
287
+ this._lastEffectiveFingerprint = null;
288
+ }
289
+
290
+ /**
291
+ * Readonly caches live in the resolver; writable caches use the attached node.
292
+ *
293
+ * @returns {*} The cached value.
294
+ */
295
+ get cachedValue() {
296
+ return !this.readOnly && this._node ? this._node._value : this._cachedValue;
297
+ }
298
+
299
+ /**
300
+ * Set cached value in parent node or local storage.
301
+ *
302
+ * @param {*} value
303
+ */
304
+ set cachedValue(value) {
305
+ if (!this.readOnly && this._node) {
306
+ this._node.setValue(value, false);
307
+ } else {
308
+ this._cachedValue = value;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Compute fingerprint of effective parameters for cache invalidation.
314
+ *
315
+ * @param {Object} effectiveKw - The effective kwargs.
316
+ * @returns {string} JSON string fingerprint.
317
+ * @private
318
+ */
319
+ _computeEffectiveFingerprint(effectiveKw) {
320
+ return JSON.stringify(Object.entries(effectiveKw).sort());
321
+ }
322
+
323
+ /**
324
+ * Resolve the value. Main entry point.
325
+ *
326
+ * @param {Object} options - Resolution options.
327
+ * @param {boolean} options.static - If true, return cached value without resolving.
328
+ * @param {Object} options.kwargs - Additional kwargs merged with resolver's _kw.
329
+ * @returns {*} The resolved value (or Promise if load() is async).
330
+ */
331
+ resolve(options = {}) {
332
+ const { static: isStatic = false, ...callKwargs } = options;
333
+
334
+ // Static mode: return cached value without resolving
335
+ if (isStatic) {
336
+ return this._node ? this._node._value : this.cachedValue;
337
+ }
338
+
339
+ // Build kwargs: resolver._kw merged with call kwargs
340
+ // Node attributes can also contribute (priority: callKwargs > node.attr > _kw)
341
+ const kwargs = { ...this._kw };
342
+ if (this._node && this._node.attr) {
343
+ const internalParams = this.constructor.internalParams;
344
+ for (const [key, value] of Object.entries(this._node.attr)) {
345
+ if (!internalParams.has(key) && !(key in kwargs)) {
346
+ kwargs[key] = value;
347
+ }
348
+ }
349
+ }
350
+ Object.assign(kwargs, callKwargs);
351
+
352
+ // Fingerprint-based cache invalidation:
353
+ // If params changed, force reload even if TTL hasn't expired
354
+ const currentFingerprint = this._computeEffectiveFingerprint(kwargs);
355
+ if (currentFingerprint === this._lastEffectiveFingerprint
356
+ && !this.expired) {
357
+ return this.cachedValue;
358
+ }
359
+ this._lastEffectiveFingerprint = currentFingerprint;
360
+
361
+ // Get retry policy
362
+ const policy = getRetryPolicy(this);
363
+
364
+ // Call load with retry
365
+ const doLoad = () => this.load(kwargs);
366
+
367
+ if (policy) {
368
+ // With retry - always async
369
+ return withRetry(doLoad, policy).then(value => this._finalize(value));
370
+ }
371
+
372
+ // Without retry - can be sync or async
373
+ const result = doLoad();
374
+ if (result && typeof result.then === 'function') {
375
+ return result.then(value => this._finalize(value));
376
+ }
377
+ return this._finalize(result);
378
+ }
379
+
380
+ /**
381
+ * Finalize resolution: update cache timestamp and optionally store value.
382
+ *
383
+ * @param {*} value - The resolved value.
384
+ * @returns {*} The value (possibly converted to Bag).
385
+ * @private
386
+ */
387
+ _finalize(value) {
388
+ // asBag conversion:
389
+ // true → always convert
390
+ // false → never convert
391
+ // null → convert if will be cached (!readOnly)
392
+ let shouldConvert;
393
+ if (this._asBag === true) {
394
+ shouldConvert = true;
395
+ } else if (this._asBag === false) {
396
+ shouldConvert = false;
397
+ } else {
398
+ shouldConvert = !this._readOnly;
399
+ }
400
+
401
+ if (shouldConvert && value !== null && value !== undefined) {
402
+ value = this._convertToBag(value);
403
+ }
404
+
405
+ this._lastUpdate = Date.now();
406
+
407
+ // Cache duration and node storage policy are independent.
408
+ if (!this.readOnly || this.cacheTime !== 0) {
409
+ this.cachedValue = value;
410
+ }
411
+
412
+ return value;
413
+ }
414
+
415
+ /**
416
+ * Convert result to Bag if possible.
417
+ * Uses the Bag class registered via BagResolver.registerBagClass().
418
+ *
419
+ * @param {*} result - The value to convert.
420
+ * @returns {*} A Bag if conversion succeeded, otherwise the original value.
421
+ * @private
422
+ */
423
+ _convertToBag(result) {
424
+ const BagClass = BagResolver._BagClass;
425
+ if (!BagClass) {
426
+ return result;
427
+ }
428
+
429
+ if (result && typeof result._htraverse === 'function') {
430
+ // Already a Bag (duck typing)
431
+ return result;
432
+ }
433
+ if (typeof result === 'string') {
434
+ result = result.trim();
435
+ if (result.startsWith('<') && BagClass.fromXml) {
436
+ return BagClass.fromXml(result);
437
+ }
438
+ if ((result.startsWith('{') || result.startsWith('[')) && BagClass.fromJson) {
439
+ return BagClass.fromJson(result);
440
+ }
441
+ }
442
+ return result;
443
+ }
444
+
445
+ /**
446
+ * Register the Bag class for asBag conversion.
447
+ * Called by Bag module to avoid circular imports.
448
+ *
449
+ * @param {Function} BagClass - The Bag constructor.
450
+ */
451
+ static registerBagClass(BagClass) {
452
+ BagResolver._BagClass = BagClass;
453
+ }
454
+
455
+ /**
456
+ * Load the value. Override in subclasses.
457
+ *
458
+ * Can return a value directly (sync) or a Promise (async).
459
+ *
460
+ * @param {Object} kwargs - Parameters for loading.
461
+ * @returns {*} The loaded value or Promise.
462
+ */
463
+ load(kwargs) {
464
+ // Must be implemented by subclasses
465
+ return null;
466
+ }
467
+ }
468
+
469
+ // Registry for Bag class (set by Bag module to avoid circular import)
470
+ BagResolver._BagClass = null;
471
+
472
+ /**
473
+ * BagCbResolver - Resolver that delegates to a callback function.
474
+ *
475
+ * The callback can be sync or async. The resolver handles both transparently.
476
+ *
477
+ * @example
478
+ * // Sync callback
479
+ * const resolver = new BagCbResolver({
480
+ * callback: (kw) => kw.a + kw.b,
481
+ * a: 10,
482
+ * b: 20
483
+ * });
484
+ *
485
+ * @example
486
+ * // Async callback
487
+ * const resolver = new BagCbResolver({
488
+ * callback: async (kw) => {
489
+ * const response = await fetch(kw.url);
490
+ * return response.json();
491
+ * },
492
+ * url: 'https://api.example.com/data'
493
+ * });
494
+ */
495
+ export class BagCbResolver extends BagResolver {
496
+ static classKwargs = {
497
+ cacheTime: 0,
498
+ readOnly: false,
499
+ asBag: false,
500
+ retryPolicy: null,
501
+ callback: null
502
+ };
503
+
504
+ static classArgs = ['callback'];
505
+
506
+ static internalParams = new Set(['cacheTime', 'readOnly', 'asBag', 'retryPolicy', 'callback']);
507
+
508
+ /**
509
+ * Create a callback resolver.
510
+ *
511
+ * @param {Object|Function} kwargs - Options or callback function directly.
512
+ */
513
+ constructor(kwargs = {}) {
514
+ // Allow passing callback as first positional arg
515
+ if (typeof kwargs === 'function') {
516
+ kwargs = { callback: kwargs };
517
+ }
518
+ super(kwargs);
519
+ this._callback = kwargs.callback || null;
520
+ }
521
+
522
+ /**
523
+ * Load by calling the callback.
524
+ *
525
+ * // DIFF-PYTHON: Python calls callback(**params) with expanded kwargs.
526
+ * // JS calls callback(params) with a single kwargs object because JS
527
+ * // has no **kwargs syntax. This is an intentional, unavoidable difference.
528
+ *
529
+ * @param {Object} kwargs - Parameters passed to callback as single object.
530
+ * @returns {*} Result of callback (value or Promise).
531
+ */
532
+ load(kwargs) {
533
+ if (!this._callback) {
534
+ return null;
535
+ }
536
+ return this._callback.call(this, kwargs);
537
+ }
538
+ }
@@ -0,0 +1,102 @@
1
+ // Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { BagResolver } from '../resolver.js';
4
+ import { fromTytx } from '@genrojs/tytx';
5
+
6
+ /**
7
+ * StorageResolver - resolver that reads from browser Web Storage.
8
+ * Optional dtype converts non-null values/defaults through TYTX.
9
+ *
10
+ * // DIFF-PYTHON: This is the browser counterpart of Python's EnvResolver
11
+ * // (which reads os.environ). Not a 1:1 port — platform-specific resolver.
12
+ *
13
+ * @example
14
+ * // Read from localStorage
15
+ * const resolver = new StorageResolver({ key: 'user.theme', defaultValue: 'light' });
16
+ *
17
+ * @example
18
+ * // Read from sessionStorage
19
+ * const resolver = new StorageResolver({
20
+ * key: 'auth.token',
21
+ * storageType: 'session',
22
+ * cacheTime: 60
23
+ * });
24
+ */
25
+ export class StorageResolver extends BagResolver {
26
+ static classKwargs = {
27
+ cacheTime: -1,
28
+ readOnly: true,
29
+ asBag: false,
30
+ retryPolicy: null,
31
+ key: null,
32
+ storageType: 'local',
33
+ defaultValue: null,
34
+ dtype: null
35
+ };
36
+
37
+ static classArgs = ['key'];
38
+
39
+ static internalParams = new Set([
40
+ 'cacheTime', 'readOnly', 'asBag', 'retryPolicy'
41
+ ]);
42
+
43
+ /**
44
+ * Create a StorageResolver.
45
+ *
46
+ * @param {string|Object} keyOrKwargs - Storage key or options object.
47
+ * @param {Object} [kwargs] - Additional options if first arg is key.
48
+ */
49
+ constructor(keyOrKwargs, kwargs = {}) {
50
+ if (typeof keyOrKwargs === 'string') {
51
+ kwargs = { ...kwargs, key: keyOrKwargs };
52
+ } else {
53
+ kwargs = keyOrKwargs || {};
54
+ }
55
+ super(kwargs);
56
+ }
57
+
58
+ /**
59
+ * Get the storage backend.
60
+ *
61
+ * @returns {Storage|null} localStorage or sessionStorage, or null if unavailable.
62
+ * @private
63
+ */
64
+ _getStorage(kwargs) {
65
+ const type = kwargs.storageType || 'local';
66
+ if (type === 'session') {
67
+ return typeof sessionStorage !== 'undefined' ? sessionStorage : null;
68
+ }
69
+ return typeof localStorage !== 'undefined' ? localStorage : null;
70
+ }
71
+
72
+ /**
73
+ * Load value from Web Storage.
74
+ *
75
+ * @param {Object} kwargs - Parameters (key can be overridden via node attrs).
76
+ * @returns {*} The stored value, or defaultValue if not found.
77
+ */
78
+ load(kwargs) {
79
+ const key = kwargs.key;
80
+ const defaultValue = kwargs.defaultValue;
81
+
82
+ const storage = key ? this._getStorage(kwargs) : null;
83
+ const stored = storage ? storage.getItem(key) : null;
84
+ const value = stored !== null ? stored : defaultValue;
85
+ if (kwargs.dtype == null || value == null) return value;
86
+ const text = String(value);
87
+ // Reject partial parseInt/parseFloat conversions (e.g. "12oops").
88
+ if (kwargs.dtype === 'L' && !/^[+-]?\d+(?:_\d+)*$/.test(text.trim())) {
89
+ throw new TypeError(`Invalid integer in Web Storage: ${text}`);
90
+ }
91
+ if (kwargs.dtype === 'R' && !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(text.trim())) {
92
+ throw new TypeError(`Invalid real number in Web Storage: ${text}`);
93
+ }
94
+ const input = kwargs.dtype === 'L' ? text.replaceAll('_', '') : text;
95
+ const converted = fromTytx(`${input}::${kwargs.dtype}`);
96
+ if ((typeof converted === 'number' && Number.isNaN(converted))
97
+ || (converted instanceof Date && Number.isNaN(converted.getTime()))) {
98
+ throw new TypeError(`Invalid ${kwargs.dtype} value in Web Storage: ${text}`);
99
+ }
100
+ return converted;
101
+ }
102
+ }