@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/LICENSE +176 -0
- package/NOTICE +7 -0
- package/README.md +191 -0
- package/docs/python-alignment.md +156 -0
- package/package.json +51 -0
- package/src/bag-node-container.js +459 -0
- package/src/bag-node.js +665 -0
- package/src/bag.js +2497 -0
- package/src/browser.js +7 -0
- package/src/devtools/html-repr.css +69 -0
- package/src/devtools/html-repr.js +78 -0
- package/src/devtools/index.js +1 -0
- package/src/index.js +9 -0
- package/src/resolver-wire.js +128 -0
- package/src/resolver.js +538 -0
- package/src/resolvers/StorageResolver.js +102 -0
- package/src/resolvers/UrlResolver.js +209 -0
- package/src/resolvers/UuidResolver.js +37 -0
- package/src/resolvers/index.js +9 -0
- package/src/subtype-wire.js +61 -0
package/src/resolver.js
ADDED
|
@@ -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
|
+
}
|