diff --git a/.gitignore b/.gitignore index b724b2a3..8fb4a0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,7 @@ dist .pnp.* config/*.json +config/mscache config/yti-* *.txt .idea/ diff --git a/package-lock.json b/package-lock.json index 42cb2270..5468d6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", + "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", @@ -37,6 +38,7 @@ "avahi-browse": "^1.1.4", "better-sse": "^0.8.0", "body-parser": "^1.19.0", + "cacheable": "^1.10.4", "castv2": "^0.1.10", "clsx": "^2.0.0", "common-tags": "^1.8.2", @@ -50,6 +52,7 @@ "express-session": "^1.17.2", "fast-deep-equal": "^3.1.3", "fixed-size-list": "^0.3.0", + "flat-cache": "^6.1.13", "formidable": "^3.5", "glob": "^11.0.0", "google-auth-library": "^9.15.0", @@ -59,6 +62,7 @@ "iso-websocket": "^0.3.0", "iti": "^0.6.0", "json5": "^2.2.3", + "keyv": "^5.5.0", "kodi-api": "^0.2.1", "lastfm-node-client": "^2.2.0", "mopidy": "^1.3.0", @@ -1317,6 +1321,12 @@ "@types/node": ">=18" } }, + "node_modules/@iovalkey/commands": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz", + "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1545,6 +1555,24 @@ "resolved": "https://registry.npmjs.org/@kenyip/backoff-strategies/-/backoff-strategies-1.0.4.tgz", "integrity": "sha512-vduQZw2ctS3kIuSnCSSRiE4J90Y8WShR9xVG+e1lvFWksU2aTxjdkArcQqJ+XLm22JS380OZmrIPY1U06TAsng==" }, + "node_modules/@keyv/serialize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.0.tgz", + "integrity": "sha512-RlDgexML7Z63Q8BSaqhXdCYNBy/JQnqYIwxofUrNLGCblOMHp+xux2Q8nLMLlPpgHQPoU0Do8Z6btCpRBEqZ8g==", + "license": "MIT" + }, + "node_modules/@keyv/valkey": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@keyv/valkey/-/valkey-1.0.8.tgz", + "integrity": "sha512-V2GziCVL5xpeVKrOo0EztKErQ+N9ZQ+a1ROPoxCU7AQ/aSIa7oRaQZzN2kOOFG6upTKLYeiUFz2q5vjacMEeIg==", + "license": "MIT", + "dependencies": { + "iovalkey": "^0.3.3" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@lukehagar/plexjs": { "version": "0.39.0", "resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.39.0.tgz", @@ -3803,6 +3831,16 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.10.4.tgz", + "integrity": "sha512-Gd7ccIUkZ9TE2odLQVS+PDjIvQCdJKUlLdJRVvZu0aipj07Qfx+XIej7hhDrKGGoIxV5m5fT/kOJNJPQhQneRg==", + "license": "MIT", + "dependencies": { + "hookified": "^1.11.0", + "keyv": "^5.5.0" + } + }, "node_modules/cacheable-lookup": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz", @@ -3815,6 +3853,15 @@ "node": ">=10" } }, + "node_modules/cacheable-lookup/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/cacheable-request": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", @@ -3832,6 +3879,15 @@ "node": ">=8" } }, + "node_modules/cacheable-request/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/cacheable-request/node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -4161,6 +4217,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/codec-parser": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/codec-parser/-/codec-parser-2.5.0.tgz", @@ -4556,6 +4621,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5336,6 +5410,31 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-entry-cache/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-entry-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -5453,24 +5552,21 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.13.tgz", + "integrity": "sha512-gmtS2PaUjSPa4zjObEIn4WWliKyZzYljgxODBfxugpK6q6HU9ClXzgCJ+nlcPKY9Bt090ypTOLIFWkV0jbKFjw==", + "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "cacheable": "^1.10.4", + "flatted": "^3.3.3", + "hookified": "^1.11.0" } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.15.9", @@ -6107,6 +6203,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/hookified": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.0.tgz", + "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6319,6 +6421,26 @@ "node": ">= 0.4" } }, + "node_modules/iovalkey": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.3.3.tgz", + "integrity": "sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==", + "license": "MIT", + "dependencies": { + "@iovalkey/commands": "^0.1.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7048,7 +7170,8 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -7164,11 +7287,12 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.0.tgz", + "integrity": "sha512-QG7qR2tijh1ftOvClut4YKKg1iW6cx3GZsKoGyJPxHkGWK9oJhG9P3j5deP0QQOGDowBMVQFaP+Vm4NpGYvmIQ==", + "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.0" } }, "node_modules/klaw-sync": { @@ -7252,6 +7376,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -7259,6 +7389,12 @@ "dev": true, "peer": true }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9538,6 +9674,27 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -10203,6 +10360,12 @@ "dev": true, "peer": true }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index d51be5b5..d6693ace 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", + "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", @@ -69,6 +70,7 @@ "avahi-browse": "^1.1.4", "better-sse": "^0.8.0", "body-parser": "^1.19.0", + "cacheable": "^1.10.4", "castv2": "^0.1.10", "clsx": "^2.0.0", "common-tags": "^1.8.2", @@ -82,6 +84,7 @@ "express-session": "^1.17.2", "fast-deep-equal": "^3.1.3", "fixed-size-list": "^0.3.0", + "flat-cache": "^6.1.13", "formidable": "^3.5", "glob": "^11.0.0", "google-auth-library": "^9.15.0", @@ -91,6 +94,7 @@ "iso-websocket": "^0.3.0", "iti": "^0.6.0", "json5": "^2.2.3", + "keyv": "^5.5.0", "kodi-api": "^0.2.1", "lastfm-node-client": "^2.2.0", "mopidy": "^1.3.0", diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts new file mode 100644 index 00000000..6d3c4d01 --- /dev/null +++ b/src/backend/common/Cache.ts @@ -0,0 +1,166 @@ +import { Cacheable, CacheableMemory, Keyv, KeyvStoreAdapter } from 'cacheable'; +import { FlatCache } from 'flat-cache'; +import KeyvValkey from '@keyv/valkey'; +import { childLogger, Logger } from '@foxxmd/logging'; +import { projectDir } from './index.js'; +import path from 'path'; +import { fileOrDirectoryIsWriteable } from '../utils.js'; + +export type CacheProvider = 'memory' | 'valkey' | 'file'; + +interface CacheConfig { + provider: T + connection?: string +} + +export type CacheMetadaProvider = Exclude; +export type CacheMetadataConfig = CacheConfig + +const asCacheMetadataProvider = (val: string): val is CacheScrobbleProvider => { + return ['memory', 'valkey'].includes(val); +} + +export type CacheScrobbleProvider = CacheProvider; +export type CacheScrobbleConfig = CacheConfig; + +const asCacheScrobbleProvider = (val: string): val is CacheScrobbleProvider => { + return ['memory', 'valkey', 'file'].includes(val); +} + +export interface CacheConfigOptions { + metadata?: CacheMetadataConfig + scrobble?: CacheScrobbleConfig +} + +const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); + +export class MSCache { + + config: Required + + cacheMetadata: Cacheable; + cacheScrobble: Cacheable; + + logger: Logger; + + constructor(logger: Logger, config: CacheConfigOptions) { + this.logger = childLogger(logger, 'Cache'); + + const { + metadata: { + provider: mProvider = 'memory', + ...restMetadata + } = {}, + scrobble: { + provider: sProvider = 'memory', + connection = configDir, + ...restScrobble + } = {}, + } = config; + + this.config = { + metadata: { + provider: mProvider, + ...restMetadata, + }, + scrobble: { + provider: sProvider, + connection, + ...restScrobble + } + }; + } + + init = async () => { + await this.initMetadataCache(); + await this.initScrobbleCache(); + } + + protected initCacheable = async (config: CacheConfig, cacheFor: string) => { + + let logger = childLogger(this.logger, cacheFor); + + const ns = `ms-${cacheFor.toLocaleLowerCase()}`; + + const memoryCache = new CacheableMemory({ + ttl: '1h', + useClone: true, + lruSize: 200 + }); + const primaryCache = new Keyv({ store: memoryCache, namespace: ns }); + + let secondaryCache: Keyv | KeyvStoreAdapter | undefined; + + if (config.provider === 'valkey') { + logger.debug('Building valkey cache...'); + const valkey = new KeyvValkey(config.connection, {maxRetriesPerRequest: 5, connectTimeout: 1100}); + const metadataKv = new Keyv({ store: valkey, throwOnErrors: true, namespace: ns}); + try { + await metadataKv.get('test'); + secondaryCache = metadataKv; + logger.debug('valkey cache connected'); + } catch (e) { + this.logger.warn(new Error(`Unable to connect to cache ${config.connection}`, { cause: e })); + } + } else if(config.provider === 'file') { + logger.debug('Building file cache...'); + try { + fileOrDirectoryIsWriteable(config.connection); + } catch (e) { + logger.warn(new Error(`Unable to use path for file cache at ${config.connection}`, {cause: e})); + } + const flatCache = new FlatCache({ + ttl: 0, + lruSize: 500, + cacheDir: config.connection, + cacheId: 'mscache', + persistInterval: 1 * 1000 * 60, + expirationInterval: 1 * 1000 * 60, // 1 minute + }); + + let loadError: Error; + + const onlySaveError = (e: Error) => { + loadError = e; + } + flatCache.on('error', onlySaveError); + try { + logger.debug('Loading cache from file...'); + flatCache.load('mscache'); + if(loadError !== undefined) { + throw loadError; + } + logger.debug('File cache loaded.'); + flatCache.off('error', onlySaveError); + flatCache.on('error', (e) => { + logger.warn(e); + }) + } catch (e) { + logger.warn(new Error(`Unable to use file cache at ${path.join(config.connection, 'mscache')}`, {cause: e})); + } + secondaryCache = new Keyv({store: flatCache, throwOnErrors: true}); + } + return new Cacheable({ primary: primaryCache, secondary: secondaryCache }); + + } + + initScrobbleCache = async () => { + if (this.cacheScrobble === undefined) { + if (!asCacheScrobbleProvider(this.config.scrobble.provider)) { + throw new Error(`Cache Scrobble provider '${this.config.scrobble.provider}' must be one of: memory, valkey, file`); + } + + this.cacheScrobble = await this.initCacheable(this.config.scrobble, 'Scrobble'); + } + } + + initMetadataCache = async () => { + if (this.cacheMetadata === undefined) { + if (!asCacheMetadataProvider(this.config.metadata.provider)) { + throw new Error(`Cache Metadata provider '${this.config.metadata.provider}' must be one of: memory, valkey`); + } + + this.cacheMetadata = await this.initCacheable(this.config.metadata, 'Metadata'); + } + } +} \ No newline at end of file diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 90902dcb..80be609f 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -345,3 +345,7 @@ export type WhenParts = PlayTransformPartsAtomic; export type WhenConditions = WhenParts[]; export type WhenConditionsConfig = WhenConditions; + +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; \ No newline at end of file