@onlineapps/conn-base-hub 1.0.6 → 1.0.8

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.
@@ -23,30 +23,30 @@
23
23
  <div class='clearfix'>
24
24
 
25
25
  <div class='fl pad1y space-right2'>
26
- <span class="strong">72.54% </span>
26
+ <span class="strong">59.45% </span>
27
27
  <span class="quiet">Statements</span>
28
- <span class='fraction'>37/51</span>
28
+ <span class='fraction'>44/74</span>
29
29
  </div>
30
30
 
31
31
 
32
32
  <div class='fl pad1y space-right2'>
33
- <span class="strong">64.91% </span>
33
+ <span class="strong">43.83% </span>
34
34
  <span class="quiet">Branches</span>
35
- <span class='fraction'>37/57</span>
35
+ <span class='fraction'>32/73</span>
36
36
  </div>
37
37
 
38
38
 
39
39
  <div class='fl pad1y space-right2'>
40
- <span class="strong">71.42% </span>
40
+ <span class="strong">63.15% </span>
41
41
  <span class="quiet">Functions</span>
42
- <span class='fraction'>10/14</span>
42
+ <span class='fraction'>12/19</span>
43
43
  </div>
44
44
 
45
45
 
46
46
  <div class='fl pad1y space-right2'>
47
- <span class="strong">72.54% </span>
47
+ <span class="strong">60.27% </span>
48
48
  <span class="quiet">Lines</span>
49
- <span class='fraction'>37/51</span>
49
+ <span class='fraction'>44/73</span>
50
50
  </div>
51
51
 
52
52
 
@@ -336,7 +336,47 @@
336
336
  <a name='L271'></a><a href='#L271'>271</a>
337
337
  <a name='L272'></a><a href='#L272'>272</a>
338
338
  <a name='L273'></a><a href='#L273'>273</a>
339
- <a name='L274'></a><a href='#L274'>274</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
339
+ <a name='L274'></a><a href='#L274'>274</a>
340
+ <a name='L275'></a><a href='#L275'>275</a>
341
+ <a name='L276'></a><a href='#L276'>276</a>
342
+ <a name='L277'></a><a href='#L277'>277</a>
343
+ <a name='L278'></a><a href='#L278'>278</a>
344
+ <a name='L279'></a><a href='#L279'>279</a>
345
+ <a name='L280'></a><a href='#L280'>280</a>
346
+ <a name='L281'></a><a href='#L281'>281</a>
347
+ <a name='L282'></a><a href='#L282'>282</a>
348
+ <a name='L283'></a><a href='#L283'>283</a>
349
+ <a name='L284'></a><a href='#L284'>284</a>
350
+ <a name='L285'></a><a href='#L285'>285</a>
351
+ <a name='L286'></a><a href='#L286'>286</a>
352
+ <a name='L287'></a><a href='#L287'>287</a>
353
+ <a name='L288'></a><a href='#L288'>288</a>
354
+ <a name='L289'></a><a href='#L289'>289</a>
355
+ <a name='L290'></a><a href='#L290'>290</a>
356
+ <a name='L291'></a><a href='#L291'>291</a>
357
+ <a name='L292'></a><a href='#L292'>292</a>
358
+ <a name='L293'></a><a href='#L293'>293</a>
359
+ <a name='L294'></a><a href='#L294'>294</a>
360
+ <a name='L295'></a><a href='#L295'>295</a>
361
+ <a name='L296'></a><a href='#L296'>296</a>
362
+ <a name='L297'></a><a href='#L297'>297</a>
363
+ <a name='L298'></a><a href='#L298'>298</a>
364
+ <a name='L299'></a><a href='#L299'>299</a>
365
+ <a name='L300'></a><a href='#L300'>300</a>
366
+ <a name='L301'></a><a href='#L301'>301</a>
367
+ <a name='L302'></a><a href='#L302'>302</a>
368
+ <a name='L303'></a><a href='#L303'>303</a>
369
+ <a name='L304'></a><a href='#L304'>304</a>
370
+ <a name='L305'></a><a href='#L305'>305</a>
371
+ <a name='L306'></a><a href='#L306'>306</a>
372
+ <a name='L307'></a><a href='#L307'>307</a>
373
+ <a name='L308'></a><a href='#L308'>308</a>
374
+ <a name='L309'></a><a href='#L309'>309</a>
375
+ <a name='L310'></a><a href='#L310'>310</a>
376
+ <a name='L311'></a><a href='#L311'>311</a>
377
+ <a name='L312'></a><a href='#L312'>312</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
378
+ <span class="cline-any cline-neutral">&nbsp;</span>
379
+ <span class="cline-any cline-neutral">&nbsp;</span>
340
380
  <span class="cline-any cline-neutral">&nbsp;</span>
341
381
  <span class="cline-any cline-neutral">&nbsp;</span>
342
382
  <span class="cline-any cline-neutral">&nbsp;</span>
@@ -366,38 +406,46 @@
366
406
  <span class="cline-any cline-neutral">&nbsp;</span>
367
407
  <span class="cline-any cline-neutral">&nbsp;</span>
368
408
  <span class="cline-any cline-neutral">&nbsp;</span>
409
+ <span class="cline-any cline-neutral">&nbsp;</span>
410
+ <span class="cline-any cline-neutral">&nbsp;</span>
411
+ <span class="cline-any cline-neutral">&nbsp;</span>
369
412
  <span class="cline-any cline-yes">1x</span>
370
413
  <span class="cline-any cline-yes">1x</span>
371
414
  <span class="cline-any cline-neutral">&nbsp;</span>
372
- <span class="cline-any cline-neutral">&nbsp;</span>
373
- <span class="cline-any cline-no">&nbsp;</span>
374
- <span class="cline-any cline-no">&nbsp;</span>
415
+ <span class="cline-any cline-yes">3x</span>
375
416
  <span class="cline-any cline-neutral">&nbsp;</span>
376
417
  <span class="cline-any cline-neutral">&nbsp;</span>
377
418
  <span class="cline-any cline-neutral">&nbsp;</span>
378
419
  <span class="cline-any cline-neutral">&nbsp;</span>
379
420
  <span class="cline-any cline-neutral">&nbsp;</span>
421
+ <span class="cline-any cline-yes">7x</span>
380
422
  <span class="cline-any cline-neutral">&nbsp;</span>
381
423
  <span class="cline-any cline-neutral">&nbsp;</span>
382
424
  <span class="cline-any cline-neutral">&nbsp;</span>
425
+ <span class="cline-any cline-yes">7x</span>
426
+ <span class="cline-any cline-yes">7x</span>
427
+ <span class="cline-any cline-no">&nbsp;</span>
428
+ <span class="cline-any cline-no">&nbsp;</span>
429
+ <span class="cline-any cline-no">&nbsp;</span>
430
+ <span class="cline-any cline-no">&nbsp;</span>
383
431
  <span class="cline-any cline-neutral">&nbsp;</span>
432
+ <span class="cline-any cline-yes">1x</span>
433
+ <span class="cline-any cline-yes">1x</span>
384
434
  <span class="cline-any cline-neutral">&nbsp;</span>
385
435
  <span class="cline-any cline-neutral">&nbsp;</span>
386
436
  <span class="cline-any cline-neutral">&nbsp;</span>
387
437
  <span class="cline-any cline-neutral">&nbsp;</span>
438
+ <span class="cline-any cline-yes">7x</span>
388
439
  <span class="cline-any cline-neutral">&nbsp;</span>
389
440
  <span class="cline-any cline-neutral">&nbsp;</span>
390
441
  <span class="cline-any cline-neutral">&nbsp;</span>
391
- <span class="cline-any cline-yes">7x</span>
392
442
  <span class="cline-any cline-neutral">&nbsp;</span>
393
443
  <span class="cline-any cline-neutral">&nbsp;</span>
394
- <span class="cline-any cline-yes">7x</span>
395
444
  <span class="cline-any cline-neutral">&nbsp;</span>
396
445
  <span class="cline-any cline-neutral">&nbsp;</span>
397
446
  <span class="cline-any cline-neutral">&nbsp;</span>
398
447
  <span class="cline-any cline-neutral">&nbsp;</span>
399
448
  <span class="cline-any cline-neutral">&nbsp;</span>
400
- <span class="cline-any cline-yes">7x</span>
401
449
  <span class="cline-any cline-neutral">&nbsp;</span>
402
450
  <span class="cline-any cline-neutral">&nbsp;</span>
403
451
  <span class="cline-any cline-neutral">&nbsp;</span>
@@ -420,14 +468,23 @@
420
468
  <span class="cline-any cline-neutral">&nbsp;</span>
421
469
  <span class="cline-any cline-neutral">&nbsp;</span>
422
470
  <span class="cline-any cline-neutral">&nbsp;</span>
471
+ <span class="cline-any cline-yes">3x</span>
423
472
  <span class="cline-any cline-neutral">&nbsp;</span>
424
473
  <span class="cline-any cline-neutral">&nbsp;</span>
474
+ <span class="cline-any cline-yes">3x</span>
475
+ <span class="cline-any cline-yes">3x</span>
425
476
  <span class="cline-any cline-neutral">&nbsp;</span>
426
477
  <span class="cline-any cline-neutral">&nbsp;</span>
427
478
  <span class="cline-any cline-neutral">&nbsp;</span>
428
479
  <span class="cline-any cline-neutral">&nbsp;</span>
429
480
  <span class="cline-any cline-yes">3x</span>
430
481
  <span class="cline-any cline-neutral">&nbsp;</span>
482
+ <span class="cline-any cline-no">&nbsp;</span>
483
+ <span class="cline-any cline-neutral">&nbsp;</span>
484
+ <span class="cline-any cline-neutral">&nbsp;</span>
485
+ <span class="cline-any cline-no">&nbsp;</span>
486
+ <span class="cline-any cline-neutral">&nbsp;</span>
487
+ <span class="cline-any cline-neutral">&nbsp;</span>
431
488
  <span class="cline-any cline-neutral">&nbsp;</span>
432
489
  <span class="cline-any cline-yes">3x</span>
433
490
  <span class="cline-any cline-yes">3x</span>
@@ -596,8 +653,27 @@
596
653
  <span class="cline-any cline-neutral">&nbsp;</span>
597
654
  <span class="cline-any cline-neutral">&nbsp;</span>
598
655
  <span class="cline-any cline-neutral">&nbsp;</span>
656
+ <span class="cline-any cline-neutral">&nbsp;</span>
657
+ <span class="cline-any cline-no">&nbsp;</span>
658
+ <span class="cline-any cline-neutral">&nbsp;</span>
659
+ <span class="cline-any cline-no">&nbsp;</span>
599
660
  <span class="cline-any cline-no">&nbsp;</span>
600
661
  <span class="cline-any cline-neutral">&nbsp;</span>
662
+ <span class="cline-any cline-neutral">&nbsp;</span>
663
+ <span class="cline-any cline-neutral">&nbsp;</span>
664
+ <span class="cline-any cline-no">&nbsp;</span>
665
+ <span class="cline-any cline-no">&nbsp;</span>
666
+ <span class="cline-any cline-no">&nbsp;</span>
667
+ <span class="cline-any cline-no">&nbsp;</span>
668
+ <span class="cline-any cline-no">&nbsp;</span>
669
+ <span class="cline-any cline-neutral">&nbsp;</span>
670
+ <span class="cline-any cline-no">&nbsp;</span>
671
+ <span class="cline-any cline-neutral">&nbsp;</span>
672
+ <span class="cline-any cline-neutral">&nbsp;</span>
673
+ <span class="cline-any cline-neutral">&nbsp;</span>
674
+ <span class="cline-any cline-no">&nbsp;</span>
675
+ <span class="cline-any cline-no">&nbsp;</span>
676
+ <span class="cline-any cline-no">&nbsp;</span>
601
677
  <span class="cline-any cline-no">&nbsp;</span>
602
678
  <span class="cline-any cline-no">&nbsp;</span>
603
679
  <span class="cline-any cline-no">&nbsp;</span>
@@ -621,60 +697,61 @@
621
697
  * - Centralized logging
622
698
  */
623
699
  &nbsp;
624
- require('dotenv').config();
700
+ // NOTE: dotenv.config() removed to avoid side effects in library code
701
+ // Consumers are responsible for loading their own .env
702
+ const runtimeCfg = require('./config');
625
703
  &nbsp;
626
704
  // Re-export all connectors
627
705
  module.exports = {
628
706
  // MQ Client for RabbitMQ communication
629
- MQClient: require('@onlineapps/connector-mq-client'),
707
+ MQClient: require('@onlineapps/conn-infra-mq'),
630
708
  &nbsp;
631
709
  // Service Registry client for registration and event consumption
632
- ...require('@onlineapps/connector-registry-client'),
710
+ ...require('@onlineapps/conn-orch-registry'),
633
711
  &nbsp;
634
712
  // Cookbook validation and processing
635
- ...require('@onlineapps/connector-cookbook'),
713
+ ...require('@onlineapps/conn-orch-cookbook'),
636
714
  &nbsp;
637
715
  // Storage connector for MinIO with fingerprinting
638
- StorageConnector: require('@onlineapps/connector-storage'),
716
+ StorageConnector: require('@onlineapps/conn-base-storage'),
717
+ &nbsp;
718
+ // Monitoring connector
719
+ MonitoringConnector: require('@onlineapps/conn-base-monitoring'),
639
720
  &nbsp;
640
- // Logger (if available)
721
+ // Legacy createLogger for backward compatibility
641
722
  createLogger: (() =&gt; {
642
- try {
643
- return require('@onlineapps/connector-logger');
644
- } catch (e) {
645
- // Logger not available, return console as fallback
646
- <span class="cstat-no" title="statement not covered" > return <span class="fstat-no" title="function not covered" >fu</span>nction(config) {</span>
647
- <span class="cstat-no" title="statement not covered" > return {</span>
648
- info: console.log,
649
- error: console.error,
650
- warn: console.warn,
651
- debug: console.debug,
652
- api: { info: console.log, error: console.error },
653
- mq: { info: console.log, error: console.error },
654
- workflow: { info: console.log, error: console.error },
655
- registry: { info: console.log, error: console.error },
656
- close: <span class="fstat-no" title="function not covered" >as</span>ync () =&gt; {}
657
- };
658
- };
659
- }
723
+ const monitoring = require('@onlineapps/conn-base-monitoring');
724
+ return async function(config) {
725
+ // Initialize monitoring and return logger-like interface (API returned by init)
726
+ return monitoring.init(config);
727
+ };
660
728
  })(),
661
729
  &nbsp;
662
730
  // Convenience factory for creating a fully configured microservice
663
731
  createMicroservice: function(config) {
664
732
  const { ServiceRegistryClient, MQClient, StorageConnector, createLogger } = module.exports;
665
733
  &nbsp;
666
- // Create logger instance
667
- const logger = createLogger({
668
- serviceName: config.serviceName,
669
- version: config.version,
670
- ...config.logger
671
- });
734
+ // Logger instance is created lazily in init() to avoid side effects during construction.
735
+ // This also prevents unhandled promise rejections when env/config is incomplete in tests/dev.
736
+ let loggerApi = null;
737
+ const logger = {
738
+ info: <span class="fstat-no" title="function not covered" >(m</span>essage, data) =&gt; (<span class="cstat-no" title="statement not covered" >loggerApi ? loggerApi.info(message, data) : console.log(message, data))</span>,
739
+ warn: <span class="fstat-no" title="function not covered" >(m</span>essage, data) =&gt; (<span class="cstat-no" title="statement not covered" >loggerApi ? loggerApi.warn(message, data) : console.warn(message, data))</span>,
740
+ error: <span class="fstat-no" title="function not covered" >(m</span>essage, data) =&gt; (<span class="cstat-no" title="statement not covered" >loggerApi ? loggerApi.error(message, data) : console.error(message, data))</span>,
741
+ debug: <span class="fstat-no" title="function not covered" >(m</span>essage, data) =&gt; (<span class="cstat-no" title="statement not covered" >loggerApi ? loggerApi.debug(message, data) : console.debug(message, data))</span>,
742
+ close: async () =&gt; {
743
+ <span class="missing-if-branch" title="else path not taken" >E</span>if (loggerApi &amp;&amp; typeof loggerApi.shutdown === 'function') {
744
+ await loggerApi.shutdown();
745
+ }
746
+ }
747
+ };
672
748
  &nbsp;
673
749
  return {
674
750
  logger,
675
751
  // Initialize Registry Client
676
752
  registry: config.registry ? new ServiceRegistryClient({
677
- amqpUrl: config.amqpUrl || process.env.RABBITMQ_URL,
753
+ // Priority: explicit registry config top-level config → ENV
754
+ amqpUrl: config.registry?.amqpUrl || config.registry?.url || <span class="branch-2 cbranch-no" title="branch not covered" >config.amqpUrl </span>|| <span class="branch-3 cbranch-no" title="branch not covered" >runtimeCfg.get('rabbitmqUrl'),</span>
678
755
  serviceName: config.serviceName,
679
756
  version: config.version || <span class="branch-1 cbranch-no" title="branch not covered" >'1.0.0',</span>
680
757
  ...config.registry
@@ -682,24 +759,42 @@ module.exports = {
682
759
  &nbsp;
683
760
  // Initialize MQ Client
684
761
  mq: config.mq ? new MQClient({
685
- type: 'rabbitmq',
686
- host: config.amqpUrl || process.env.RABBITMQ_URL,
687
- queue: `${config.serviceName}_queue`,
762
+ type: runtimeCfg.get('mqType'),
763
+ // Priority: explicit mq config top-level config → ENV
764
+ host: config.mq?.host || config.mq?.url || <span class="branch-2 cbranch-no" title="branch not covered" >config.amqpUrl </span>|| <span class="branch-3 cbranch-no" title="branch not covered" >runtimeCfg.get('rabbitmqUrl'),</span>
765
+ queue: `${config.serviceName}${runtimeCfg.get('mqDefaultServiceQueueSuffix')}`,
688
766
  ...config.mq
689
767
  }) : null,
690
768
  &nbsp;
691
- // Initialize Storage
769
+ // Initialize Storage (all MinIO config MUST come from env or config - no fallbacks)
692
770
  storage: config.storage ? new StorageConnector({
693
- endPoint: process.env.MINIO_ENDPOINT || 'localhost',
694
- port: parseInt(process.env.MINIO_PORT || 9000),
695
- accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
696
- secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
771
+ // Priority: explicit storage config → ENV (resolved via runtimeCfg)
772
+ endPoint: runtimeCfg.get('minioEndpoint', config.storage?.endPoint),
773
+ port: runtimeCfg.get('minioPort', config.storage?.port),
774
+ useSSL: runtimeCfg.get('minioUseSSL', config.storage?.useSSL),
775
+ accessKey: runtimeCfg.get('minioAccessKey', config.storage?.accessKey),
776
+ secretKey: runtimeCfg.get('minioSecretKey', config.storage?.secretKey),
697
777
  ...config.storage
698
778
  }) : null,
699
779
  &nbsp;
700
780
  // Initialize all components
701
781
  async init() {
702
782
  const results = {};
783
+ &nbsp;
784
+ // Initialize Logger (Monitoring) - optional, keep microservice usable even if monitoring can't start
785
+ try {
786
+ loggerApi = await createLogger({
787
+ serviceName: config.serviceName,
788
+ version: config.version,
789
+ ...config.logger
790
+ });
791
+ results.logger = true;
792
+ } catch (err) {
793
+ <span class="cstat-no" title="statement not covered" > console.warn(</span>
794
+ `[conn-base-hub] Monitoring init failed - Continuing with console logger. Fix: set RABBITMQ_URL or pass logger.rabbitmq.url. (${err.message})`
795
+ );
796
+ <span class="cstat-no" title="statement not covered" > results.logger = false;</span>
797
+ }
703
798
  &nbsp;
704
799
  // Initialize Registry
705
800
  <span class="missing-if-branch" title="else path not taken" >E</span>if (this.registry) {
@@ -742,7 +837,7 @@ module.exports = {
742
837
  promises.push(this.mq.disconnect());
743
838
  }
744
839
  &nbsp;
745
- <span class="missing-if-branch" title="else path not taken" >E</span>if (this.logger &amp;&amp; this.logger.close) {
840
+ <span class="missing-if-branch" title="else path not taken" >E</span>if (this.logger &amp;&amp; typeof this.logger.close === 'function') {
746
841
  promises.push(this.logger.close());
747
842
  }
748
843
  &nbsp;
@@ -787,7 +882,7 @@ module.exports = {
787
882
  if (step.queue) {
788
883
  return step.queue;
789
884
  }
790
- return `${step.service}.queue`;
885
+ return `${step.service}${runtimeCfg.get('workflowDefaultServiceQueueSuffix')}`;
791
886
  },
792
887
  &nbsp;
793
888
  /**
@@ -865,18 +960,37 @@ module.exports = {
865
960
  &nbsp;
866
961
  /**
867
962
  * Determine next queue based on workflow state
963
+ * V2.1: steps is an array, current_step is step_id (string)
868
964
  * @param {Object} message - Workflow message
869
965
  * @returns {string} - Next queue name
870
966
  */
871
967
  <span class="fstat-no" title="function not covered" > ge</span>tNextQueueFromMessage(message) {
872
968
  const { current_step, cookbook } = <span class="cstat-no" title="statement not covered" >message;</span>
873
969
  &nbsp;
874
- <span class="cstat-no" title="statement not covered" > if (cookbook &amp;&amp; current_step &lt; cookbook.steps.length) {</span>
875
- const nextStep = <span class="cstat-no" title="statement not covered" >cookbook.steps[current_step];</span>
876
- <span class="cstat-no" title="statement not covered" > return `${nextStep.service}.queue`;</span>
970
+ <span class="cstat-no" title="statement not covered" > if (!cookbook?.steps) {</span>
971
+ <span class="cstat-no" title="statement not covered" > return runtimeCfg.get('workflowCompletedQueue');</span>
972
+ }
973
+
974
+ // V2.1: steps is array, find current step by step_id
975
+ <span class="cstat-no" title="statement not covered" > if (Array.isArray(cookbook.steps)) {</span>
976
+ const currentIndex = <span class="cstat-no" title="statement not covered" >cookbook.steps.findIndex(<span class="fstat-no" title="function not covered" >s </span>=&gt; <span class="cstat-no" title="statement not covered" >s.step_id === current_step)</span>;</span>
977
+ <span class="cstat-no" title="statement not covered" > if (currentIndex &gt;= 0 &amp;&amp; currentIndex &lt; cookbook.steps.length - 1) {</span>
978
+ const nextStep = <span class="cstat-no" title="statement not covered" >cookbook.steps[currentIndex + 1];</span>
979
+ <span class="cstat-no" title="statement not covered" > return `${nextStep.service}${runtimeCfg.get('workflowDefaultServiceQueueSuffix')}`;</span>
980
+ }
981
+ <span class="cstat-no" title="statement not covered" > return runtimeCfg.get('workflowCompletedQueue');</span>
982
+ }
983
+
984
+ // V2.0 (deprecated): steps is object, current_step is step_id
985
+ const stepIds = <span class="cstat-no" title="statement not covered" >Object.keys(cookbook.steps);</span>
986
+ const currentIndex = <span class="cstat-no" title="statement not covered" >stepIds.indexOf(current_step);</span>
987
+ <span class="cstat-no" title="statement not covered" > if (currentIndex &gt;= 0 &amp;&amp; currentIndex &lt; stepIds.length - 1) {</span>
988
+ const nextStepId = <span class="cstat-no" title="statement not covered" >stepIds[currentIndex + 1];</span>
989
+ const nextStep = <span class="cstat-no" title="statement not covered" >cookbook.steps[nextStepId];</span>
990
+ <span class="cstat-no" title="statement not covered" > return `${nextStep.service}${runtimeCfg.get('workflowDefaultServiceQueueSuffix')}`;</span>
877
991
  }
878
992
  &nbsp;
879
- <span class="cstat-no" title="statement not covered" > return 'workflow.completed';</span>
993
+ <span class="cstat-no" title="statement not covered" > return runtimeCfg.get('workflowCompletedQueue');</span>
880
994
  }
881
995
  }
882
996
  };
@@ -889,7 +1003,7 @@ module.exports.ConnectorCore = module.exports;</pre></td></tr></table></pre>
889
1003
  <div class='footer quiet pad2 space-top1 center small'>
890
1004
  Code coverage generated by
891
1005
  <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
892
- at 2025-09-16T20:24:35.352Z
1006
+ at 2025-12-22T10:23:18.540Z
893
1007
  </div>
894
1008
  <script src="prettify.js"></script>
895
1009
  <script>