]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(performance-storage): make MikroORM integration work (#1701)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 6 Mar 2026 14:48:07 +0000 (15:48 +0100)
committerGitHub <noreply@github.com>
Fri, 6 Mar 2026 14:48:07 +0000 (15:48 +0100)
* fix(performance-storage): make MikroORM integration work

Fix MikroORM performance storage integration by addressing:
- Fork EntityManager per operation (prevents identity map accumulation)
- Pass entity class to em.upsert() (MikroORM v6 API)
- Add schema generation in open() (table creation)
- Fix entity: add tableName, JSON type for statisticsData
- Add connection validation before operations
- Use direct entity class import instead of fragile glob paths
- Ensure SQLite directory exists before ORM init
- Fix type-only import to value import (verbatimModuleSyntax)

Closes #42

* fix(performance-storage): address PR review feedback

- Swap setPerformanceStatistics/checkDBConnection call order to match
  MongoDBStorage pattern (in-memory stats updated first so UI stays
  current even when DB is down)
- Close ORM connection on schema updateSchema() failure to prevent
  resource leak when init succeeds but schema generation fails

* test(performance-storage): add unit tests for MikroOrmStorage

Add 18 tests covering constructor, close, storePerformanceStatistics,
and error handling. Uses mock ORM to avoid sqlite3 native binding
dependency in CI (--ignore-scripts). Explicit decorator types on
PerformanceRecord for tsx/esbuild compatibility.

* refactor(tests): harmonize MikroOrmStorage tests with project conventions

Use TestableMikroOrmStorage subclass with Reflect.get/set (matches
UIHttpServer pattern). Replace toBeGreaterThan with exact toBe counts.
Use .mock.calls.length consistently. Remove constructor tests that
tested implementation details.

* refactor(performance-storage): migrate from @mikro-orm/sqlite to @mikro-orm/better-sqlite

The sqlite3 native bindings used by @mikro-orm/sqlite are broken on
Node v24 (no prebuilt binaries for ABI v137, node-gyp@8.4.1 cannot
compile with Python 3.13). Replace with better-sqlite3 which provides
an identical MikroORM API surface while using a more portable native
binding.

* test(performance-storage): add integration tests for MikroOrmStorage

Add 6 integration tests that exercise real SQLite database operations
(open, persist, upsert, JSON serialization, close/reopen, multi-record).
Tests detect better-sqlite3 availability at module load and skip
gracefully via { skip: SKIP_SQLITE } when the native binding is not
built (e.g. CI with --ignore-scripts).

* fix(performance-storage): use caret version for @mikro-orm/better-sqlite and fix test JSDoc order

Use ^6.6.8 (caret) consistently across all @mikro-orm/* packages to
prevent version skew when updating dependencies. MikroORM requires all
packages be on the same version.

Also move @file JSDoc block before all imports in the test file to
match project conventions.

* refactor(performance-storage): extract ensureDBDirectory into Storage base class and audit fixes

Move duplicated directory creation logic from JsonFileStorage and
MikroOrmStorage into a shared protected method in the abstract Storage
base class. Place the call inside the existing switch case instead of
a separate if block.

Also use ^6.6.8 (caret) consistently for @mikro-orm/better-sqlite and
fix test JSDoc ordering to match project conventions.

* refactor(performance-storage): factorize serialization into base class and fix MongoDBStorage

- Extract serializePerformanceStatistics() into Storage base class (DRY)
- Update MikroOrmStorage to use shared serialization method
- Fix MongoDBStorage: remove unnecessary optional client, use shared
  serialization for CircularBuffer, remove eslint-disable comments,
  rename connected to opened for lifecycle clarity
- Add 20 unit tests for MongoDBStorage mirroring MikroOrmStorage pattern

* refactor(performance-storage): harmonize storage implementations

- Remove 2 eslint-disable comments in Storage.handleDBStorageError by
  using nullish coalescing and proper string typing
- Simplify handleDBStorageError params default spread
- Refactor JsonFileStorage.checkPerformanceRecordsFile to return fd,
  eliminating non-null assertion and eslint-disable
- Remove eslint-disable in StorageFactory by casting enum to string
- Fix StorageFactory.getStorage return type: Storage (not | undefined)
- Update None.ts copyright year to 2025 for consistency

* refactor(tests): extract buildTestStatistics into shared StorageTestHelpers

* fix(lint): remove unnecessary optional chain after StorageFactory return type fix

14 files changed:
mikro-orm.config-template.ts
package.json
pnpm-lock.yaml
src/charging-station/Bootstrap.ts
src/performance/storage/JsonFileStorage.ts
src/performance/storage/MikroOrmStorage.ts
src/performance/storage/MongoDBStorage.ts
src/performance/storage/None.ts
src/performance/storage/Storage.ts
src/performance/storage/StorageFactory.ts
src/types/orm/entities/PerformanceRecord.ts
tests/performance/storage/MikroOrmStorage.test.ts [new file with mode: 0644]
tests/performance/storage/MongoDBStorage.test.ts [new file with mode: 0644]
tests/performance/storage/StorageTestHelpers.ts [new file with mode: 0644]

index d2796255b90658960a17afbf5952ad2940154538..d96aac08cd7c9dda46ef7eed1221bbc39d527254 100644 (file)
@@ -1,10 +1,10 @@
-import { defineConfig } from '@mikro-orm/sqlite'
+import { defineConfig } from '@mikro-orm/better-sqlite'
 
+import { PerformanceRecord } from './src/types/orm/entities/PerformanceRecord.js'
 import { Constants } from './src/utils/index.js'
 
 export default defineConfig({
   dbName: `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_DB_NAME}.db`,
   debug: true,
-  entities: ['./dist/types/orm/entities/*.js'],
-  entitiesTs: ['./src/types/orm/entities/*.ts'],
+  entities: [PerformanceRecord],
 })
index f760d7cf893b108728d17049c6669403f53878c8..fad6e6c3499685be38c58cadd18e1cb9c9df658d 100644 (file)
     "sea": "pnpm exec rimraf ./dist/evse-simulator ./dist/evse-simulator.blob && node --experimental-sea-config sea-config.json && pnpm dlx ncp $(volta which node || n which lts || nvm which node || command -v node) ./dist/evse-simulator && pnpm dlx postject ./dist/evse-simulator NODE_SEA_BLOB ./dist/evse-simulator.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 && pnpm exec rimraf ./dist/evse-simulator.blob"
   },
   "dependencies": {
+    "@mikro-orm/better-sqlite": "^6.6.9",
     "@mikro-orm/core": "^6.6.9",
     "@mikro-orm/mariadb": "^6.6.9",
     "@mikro-orm/reflection": "^6.6.9",
-    "@mikro-orm/sqlite": "^6.6.9",
     "ajv": "^8.18.0",
     "ajv-formats": "^3.0.1",
     "basic-ftp": "^5.2.0",
     "tsx": "^4.21.0",
     "typescript": "~5.9.3",
     "vue-eslint-parser": "^10.4.0"
+  },
+  "pnpm": {
+    "onlyBuiltDependencies": [
+      "better-sqlite3"
+    ]
   }
 }
index 60dc9ec8cd1c23c7b180ebd3bceba98abe350747..2fe38c20dd3d2a7ca60c86b2f5b2b9e1bde91200 100644 (file)
@@ -21,18 +21,18 @@ importers:
 
   .:
     dependencies:
+      '@mikro-orm/better-sqlite':
+        specifier: ^6.6.9
+        version: 6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)
       '@mikro-orm/core':
         specifier: ^6.6.9
         version: 6.6.9
       '@mikro-orm/mariadb':
         specifier: ^6.6.9
-        version: 6.6.9(@mikro-orm/core@6.6.9)
+        version: 6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)
       '@mikro-orm/reflection':
         specifier: ^6.6.9
         version: 6.6.9(@mikro-orm/core@6.6.9)
-      '@mikro-orm/sqlite':
-        specifier: ^6.6.9
-        version: 6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)
       ajv:
         specifier: ^8.18.0
         version: 8.18.0
@@ -90,7 +90,7 @@ importers:
         version: 9.39.3
       '@mikro-orm/cli':
         specifier: ^6.6.9
-        version: 6.6.9(mariadb@3.4.5)
+        version: 6.6.9(better-sqlite3@11.10.0)(mariadb@3.4.5)
       '@std/expect':
         specifier: npm:@jsr/std__expect@^1.0.18
         version: '@jsr/std__expect@1.0.18'
@@ -964,9 +964,6 @@ packages:
       '@noble/hashes':
         optional: true
 
-  '@gar/promisify@1.1.3':
-    resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
-
   '@humanfs/core@0.19.1':
     resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
     engines: {node: '>=18.18.0'}
@@ -1031,6 +1028,12 @@ packages:
   '@keyv/serialize@1.1.1':
     resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
 
+  '@mikro-orm/better-sqlite@6.6.9':
+    resolution: {integrity: sha512-vd3VF1n6FWbkR2dbDH1CR+rdM3MLgWUPMkL9nTLM3hOSLzhFQFbuVGxfSTjDMPvjZoJN6eSXMVI8mAdWjFLu2g==}
+    engines: {node: '>= 18.12.0'}
+    peerDependencies:
+      '@mikro-orm/core': ^6.0.0
+
   '@mikro-orm/cli@6.6.9':
     resolution: {integrity: sha512-CjYTVoVds82hTsvahq5pZedW3Wf3owt6qCbH8En3DtZCH64iSoG1XQ9LSAGJbyer+YIodeHwBv1aRALc6VoTUA==}
     engines: {node: '>= 18.12.0'}
@@ -1068,12 +1071,6 @@ packages:
     peerDependencies:
       '@mikro-orm/core': ^6.0.0
 
-  '@mikro-orm/sqlite@6.6.9':
-    resolution: {integrity: sha512-kl9rHS0DH7hWg7Qz6rNzcmsHQmzmmxsl1u3luwPuGTvez7dTM0f3Z+36NxFyQA1gcyHagyZO4pxIn0ZyiU+E4Q==}
-    engines: {node: '>= 18.12.0'}
-    peerDependencies:
-      '@mikro-orm/core': ^6.0.0
-
   '@mongodb-js/saslprep@1.4.6':
     resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==}
 
@@ -1093,14 +1090,6 @@ packages:
     resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
     engines: {node: '>= 8'}
 
-  '@npmcli/fs@1.1.1':
-    resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==}
-
-  '@npmcli/move-file@1.1.2':
-    resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==}
-    engines: {node: '>=10'}
-    deprecated: This functionality has been moved to @npmcli/fs
-
   '@one-ini/wasm@0.1.1':
     resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
 
@@ -1293,10 +1282,6 @@ packages:
     resolution: {integrity: sha512-YSfsswOqWfd+M4bXIhT3hwtAb+IV8+ODwIxwdFR/7jTAPZP1wMVnSlpKnXHAN64HFOiP+Tm3HmKusEZ0+09A0w==}
     engines: {yarn: '>= 1.3.2'}
 
-  '@tootallnate/once@1.1.2':
-    resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
-    engines: {node: '>= 6'}
-
   '@ts-morph/common@0.28.1':
     resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==}
 
@@ -1567,9 +1552,6 @@ packages:
     resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
     hasBin: true
 
-  abbrev@1.1.1:
-    resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
-
   abbrev@2.0.0:
     resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -1604,18 +1586,10 @@ packages:
     engines: {node: '>=0.4.0'}
     hasBin: true
 
-  agent-base@6.0.2:
-    resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
-    engines: {node: '>= 6.0.0'}
-
   agent-base@7.1.4:
     resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
     engines: {node: '>= 14'}
 
-  agentkeepalive@4.6.0:
-    resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
-    engines: {node: '>= 8.0.0'}
-
   aggregate-error@3.1.0:
     resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
     engines: {node: '>=8'}
@@ -1696,18 +1670,10 @@ packages:
     resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
     engines: {node: '>= 8'}
 
-  aproba@2.1.0:
-    resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
-
   are-docs-informative@0.0.2:
     resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
     engines: {node: '>=14'}
 
-  are-we-there-yet@3.0.1:
-    resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
-    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
-    deprecated: This package is no longer supported.
-
   arg@4.1.3:
     resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
 
@@ -1851,6 +1817,9 @@ packages:
   bcrypt-pbkdf@1.0.2:
     resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
 
+  better-sqlite3@11.10.0:
+    resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
+
   bidi-js@1.0.3:
     resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
 
@@ -1969,10 +1938,6 @@ packages:
     resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==}
     engines: {node: '>=20'}
 
-  cacache@15.3.0:
-    resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==}
-    engines: {node: '>= 10'}
-
   cacheable-lookup@7.0.0:
     resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
     engines: {node: '>=14.16'}
@@ -2057,10 +2022,6 @@ packages:
   chownr@1.1.4:
     resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
 
-  chownr@2.0.0:
-    resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
-    engines: {node: '>=10'}
-
   chownr@3.0.0:
     resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
     engines: {node: '>=18'}
@@ -2238,9 +2199,6 @@ packages:
   console-browserify@1.2.0:
     resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==}
 
-  console-control-strings@1.1.0:
-    resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
-
   constants-browserify@1.0.0:
     resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
 
@@ -2551,9 +2509,6 @@ packages:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
 
-  delegates@1.0.0:
-    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
-
   denque@2.1.0:
     resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
     engines: {node: '>=0.10'}
@@ -2700,9 +2655,6 @@ packages:
     resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
     engines: {node: '>=18'}
 
-  err-code@2.0.3:
-    resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
-
   error-ex@1.3.4:
     resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
 
@@ -3132,10 +3084,6 @@ packages:
     resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
     engines: {node: '>=14.14'}
 
-  fs-minipass@2.1.0:
-    resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
-    engines: {node: '>= 8'}
-
   fs.realpath@1.0.0:
     resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
 
@@ -3154,11 +3102,6 @@ packages:
   functions-have-names@1.2.3:
     resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
 
-  gauge@4.0.4:
-    resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
-    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
-    deprecated: This package is no longer supported.
-
   generate-function@2.3.1:
     resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
 
@@ -3411,10 +3354,6 @@ packages:
   http-parser-js@0.5.10:
     resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==}
 
-  http-proxy-agent@4.0.1:
-    resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==}
-    engines: {node: '>= 6'}
-
   http-proxy-agent@7.0.2:
     resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
     engines: {node: '>= 14'}
@@ -3433,10 +3372,6 @@ packages:
   https-browserify@1.0.0:
     resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
 
-  https-proxy-agent@5.0.1:
-    resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
-    engines: {node: '>= 6'}
-
   https-proxy-agent@7.0.6:
     resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
     engines: {node: '>= 14'}
@@ -3445,9 +3380,6 @@ packages:
     resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
     engines: {node: '>=8.12.0'}
 
-  humanize-ms@1.2.1:
-    resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
-
   husky@9.1.7:
     resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
     engines: {node: '>=18'}
@@ -3500,9 +3432,6 @@ packages:
     resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
     engines: {node: '>=8'}
 
-  infer-owner@1.0.4:
-    resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==}
-
   inflight@1.0.6:
     resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
     deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -3666,9 +3595,6 @@ packages:
     resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
     engines: {node: '>=8'}
 
-  is-lambda@1.0.1:
-    resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==}
-
   is-map@2.0.3:
     resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
     engines: {node: '>= 0.4'}
@@ -4060,10 +3986,6 @@ packages:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
-  lru-cache@6.0.0:
-    resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
-    engines: {node: '>=10'}
-
   macos-release@2.5.1:
     resolution: {integrity: sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==}
     engines: {node: '>=6'}
@@ -4095,10 +4017,6 @@ packages:
   make-error@1.3.6:
     resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
 
-  make-fetch-happen@9.1.0:
-    resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
-    engines: {node: '>= 10'}
-
   manage-path@2.0.0:
     resolution: {integrity: sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==}
 
@@ -4209,38 +4127,10 @@ packages:
   minimist@1.2.8:
     resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
 
-  minipass-collect@1.0.2:
-    resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
-    engines: {node: '>= 8'}
-
-  minipass-fetch@1.4.1:
-    resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==}
-    engines: {node: '>=8'}
-
-  minipass-flush@1.0.5:
-    resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==}
-    engines: {node: '>= 8'}
-
-  minipass-pipeline@1.2.4:
-    resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==}
-    engines: {node: '>=8'}
-
-  minipass-sized@1.0.3:
-    resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==}
-    engines: {node: '>=8'}
-
-  minipass@3.3.6:
-    resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
-    engines: {node: '>=8'}
-
   minipass@7.1.3:
     resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
     engines: {node: '>=16 || 14 >=14.17'}
 
-  minizlib@2.1.2:
-    resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
-    engines: {node: '>= 8'}
-
   minizlib@3.1.0:
     resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
     engines: {node: '>= 18'}
@@ -4398,10 +4288,6 @@ packages:
   ndarray@1.0.19:
     resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==}
 
-  negotiator@0.6.4:
-    resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
-    engines: {node: '>= 0.6'}
-
   neostandard@0.13.0:
     resolution: {integrity: sha512-R3iglFr+Dla/8qFBqsMxBvcYBOgP6rAGw7uRHKMpM3bUP0wLDRzUstxtEI9RfEwn7xszE/UUnh8H090Ru4Z52A==}
     engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -4419,9 +4305,6 @@ packages:
     resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
     engines: {node: '>=10'}
 
-  node-addon-api@7.1.1:
-    resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
-
   node-exports-info@1.6.0:
     resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
     engines: {node: '>= 0.4'}
@@ -4439,19 +4322,9 @@ packages:
     resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
     hasBin: true
 
-  node-gyp@8.4.1:
-    resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==}
-    engines: {node: '>= 10.12.0'}
-    hasBin: true
-
   node-releases@2.0.36:
     resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
 
-  nopt@5.0.0:
-    resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
-    engines: {node: '>=6'}
-    hasBin: true
-
   nopt@7.2.1:
     resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -4473,11 +4346,6 @@ packages:
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     engines: {node: '>=8'}
 
-  npmlog@6.0.2:
-    resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
-    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
-    deprecated: This package is no longer supported.
-
   nth-check@2.1.1:
     resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
 
@@ -4817,18 +4685,6 @@ packages:
     resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
     engines: {node: '>=0.4.0'}
 
-  promise-inflight@1.0.1:
-    resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
-    peerDependencies:
-      bluebird: '*'
-    peerDependenciesMeta:
-      bluebird:
-        optional: true
-
-  promise-retry@2.0.1:
-    resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
-    engines: {node: '>=10'}
-
   prop-types@15.8.1:
     resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
 
@@ -5020,10 +4876,6 @@ packages:
   retimer@3.0.0:
     resolution: {integrity: sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==}
 
-  retry@0.12.0:
-    resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
-    engines: {node: '>= 4'}
-
   reusify@1.1.0:
     resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -5227,10 +5079,6 @@ packages:
     resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
     engines: {node: '>= 18'}
 
-  socks-proxy-agent@6.2.1:
-    resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==}
-    engines: {node: '>= 10'}
-
   socks@2.8.7:
     resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
     engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
@@ -5273,9 +5121,6 @@ packages:
     resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
     engines: {node: '>= 10.x'}
 
-  sqlite3@5.1.7:
-    resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==}
-
   sqlstring-sqlite@0.1.1:
     resolution: {integrity: sha512-9CAYUJ0lEUPYJrswqiqdINNSfq3jqWo/bFJ7tufdoNeSK0Fy+d1kFTxjqO9PIqza0Kri+ZtYMfPVf1aZaFOvrQ==}
     engines: {node: '>= 0.6'}
@@ -5289,10 +5134,6 @@ packages:
     engines: {node: '>=0.10.0'}
     hasBin: true
 
-  ssri@8.0.1:
-    resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==}
-    engines: {node: '>= 8'}
-
   stack-trace@0.0.10:
     resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
 
@@ -5733,12 +5574,6 @@ packages:
   uniq@1.0.1:
     resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
 
-  unique-filename@1.1.1:
-    resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==}
-
-  unique-slug@2.0.2:
-    resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==}
-
   unique-string@2.0.0:
     resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
     engines: {node: '>=8'}
@@ -6000,9 +5835,6 @@ packages:
     engines: {node: '>=8'}
     hasBin: true
 
-  wide-align@1.1.5:
-    resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
-
   widest-line@3.1.0:
     resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
     engines: {node: '>=8'}
@@ -6096,9 +5928,6 @@ packages:
   yallist@3.1.1:
     resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
 
-  yallist@4.0.0:
-    resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
-
   yallist@5.0.0:
     resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
     engines: {node: '>=18'}
@@ -6990,9 +6819,6 @@ snapshots:
 
   '@exodus/bytes@1.15.0': {}
 
-  '@gar/promisify@1.1.3':
-    optional: true
-
   '@humanfs/core@0.19.1': {}
 
   '@humanfs/node@0.16.7':
@@ -7067,11 +6893,29 @@ snapshots:
 
   '@keyv/serialize@1.1.1': {}
 
-  '@mikro-orm/cli@6.6.9(mariadb@3.4.5)':
+  '@mikro-orm/better-sqlite@6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)':
+    dependencies:
+      '@mikro-orm/core': 6.6.9
+      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)(mariadb@3.4.5)
+      better-sqlite3: 11.10.0
+      fs-extra: 11.3.3
+      sqlstring-sqlite: 0.1.1
+    transitivePeerDependencies:
+      - libsql
+      - mariadb
+      - mysql
+      - mysql2
+      - pg
+      - pg-native
+      - sqlite3
+      - supports-color
+      - tedious
+
+  '@mikro-orm/cli@6.6.9(better-sqlite3@11.10.0)(mariadb@3.4.5)':
     dependencies:
       '@jercle/yargonaut': 1.1.5
       '@mikro-orm/core': 6.6.9
-      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)(sqlite3@5.1.7)
+      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)(mariadb@3.4.5)
       fs-extra: 11.3.3
       tsconfig-paths: 4.2.0
       yargs: 17.7.2
@@ -7097,13 +6941,14 @@ snapshots:
       mikro-orm: 6.6.9
       reflect-metadata: 0.2.2
 
-  '@mikro-orm/knex@6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)(sqlite3@5.1.7)':
+  '@mikro-orm/knex@6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)(mariadb@3.4.5)':
     dependencies:
       '@mikro-orm/core': 6.6.9
       fs-extra: 11.3.3
-      knex: 3.1.0(sqlite3@5.1.7)
+      knex: 3.1.0(better-sqlite3@11.10.0)
       sqlstring: 2.3.3
     optionalDependencies:
+      better-sqlite3: 11.10.0
       mariadb: 3.4.5
     transitivePeerDependencies:
       - mysql
@@ -7114,10 +6959,10 @@ snapshots:
       - supports-color
       - tedious
 
-  '@mikro-orm/mariadb@6.6.9(@mikro-orm/core@6.6.9)':
+  '@mikro-orm/mariadb@6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)':
     dependencies:
       '@mikro-orm/core': 6.6.9
-      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)(sqlite3@5.1.7)
+      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(better-sqlite3@11.10.0)(mariadb@3.4.5)
       mariadb: 3.4.5
     transitivePeerDependencies:
       - better-sqlite3
@@ -7136,25 +6981,6 @@ snapshots:
       globby: 11.1.0
       ts-morph: 27.0.2
 
-  '@mikro-orm/sqlite@6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)':
-    dependencies:
-      '@mikro-orm/core': 6.6.9
-      '@mikro-orm/knex': 6.6.9(@mikro-orm/core@6.6.9)(mariadb@3.4.5)(sqlite3@5.1.7)
-      fs-extra: 11.3.3
-      sqlite3: 5.1.7
-      sqlstring-sqlite: 0.1.1
-    transitivePeerDependencies:
-      - better-sqlite3
-      - bluebird
-      - libsql
-      - mariadb
-      - mysql
-      - mysql2
-      - pg
-      - pg-native
-      - supports-color
-      - tedious
-
   '@mongodb-js/saslprep@1.4.6':
     dependencies:
       sparse-bitfield: 3.0.3
@@ -7176,18 +7002,6 @@ snapshots:
       '@nodelib/fs.scandir': 2.1.5
       fastq: 1.20.1
 
-  '@npmcli/fs@1.1.1':
-    dependencies:
-      '@gar/promisify': 1.1.3
-      semver: 7.7.4
-    optional: true
-
-  '@npmcli/move-file@1.1.2':
-    dependencies:
-      mkdirp: 1.0.4
-      rimraf: 3.0.2
-    optional: true
-
   '@one-ini/wasm@0.1.1': {}
 
   '@pkgjs/parseargs@0.11.0':
@@ -7320,9 +7134,6 @@ snapshots:
     transitivePeerDependencies:
       - encoding
 
-  '@tootallnate/once@1.1.2':
-    optional: true
-
   '@ts-morph/common@0.28.1':
     dependencies:
       minimatch: 10.2.4
@@ -7676,9 +7487,6 @@ snapshots:
       jsonparse: 1.3.1
       through: 2.3.8
 
-  abbrev@1.1.1:
-    optional: true
-
   abbrev@2.0.0: {}
 
   abort-controller@3.0.0:
@@ -7705,20 +7513,8 @@ snapshots:
 
   acorn@8.16.0: {}
 
-  agent-base@6.0.2:
-    dependencies:
-      debug: 4.4.3
-    transitivePeerDependencies:
-      - supports-color
-    optional: true
-
   agent-base@7.1.4: {}
 
-  agentkeepalive@4.6.0:
-    dependencies:
-      humanize-ms: 1.2.1
-    optional: true
-
   aggregate-error@3.1.0:
     dependencies:
       clean-stack: 2.2.0
@@ -7785,17 +7581,8 @@ snapshots:
       normalize-path: 3.0.0
       picomatch: 2.3.1
 
-  aproba@2.1.0:
-    optional: true
-
   are-docs-informative@0.0.2: {}
 
-  are-we-there-yet@3.0.1:
-    dependencies:
-      delegates: 1.0.0
-      readable-stream: 3.6.2
-    optional: true
-
   arg@4.1.3: {}
 
   argparse@2.0.1: {}
@@ -7966,6 +7753,11 @@ snapshots:
     dependencies:
       tweetnacl: 0.14.5
 
+  better-sqlite3@11.10.0:
+    dependencies:
+      bindings: 1.5.0
+      prebuild-install: 7.1.3
+
   bidi-js@1.0.3:
     dependencies:
       require-from-string: 2.0.2
@@ -8168,30 +7960,6 @@ snapshots:
 
   byte-counter@0.1.0: {}
 
-  cacache@15.3.0:
-    dependencies:
-      '@npmcli/fs': 1.1.1
-      '@npmcli/move-file': 1.1.2
-      chownr: 2.0.0
-      fs-minipass: 2.1.0
-      glob: 7.2.3
-      infer-owner: 1.0.4
-      lru-cache: 6.0.0
-      minipass: 3.3.6
-      minipass-collect: 1.0.2
-      minipass-flush: 1.0.5
-      minipass-pipeline: 1.2.4
-      mkdirp: 1.0.4
-      p-map: 4.0.0
-      promise-inflight: 1.0.1
-      rimraf: 3.0.2
-      ssri: 8.0.1
-      tar: 7.5.10
-      unique-filename: 1.1.1
-    transitivePeerDependencies:
-      - bluebird
-    optional: true
-
   cacheable-lookup@7.0.0: {}
 
   cacheable-request@13.0.18:
@@ -8285,9 +8053,6 @@ snapshots:
 
   chownr@1.1.4: {}
 
-  chownr@2.0.0:
-    optional: true
-
   chownr@3.0.0: {}
 
   ci-info@2.0.0: {}
@@ -8497,9 +8262,6 @@ snapshots:
 
   console-browserify@1.2.0: {}
 
-  console-control-strings@1.1.0:
-    optional: true
-
   constants-browserify@1.0.0: {}
 
   conventional-changelog-angular@8.2.0:
@@ -8871,9 +8633,6 @@ snapshots:
 
   delayed-stream@1.0.0: {}
 
-  delegates@1.0.0:
-    optional: true
-
   denque@2.1.0: {}
 
   depd@2.0.0: {}
@@ -9021,9 +8780,6 @@ snapshots:
 
   environment@1.1.0: {}
 
-  err-code@2.0.3:
-    optional: true
-
   error-ex@1.3.4:
     dependencies:
       is-arrayish: 0.2.1
@@ -9638,11 +9394,6 @@ snapshots:
       jsonfile: 6.2.0
       universalify: 2.0.1
 
-  fs-minipass@2.1.0:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
   fs.realpath@1.0.0: {}
 
   fsevents@2.3.3:
@@ -9661,18 +9412,6 @@ snapshots:
 
   functions-have-names@1.2.3: {}
 
-  gauge@4.0.4:
-    dependencies:
-      aproba: 2.1.0
-      color-support: 1.1.3
-      console-control-strings: 1.1.0
-      has-unicode: 2.0.1
-      signal-exit: 3.0.7
-      string-width: 4.2.3
-      strip-ansi: 6.0.1
-      wide-align: 1.1.5
-    optional: true
-
   generate-function@2.3.1:
     dependencies:
       is-property: 1.0.2
@@ -9944,15 +9683,6 @@ snapshots:
 
   http-parser-js@0.5.10: {}
 
-  http-proxy-agent@4.0.1:
-    dependencies:
-      '@tootallnate/once': 1.1.2
-      agent-base: 6.0.2
-      debug: 4.4.3
-    transitivePeerDependencies:
-      - supports-color
-    optional: true
-
   http-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.4
@@ -9975,14 +9705,6 @@ snapshots:
 
   https-browserify@1.0.0: {}
 
-  https-proxy-agent@5.0.1:
-    dependencies:
-      agent-base: 6.0.2
-      debug: 4.4.3
-    transitivePeerDependencies:
-      - supports-color
-    optional: true
-
   https-proxy-agent@7.0.6:
     dependencies:
       agent-base: 7.1.4
@@ -9992,11 +9714,6 @@ snapshots:
 
   human-signals@1.1.1: {}
 
-  humanize-ms@1.2.1:
-    dependencies:
-      ms: 2.1.3
-    optional: true
-
   husky@9.1.7: {}
 
   hyperid@3.3.0:
@@ -10038,9 +9755,6 @@ snapshots:
 
   indent-string@4.0.0: {}
 
-  infer-owner@1.0.4:
-    optional: true
-
   inflight@1.0.6:
     dependencies:
       once: 1.4.0
@@ -10220,9 +9934,6 @@ snapshots:
 
   is-interactive@1.0.0: {}
 
-  is-lambda@1.0.1:
-    optional: true
-
   is-map@2.0.3: {}
 
   is-negative-zero@2.0.3: {}
@@ -10448,7 +10159,7 @@ snapshots:
     dependencies:
       '@keyv/serialize': 1.1.1
 
-  knex@3.1.0(sqlite3@5.1.7):
+  knex@3.1.0(better-sqlite3@11.10.0):
     dependencies:
       colorette: 2.0.19
       commander: 10.0.1
@@ -10465,7 +10176,7 @@ snapshots:
       tarn: 3.0.2
       tildify: 2.0.0
     optionalDependencies:
-      sqlite3: 5.1.7
+      better-sqlite3: 11.10.0
     transitivePeerDependencies:
       - supports-color
 
@@ -10597,11 +10308,6 @@ snapshots:
     dependencies:
       yallist: 3.1.1
 
-  lru-cache@6.0.0:
-    dependencies:
-      yallist: 4.0.0
-    optional: true
-
   macos-release@2.5.1: {}
 
   magic-string-ast@1.0.3:
@@ -10636,29 +10342,6 @@ snapshots:
 
   make-error@1.3.6: {}
 
-  make-fetch-happen@9.1.0:
-    dependencies:
-      agentkeepalive: 4.6.0
-      cacache: 15.3.0
-      http-cache-semantics: 4.2.0
-      http-proxy-agent: 4.0.1
-      https-proxy-agent: 5.0.1
-      is-lambda: 1.0.1
-      lru-cache: 6.0.0
-      minipass: 3.3.6
-      minipass-collect: 1.0.2
-      minipass-fetch: 1.4.1
-      minipass-flush: 1.0.5
-      minipass-pipeline: 1.2.4
-      negotiator: 0.6.4
-      promise-retry: 2.0.1
-      socks-proxy-agent: 6.2.1
-      ssri: 8.0.1
-    transitivePeerDependencies:
-      - bluebird
-      - supports-color
-    optional: true
-
   manage-path@2.0.0: {}
 
   mariadb@3.4.5:
@@ -10752,48 +10435,8 @@ snapshots:
 
   minimist@1.2.8: {}
 
-  minipass-collect@1.0.2:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
-  minipass-fetch@1.4.1:
-    dependencies:
-      minipass: 3.3.6
-      minipass-sized: 1.0.3
-      minizlib: 2.1.2
-    optionalDependencies:
-      encoding: 0.1.13
-    optional: true
-
-  minipass-flush@1.0.5:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
-  minipass-pipeline@1.2.4:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
-  minipass-sized@1.0.3:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
-  minipass@3.3.6:
-    dependencies:
-      yallist: 4.0.0
-    optional: true
-
   minipass@7.1.3: {}
 
-  minizlib@2.1.2:
-    dependencies:
-      minipass: 3.3.6
-      yallist: 4.0.0
-    optional: true
-
   minizlib@3.1.0:
     dependencies:
       minipass: 7.1.3
@@ -10975,9 +10618,6 @@ snapshots:
       iota-array: 1.0.0
       is-buffer: 1.1.6
 
-  negotiator@0.6.4:
-    optional: true
-
   neostandard@0.13.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
     dependencies:
       '@humanwhocodes/gitignore-to-minimatch': 1.0.2
@@ -11004,8 +10644,6 @@ snapshots:
     dependencies:
       semver: 7.7.4
 
-  node-addon-api@7.1.1: {}
-
   node-exports-info@1.6.0:
     dependencies:
       array.prototype.flatmap: 1.3.3
@@ -11022,30 +10660,8 @@ snapshots:
   node-gyp-build@4.8.4:
     optional: true
 
-  node-gyp@8.4.1:
-    dependencies:
-      env-paths: 2.2.1
-      glob: 7.2.3
-      graceful-fs: 4.2.11
-      make-fetch-happen: 9.1.0
-      nopt: 5.0.0
-      npmlog: 6.0.2
-      rimraf: 3.0.2
-      semver: 7.7.4
-      tar: 7.5.10
-      which: 2.0.2
-    transitivePeerDependencies:
-      - bluebird
-      - supports-color
-    optional: true
-
   node-releases@2.0.36: {}
 
-  nopt@5.0.0:
-    dependencies:
-      abbrev: 1.1.1
-    optional: true
-
   nopt@7.2.1:
     dependencies:
       abbrev: 2.0.0
@@ -11060,14 +10676,6 @@ snapshots:
     dependencies:
       path-key: 3.1.1
 
-  npmlog@6.0.2:
-    dependencies:
-      are-we-there-yet: 3.0.1
-      console-control-strings: 1.1.0
-      gauge: 4.0.4
-      set-blocking: 2.0.0
-    optional: true
-
   nth-check@2.1.1:
     dependencies:
       boolbase: 1.0.0
@@ -11410,15 +11018,6 @@ snapshots:
 
   progress@2.0.3: {}
 
-  promise-inflight@1.0.1:
-    optional: true
-
-  promise-retry@2.0.1:
-    dependencies:
-      err-code: 2.0.3
-      retry: 0.12.0
-    optional: true
-
   prop-types@15.8.1:
     dependencies:
       loose-envify: 1.4.0
@@ -11659,9 +11258,6 @@ snapshots:
 
   retimer@3.0.0: {}
 
-  retry@0.12.0:
-    optional: true
-
   reusify@1.1.0: {}
 
   rfdc@1.4.1: {}
@@ -11918,15 +11514,6 @@ snapshots:
 
   smol-toml@1.6.0: {}
 
-  socks-proxy-agent@6.2.1:
-    dependencies:
-      agent-base: 6.0.2
-      debug: 4.4.3
-      socks: 2.8.7
-    transitivePeerDependencies:
-      - supports-color
-    optional: true
-
   socks@2.8.7:
     dependencies:
       ip-address: 10.1.0
@@ -11966,18 +11553,6 @@ snapshots:
 
   split2@4.2.0: {}
 
-  sqlite3@5.1.7:
-    dependencies:
-      bindings: 1.5.0
-      node-addon-api: 7.1.1
-      prebuild-install: 7.1.3
-      tar: 7.5.10
-    optionalDependencies:
-      node-gyp: 8.4.1
-    transitivePeerDependencies:
-      - bluebird
-      - supports-color
-
   sqlstring-sqlite@0.1.1: {}
 
   sqlstring@2.3.3: {}
@@ -11994,11 +11569,6 @@ snapshots:
       safer-buffer: 2.1.2
       tweetnacl: 0.14.5
 
-  ssri@8.0.1:
-    dependencies:
-      minipass: 3.3.6
-    optional: true
-
   stack-trace@0.0.10: {}
 
   stackback@0.0.2: {}
@@ -12513,16 +12083,6 @@ snapshots:
 
   uniq@1.0.1: {}
 
-  unique-filename@1.1.1:
-    dependencies:
-      unique-slug: 2.0.2
-    optional: true
-
-  unique-slug@2.0.2:
-    dependencies:
-      imurmurhash: 0.1.4
-    optional: true
-
   unique-string@2.0.0:
     dependencies:
       crypto-random-string: 2.0.0
@@ -12811,11 +12371,6 @@ snapshots:
       siginfo: 2.0.0
       stackback: 0.0.2
 
-  wide-align@1.1.5:
-    dependencies:
-      string-width: 4.2.3
-    optional: true
-
   widest-line@3.1.0:
     dependencies:
       string-width: 4.2.3
@@ -12910,9 +12465,6 @@ snapshots:
 
   yallist@3.1.1: {}
 
-  yallist@4.0.0:
-    optional: true
-
   yallist@5.0.0: {}
 
   yaml@2.8.2: {}
index 65831ac5c04daddc9cc8a0435a33e352ada88b84..39bf5fbcdcc9adae1036d5e9b8fe503940f5d1f0 100644 (file)
@@ -219,7 +219,7 @@ export class Bootstrap extends EventEmitter {
             performanceStorageConfiguration.uri!,
             this.logPrefix()
           )
-          await this.storage?.open()
+          await this.storage.open()
         }
         this.uiServer.setChargingStationTemplates(
           Configuration.getStationTemplateUrls()?.map(stationTemplateUrl =>
index 6103235e86da7fcb3086e407f3db5c9b1e834fe7..ee145879ec3dd6d69c25faaea49abf9903d0319a 100644 (file)
@@ -1,7 +1,6 @@
 // Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
 
-import { closeSync, existsSync, mkdirSync, openSync, writeSync } from 'node:fs'
-import { dirname } from 'node:path'
+import { closeSync, openSync, writeSync } from 'node:fs'
 
 import { BaseError } from '../../exception/index.js'
 import { FileType, MapStringifyFormat, type Statistics } from '../../types/index.js'
@@ -36,9 +35,7 @@ export class JsonFileStorage extends Storage {
   public open (): void {
     try {
       if (this.fd == null) {
-        if (!existsSync(dirname(this.dbName))) {
-          mkdirSync(dirname(this.dbName), { recursive: true })
-        }
+        this.ensureDBDirectory()
         this.fd = openSync(this.dbName, 'w')
       }
     } catch (error) {
@@ -53,11 +50,10 @@ export class JsonFileStorage extends Storage {
 
   public storePerformanceStatistics (performanceStatistics: Statistics): void {
     this.setPerformanceStatistics(performanceStatistics)
-    this.checkPerformanceRecordsFile()
+    const fd = this.checkPerformanceRecordsFile()
     AsyncLock.runExclusive(AsyncLockType.performance, () => {
       writeSync(
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        this.fd!,
+        fd,
         JSONStringify([...this.getPerformanceStatistics()], 2, MapStringifyFormat.object),
         0,
         'utf8'
@@ -72,11 +68,12 @@ export class JsonFileStorage extends Storage {
     })
   }
 
-  private checkPerformanceRecordsFile (): void {
+  private checkPerformanceRecordsFile (): number {
     if (this.fd == null) {
       throw new BaseError(
         `${this.logPrefix} Performance records '${this.dbName}' file descriptor not found`
       )
     }
+    return this.fd
   }
 }
index 1a1d0d9727b0dd97eba189da2473f36952764f1a..42da83d3328eacfadcbf1a57a055d3abd3dcf038 100644 (file)
@@ -1,9 +1,10 @@
 // Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
 
+import { type Options as SqliteOptions, MikroORM as SqliteORM } from '@mikro-orm/better-sqlite'
 import { type Options as MariaDbOptions, MikroORM as MariaDbORM } from '@mikro-orm/mariadb'
-import { type Options as SqliteOptions, MikroORM as SqliteORM } from '@mikro-orm/sqlite'
 
-import { type PerformanceRecord, type Statistics, StorageType } from '../../types/index.js'
+import { BaseError } from '../../exception/index.js'
+import { PerformanceRecord, type Statistics, StorageType } from '../../types/index.js'
 import { Constants } from '../../utils/index.js'
 import { Storage } from './Storage.js'
 
@@ -32,15 +33,26 @@ export class MikroOrmStorage extends Storage {
   public async open (): Promise<void> {
     try {
       if (this.orm == null) {
+        let orm: MariaDbORM | SqliteORM | undefined
         switch (this.storageType) {
           case StorageType.MARIA_DB:
           case StorageType.MYSQL:
-            this.orm = await MariaDbORM.init(this.getOptions() as MariaDbOptions)
+            orm = await MariaDbORM.init(this.getOptions() as MariaDbOptions)
             break
           case StorageType.SQLITE:
-            this.orm = await SqliteORM.init(this.getOptions() as SqliteOptions)
+            this.ensureDBDirectory()
+            orm = await SqliteORM.init(this.getOptions() as SqliteOptions)
             break
         }
+        if (orm != null) {
+          try {
+            await orm.schema.updateSchema()
+          } catch (error) {
+            await orm.close()
+            throw error
+          }
+          this.orm = orm
+        }
       }
     } catch (error) {
       this.handleDBStorageError(this.storageType, error as Error)
@@ -50,15 +62,12 @@ export class MikroOrmStorage extends Storage {
   public async storePerformanceStatistics (performanceStatistics: Statistics): Promise<void> {
     try {
       this.setPerformanceStatistics(performanceStatistics)
-      await this.orm?.em.upsert({
-        ...performanceStatistics,
-        statisticsData: Array.from(performanceStatistics.statisticsData, ([name, value]) => ({
-          ...value,
-          measurementTimeSeries:
-            value.measurementTimeSeries != null ? [...value.measurementTimeSeries] : undefined,
-          name,
-        })),
-      } satisfies PerformanceRecord)
+      this.checkDBConnection()
+      const em = this.orm?.em.fork()
+      await em?.upsert(
+        PerformanceRecord,
+        this.serializePerformanceStatistics(performanceStatistics)
+      )
     } catch (error) {
       this.handleDBStorageError(
         this.storageType,
@@ -68,6 +77,14 @@ export class MikroOrmStorage extends Storage {
     }
   }
 
+  private checkDBConnection (): void {
+    if (this.orm == null) {
+      throw new BaseError(
+        `${this.logPrefix} ${this.getDBNameFromStorageType(this.storageType) ?? 'Unknown'} ORM not initialized while trying to issue a request`
+      )
+    }
+  }
+
   private getClientUrl (): string | undefined {
     switch (this.storageType) {
       case StorageType.MARIA_DB:
@@ -88,8 +105,7 @@ export class MikroOrmStorage extends Storage {
     return {
       clientUrl: this.getClientUrl(),
       dbName: this.dbName,
-      entities: ['./dist/types/orm/entities/*.js'],
-      entitiesTs: ['./src/types/orm/entities/*.ts'],
+      entities: [PerformanceRecord],
     }
   }
 }
index f11e8f2577e8e69a612ed0b56dc9d97dcd9a007c..0dc033d239873810403645804431d57110625201 100644 (file)
@@ -8,22 +8,22 @@ import { Constants } from '../../utils/index.js'
 import { Storage } from './Storage.js'
 
 export class MongoDBStorage extends Storage {
-  private readonly client?: MongoClient
-  private connected: boolean
+  private readonly client: MongoClient
+  private opened: boolean
 
   constructor (storageUri: string, logPrefix: string) {
     super(storageUri, logPrefix)
     this.client = new MongoClient(this.storageUri.toString())
-    this.connected = false
+    this.opened = false
     this.dbName = this.storageUri.pathname.replace(/(?:^\/)|(?:\/$)/g, '')
   }
 
   public async close (): Promise<void> {
     this.clearPerformanceStatistics()
     try {
-      if (this.connected && this.client != null) {
+      if (this.opened) {
         await this.client.close()
-        this.connected = false
+        this.opened = false
       }
     } catch (error) {
       this.handleDBStorageError(StorageType.MONGO_DB, error as Error)
@@ -32,9 +32,9 @@ export class MongoDBStorage extends Storage {
 
   public async open (): Promise<void> {
     try {
-      if (!this.connected && this.client != null) {
+      if (!this.opened) {
         await this.client.connect()
-        this.connected = true
+        this.opened = true
       }
     } catch (error) {
       this.handleDBStorageError(StorageType.MONGO_DB, error as Error)
@@ -46,11 +46,13 @@ export class MongoDBStorage extends Storage {
       this.setPerformanceStatistics(performanceStatistics)
       this.checkDBConnection()
       await this.client
-        ?.db(this.dbName)
+        .db(this.dbName)
         .collection<Statistics>(Constants.PERFORMANCE_RECORDS_TABLE)
-        .replaceOne({ id: performanceStatistics.id }, performanceStatistics, {
-          upsert: true,
-        })
+        .replaceOne(
+          { id: performanceStatistics.id },
+          this.serializePerformanceStatistics(performanceStatistics) as unknown as Statistics,
+          { upsert: true }
+        )
     } catch (error) {
       this.handleDBStorageError(
         StorageType.MONGO_DB,
@@ -61,20 +63,9 @@ export class MongoDBStorage extends Storage {
   }
 
   private checkDBConnection (): void {
-    if (this.client == null) {
+    if (!this.opened) {
       throw new BaseError(
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-        `${this.logPrefix} ${this.getDBNameFromStorageType(
-          StorageType.MONGO_DB
-        )} client initialization failed while trying to issue a request`
-      )
-    }
-    if (!this.connected) {
-      throw new BaseError(
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-        `${this.logPrefix} ${this.getDBNameFromStorageType(
-          StorageType.MONGO_DB
-        )} connection not opened while trying to issue a request`
+        `${this.logPrefix} ${this.getDBNameFromStorageType(StorageType.MONGO_DB) ?? 'Unknown'} connection not opened while trying to issue a request`
       )
     }
   }
index ceda1ab57c8b6c7ad33e90dca5d1b0a9cbabe0c7..4c03bdcf20ab5c0e2d48d1a14c8a1232420e5389 100644 (file)
@@ -1,4 +1,4 @@
-// Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
 
 import type { Statistics } from '../../types/index.js'
 
index b7711f21cc7d1c063fb0c5d0746e558fc8255695..22b91990b48ac024ed943fc484f49bcabef3ef9a 100644 (file)
@@ -1,5 +1,7 @@
 // Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
 
+import { existsSync, mkdirSync } from 'node:fs'
+import { dirname } from 'node:path'
 import { URL } from 'node:url'
 
 import {
@@ -38,6 +40,12 @@ export abstract class Storage {
     Storage.performanceStatistics.clear()
   }
 
+  protected ensureDBDirectory (): void {
+    if (!existsSync(dirname(this.dbName))) {
+      mkdirSync(dirname(this.dbName), { recursive: true })
+    }
+  }
+
   protected getDBNameFromStorageType (type: StorageType): DBName | undefined {
     switch (type) {
       case StorageType.MARIA_DB:
@@ -60,20 +68,9 @@ export abstract class Storage {
       throwError: false,
     }
   ): void {
-    params = {
-      ...{
-        consoleOut: false,
-        throwError: false,
-      },
-      ...params,
-    }
-    const inTableOrCollectionStr = table != null && ` in table or collection '${table}'`
+    const inTableOrCollectionStr = table != null ? ` in table or collection '${table}'` : ''
     logger.error(
-      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-      `${this.logPrefix} ${this.getDBNameFromStorageType(type)} error '${
-        error.message
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-      }'${inTableOrCollectionStr}:`,
+      `${this.logPrefix} ${this.getDBNameFromStorageType(type) ?? 'Unknown'} error '${error.message}'${inTableOrCollectionStr}:`,
       error
     )
     if (params.throwError === true) {
@@ -81,6 +78,20 @@ export abstract class Storage {
     }
   }
 
+  protected serializePerformanceStatistics (
+    performanceStatistics: Statistics
+  ): Record<string, unknown> {
+    return {
+      ...performanceStatistics,
+      statisticsData: Array.from(performanceStatistics.statisticsData, ([name, value]) => ({
+        ...value,
+        measurementTimeSeries:
+          value.measurementTimeSeries != null ? [...value.measurementTimeSeries] : undefined,
+        name,
+      })),
+    }
+  }
+
   protected setPerformanceStatistics (performanceStatistics: Statistics): void {
     Storage.performanceStatistics.set(performanceStatistics.id, performanceStatistics)
   }
index c7d243736739a9adb5570d53b6023243ccf5c9f2..f6e0bd9ac9127de62ae2c164ca60b1e5e981d647 100644 (file)
@@ -15,11 +15,7 @@ export class StorageFactory {
     // This is intentional
   }
 
-  public static getStorage (
-    type: StorageType,
-    connectionUri: string,
-    logPrefix: string
-  ): Storage | undefined {
+  public static getStorage (type: StorageType, connectionUri: string, logPrefix: string): Storage {
     let storageInstance: Storage
     switch (type) {
       case StorageType.JSON_FILE:
@@ -37,8 +33,7 @@ export class StorageFactory {
         storageInstance = new None()
         break
       default:
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-        throw new BaseError(`${logPrefix} Unknown storage type: ${type}`)
+        throw new BaseError(`${logPrefix} Unknown storage type: ${type as string}`)
     }
     return storageInstance
   }
index 8f9b9204c32eb0ada6afe4f9df74f36b9c2cca10..360cb2b22071f901259169ae42e6986bb8928576 100644 (file)
@@ -1,42 +1,22 @@
 import { Entity, PrimaryKey, Property } from '@mikro-orm/core'
 
-interface StatisticsData {
-  avgTimeMeasurement: number
-  currentTimeMeasurement: number
-  errorCount: number
-  maxTimeMeasurement: number
-  measurementTimeSeries: {
-    timestamp: number
-    value: number
-  }[]
-  medTimeMeasurement: number
-  minTimeMeasurement: number
-  name: string
-  ninetyFiveThPercentileTimeMeasurement: number
-  requestCount: number
-  responseCount: number
-  stdTimeMeasurement: number
-  timeMeasurementCount: number
-  totalTimeMeasurement: number
-}
-
-@Entity()
+@Entity({ tableName: 'performance_records' })
 export class PerformanceRecord {
-  @Property()
+  @Property({ type: 'datetime' })
   createdAt!: Date
 
-  @PrimaryKey()
+  @PrimaryKey({ type: 'string' })
   id!: string
 
-  @Property()
+  @Property({ type: 'string' })
   name!: string
 
-  @Property()
-  statisticsData!: Partial<StatisticsData>[]
+  @Property({ type: 'json' })
+  statisticsData!: Record<string, unknown>[]
 
-  @Property()
+  @Property({ nullable: true, type: 'datetime' })
   updatedAt?: Date
 
-  @Property()
+  @Property({ type: 'string' })
   uri!: string
 }
diff --git a/tests/performance/storage/MikroOrmStorage.test.ts b/tests/performance/storage/MikroOrmStorage.test.ts
new file mode 100644 (file)
index 0000000..1f92edb
--- /dev/null
@@ -0,0 +1,446 @@
+/**
+ * @file Tests for MikroOrmStorage
+ * @description Unit and integration tests for MikroORM-based performance storage
+ */
+import { MikroORM } from '@mikro-orm/better-sqlite'
+import { expect } from '@std/expect'
+import { existsSync, rmSync } from 'node:fs'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import { MikroOrmStorage } from '../../../src/performance/storage/MikroOrmStorage.js'
+import { StorageType } from '../../../src/types/index.js'
+import { PerformanceRecord } from '../../../src/types/orm/entities/PerformanceRecord.js'
+import { Constants } from '../../../src/utils/index.js'
+import { logger } from '../../../src/utils/Logger.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { buildTestStatistics } from './StorageTestHelpers.js'
+
+const TEST_LOG_PREFIX = '[MikroOrmStorage Test]'
+const TEST_STORAGE_URI = 'file:performance/e-mobility-charging-stations-simulator.db'
+const TEST_DB_PATH = `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_DB_NAME}.db`
+
+let sqliteAvailable = false
+try {
+  const orm = await MikroORM.init({
+    dbName: ':memory:',
+    discovery: { warnWhenNoEntities: false },
+    entities: [],
+  })
+  await orm.close()
+  sqliteAvailable = true
+} catch {
+  // better-sqlite3 native binding not available (CI --ignore-scripts)
+}
+
+const SKIP_SQLITE = !sqliteAvailable ? 'better-sqlite3 native binding not available' : undefined
+
+interface MockEntityManager {
+  fork: () => MockEntityManager
+  upsert: (entity: unknown, data: unknown) => Promise<unknown>
+}
+
+interface MockOrm {
+  close: () => Promise<void>
+  em: MockEntityManager
+  schema: {
+    updateSchema: () => Promise<void>
+  }
+}
+
+class TestableMikroOrmStorage extends MikroOrmStorage {
+  public getOrm (): unknown {
+    return Reflect.get(this, 'orm')
+  }
+
+  public setOrm (orm: MockOrm): void {
+    Reflect.set(this, 'orm', orm)
+  }
+}
+
+/**
+ * @returns Mock ORM instance and captured upsert calls
+ */
+function buildMockOrm (): { mockOrm: MockOrm; upsertCalls: unknown[] } {
+  const upsertCalls: unknown[] = []
+  const mockEm: MockEntityManager = {
+    fork: () => mockEm,
+    upsert: (_entity: unknown, data: unknown) => {
+      upsertCalls.push({ data, entity: _entity })
+      return Promise.resolve(data)
+    },
+  }
+  const mockOrm: MockOrm = {
+    close: () => Promise.resolve(),
+    em: mockEm,
+    schema: {
+      updateSchema: () => Promise.resolve(),
+    },
+  }
+  return { mockOrm, upsertCalls }
+}
+
+await describe('MikroOrmStorage', async () => {
+  let storage: TestableMikroOrmStorage
+
+  beforeEach(() => {
+    storage = new TestableMikroOrmStorage(TEST_STORAGE_URI, TEST_LOG_PREFIX, StorageType.SQLITE)
+  })
+
+  afterEach(async () => {
+    try {
+      await storage.close()
+    } catch {
+      // Storage may not have been opened
+    }
+    if (existsSync(TEST_DB_PATH)) {
+      rmSync(TEST_DB_PATH)
+    }
+    standardCleanup()
+  })
+
+  await describe('close', async () => {
+    await it('should clear cached performance statistics', async () => {
+      // Arrange
+      const { mockOrm } = buildMockOrm()
+      storage.setOrm(mockOrm)
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      expect([...storage.getPerformanceStatistics()].length).toBe(1)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect([...storage.getPerformanceStatistics()].length).toBe(0)
+    })
+
+    await it('should call orm.close when ORM is initialized', async t => {
+      // Arrange
+      const { mockOrm } = buildMockOrm()
+      const closeMock = t.mock.method(mockOrm, 'close')
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(closeMock.mock.calls.length).toBe(1)
+    })
+
+    await it('should delete orm reference after closing', async () => {
+      // Arrange
+      const { mockOrm } = buildMockOrm()
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(storage.getOrm()).toBeUndefined()
+    })
+
+    await it('should not fail when closing without prior open', async () => {
+      await storage.close()
+      await storage.close()
+    })
+
+    await it('should log error when orm.close throws', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+      const failingOrm: MockOrm = {
+        close: () => Promise.reject(new Error('close failed')),
+        em: { fork: () => ({}) as MockEntityManager, upsert: () => Promise.resolve({}) },
+        schema: { updateSchema: () => Promise.resolve() },
+      }
+      storage.setOrm(failingOrm)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+  })
+
+  await describe('storePerformanceStatistics', async () => {
+    await it('should transform statisticsData map to array with name field', async () => {
+      // Arrange
+      const { mockOrm, upsertCalls } = buildMockOrm()
+      storage.setOrm(mockOrm)
+      const stats = buildTestStatistics('station-1')
+
+      // Act
+      await storage.storePerformanceStatistics(stats)
+
+      // Assert
+      expect(upsertCalls.length).toBe(1)
+      const call = upsertCalls[0] as { data: Record<string, unknown>; entity: unknown }
+      expect(call.entity).toBe(PerformanceRecord)
+      const statsArray = call.data.statisticsData as Record<string, unknown>[]
+      expect(Array.isArray(statsArray)).toBe(true)
+      expect(statsArray.length).toBe(1)
+      expect(statsArray[0].name).toBe('Heartbeat')
+      expect(statsArray[0].requestCount).toBe(100)
+      expect(statsArray[0].avgTimeMeasurement).toBe(10.5)
+    })
+
+    await it('should spread measurementTimeSeries into plain array', async () => {
+      // Arrange
+      const { mockOrm, upsertCalls } = buildMockOrm()
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const call = upsertCalls[0] as { data: Record<string, unknown> }
+      const statsArray = call.data.statisticsData as Record<string, unknown>[]
+      const timeSeries = statsArray[0].measurementTimeSeries as unknown[]
+      expect(Array.isArray(timeSeries)).toBe(true)
+      expect(timeSeries.length).toBe(2)
+    })
+
+    await it('should call upsert with PerformanceRecord entity class', async () => {
+      // Arrange
+      const { mockOrm, upsertCalls } = buildMockOrm()
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const call = upsertCalls[0] as { entity: unknown }
+      expect(call.entity).toBe(PerformanceRecord)
+    })
+
+    await it('should cache statistics in memory after store', async () => {
+      // Arrange
+      const { mockOrm } = buildMockOrm()
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(1)
+      expect(cached[0].id).toBe('station-1')
+    })
+
+    await it('should handle multiple distinct records', async () => {
+      // Arrange
+      const { mockOrm, upsertCalls } = buildMockOrm()
+      storage.setOrm(mockOrm)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-2'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-3'))
+
+      // Assert
+      expect(upsertCalls.length).toBe(3)
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(3)
+    })
+
+    await it('should handle statisticsData entry without measurementTimeSeries', async () => {
+      // Arrange
+      const { mockOrm, upsertCalls } = buildMockOrm()
+      storage.setOrm(mockOrm)
+      const stats = buildTestStatistics('station-1')
+      stats.statisticsData.set('StatusNotification', {
+        requestCount: 50,
+        responseCount: 50,
+      } as unknown as Record<string, unknown>)
+
+      // Act
+      await storage.storePerformanceStatistics(stats)
+
+      // Assert
+      const call = upsertCalls[0] as { data: Record<string, unknown> }
+      const statsArray = call.data.statisticsData as Record<string, unknown>[]
+      expect(statsArray.length).toBe(2)
+      const statusEntry = statsArray.find(e => e.name === 'StatusNotification')
+      expect(statusEntry).toBeDefined()
+      expect(statusEntry?.measurementTimeSeries).toBeUndefined()
+    })
+  })
+
+  await describe('Error Handling', async () => {
+    await it('should log error when storing without open', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+
+    await it('should still cache statistics even when store fails', async () => {
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(1)
+      expect(cached[0].id).toBe('station-1')
+    })
+
+    await it('should log error when upsert throws', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+      const failingEm: MockEntityManager = {
+        fork: () => failingEm,
+        upsert: () => Promise.reject(new Error('upsert failed')),
+      }
+      const failingOrm: MockOrm = {
+        close: () => Promise.resolve(),
+        em: failingEm,
+        schema: { updateSchema: () => Promise.resolve() },
+      }
+      storage.setOrm(failingOrm)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+  })
+
+  await describe('Integration (SQLite)', async () => {
+    await it('should open database and create schema', { skip: SKIP_SQLITE }, async () => {
+      await storage.open()
+
+      expect(existsSync(TEST_DB_PATH)).toBe(true)
+    })
+
+    await it('should persist record to SQLite database', { skip: SKIP_SQLITE }, async () => {
+      // Arrange
+      await storage.open()
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const verifyOrm = await MikroORM.init({
+        dbName: TEST_DB_PATH,
+        entities: [PerformanceRecord],
+      })
+      try {
+        const record = await verifyOrm.em.fork().findOne(PerformanceRecord, { id: 'station-1' })
+        expect(record).toBeDefined()
+        expect(record?.name).toBe('cs-station-1')
+        expect(record?.uri).toBe('ws://localhost:8080')
+      } finally {
+        await verifyOrm.close()
+      }
+    })
+
+    await it('should upsert existing record with same id', { skip: SKIP_SQLITE }, async () => {
+      // Arrange
+      await storage.open()
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1', 'original'))
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1', 'updated'))
+
+      // Assert
+      const verifyOrm = await MikroORM.init({
+        dbName: TEST_DB_PATH,
+        entities: [PerformanceRecord],
+      })
+      try {
+        const records = await verifyOrm.em.fork().findAll(PerformanceRecord)
+        expect(records.length).toBe(1)
+        expect(records[0].name).toBe('updated')
+      } finally {
+        await verifyOrm.close()
+      }
+    })
+
+    await it(
+      'should serialize statisticsData as JSON array with name field',
+      { skip: SKIP_SQLITE },
+      async () => {
+        // Arrange
+        await storage.open()
+
+        // Act
+        await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+        // Assert
+        const verifyOrm = await MikroORM.init({
+          dbName: TEST_DB_PATH,
+          entities: [PerformanceRecord],
+        })
+        try {
+          const record = await verifyOrm.em.fork().findOne(PerformanceRecord, { id: 'station-1' })
+          expect(record).toBeDefined()
+          expect(Array.isArray(record?.statisticsData)).toBe(true)
+          expect(record?.statisticsData.length).toBe(1)
+          const entry = record?.statisticsData[0]
+          expect(entry).toBeDefined()
+          expect(entry?.name).toBe('Heartbeat')
+          expect(entry?.requestCount).toBe(100)
+        } finally {
+          await verifyOrm.close()
+        }
+      }
+    )
+
+    await it('should persist data across close and reopen', { skip: SKIP_SQLITE }, async () => {
+      // Arrange
+      await storage.open()
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      await storage.close()
+
+      // Act
+      const freshStorage = new TestableMikroOrmStorage(
+        TEST_STORAGE_URI,
+        TEST_LOG_PREFIX,
+        StorageType.SQLITE
+      )
+      await freshStorage.open()
+
+      // Assert
+      const verifyOrm = await MikroORM.init({
+        dbName: TEST_DB_PATH,
+        entities: [PerformanceRecord],
+      })
+      try {
+        const record = await verifyOrm.em.fork().findOne(PerformanceRecord, { id: 'station-1' })
+        expect(record).toBeDefined()
+        expect(record?.name).toBe('cs-station-1')
+      } finally {
+        await verifyOrm.close()
+        await freshStorage.close()
+      }
+    })
+
+    await it('should store multiple distinct records', { skip: SKIP_SQLITE }, async () => {
+      // Arrange
+      await storage.open()
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-2'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-3'))
+
+      // Assert
+      const verifyOrm = await MikroORM.init({
+        dbName: TEST_DB_PATH,
+        entities: [PerformanceRecord],
+      })
+      try {
+        const records = await verifyOrm.em.fork().findAll(PerformanceRecord)
+        expect(records.length).toBe(3)
+        const ids = records.map(r => r.id).sort()
+        expect(ids).toStrictEqual(['station-1', 'station-2', 'station-3'])
+      } finally {
+        await verifyOrm.close()
+      }
+    })
+  })
+})
diff --git a/tests/performance/storage/MongoDBStorage.test.ts b/tests/performance/storage/MongoDBStorage.test.ts
new file mode 100644 (file)
index 0000000..1ca2984
--- /dev/null
@@ -0,0 +1,411 @@
+/**
+ * @file Tests for MongoDBStorage
+ * @description Unit tests for MongoDB-based performance storage
+ */
+import { expect } from '@std/expect'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import { MongoDBStorage } from '../../../src/performance/storage/MongoDBStorage.js'
+import { Constants } from '../../../src/utils/index.js'
+import { logger } from '../../../src/utils/Logger.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { buildTestStatistics } from './StorageTestHelpers.js'
+
+const TEST_LOG_PREFIX = '[MongoDBStorage Test]'
+const TEST_STORAGE_URI = 'mongodb://localhost:27017/e-mobility-test'
+
+interface MockCollection {
+  replaceOne: (
+    filter: Record<string, unknown>,
+    replacement: unknown,
+    options: Record<string, unknown>
+  ) => Promise<unknown>
+}
+
+interface MockDb {
+  collection: (name: string) => MockCollection
+}
+
+interface MockMongoClient {
+  close: () => Promise<void>
+  connect: () => Promise<void>
+  db: (name: string) => MockDb
+}
+
+class TestableMongoDBStorage extends MongoDBStorage {
+  public getClient (): unknown {
+    return Reflect.get(this, 'client')
+  }
+
+  public getOpened (): boolean {
+    return Reflect.get(this, 'opened') as boolean
+  }
+
+  public setClient (client: MockMongoClient): void {
+    Reflect.set(this, 'client', client)
+  }
+
+  public setOpened (opened: boolean): void {
+    Reflect.set(this, 'opened', opened)
+  }
+}
+
+/**
+ * @returns Mock MongoDB client and captured call logs
+ */
+function buildMockMongoClient (): {
+  collectionCalls: { collectionName: string }[]
+  mockClient: MockMongoClient
+  replaceOneCalls: {
+    filter: Record<string, unknown>
+    options: Record<string, unknown>
+    replacement: unknown
+  }[]
+} {
+  const replaceOneCalls: {
+    filter: Record<string, unknown>
+    options: Record<string, unknown>
+    replacement: unknown
+  }[] = []
+  const collectionCalls: { collectionName: string }[] = []
+  const mockCollection: MockCollection = {
+    replaceOne: (
+      filter: Record<string, unknown>,
+      replacement: unknown,
+      options: Record<string, unknown>
+    ) => {
+      replaceOneCalls.push({ filter, options, replacement })
+      return Promise.resolve({ acknowledged: true, modifiedCount: 1 })
+    },
+  }
+  const mockDb: MockDb = {
+    collection: (name: string) => {
+      collectionCalls.push({ collectionName: name })
+      return mockCollection
+    },
+  }
+  const mockClient: MockMongoClient = {
+    close: () => Promise.resolve(),
+    connect: () => Promise.resolve(),
+    db: (_name: string) => mockDb,
+  }
+  return { collectionCalls, mockClient, replaceOneCalls }
+}
+
+await describe('MongoDBStorage', async () => {
+  let storage: TestableMongoDBStorage
+
+  beforeEach(() => {
+    storage = new TestableMongoDBStorage(TEST_STORAGE_URI, TEST_LOG_PREFIX)
+  })
+
+  afterEach(async () => {
+    try {
+      await storage.close()
+    } catch {
+      // Storage may not have been opened
+    }
+    standardCleanup()
+  })
+
+  await describe('constructor', async () => {
+    await it('should extract database name from URI path', () => {
+      const dbName = Reflect.get(storage, 'dbName') as string
+      expect(dbName).toBe('e-mobility-test')
+    })
+
+    await it('should initialize with opened set to false', () => {
+      expect(storage.getOpened()).toBe(false)
+    })
+  })
+
+  await describe('close', async () => {
+    await it('should clear cached performance statistics', async () => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      expect([...storage.getPerformanceStatistics()].length).toBe(1)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect([...storage.getPerformanceStatistics()].length).toBe(0)
+    })
+
+    await it('should call client.close when opened', async t => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      const closeMock = t.mock.method(mockClient, 'close')
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(closeMock.mock.calls.length).toBe(1)
+    })
+
+    await it('should set opened to false after closing', async () => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(storage.getOpened()).toBe(false)
+    })
+
+    await it('should not call client.close when not opened', async t => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      const closeMock = t.mock.method(mockClient, 'close')
+      storage.setClient(mockClient)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(closeMock.mock.calls.length).toBe(0)
+    })
+
+    await it('should log error when client.close throws', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+      const failingClient: MockMongoClient = {
+        close: () => Promise.reject(new Error('close failed')),
+        connect: () => Promise.resolve(),
+        db: () => ({}) as MockDb,
+      }
+      storage.setClient(failingClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.close()
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+  })
+
+  await describe('open', async () => {
+    await it('should call client.connect', async t => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      const connectMock = t.mock.method(mockClient, 'connect')
+      storage.setClient(mockClient)
+
+      // Act
+      await storage.open()
+
+      // Assert
+      expect(connectMock.mock.calls.length).toBe(1)
+      expect(storage.getOpened()).toBe(true)
+    })
+
+    await it('should not connect when already opened', async t => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      const connectMock = t.mock.method(mockClient, 'connect')
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.open()
+
+      // Assert
+      expect(connectMock.mock.calls.length).toBe(0)
+    })
+
+    await it('should log error when connect throws', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+      const failingClient: MockMongoClient = {
+        close: () => Promise.resolve(),
+        connect: () => Promise.reject(new Error('connect failed')),
+        db: () => ({}) as MockDb,
+      }
+      storage.setClient(failingClient)
+
+      // Act
+      await storage.open()
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+  })
+
+  await describe('storePerformanceStatistics', async () => {
+    await it('should serialize statisticsData map to array with name field', async () => {
+      // Arrange
+      const { mockClient, replaceOneCalls } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+      const stats = buildTestStatistics('station-1')
+
+      // Act
+      await storage.storePerformanceStatistics(stats)
+
+      // Assert
+      expect(replaceOneCalls.length).toBe(1)
+      const replacement = replaceOneCalls[0].replacement as Record<string, unknown>
+      const statsArray = replacement.statisticsData as Record<string, unknown>[]
+      expect(Array.isArray(statsArray)).toBe(true)
+      expect(statsArray.length).toBe(1)
+      expect(statsArray[0].name).toBe('Heartbeat')
+      expect(statsArray[0].requestCount).toBe(100)
+      expect(statsArray[0].avgTimeMeasurement).toBe(10.5)
+    })
+
+    await it('should spread measurementTimeSeries into plain array', async () => {
+      // Arrange
+      const { mockClient, replaceOneCalls } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const replacement = replaceOneCalls[0].replacement as Record<string, unknown>
+      const statsArray = replacement.statisticsData as Record<string, unknown>[]
+      const timeSeries = statsArray[0].measurementTimeSeries as unknown[]
+      expect(Array.isArray(timeSeries)).toBe(true)
+      expect(timeSeries.length).toBe(2)
+    })
+
+    await it('should call replaceOne with upsert and correct filter', async () => {
+      // Arrange
+      const { mockClient, replaceOneCalls } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(replaceOneCalls.length).toBe(1)
+      expect(replaceOneCalls[0].filter).toStrictEqual({ id: 'station-1' })
+      expect(replaceOneCalls[0].options).toStrictEqual({ upsert: true })
+    })
+
+    await it('should use correct collection name', async () => {
+      // Arrange
+      const { collectionCalls, mockClient } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(collectionCalls.length).toBe(1)
+      expect(collectionCalls[0].collectionName).toBe(Constants.PERFORMANCE_RECORDS_TABLE)
+    })
+
+    await it('should cache statistics in memory after store', async () => {
+      // Arrange
+      const { mockClient } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(1)
+      expect(cached[0].id).toBe('station-1')
+    })
+
+    await it('should handle multiple distinct records', async () => {
+      // Arrange
+      const { mockClient, replaceOneCalls } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-2'))
+      await storage.storePerformanceStatistics(buildTestStatistics('station-3'))
+
+      // Assert
+      expect(replaceOneCalls.length).toBe(3)
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(3)
+    })
+
+    await it('should handle statisticsData entry without measurementTimeSeries', async () => {
+      // Arrange
+      const { mockClient, replaceOneCalls } = buildMockMongoClient()
+      storage.setClient(mockClient)
+      storage.setOpened(true)
+      const stats = buildTestStatistics('station-1')
+      stats.statisticsData.set('StatusNotification', {
+        requestCount: 50,
+        responseCount: 50,
+      } as unknown as Record<string, unknown>)
+
+      // Act
+      await storage.storePerformanceStatistics(stats)
+
+      // Assert
+      const replacement = replaceOneCalls[0].replacement as Record<string, unknown>
+      const statsArray = replacement.statisticsData as Record<string, unknown>[]
+      expect(statsArray.length).toBe(2)
+      const statusEntry = statsArray.find(e => e.name === 'StatusNotification')
+      expect(statusEntry).toBeDefined()
+      expect(statusEntry?.measurementTimeSeries).toBeUndefined()
+    })
+  })
+
+  await describe('Error Handling', async () => {
+    await it('should log error when storing without open', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+
+    await it('should still cache statistics even when store fails', async () => {
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      const cached = [...storage.getPerformanceStatistics()]
+      expect(cached.length).toBe(1)
+      expect(cached[0].id).toBe('station-1')
+    })
+
+    await it('should log error when replaceOne throws', async t => {
+      // Arrange
+      const errorMock = t.mock.method(logger, 'error')
+      const failingCollection: MockCollection = {
+        replaceOne: () => Promise.reject(new Error('replaceOne failed')),
+      }
+      const failingClient: MockMongoClient = {
+        close: () => Promise.resolve(),
+        connect: () => Promise.resolve(),
+        db: () => ({ collection: () => failingCollection }) as unknown as MockDb,
+      }
+      storage.setClient(failingClient)
+      storage.setOpened(true)
+
+      // Act
+      await storage.storePerformanceStatistics(buildTestStatistics('station-1'))
+
+      // Assert
+      expect(errorMock.mock.calls.length).toBe(1)
+    })
+  })
+})
diff --git a/tests/performance/storage/StorageTestHelpers.ts b/tests/performance/storage/StorageTestHelpers.ts
new file mode 100644 (file)
index 0000000..79240f0
--- /dev/null
@@ -0,0 +1,31 @@
+import type { Statistics } from '../../../src/types/index.js'
+
+/**
+ * @param id - Performance record identifier
+ * @param name - Charging station name
+ * @returns Statistics object with sample measurement data
+ */
+export function buildTestStatistics (id: string, name?: string): Statistics {
+  const statsData = new Map<string, Record<string, unknown>>()
+  statsData.set('Heartbeat', {
+    avgTimeMeasurement: 10.5,
+    currentTimeMeasurement: 12,
+    maxTimeMeasurement: 20,
+    measurementTimeSeries: [
+      { timestamp: 1000, value: 10 },
+      { timestamp: 2000, value: 12 },
+    ],
+    minTimeMeasurement: 5,
+    requestCount: 100,
+    responseCount: 99,
+    timeMeasurementCount: 100,
+    totalTimeMeasurement: 1050,
+  })
+  return {
+    createdAt: new Date('2025-01-01T00:00:00.000Z'),
+    id,
+    name: name ?? `cs-${id}`,
+    statisticsData: statsData,
+    uri: 'ws://localhost:8080',
+  } as unknown as Statistics
+}